Initial release — booru image viewer with Qt6 GUI and Textual TUI
Supports Danbooru, Gelbooru, Moebooru, and e621. Features include tag search with autocomplete, favorites with folders, save-to-library, video playback, drag-and-drop, multi-select, custom CSS theming, and cross-platform support.
This commit is contained in:
commit
b10c00d6bf
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
*.egg
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
project.md
|
||||||
|
*.bak/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 pax
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
91
README.md
Normal file
91
README.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# booru-viewer
|
||||||
|
|
||||||
|
Local desktop application for browsing, searching, and favoriting images from booru-style imageboards.
|
||||||
|
|
||||||
|
Dual interface — **Qt6 GUI** and **Textual TUI**. Green-on-black theme.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Tag-based search across multiple booru sites (Danbooru, Gelbooru, Moebooru)
|
||||||
|
- Auto-detect site API type — just paste the URL
|
||||||
|
- Thumbnail grid with full image preview (zoom/pan)
|
||||||
|
- Favorites system with local download cache and offline browsing
|
||||||
|
- Tag autocomplete
|
||||||
|
- Per-site API key support
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install -e ".[all]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install only what you need:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install -e ".[gui]" # Qt6 GUI only
|
||||||
|
pip install -e ".[tui]" # Textual TUI only
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- **GUI**: PySide6 (Qt6)
|
||||||
|
- **TUI**: Textual
|
||||||
|
- **Core**: httpx, Pillow, SQLite (stdlib)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Qt6 GUI
|
||||||
|
booru-gui
|
||||||
|
|
||||||
|
# Terminal TUI
|
||||||
|
booru-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
### TUI Keybinds
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `/` | Focus search |
|
||||||
|
| `Enter` | Preview selected |
|
||||||
|
| `f` | Toggle favorite |
|
||||||
|
| `j`/`k` | Navigate down/up |
|
||||||
|
| `n`/`p` | Next/previous page |
|
||||||
|
| `1`/`2`/`3` | Browse / Favorites / Sites |
|
||||||
|
| `Escape` | Close preview |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
### GUI Keybinds
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `F` | Toggle favorite on selected |
|
||||||
|
| `Ctrl+S` | Manage sites |
|
||||||
|
| `Ctrl+Q` | Quit |
|
||||||
|
| Scroll wheel | Zoom in preview |
|
||||||
|
| Right click | Close preview |
|
||||||
|
| `0` | Fit to view |
|
||||||
|
| `+`/`-` | Zoom in/out |
|
||||||
|
|
||||||
|
## Adding Sites
|
||||||
|
|
||||||
|
Open the site manager (GUI: `Ctrl+S`, or in the Sites tab). Enter a URL and click Auto-Detect — the app probes for Danbooru, Gelbooru, and Moebooru APIs automatically.
|
||||||
|
|
||||||
|
Or via Python:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from booru_viewer.core.db import Database
|
||||||
|
db = Database()
|
||||||
|
db.add_site("Danbooru", "https://danbooru.donmai.us", "danbooru")
|
||||||
|
db.add_site("Gelbooru", "https://gelbooru.com", "gelbooru")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
- Database: `~/.local/share/booru-viewer/booru.db`
|
||||||
|
- Image cache: `~/.local/share/booru-viewer/cache/`
|
||||||
|
- Thumbnails: `~/.local/share/booru-viewer/thumbnails/`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
59
booru-viewer.spec
Normal file
59
booru-viewer.spec
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
hiddenimports = [
|
||||||
|
*collect_submodules('booru_viewer'),
|
||||||
|
'httpx',
|
||||||
|
'httpx._transports',
|
||||||
|
'httpx._transports.default',
|
||||||
|
'h2',
|
||||||
|
'hpack',
|
||||||
|
'hyperframe',
|
||||||
|
'PIL',
|
||||||
|
'PIL.Image',
|
||||||
|
'PIL.JpegImagePlugin',
|
||||||
|
'PIL.PngImagePlugin',
|
||||||
|
'PIL.GifImagePlugin',
|
||||||
|
'PIL.WebPImagePlugin',
|
||||||
|
'PIL.BmpImagePlugin',
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['booru_viewer/main_gui.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[('icon.png', '.'), ('booru_viewer/gui/custom_css_guide.txt', 'booru_viewer/gui')],
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=['textual', 'tkinter', 'unittest'],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
cipher=block_cipher,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='booru-viewer',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
icon='icon.ico',
|
||||||
|
)
|
||||||
0
booru_viewer/__init__.py
Normal file
0
booru_viewer/__init__.py
Normal file
0
booru_viewer/core/__init__.py
Normal file
0
booru_viewer/core/__init__.py
Normal file
16
booru_viewer/core/api/__init__.py
Normal file
16
booru_viewer/core/api/__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from .base import BooruClient, Post
|
||||||
|
from .danbooru import DanbooruClient
|
||||||
|
from .gelbooru import GelbooruClient
|
||||||
|
from .moebooru import MoebooruClient
|
||||||
|
from .e621 import E621Client
|
||||||
|
from .detect import detect_site_type
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BooruClient",
|
||||||
|
"Post",
|
||||||
|
"DanbooruClient",
|
||||||
|
"GelbooruClient",
|
||||||
|
"MoebooruClient",
|
||||||
|
"E621Client",
|
||||||
|
"detect_site_type",
|
||||||
|
]
|
||||||
85
booru_viewer/core/api/base.py
Normal file
85
booru_viewer/core/api/base.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""Abstract booru client and shared Post dataclass."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import USER_AGENT, DEFAULT_PAGE_SIZE
|
||||||
|
|
||||||
|
log = logging.getLogger("booru")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Post:
|
||||||
|
id: int
|
||||||
|
file_url: str
|
||||||
|
preview_url: str | None
|
||||||
|
tags: str
|
||||||
|
score: int
|
||||||
|
rating: str | None
|
||||||
|
source: str | None
|
||||||
|
width: int = 0
|
||||||
|
height: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag_list(self) -> list[str]:
|
||||||
|
return self.tags.split()
|
||||||
|
|
||||||
|
|
||||||
|
class BooruClient(ABC):
|
||||||
|
"""Base class for booru API clients."""
|
||||||
|
|
||||||
|
api_type: str = ""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_user: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.api_user = api_user
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=20.0,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._client and not self._client.is_closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def search(
|
||||||
|
self, tags: str = "", page: int = 1, limit: int = DEFAULT_PAGE_SIZE
|
||||||
|
) -> list[Post]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_post(self, post_id: int) -> Post | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||||
|
"""Tag autocomplete. Override in subclasses that support it."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def test_connection(self) -> tuple[bool, str]:
|
||||||
|
"""Test connection. Returns (success, detail_message)."""
|
||||||
|
try:
|
||||||
|
posts = await self.search(limit=1)
|
||||||
|
return True, f"OK — got {len(posts)} post(s)"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return False, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
107
booru_viewer/core/api/danbooru.py
Normal file
107
booru_viewer/core/api/danbooru.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""Danbooru-style API client (Danbooru, Safebooru, Szurubooru variants)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..config import DEFAULT_PAGE_SIZE
|
||||||
|
from .base import BooruClient, Post
|
||||||
|
|
||||||
|
log = logging.getLogger("booru")
|
||||||
|
|
||||||
|
|
||||||
|
class DanbooruClient(BooruClient):
|
||||||
|
api_type = "danbooru"
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self, tags: str = "", page: int = 1, limit: int = DEFAULT_PAGE_SIZE
|
||||||
|
) -> list[Post]:
|
||||||
|
params: dict = {"tags": tags, "page": page, "limit": limit}
|
||||||
|
if self.api_key and self.api_user:
|
||||||
|
params["login"] = self.api_user
|
||||||
|
params["api_key"] = self.api_key
|
||||||
|
|
||||||
|
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)
|
||||||
|
log.info(f" -> {resp.status_code}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
log.warning(f" body: {resp.text[:500]}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Some Danbooru forks wrap in {"posts": [...]}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("posts", [])
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
for item in data:
|
||||||
|
file_url = item.get("file_url") or item.get("large_file_url") or ""
|
||||||
|
if not file_url:
|
||||||
|
continue
|
||||||
|
posts.append(
|
||||||
|
Post(
|
||||||
|
id=item["id"],
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=item.get("preview_file_url") or item.get("preview_url"),
|
||||||
|
tags=self._extract_tags(item),
|
||||||
|
score=item.get("score", 0),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
source=item.get("source"),
|
||||||
|
width=item.get("image_width", 0),
|
||||||
|
height=item.get("image_height", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
async def get_post(self, post_id: int) -> Post | None:
|
||||||
|
params: dict = {}
|
||||||
|
if self.api_key and self.api_user:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
item = resp.json()
|
||||||
|
file_url = item.get("file_url") or item.get("large_file_url") or ""
|
||||||
|
if not file_url:
|
||||||
|
return None
|
||||||
|
return Post(
|
||||||
|
id=item["id"],
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=item.get("preview_file_url") or item.get("preview_url"),
|
||||||
|
tags=self._extract_tags(item),
|
||||||
|
score=item.get("score", 0),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
source=item.get("source"),
|
||||||
|
width=item.get("image_width", 0),
|
||||||
|
height=item.get("image_height", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||||
|
try:
|
||||||
|
resp = await self.client.get(
|
||||||
|
f"{self.base_url}/autocomplete.json",
|
||||||
|
params={"search[query]": query, "search[type]": "tag_query", "limit": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return [item.get("value", item.get("label", "")) for item in resp.json()]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_tags(item: dict) -> str:
|
||||||
|
"""Pull tags from Danbooru's split tag fields or a single tag_string."""
|
||||||
|
if "tag_string" in item:
|
||||||
|
return item["tag_string"]
|
||||||
|
parts = []
|
||||||
|
for key in ("tag_string_general", "tag_string_character",
|
||||||
|
"tag_string_copyright", "tag_string_artist", "tag_string_meta"):
|
||||||
|
if key in item and item[key]:
|
||||||
|
parts.append(item[key])
|
||||||
|
return " ".join(parts) if parts else ""
|
||||||
119
booru_viewer/core/api/detect.py
Normal file
119
booru_viewer/core/api/detect.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""Auto-detect which API type a booru site uses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import USER_AGENT
|
||||||
|
from .danbooru import DanbooruClient
|
||||||
|
from .gelbooru import GelbooruClient
|
||||||
|
from .moebooru import MoebooruClient
|
||||||
|
from .e621 import E621Client
|
||||||
|
from .base import BooruClient
|
||||||
|
|
||||||
|
|
||||||
|
async def detect_site_type(
|
||||||
|
url: str,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_user: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Probe a URL and return the API type string: 'danbooru', 'gelbooru', or 'moebooru'.
|
||||||
|
Returns None if detection fails.
|
||||||
|
"""
|
||||||
|
url = url.rstrip("/")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=10.0,
|
||||||
|
) as client:
|
||||||
|
# Try Danbooru / e621 first — /posts.json is a definitive endpoint
|
||||||
|
try:
|
||||||
|
params: dict = {"limit": 1}
|
||||||
|
if api_key and api_user:
|
||||||
|
params["login"] = api_user
|
||||||
|
params["api_key"] = api_key
|
||||||
|
resp = await client.get(f"{url}/posts.json", params=params)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict) and "posts" in data:
|
||||||
|
# e621/e926 wraps in {"posts": [...]}, with nested file/tags dicts
|
||||||
|
posts = data["posts"]
|
||||||
|
if isinstance(posts, list) and posts:
|
||||||
|
p = posts[0]
|
||||||
|
if isinstance(p.get("file"), dict) and isinstance(p.get("tags"), dict):
|
||||||
|
return "e621"
|
||||||
|
return "danbooru"
|
||||||
|
elif isinstance(data, list) and data:
|
||||||
|
# Danbooru returns a flat list of post objects
|
||||||
|
if isinstance(data[0], dict) and any(
|
||||||
|
k in data[0] for k in ("tag_string", "image_width", "large_file_url")
|
||||||
|
):
|
||||||
|
return "danbooru"
|
||||||
|
elif resp.status_code in (401, 403):
|
||||||
|
if "e621" in url or "e926" in url:
|
||||||
|
return "e621"
|
||||||
|
return "danbooru"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try Gelbooru — /index.php?page=dapi
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"page": "dapi", "s": "post", "q": "index", "json": "1", "limit": 1,
|
||||||
|
}
|
||||||
|
if api_key and api_user:
|
||||||
|
params["api_key"] = api_key
|
||||||
|
params["user_id"] = api_user
|
||||||
|
resp = await client.get(f"{url}/index.php", params=params)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||||
|
if any(k in data[0] for k in ("file_url", "preview_url", "directory")):
|
||||||
|
return "gelbooru"
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
if "post" in data or "@attributes" in data:
|
||||||
|
return "gelbooru"
|
||||||
|
elif resp.status_code in (401, 403):
|
||||||
|
if "gelbooru" in url or "safebooru.org" in url or "rule34" in url:
|
||||||
|
return "gelbooru"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try Moebooru — /post.json (singular)
|
||||||
|
try:
|
||||||
|
params = {"limit": 1}
|
||||||
|
if api_key and api_user:
|
||||||
|
params["login"] = api_user
|
||||||
|
params["password_hash"] = api_key
|
||||||
|
resp = await client.get(f"{url}/post.json", params=params)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, list) or (isinstance(data, dict) and "posts" in data):
|
||||||
|
return "moebooru"
|
||||||
|
elif resp.status_code in (401, 403):
|
||||||
|
return "moebooru"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def client_for_type(
|
||||||
|
api_type: str,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_user: str | None = None,
|
||||||
|
) -> BooruClient:
|
||||||
|
"""Return the appropriate client class for an API type string."""
|
||||||
|
clients = {
|
||||||
|
"danbooru": DanbooruClient,
|
||||||
|
"gelbooru": GelbooruClient,
|
||||||
|
"moebooru": MoebooruClient,
|
||||||
|
"e621": E621Client,
|
||||||
|
}
|
||||||
|
cls = clients.get(api_type)
|
||||||
|
if cls is None:
|
||||||
|
raise ValueError(f"Unknown API type: {api_type}")
|
||||||
|
return cls(base_url, api_key=api_key, api_user=api_user)
|
||||||
175
booru_viewer/core/api/e621.py
Normal file
175
booru_viewer/core/api/e621.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
"""e621 API client — Danbooru fork with different response structure."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import DEFAULT_PAGE_SIZE, USER_AGENT
|
||||||
|
from .base import BooruClient, Post
|
||||||
|
|
||||||
|
log = logging.getLogger("booru")
|
||||||
|
|
||||||
|
|
||||||
|
class E621Client(BooruClient):
|
||||||
|
api_type = "e621"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
# e621 requires a descriptive User-Agent with username
|
||||||
|
ua = USER_AGENT
|
||||||
|
if self.api_user:
|
||||||
|
ua = f"{USER_AGENT} (by {self.api_user} on e621)"
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
headers={"User-Agent": ua},
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=20.0,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self, tags: str = "", page: int = 1, limit: int = DEFAULT_PAGE_SIZE
|
||||||
|
) -> list[Post]:
|
||||||
|
params: dict = {"tags": tags, "page": page, "limit": min(limit, 320)}
|
||||||
|
if self.api_key and self.api_user:
|
||||||
|
params["login"] = self.api_user
|
||||||
|
params["api_key"] = self.api_key
|
||||||
|
|
||||||
|
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)
|
||||||
|
log.info(f" -> {resp.status_code}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
log.warning(f" body: {resp.text[:500]}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# e621 wraps posts in {"posts": [...]}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("posts", [])
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
for item in data:
|
||||||
|
file_url = self._get_file_url(item)
|
||||||
|
if not file_url:
|
||||||
|
continue
|
||||||
|
posts.append(
|
||||||
|
Post(
|
||||||
|
id=item["id"],
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=self._get_nested(item, "preview", "url"),
|
||||||
|
tags=self._extract_tags(item),
|
||||||
|
score=self._get_score(item),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
source=self._get_source(item),
|
||||||
|
width=self._get_nested(item, "file", "width") or 0,
|
||||||
|
height=self._get_nested(item, "file", "height") or 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
async def get_post(self, post_id: int) -> Post | None:
|
||||||
|
params: dict = {}
|
||||||
|
if self.api_key and self.api_user:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
item = data.get("post", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
file_url = self._get_file_url(item)
|
||||||
|
if not file_url:
|
||||||
|
return None
|
||||||
|
return Post(
|
||||||
|
id=item["id"],
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=self._get_nested(item, "preview", "url"),
|
||||||
|
tags=self._extract_tags(item),
|
||||||
|
score=self._get_score(item),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
source=self._get_source(item),
|
||||||
|
width=self._get_nested(item, "file", "width") or 0,
|
||||||
|
height=self._get_nested(item, "file", "height") or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||||
|
try:
|
||||||
|
resp = await self.client.get(
|
||||||
|
f"{self.base_url}/tags.json",
|
||||||
|
params={
|
||||||
|
"search[name_matches]": f"{query}*",
|
||||||
|
"search[order]": "count",
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return [item.get("name", "") for item in resp.json() if item.get("name")]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_file_url(item: dict) -> str:
|
||||||
|
"""Extract file URL from e621's nested structure."""
|
||||||
|
# e621: item["file"]["url"], fallback to item["sample"]["url"]
|
||||||
|
f = item.get("file")
|
||||||
|
if isinstance(f, dict) and f.get("url"):
|
||||||
|
return f["url"]
|
||||||
|
s = item.get("sample")
|
||||||
|
if isinstance(s, dict) and s.get("url"):
|
||||||
|
return s["url"]
|
||||||
|
# Some posts have null URLs (deleted/flagged)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_nested(item: dict, *keys) -> str | int | None:
|
||||||
|
"""Safely get nested dict value."""
|
||||||
|
current = item
|
||||||
|
for key in keys:
|
||||||
|
if isinstance(current, dict):
|
||||||
|
current = current.get(key)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_tags(item: dict) -> str:
|
||||||
|
"""e621 tags are a dict of category -> list[str]."""
|
||||||
|
tags_obj = item.get("tags")
|
||||||
|
if isinstance(tags_obj, dict):
|
||||||
|
all_tags = []
|
||||||
|
for category in ("general", "artist", "copyright", "character",
|
||||||
|
"species", "meta", "lore"):
|
||||||
|
tag_list = tags_obj.get(category, [])
|
||||||
|
if isinstance(tag_list, list):
|
||||||
|
all_tags.extend(tag_list)
|
||||||
|
return " ".join(all_tags)
|
||||||
|
if isinstance(tags_obj, str):
|
||||||
|
return tags_obj
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_score(item: dict) -> int:
|
||||||
|
"""e621 score is a dict with up/down/total."""
|
||||||
|
score = item.get("score")
|
||||||
|
if isinstance(score, dict):
|
||||||
|
return score.get("total", 0)
|
||||||
|
if isinstance(score, int):
|
||||||
|
return score
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_source(item: dict) -> str | None:
|
||||||
|
"""e621 sources is a list."""
|
||||||
|
sources = item.get("sources")
|
||||||
|
if isinstance(sources, list) and sources:
|
||||||
|
return sources[0]
|
||||||
|
return item.get("source")
|
||||||
132
booru_viewer/core/api/gelbooru.py
Normal file
132
booru_viewer/core/api/gelbooru.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""Gelbooru-style API client."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..config import DEFAULT_PAGE_SIZE
|
||||||
|
from .base import BooruClient, Post
|
||||||
|
|
||||||
|
log = logging.getLogger("booru")
|
||||||
|
|
||||||
|
|
||||||
|
class GelbooruClient(BooruClient):
|
||||||
|
api_type = "gelbooru"
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self, tags: str = "", page: int = 1, limit: int = DEFAULT_PAGE_SIZE
|
||||||
|
) -> list[Post]:
|
||||||
|
# Gelbooru uses pid (0-indexed page) not page number
|
||||||
|
params: dict = {
|
||||||
|
"page": "dapi",
|
||||||
|
"s": "post",
|
||||||
|
"q": "index",
|
||||||
|
"json": "1",
|
||||||
|
"tags": tags,
|
||||||
|
"limit": limit,
|
||||||
|
"pid": page - 1,
|
||||||
|
}
|
||||||
|
if self.api_key and self.api_user:
|
||||||
|
# Only send if they look like real values, not leftover URL fragments
|
||||||
|
key = self.api_key.strip().lstrip("&")
|
||||||
|
user = self.api_user.strip().lstrip("&")
|
||||||
|
if key and not key.startswith("api_key="):
|
||||||
|
params["api_key"] = key
|
||||||
|
if user and not user.startswith("user_id="):
|
||||||
|
params["user_id"] = user
|
||||||
|
|
||||||
|
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)
|
||||||
|
log.info(f" -> {resp.status_code}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
log.warning(f" body: {resp.text[:500]}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
log.debug(f" json type: {type(data).__name__}, keys: {list(data.keys()) if isinstance(data, dict) else f'list[{len(data)}]'}")
|
||||||
|
# Gelbooru wraps posts in {"post": [...]} or returns {"post": []}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("post", [])
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
for item in data:
|
||||||
|
file_url = item.get("file_url", "")
|
||||||
|
if not file_url:
|
||||||
|
continue
|
||||||
|
posts.append(
|
||||||
|
Post(
|
||||||
|
id=item["id"],
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=item.get("preview_url"),
|
||||||
|
tags=item.get("tags", ""),
|
||||||
|
score=item.get("score", 0),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
source=item.get("source"),
|
||||||
|
width=item.get("width", 0),
|
||||||
|
height=item.get("height", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
async def get_post(self, post_id: int) -> Post | None:
|
||||||
|
params: dict = {
|
||||||
|
"page": "dapi",
|
||||||
|
"s": "post",
|
||||||
|
"q": "index",
|
||||||
|
"json": "1",
|
||||||
|
"id": post_id,
|
||||||
|
}
|
||||||
|
if self.api_key and self.api_user:
|
||||||
|
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)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("post", [])
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
item = data[0]
|
||||||
|
file_url = item.get("file_url", "")
|
||||||
|
if not file_url:
|
||||||
|
return None
|
||||||
|
return Post(
|
||||||
|
id=item["id"],
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=item.get("preview_url"),
|
||||||
|
tags=item.get("tags", ""),
|
||||||
|
score=item.get("score", 0),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
source=item.get("source"),
|
||||||
|
width=item.get("width", 0),
|
||||||
|
height=item.get("height", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||||
|
try:
|
||||||
|
resp = await self.client.get(
|
||||||
|
f"{self.base_url}/index.php",
|
||||||
|
params={
|
||||||
|
"page": "dapi",
|
||||||
|
"s": "tag",
|
||||||
|
"q": "index",
|
||||||
|
"json": "1",
|
||||||
|
"name_pattern": f"%{query}%",
|
||||||
|
"limit": limit,
|
||||||
|
"orderby": "count",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("tag", [])
|
||||||
|
return [t.get("name", "") for t in data if t.get("name")]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
92
booru_viewer/core/api/moebooru.py
Normal file
92
booru_viewer/core/api/moebooru.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Moebooru-style API client (Yande.re, Konachan, etc.)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..config import DEFAULT_PAGE_SIZE
|
||||||
|
from .base import BooruClient, Post
|
||||||
|
|
||||||
|
log = logging.getLogger("booru")
|
||||||
|
|
||||||
|
|
||||||
|
class MoebooruClient(BooruClient):
|
||||||
|
api_type = "moebooru"
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self, tags: str = "", page: int = 1, limit: int = DEFAULT_PAGE_SIZE
|
||||||
|
) -> list[Post]:
|
||||||
|
params: dict = {"tags": tags, "page": page, "limit": limit}
|
||||||
|
if self.api_key and self.api_user:
|
||||||
|
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.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("posts", data.get("post", []))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
for item in data:
|
||||||
|
file_url = item.get("file_url") or item.get("jpeg_url") or ""
|
||||||
|
if not file_url:
|
||||||
|
continue
|
||||||
|
posts.append(
|
||||||
|
Post(
|
||||||
|
id=item["id"],
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=item.get("preview_url") or item.get("actual_preview_url"),
|
||||||
|
tags=item.get("tags", ""),
|
||||||
|
score=item.get("score", 0),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
source=item.get("source"),
|
||||||
|
width=item.get("width", 0),
|
||||||
|
height=item.get("height", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
async def get_post(self, post_id: int) -> Post | None:
|
||||||
|
params: dict = {"tags": f"id:{post_id}"}
|
||||||
|
if self.api_key and self.api_user:
|
||||||
|
params["login"] = self.api_user
|
||||||
|
params["password_hash"] = self.api_key
|
||||||
|
|
||||||
|
resp = await self.client.get(f"{self.base_url}/post.json", params=params)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("posts", data.get("post", []))
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
item = data[0]
|
||||||
|
file_url = item.get("file_url") or item.get("jpeg_url") or ""
|
||||||
|
if not file_url:
|
||||||
|
return None
|
||||||
|
return Post(
|
||||||
|
id=item["id"],
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=item.get("preview_url") or item.get("actual_preview_url"),
|
||||||
|
tags=item.get("tags", ""),
|
||||||
|
score=item.get("score", 0),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
source=item.get("source"),
|
||||||
|
width=item.get("width", 0),
|
||||||
|
height=item.get("height", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||||
|
try:
|
||||||
|
resp = await self.client.get(
|
||||||
|
f"{self.base_url}/tag.json",
|
||||||
|
params={"name": f"*{query}*", "order": "count", "limit": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return [t["name"] for t in resp.json() if "name" in t]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
207
booru_viewer/core/cache.py
Normal file
207
booru_viewer/core/cache.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"""Download manager and local file cache."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import cache_dir, thumbnails_dir, USER_AGENT
|
||||||
|
|
||||||
|
|
||||||
|
def _url_hash(url: str) -> str:
|
||||||
|
return hashlib.sha256(url.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
_IMAGE_MAGIC = {
|
||||||
|
b'\x89PNG': True,
|
||||||
|
b'\xff\xd8\xff': True, # JPEG
|
||||||
|
b'GIF8': True,
|
||||||
|
b'RIFF': True, # WebP
|
||||||
|
b'\x00\x00\x00': True, # MP4/MOV
|
||||||
|
b'\x1aE\xdf\xa3': True, # WebM/MKV
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_media(path: Path) -> bool:
|
||||||
|
"""Check if a file looks like actual media, not an HTML error page."""
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
header = f.read(16)
|
||||||
|
if not header or header.startswith(b'<') or header.startswith(b'<!'):
|
||||||
|
return False
|
||||||
|
# Check for known magic bytes
|
||||||
|
for magic in _IMAGE_MAGIC:
|
||||||
|
if header.startswith(magic):
|
||||||
|
return True
|
||||||
|
# If not a known type but not HTML, assume it's ok
|
||||||
|
return b'<html' not in header.lower() and b'<!doctype' not in header.lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ext_from_url(url: str) -> str:
|
||||||
|
path = url.split("?")[0]
|
||||||
|
if "." in path.split("/")[-1]:
|
||||||
|
return "." + path.split("/")[-1].rsplit(".", 1)[-1]
|
||||||
|
return ".jpg"
|
||||||
|
|
||||||
|
|
||||||
|
async def download_image(
|
||||||
|
url: str,
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
|
dest_dir: Path | None = None,
|
||||||
|
progress_callback=None,
|
||||||
|
) -> Path:
|
||||||
|
"""Download an image to the cache, returning the local path. Skips if already cached.
|
||||||
|
|
||||||
|
progress_callback: optional callable(bytes_downloaded, total_bytes)
|
||||||
|
"""
|
||||||
|
dest_dir = dest_dir or cache_dir()
|
||||||
|
filename = _url_hash(url) + _ext_from_url(url)
|
||||||
|
local = dest_dir / filename
|
||||||
|
|
||||||
|
# Validate cached file isn't corrupt (e.g. HTML error page saved as image)
|
||||||
|
if local.exists():
|
||||||
|
if _is_valid_media(local):
|
||||||
|
return local
|
||||||
|
else:
|
||||||
|
local.unlink() # Remove corrupt cache entry
|
||||||
|
|
||||||
|
# Extract referer from URL domain (needed for Gelbooru CDN etc.)
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed = urlparse(url)
|
||||||
|
# Map CDN hostnames back to the main site
|
||||||
|
referer_host = parsed.netloc
|
||||||
|
if referer_host.startswith("img") and "gelbooru" in referer_host:
|
||||||
|
referer_host = "gelbooru.com"
|
||||||
|
elif referer_host.startswith("cdn") and "donmai" in referer_host:
|
||||||
|
referer_host = "danbooru.donmai.us"
|
||||||
|
referer = f"{parsed.scheme}://{referer_host}/"
|
||||||
|
|
||||||
|
own_client = client is None
|
||||||
|
if own_client:
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
headers={
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"Referer": referer,
|
||||||
|
"Accept": "image/*,video/*,*/*",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if progress_callback:
|
||||||
|
async with client.stream("GET", url) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
content_type = resp.headers.get("content-type", "")
|
||||||
|
if "text/html" in content_type:
|
||||||
|
raise ValueError(f"Server returned HTML instead of media (possible captcha/block)")
|
||||||
|
total = int(resp.headers.get("content-length", 0))
|
||||||
|
downloaded = 0
|
||||||
|
chunks = []
|
||||||
|
async for chunk in resp.aiter_bytes(8192):
|
||||||
|
chunks.append(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
progress_callback(downloaded, total)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
local.write_bytes(data)
|
||||||
|
else:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
content_type = resp.headers.get("content-type", "")
|
||||||
|
if "text/html" in content_type:
|
||||||
|
raise ValueError(f"Server returned HTML instead of media (possible captcha/block)")
|
||||||
|
local.write_bytes(resp.content)
|
||||||
|
|
||||||
|
# Verify the downloaded file
|
||||||
|
if not _is_valid_media(local):
|
||||||
|
local.unlink()
|
||||||
|
raise ValueError("Downloaded file is not valid media")
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
await client.aclose()
|
||||||
|
return local
|
||||||
|
|
||||||
|
|
||||||
|
async def download_thumbnail(
|
||||||
|
url: str,
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Download a thumbnail preview image."""
|
||||||
|
return await download_image(url, client, thumbnails_dir())
|
||||||
|
|
||||||
|
|
||||||
|
def cached_path_for(url: str, dest_dir: Path | None = None) -> Path:
|
||||||
|
"""Return the expected cache path for a URL (may not exist yet)."""
|
||||||
|
dest_dir = dest_dir or cache_dir()
|
||||||
|
return dest_dir / (_url_hash(url) + _ext_from_url(url))
|
||||||
|
|
||||||
|
|
||||||
|
def is_cached(url: str, dest_dir: Path | None = None) -> bool:
|
||||||
|
return cached_path_for(url, dest_dir).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_from_library(post_id: int, folder: str | None = None) -> bool:
|
||||||
|
"""Delete a saved image from the library. Returns True if a file was deleted."""
|
||||||
|
from .config import saved_dir, saved_folder_dir
|
||||||
|
search_dir = saved_folder_dir(folder) if folder else saved_dir()
|
||||||
|
from .config import MEDIA_EXTENSIONS
|
||||||
|
for ext in MEDIA_EXTENSIONS:
|
||||||
|
path = search_dir / f"{post_id}{ext}"
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cache_size_bytes(include_thumbnails: bool = True) -> int:
|
||||||
|
"""Total size of all cached files in bytes."""
|
||||||
|
total = sum(f.stat().st_size for f in cache_dir().iterdir() if f.is_file())
|
||||||
|
if include_thumbnails:
|
||||||
|
total += sum(f.stat().st_size for f in thumbnails_dir().iterdir() if f.is_file())
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def cache_file_count(include_thumbnails: bool = True) -> tuple[int, int]:
|
||||||
|
"""Return (image_count, thumbnail_count)."""
|
||||||
|
images = sum(1 for f in cache_dir().iterdir() if f.is_file())
|
||||||
|
thumbs = sum(1 for f in thumbnails_dir().iterdir() if f.is_file()) if include_thumbnails else 0
|
||||||
|
return images, thumbs
|
||||||
|
|
||||||
|
|
||||||
|
def evict_oldest(max_bytes: int, protected_paths: set[str] | None = None) -> int:
|
||||||
|
"""Delete oldest non-protected cached images until under max_bytes. Returns count deleted."""
|
||||||
|
protected = protected_paths or set()
|
||||||
|
files = sorted(cache_dir().iterdir(), key=lambda f: f.stat().st_mtime)
|
||||||
|
deleted = 0
|
||||||
|
current = cache_size_bytes(include_thumbnails=False)
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
if current <= max_bytes:
|
||||||
|
break
|
||||||
|
if not f.is_file() or str(f) in protected:
|
||||||
|
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
|
||||||
|
if clear_images:
|
||||||
|
for f in cache_dir().iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
f.unlink()
|
||||||
|
deleted += 1
|
||||||
|
if clear_thumbnails:
|
||||||
|
for f in thumbnails_dir().iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
f.unlink()
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
74
booru_viewer/core/config.py
Normal file
74
booru_viewer/core/config.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""Settings, paths, constants, platform detection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APPNAME = "booru-viewer"
|
||||||
|
IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
|
|
||||||
|
def data_dir() -> Path:
|
||||||
|
"""Return the platform-appropriate data/cache directory."""
|
||||||
|
if IS_WINDOWS:
|
||||||
|
base = Path.home() / "AppData" / "Roaming"
|
||||||
|
else:
|
||||||
|
base = Path(
|
||||||
|
__import__("os").environ.get(
|
||||||
|
"XDG_DATA_HOME", str(Path.home() / ".local" / "share")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
path = base / APPNAME
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir() -> Path:
|
||||||
|
"""Return the image cache directory."""
|
||||||
|
path = data_dir() / "cache"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def thumbnails_dir() -> Path:
|
||||||
|
"""Return the thumbnail cache directory."""
|
||||||
|
path = data_dir() / "thumbnails"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def saved_dir() -> Path:
|
||||||
|
"""Return the saved images directory."""
|
||||||
|
path = data_dir() / "saved"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def saved_folder_dir(folder: str) -> Path:
|
||||||
|
"""Return a subfolder inside saved images."""
|
||||||
|
path = saved_dir() / folder
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def db_path() -> Path:
|
||||||
|
"""Return the path to the SQLite database."""
|
||||||
|
return data_dir() / "booru.db"
|
||||||
|
|
||||||
|
|
||||||
|
# Green-on-black palette
|
||||||
|
GREEN = "#00ff00"
|
||||||
|
DARK_GREEN = "#00cc00"
|
||||||
|
DIM_GREEN = "#009900"
|
||||||
|
BG = "#000000"
|
||||||
|
BG_LIGHT = "#111111"
|
||||||
|
BG_LIGHTER = "#1a1a1a"
|
||||||
|
BORDER = "#333333"
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
DEFAULT_THUMBNAIL_SIZE = (200, 200)
|
||||||
|
DEFAULT_PAGE_SIZE = 40
|
||||||
|
USER_AGENT = f"booru-viewer/0.1 ({platform.system()})"
|
||||||
|
MEDIA_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".webm", ".mkv", ".avi", ".mov")
|
||||||
450
booru_viewer/core/db.py
Normal file
450
booru_viewer/core/db.py
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
"""SQLite database for favorites, sites, and cache metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from .config import db_path
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
api_type TEXT NOT NULL, -- danbooru | gelbooru | moebooru
|
||||||
|
api_key TEXT,
|
||||||
|
api_user TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
added_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id INTEGER NOT NULL REFERENCES sites(id),
|
||||||
|
post_id INTEGER NOT NULL,
|
||||||
|
file_url TEXT NOT NULL,
|
||||||
|
preview_url TEXT,
|
||||||
|
tags TEXT NOT NULL DEFAULT '',
|
||||||
|
rating TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
source TEXT,
|
||||||
|
cached_path TEXT,
|
||||||
|
folder TEXT,
|
||||||
|
favorited_at TEXT NOT NULL,
|
||||||
|
UNIQUE(site_id, post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_tags ON favorites(tags);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_site ON favorites(site_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_folders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS blacklisted_tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tag TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
site_id INTEGER,
|
||||||
|
searched_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
site_id INTEGER
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DEFAULTS = {
|
||||||
|
"max_cache_mb": "2048",
|
||||||
|
"auto_evict": "1",
|
||||||
|
"thumbnail_size": "180",
|
||||||
|
"page_size": "40",
|
||||||
|
"default_rating": "all",
|
||||||
|
"default_score": "0",
|
||||||
|
"confirm_favorites": "0",
|
||||||
|
"preload_thumbnails": "1",
|
||||||
|
"file_dialog_platform": "qt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Site:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
api_type: str
|
||||||
|
api_key: str | None = None
|
||||||
|
api_user: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Favorite:
|
||||||
|
id: int
|
||||||
|
site_id: int
|
||||||
|
post_id: int
|
||||||
|
file_url: str
|
||||||
|
preview_url: str | None
|
||||||
|
tags: str
|
||||||
|
rating: str | None
|
||||||
|
score: int | None
|
||||||
|
source: str | None
|
||||||
|
cached_path: str | None
|
||||||
|
folder: str | None
|
||||||
|
favorited_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, path: Path | None = None) -> None:
|
||||||
|
self._path = path or db_path()
|
||||||
|
self._conn: sqlite3.Connection | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conn(self) -> sqlite3.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
self._conn = sqlite3.connect(str(self._path), check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
self._conn.executescript(_SCHEMA)
|
||||||
|
self._migrate()
|
||||||
|
return self._conn
|
||||||
|
|
||||||
|
def _migrate(self) -> None:
|
||||||
|
"""Add columns that may not exist in older databases."""
|
||||||
|
cur = self._conn.execute("PRAGMA table_info(favorites)")
|
||||||
|
cols = {row[1] for row in cur.fetchall()}
|
||||||
|
if "folder" not in cols:
|
||||||
|
self._conn.execute("ALTER TABLE favorites ADD COLUMN folder TEXT")
|
||||||
|
self._conn.commit()
|
||||||
|
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_favorites_folder ON favorites(folder)")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._conn:
|
||||||
|
self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
# -- Sites --
|
||||||
|
|
||||||
|
def add_site(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
url: str,
|
||||||
|
api_type: str,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_user: str | None = None,
|
||||||
|
) -> Site:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"INSERT INTO sites (name, url, api_type, api_key, api_user, added_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(name, url.rstrip("/"), api_type, api_key, api_user, now),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return Site(
|
||||||
|
id=cur.lastrowid, # type: ignore[arg-type]
|
||||||
|
name=name,
|
||||||
|
url=url.rstrip("/"),
|
||||||
|
api_type=api_type,
|
||||||
|
api_key=api_key,
|
||||||
|
api_user=api_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_sites(self, enabled_only: bool = True) -> list[Site]:
|
||||||
|
q = "SELECT * FROM sites"
|
||||||
|
if enabled_only:
|
||||||
|
q += " WHERE enabled = 1"
|
||||||
|
q += " ORDER BY name"
|
||||||
|
rows = self.conn.execute(q).fetchall()
|
||||||
|
return [
|
||||||
|
Site(
|
||||||
|
id=r["id"],
|
||||||
|
name=r["name"],
|
||||||
|
url=r["url"],
|
||||||
|
api_type=r["api_type"],
|
||||||
|
api_key=r["api_key"],
|
||||||
|
api_user=r["api_user"],
|
||||||
|
enabled=bool(r["enabled"]),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete_site(self, site_id: int) -> None:
|
||||||
|
self.conn.execute("DELETE FROM favorites WHERE site_id = ?", (site_id,))
|
||||||
|
self.conn.execute("DELETE FROM sites WHERE id = ?", (site_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def update_site(self, site_id: int, **fields: str | None) -> None:
|
||||||
|
allowed = {"name", "url", "api_type", "api_key", "api_user", "enabled"}
|
||||||
|
sets = []
|
||||||
|
vals = []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if k not in allowed:
|
||||||
|
continue
|
||||||
|
sets.append(f"{k} = ?")
|
||||||
|
vals.append(v)
|
||||||
|
if not sets:
|
||||||
|
return
|
||||||
|
vals.append(site_id)
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE sites SET {', '.join(sets)} WHERE id = ?", vals
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# -- Favorites --
|
||||||
|
|
||||||
|
def add_favorite(
|
||||||
|
self,
|
||||||
|
site_id: int,
|
||||||
|
post_id: int,
|
||||||
|
file_url: str,
|
||||||
|
preview_url: str | None,
|
||||||
|
tags: str,
|
||||||
|
rating: str | None = None,
|
||||||
|
score: int | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
cached_path: str | None = None,
|
||||||
|
folder: str | None = None,
|
||||||
|
) -> Favorite:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO favorites "
|
||||||
|
"(site_id, post_id, file_url, preview_url, tags, rating, score, source, cached_path, folder, favorited_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(site_id, post_id, file_url, preview_url, tags, rating, score, source, cached_path, folder, now),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return Favorite(
|
||||||
|
id=cur.lastrowid, # type: ignore[arg-type]
|
||||||
|
site_id=site_id,
|
||||||
|
post_id=post_id,
|
||||||
|
file_url=file_url,
|
||||||
|
preview_url=preview_url,
|
||||||
|
tags=tags,
|
||||||
|
rating=rating,
|
||||||
|
score=score,
|
||||||
|
source=source,
|
||||||
|
cached_path=cached_path,
|
||||||
|
folder=folder,
|
||||||
|
favorited_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_favorite(self, site_id: int, post_id: int) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM favorites WHERE site_id = ? AND post_id = ?",
|
||||||
|
(site_id, post_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def is_favorited(self, site_id: int, post_id: int) -> bool:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT 1 FROM favorites WHERE site_id = ? AND post_id = ?",
|
||||||
|
(site_id, post_id),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def get_favorites(
|
||||||
|
self,
|
||||||
|
search: str | None = None,
|
||||||
|
site_id: int | None = None,
|
||||||
|
folder: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[Favorite]:
|
||||||
|
q = "SELECT * FROM favorites WHERE 1=1"
|
||||||
|
params: list = []
|
||||||
|
if site_id is not None:
|
||||||
|
q += " AND site_id = ?"
|
||||||
|
params.append(site_id)
|
||||||
|
if folder is not None:
|
||||||
|
q += " AND folder = ?"
|
||||||
|
params.append(folder)
|
||||||
|
if search:
|
||||||
|
for tag in search.strip().split():
|
||||||
|
q += " AND tags LIKE ?"
|
||||||
|
params.append(f"%{tag}%")
|
||||||
|
q += " ORDER BY favorited_at DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
rows = self.conn.execute(q, params).fetchall()
|
||||||
|
return [self._row_to_favorite(r) for r in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_favorite(r) -> Favorite:
|
||||||
|
return Favorite(
|
||||||
|
id=r["id"],
|
||||||
|
site_id=r["site_id"],
|
||||||
|
post_id=r["post_id"],
|
||||||
|
file_url=r["file_url"],
|
||||||
|
preview_url=r["preview_url"],
|
||||||
|
tags=r["tags"],
|
||||||
|
rating=r["rating"],
|
||||||
|
score=r["score"],
|
||||||
|
source=r["source"],
|
||||||
|
cached_path=r["cached_path"],
|
||||||
|
folder=r["folder"] if "folder" in r.keys() else None,
|
||||||
|
favorited_at=r["favorited_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_favorite_cache_path(self, fav_id: int, cached_path: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE favorites SET cached_path = ? WHERE id = ?",
|
||||||
|
(cached_path, fav_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def favorite_count(self) -> int:
|
||||||
|
row = self.conn.execute("SELECT COUNT(*) FROM favorites").fetchone()
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
# -- Folders --
|
||||||
|
|
||||||
|
def get_folders(self) -> list[str]:
|
||||||
|
rows = self.conn.execute("SELECT name FROM favorite_folders ORDER BY name").fetchall()
|
||||||
|
return [r["name"] for r in rows]
|
||||||
|
|
||||||
|
def add_folder(self, name: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO favorite_folders (name) VALUES (?)", (name.strip(),)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def remove_folder(self, name: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE favorites SET folder = NULL WHERE folder = ?", (name,)
|
||||||
|
)
|
||||||
|
self.conn.execute("DELETE FROM favorite_folders WHERE name = ?", (name,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def rename_folder(self, old: str, new: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE favorites SET folder = ? WHERE folder = ?", (new.strip(), old)
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE favorite_folders SET name = ? WHERE name = ?", (new.strip(), old)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def move_favorite_to_folder(self, fav_id: int, folder: str | None) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE favorites SET folder = ? WHERE id = ?", (folder, fav_id)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# -- Blacklist --
|
||||||
|
|
||||||
|
def add_blacklisted_tag(self, tag: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO blacklisted_tags (tag) VALUES (?)",
|
||||||
|
(tag.strip().lower(),),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def remove_blacklisted_tag(self, tag: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM blacklisted_tags WHERE tag = ?",
|
||||||
|
(tag.strip().lower(),),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_blacklisted_tags(self) -> list[str]:
|
||||||
|
rows = self.conn.execute("SELECT tag FROM blacklisted_tags ORDER BY tag").fetchall()
|
||||||
|
return [r["tag"] for r in rows]
|
||||||
|
|
||||||
|
# -- Settings --
|
||||||
|
|
||||||
|
def get_setting(self, key: str) -> str:
|
||||||
|
row = self.conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return row["value"]
|
||||||
|
return _DEFAULTS.get(key, "")
|
||||||
|
|
||||||
|
def get_setting_int(self, key: str) -> int:
|
||||||
|
return int(self.get_setting(key) or "0")
|
||||||
|
|
||||||
|
def get_setting_bool(self, key: str) -> bool:
|
||||||
|
return self.get_setting(key) == "1"
|
||||||
|
|
||||||
|
def set_setting(self, key: str, value: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
(key, str(value)),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_all_settings(self) -> dict[str, str]:
|
||||||
|
result = dict(_DEFAULTS)
|
||||||
|
rows = self.conn.execute("SELECT key, value FROM settings").fetchall()
|
||||||
|
for r in rows:
|
||||||
|
result[r["key"]] = r["value"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- Search History --
|
||||||
|
|
||||||
|
def add_search_history(self, query: str, site_id: int | None = None) -> None:
|
||||||
|
if not query.strip():
|
||||||
|
return
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
# Remove duplicate if exists, keep latest
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM search_history WHERE query = ? AND (site_id = ? OR (site_id IS NULL AND ? IS NULL))",
|
||||||
|
(query.strip(), site_id, site_id),
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO search_history (query, site_id, searched_at) VALUES (?, ?, ?)",
|
||||||
|
(query.strip(), site_id, now),
|
||||||
|
)
|
||||||
|
# Keep only last 50
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM search_history WHERE id NOT IN "
|
||||||
|
"(SELECT id FROM search_history ORDER BY searched_at DESC LIMIT 50)"
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_search_history(self, limit: int = 20) -> list[str]:
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT DISTINCT query FROM search_history ORDER BY searched_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [r["query"] for r in rows]
|
||||||
|
|
||||||
|
def clear_search_history(self) -> None:
|
||||||
|
self.conn.execute("DELETE FROM search_history")
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# -- Saved Searches --
|
||||||
|
|
||||||
|
def add_saved_search(self, name: str, query: str, site_id: int | None = None) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO saved_searches (name, query, site_id) VALUES (?, ?, ?)",
|
||||||
|
(name.strip(), query.strip(), site_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_saved_searches(self) -> list[tuple[int, str, str]]:
|
||||||
|
"""Returns list of (id, name, query)."""
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT id, name, query FROM saved_searches ORDER BY name"
|
||||||
|
).fetchall()
|
||||||
|
return [(r["id"], r["name"], r["query"]) for r in rows]
|
||||||
|
|
||||||
|
def remove_saved_search(self, search_id: int) -> None:
|
||||||
|
self.conn.execute("DELETE FROM saved_searches WHERE id = ?", (search_id,))
|
||||||
|
self.conn.commit()
|
||||||
31
booru_viewer/core/images.py
Normal file
31
booru_viewer/core/images.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""Image thumbnailing and format helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .config import DEFAULT_THUMBNAIL_SIZE, thumbnails_dir
|
||||||
|
|
||||||
|
|
||||||
|
def make_thumbnail(
|
||||||
|
source: Path,
|
||||||
|
size: tuple[int, int] = DEFAULT_THUMBNAIL_SIZE,
|
||||||
|
dest: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Create a thumbnail, returning its path. Returns existing if already made."""
|
||||||
|
dest = dest or thumbnails_dir() / f"thumb_{source.stem}_{size[0]}x{size[1]}.jpg"
|
||||||
|
if dest.exists():
|
||||||
|
return dest
|
||||||
|
with Image.open(source) as img:
|
||||||
|
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(dest, "JPEG", quality=85)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def image_dimensions(path: Path) -> tuple[int, int]:
|
||||||
|
with Image.open(path) as img:
|
||||||
|
return img.size
|
||||||
0
booru_viewer/gui/__init__.py
Normal file
0
booru_viewer/gui/__init__.py
Normal file
1320
booru_viewer/gui/app.py
Normal file
1320
booru_viewer/gui/app.py
Normal file
File diff suppressed because it is too large
Load Diff
138
booru_viewer/gui/custom_css_guide.txt
Normal file
138
booru_viewer/gui/custom_css_guide.txt
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
booru-viewer Custom Stylesheet Guide
|
||||||
|
=====================================
|
||||||
|
|
||||||
|
Place a file named "custom.qss" in your data directory to override styles:
|
||||||
|
Linux: ~/.local/share/booru-viewer/custom.qss
|
||||||
|
Windows: %APPDATA%\booru-viewer\custom.qss
|
||||||
|
|
||||||
|
The custom stylesheet is appended AFTER the default theme, so your rules
|
||||||
|
override the defaults. You can use any Qt stylesheet (QSS) syntax.
|
||||||
|
|
||||||
|
WIDGET REFERENCE
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Main window: QMainWindow
|
||||||
|
Buttons: QPushButton
|
||||||
|
Text inputs: QLineEdit
|
||||||
|
Dropdowns: QComboBox
|
||||||
|
Scroll bars: QScrollBar
|
||||||
|
Labels: QLabel
|
||||||
|
Status bar: QStatusBar
|
||||||
|
Tabs: QTabWidget, QTabBar
|
||||||
|
Lists: QListWidget
|
||||||
|
Menus: QMenu, QMenuBar
|
||||||
|
Tooltips: QToolTip
|
||||||
|
Dialogs: QDialog
|
||||||
|
Splitters: QSplitter
|
||||||
|
Progress bars: QProgressBar
|
||||||
|
Spin boxes: QSpinBox
|
||||||
|
Check boxes: QCheckBox
|
||||||
|
Sliders: QSlider
|
||||||
|
|
||||||
|
EXAMPLES
|
||||||
|
--------
|
||||||
|
|
||||||
|
Change accent color from green to cyan:
|
||||||
|
|
||||||
|
QWidget {
|
||||||
|
color: #00ffff;
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: #009999;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
QLineEdit:focus {
|
||||||
|
border-color: #00ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bigger font:
|
||||||
|
|
||||||
|
QWidget {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Different background:
|
||||||
|
|
||||||
|
QWidget {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
Custom button style:
|
||||||
|
|
||||||
|
QPushButton {
|
||||||
|
background-color: #222222;
|
||||||
|
color: #00ff00;
|
||||||
|
border: 1px solid #444444;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #333333;
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
Wider scrollbar:
|
||||||
|
|
||||||
|
QScrollBar:vertical {
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Hide the info overlay on images:
|
||||||
|
|
||||||
|
/* Target the info label in the preview */
|
||||||
|
QLabel[objectName="info-label"] {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
VIDEO PLAYER CONTROLS
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
The video player controls are standard Qt widgets:
|
||||||
|
QPushButton - Play/Pause, Mute buttons
|
||||||
|
QSlider - Seek bar, Volume slider
|
||||||
|
QLabel - Time display
|
||||||
|
|
||||||
|
Example - style the seek bar:
|
||||||
|
|
||||||
|
QSlider::groove:horizontal {
|
||||||
|
background: #333333;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QSlider::handle:horizontal {
|
||||||
|
background: #00ff00;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: -4px 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
QSlider::sub-page:horizontal {
|
||||||
|
background: #009900;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT COLOR PALETTE
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
These are the defaults you can override:
|
||||||
|
|
||||||
|
Green (accent): #00ff00
|
||||||
|
Dark green: #00cc00
|
||||||
|
Dim green: #009900
|
||||||
|
Background: #000000
|
||||||
|
Background light: #111111
|
||||||
|
Background lighter: #1a1a1a
|
||||||
|
Border: #333333
|
||||||
|
|
||||||
|
TIPS
|
||||||
|
----
|
||||||
|
|
||||||
|
- Restart the app after editing custom.qss
|
||||||
|
- Use a text editor to edit QSS - it's similar to CSS
|
||||||
|
- If something breaks, just delete custom.qss to reset
|
||||||
|
- Your custom styles override defaults, so you only need to include what you change
|
||||||
|
- The file is read at startup, not live-reloaded
|
||||||
101
booru_viewer/gui/dialogs.py
Normal file
101
booru_viewer/gui/dialogs.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""Native file dialog wrappers. Uses zenity on Linux when GTK dialogs are preferred."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QFileDialog, QWidget
|
||||||
|
|
||||||
|
from ..core.config import IS_WINDOWS
|
||||||
|
|
||||||
|
|
||||||
|
def _use_gtk() -> bool:
|
||||||
|
if IS_WINDOWS:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from ..core.db import Database
|
||||||
|
db = Database()
|
||||||
|
val = db.get_setting("file_dialog_platform")
|
||||||
|
db.close()
|
||||||
|
return val == "gtk"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def save_file(
|
||||||
|
parent: QWidget | None,
|
||||||
|
title: str,
|
||||||
|
default_name: str,
|
||||||
|
filter_str: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Show a save file dialog. Returns path or None."""
|
||||||
|
if _use_gtk():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"zenity", "--file-selection", "--save",
|
||||||
|
"--title", title,
|
||||||
|
"--filename", default_name,
|
||||||
|
"--confirm-overwrite",
|
||||||
|
],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # zenity not installed, fall through to Qt
|
||||||
|
|
||||||
|
path, _ = QFileDialog.getSaveFileName(parent, title, default_name, filter_str)
|
||||||
|
return path or None
|
||||||
|
|
||||||
|
|
||||||
|
def open_file(
|
||||||
|
parent: QWidget | None,
|
||||||
|
title: str,
|
||||||
|
filter_str: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Show an open file dialog. Returns path or None."""
|
||||||
|
if _use_gtk():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"zenity", "--file-selection",
|
||||||
|
"--title", title,
|
||||||
|
],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
path, _ = QFileDialog.getOpenFileName(parent, title, "", filter_str)
|
||||||
|
return path or None
|
||||||
|
|
||||||
|
|
||||||
|
def select_directory(
|
||||||
|
parent: QWidget | None,
|
||||||
|
title: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Show a directory picker. Returns path or None."""
|
||||||
|
if _use_gtk():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"zenity", "--file-selection", "--directory",
|
||||||
|
"--title", title,
|
||||||
|
],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
path = QFileDialog.getExistingDirectory(parent, title)
|
||||||
|
return path or None
|
||||||
301
booru_viewer/gui/favorites.py
Normal file
301
booru_viewer/gui/favorites.py
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
"""Favorites browser widget with folder support."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal, QObject
|
||||||
|
from PySide6.QtGui import QPixmap
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QLabel,
|
||||||
|
QComboBox,
|
||||||
|
QMenu,
|
||||||
|
QApplication,
|
||||||
|
QInputDialog,
|
||||||
|
QMessageBox,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.db import Database, Favorite
|
||||||
|
from ..core.cache import download_thumbnail
|
||||||
|
from .grid import ThumbnailGrid
|
||||||
|
|
||||||
|
log = logging.getLogger("booru")
|
||||||
|
|
||||||
|
|
||||||
|
class FavThumbSignals(QObject):
|
||||||
|
thumb_ready = Signal(int, str)
|
||||||
|
|
||||||
|
|
||||||
|
class FavoritesView(QWidget):
|
||||||
|
"""Browse and search local favorites with folder support."""
|
||||||
|
|
||||||
|
favorite_selected = Signal(object)
|
||||||
|
favorite_activated = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
self._favorites: list[Favorite] = []
|
||||||
|
self._signals = FavThumbSignals()
|
||||||
|
self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
# Top bar: folder selector + search
|
||||||
|
top = QHBoxLayout()
|
||||||
|
|
||||||
|
self._folder_combo = QComboBox()
|
||||||
|
self._folder_combo.setMinimumWidth(120)
|
||||||
|
self._folder_combo.currentTextChanged.connect(lambda _: self.refresh())
|
||||||
|
top.addWidget(self._folder_combo)
|
||||||
|
|
||||||
|
manage_btn = QPushButton("+ Folder")
|
||||||
|
manage_btn.setToolTip("New folder")
|
||||||
|
manage_btn.setFixedWidth(65)
|
||||||
|
manage_btn.clicked.connect(self._new_folder)
|
||||||
|
top.addWidget(manage_btn)
|
||||||
|
|
||||||
|
self._search_input = QLineEdit()
|
||||||
|
self._search_input.setPlaceholderText("Search favorites by tag...")
|
||||||
|
self._search_input.returnPressed.connect(self._do_search)
|
||||||
|
top.addWidget(self._search_input, stretch=1)
|
||||||
|
|
||||||
|
search_btn = QPushButton("Search")
|
||||||
|
search_btn.clicked.connect(self._do_search)
|
||||||
|
top.addWidget(search_btn)
|
||||||
|
|
||||||
|
top.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
layout.addLayout(top)
|
||||||
|
|
||||||
|
# Count label
|
||||||
|
self._count_label = QLabel()
|
||||||
|
layout.addWidget(self._count_label)
|
||||||
|
|
||||||
|
# Grid
|
||||||
|
self._grid = ThumbnailGrid()
|
||||||
|
self._grid.post_selected.connect(self._on_selected)
|
||||||
|
self._grid.post_activated.connect(self._on_activated)
|
||||||
|
self._grid.context_requested.connect(self._on_context_menu)
|
||||||
|
layout.addWidget(self._grid)
|
||||||
|
|
||||||
|
def _refresh_folders(self) -> None:
|
||||||
|
current = self._folder_combo.currentText()
|
||||||
|
self._folder_combo.blockSignals(True)
|
||||||
|
self._folder_combo.clear()
|
||||||
|
self._folder_combo.addItem("All Favorites")
|
||||||
|
self._folder_combo.addItem("Unfiled")
|
||||||
|
for folder in self._db.get_folders():
|
||||||
|
self._folder_combo.addItem(folder)
|
||||||
|
# Restore selection
|
||||||
|
idx = self._folder_combo.findText(current)
|
||||||
|
if idx >= 0:
|
||||||
|
self._folder_combo.setCurrentIndex(idx)
|
||||||
|
self._folder_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def refresh(self, search: str | None = None) -> None:
|
||||||
|
self._refresh_folders()
|
||||||
|
|
||||||
|
folder_text = self._folder_combo.currentText()
|
||||||
|
folder_filter = None
|
||||||
|
if folder_text == "Unfiled":
|
||||||
|
folder_filter = "" # sentinel for NULL folder
|
||||||
|
elif folder_text not in ("All Favorites", ""):
|
||||||
|
folder_filter = folder_text
|
||||||
|
|
||||||
|
if folder_filter == "":
|
||||||
|
# Get unfiled: folder IS NULL
|
||||||
|
self._favorites = [
|
||||||
|
f for f in self._db.get_favorites(search=search, limit=500)
|
||||||
|
if f.folder is None
|
||||||
|
]
|
||||||
|
elif folder_filter:
|
||||||
|
self._favorites = self._db.get_favorites(search=search, folder=folder_filter, limit=500)
|
||||||
|
else:
|
||||||
|
self._favorites = self._db.get_favorites(search=search, limit=500)
|
||||||
|
|
||||||
|
self._count_label.setText(f"{len(self._favorites)} favorites")
|
||||||
|
thumbs = self._grid.set_posts(len(self._favorites))
|
||||||
|
|
||||||
|
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS
|
||||||
|
for i, (fav, thumb) in enumerate(zip(self._favorites, thumbs)):
|
||||||
|
thumb.set_favorited(True)
|
||||||
|
# Check if saved to library
|
||||||
|
saved = False
|
||||||
|
if fav.folder:
|
||||||
|
saved = any(
|
||||||
|
(saved_folder_dir(fav.folder) / f"{fav.post_id}{ext}").exists()
|
||||||
|
for ext in MEDIA_EXTENSIONS
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
saved = any(
|
||||||
|
(saved_dir() / f"{fav.post_id}{ext}").exists()
|
||||||
|
for ext in MEDIA_EXTENSIONS
|
||||||
|
)
|
||||||
|
thumb.set_saved_locally(saved)
|
||||||
|
if fav.preview_url:
|
||||||
|
self._load_thumb_async(i, fav.preview_url)
|
||||||
|
elif fav.cached_path and Path(fav.cached_path).exists():
|
||||||
|
pix = QPixmap(fav.cached_path)
|
||||||
|
if not pix.isNull():
|
||||||
|
thumb.set_pixmap(pix)
|
||||||
|
|
||||||
|
def _load_thumb_async(self, index: int, url: str) -> None:
|
||||||
|
async def _dl():
|
||||||
|
try:
|
||||||
|
path = await download_thumbnail(url)
|
||||||
|
self._signals.thumb_ready.emit(index, str(path))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Fav thumb {index} failed: {e}")
|
||||||
|
threading.Thread(target=lambda: asyncio.run(_dl()), daemon=True).start()
|
||||||
|
|
||||||
|
def _on_thumb_ready(self, index: int, path: str) -> None:
|
||||||
|
thumbs = self._grid._thumbs
|
||||||
|
if 0 <= index < len(thumbs):
|
||||||
|
pix = QPixmap(path)
|
||||||
|
if not pix.isNull():
|
||||||
|
thumbs[index].set_pixmap(pix)
|
||||||
|
|
||||||
|
def _do_search(self) -> None:
|
||||||
|
text = self._search_input.text().strip()
|
||||||
|
self.refresh(search=text if text else None)
|
||||||
|
|
||||||
|
def _on_selected(self, index: int) -> None:
|
||||||
|
if 0 <= index < len(self._favorites):
|
||||||
|
self.favorite_selected.emit(self._favorites[index])
|
||||||
|
|
||||||
|
def _on_activated(self, index: int) -> None:
|
||||||
|
if 0 <= index < len(self._favorites):
|
||||||
|
self.favorite_activated.emit(self._favorites[index])
|
||||||
|
|
||||||
|
def _copy_to_library_unsorted(self, fav: Favorite) -> None:
|
||||||
|
"""Copy a favorited image to the unsorted library folder."""
|
||||||
|
from ..core.config import saved_dir
|
||||||
|
if fav.cached_path and Path(fav.cached_path).exists():
|
||||||
|
import shutil
|
||||||
|
src = Path(fav.cached_path)
|
||||||
|
dest = saved_dir() / f"{fav.post_id}{src.suffix}"
|
||||||
|
if not dest.exists():
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
def _copy_to_library(self, fav: Favorite, folder: str) -> None:
|
||||||
|
"""Copy a favorited image to the library folder on disk."""
|
||||||
|
from ..core.config import saved_folder_dir
|
||||||
|
if fav.cached_path and Path(fav.cached_path).exists():
|
||||||
|
import shutil
|
||||||
|
src = Path(fav.cached_path)
|
||||||
|
dest = saved_folder_dir(folder) / f"{fav.post_id}{src.suffix}"
|
||||||
|
if not dest.exists():
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
def _new_folder(self) -> None:
|
||||||
|
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||||||
|
if ok and name.strip():
|
||||||
|
self._db.add_folder(name.strip())
|
||||||
|
self._refresh_folders()
|
||||||
|
|
||||||
|
def _on_context_menu(self, index: int, pos) -> None:
|
||||||
|
if index < 0 or index >= len(self._favorites):
|
||||||
|
return
|
||||||
|
fav = self._favorites[index]
|
||||||
|
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
from .dialogs import save_file
|
||||||
|
|
||||||
|
menu = QMenu(self)
|
||||||
|
|
||||||
|
open_default = menu.addAction("Open in Default App")
|
||||||
|
menu.addSeparator()
|
||||||
|
save_as = menu.addAction("Save As...")
|
||||||
|
|
||||||
|
# Save to Library submenu
|
||||||
|
save_lib_menu = menu.addMenu("Save to Library")
|
||||||
|
save_lib_unsorted = save_lib_menu.addAction("Unsorted")
|
||||||
|
save_lib_menu.addSeparator()
|
||||||
|
save_lib_folders = {}
|
||||||
|
for folder in self._db.get_folders():
|
||||||
|
a = save_lib_menu.addAction(folder)
|
||||||
|
save_lib_folders[id(a)] = folder
|
||||||
|
save_lib_menu.addSeparator()
|
||||||
|
save_lib_new = save_lib_menu.addAction("+ New Folder...")
|
||||||
|
|
||||||
|
copy_url = menu.addAction("Copy Image URL")
|
||||||
|
copy_tags = menu.addAction("Copy Tags")
|
||||||
|
|
||||||
|
# Move to folder submenu
|
||||||
|
menu.addSeparator()
|
||||||
|
move_menu = menu.addMenu("Move to Folder")
|
||||||
|
move_none = move_menu.addAction("Unfiled")
|
||||||
|
move_menu.addSeparator()
|
||||||
|
folder_actions = {}
|
||||||
|
for folder in self._db.get_folders():
|
||||||
|
a = move_menu.addAction(folder)
|
||||||
|
folder_actions[id(a)] = folder
|
||||||
|
move_menu.addSeparator()
|
||||||
|
move_new = move_menu.addAction("+ New Folder...")
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
unfav = menu.addAction("Unfavorite")
|
||||||
|
|
||||||
|
action = menu.exec(pos)
|
||||||
|
if not action:
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == save_lib_unsorted:
|
||||||
|
self._copy_to_library_unsorted(fav)
|
||||||
|
self.refresh()
|
||||||
|
elif action == save_lib_new:
|
||||||
|
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||||||
|
if ok and name.strip():
|
||||||
|
self._db.add_folder(name.strip())
|
||||||
|
self._copy_to_library(fav, name.strip())
|
||||||
|
self._db.move_favorite_to_folder(fav.id, name.strip())
|
||||||
|
self.refresh()
|
||||||
|
elif id(action) in save_lib_folders:
|
||||||
|
folder_name = save_lib_folders[id(action)]
|
||||||
|
self._copy_to_library(fav, folder_name)
|
||||||
|
self.refresh()
|
||||||
|
elif action == open_default:
|
||||||
|
if fav.cached_path and Path(fav.cached_path).exists():
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(fav.cached_path))
|
||||||
|
elif action == save_as:
|
||||||
|
if fav.cached_path and Path(fav.cached_path).exists():
|
||||||
|
src = Path(fav.cached_path)
|
||||||
|
dest = save_file(self, "Save Image", f"post_{fav.post_id}{src.suffix}", f"Images (*{src.suffix})")
|
||||||
|
if dest:
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
elif action == copy_url:
|
||||||
|
QApplication.clipboard().setText(fav.file_url)
|
||||||
|
elif action == copy_tags:
|
||||||
|
QApplication.clipboard().setText(fav.tags)
|
||||||
|
elif action == move_none:
|
||||||
|
self._db.move_favorite_to_folder(fav.id, None)
|
||||||
|
self.refresh()
|
||||||
|
elif action == move_new:
|
||||||
|
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||||||
|
if ok and name.strip():
|
||||||
|
self._db.add_folder(name.strip())
|
||||||
|
self._db.move_favorite_to_folder(fav.id, name.strip())
|
||||||
|
self._copy_to_library(fav, name.strip())
|
||||||
|
self.refresh()
|
||||||
|
elif id(action) in folder_actions:
|
||||||
|
folder_name = folder_actions[id(action)]
|
||||||
|
self._db.move_favorite_to_folder(fav.id, folder_name)
|
||||||
|
self._copy_to_library(fav, folder_name)
|
||||||
|
self.refresh()
|
||||||
|
elif action == unfav:
|
||||||
|
from ..core.cache import delete_from_library
|
||||||
|
delete_from_library(fav.post_id, fav.folder)
|
||||||
|
self._db.remove_favorite(fav.site_id, fav.post_id)
|
||||||
|
self.refresh()
|
||||||
380
booru_viewer/gui/grid.py
Normal file
380
booru_viewer/gui/grid.py
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
"""Thumbnail grid widget for the Qt6 GUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal, QSize, QRect, QMimeData, QUrl, QPoint
|
||||||
|
from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QKeyEvent, QDrag
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QScrollArea,
|
||||||
|
QMenu,
|
||||||
|
QApplication,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.api.base import Post
|
||||||
|
|
||||||
|
THUMB_SIZE = 180
|
||||||
|
THUMB_SPACING = 8
|
||||||
|
BORDER_WIDTH = 2
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailWidget(QWidget):
|
||||||
|
"""Single clickable thumbnail cell."""
|
||||||
|
|
||||||
|
clicked = Signal(int, object) # index, QMouseEvent
|
||||||
|
double_clicked = Signal(int)
|
||||||
|
right_clicked = Signal(int, object) # index, QPoint
|
||||||
|
|
||||||
|
def __init__(self, index: int, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.index = index
|
||||||
|
self._pixmap: QPixmap | None = None
|
||||||
|
self._selected = False
|
||||||
|
self._multi_selected = False
|
||||||
|
self._favorited = False
|
||||||
|
self._saved_locally = False
|
||||||
|
self._hover = False
|
||||||
|
self._drag_start: QPoint | None = None
|
||||||
|
self._cached_path: str | None = None
|
||||||
|
self.setFixedSize(THUMB_SIZE, THUMB_SIZE)
|
||||||
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
|
||||||
|
def set_pixmap(self, pixmap: QPixmap) -> None:
|
||||||
|
self._pixmap = pixmap.scaled(
|
||||||
|
THUMB_SIZE - 4, THUMB_SIZE - 4,
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_selected(self, selected: bool) -> None:
|
||||||
|
self._selected = selected
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_multi_selected(self, selected: bool) -> None:
|
||||||
|
self._multi_selected = selected
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_favorited(self, favorited: bool) -> None:
|
||||||
|
self._favorited = favorited
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_saved_locally(self, saved: bool) -> None:
|
||||||
|
self._saved_locally = saved
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None:
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
pal = self.palette()
|
||||||
|
highlight = pal.color(pal.ColorRole.Highlight)
|
||||||
|
base = pal.color(pal.ColorRole.Base)
|
||||||
|
mid = pal.color(pal.ColorRole.Mid)
|
||||||
|
window = pal.color(pal.ColorRole.Window)
|
||||||
|
|
||||||
|
# Background
|
||||||
|
if self._multi_selected:
|
||||||
|
bg = highlight.darker(200)
|
||||||
|
elif self._hover:
|
||||||
|
bg = window.lighter(130)
|
||||||
|
else:
|
||||||
|
bg = window
|
||||||
|
p.fillRect(self.rect(), bg)
|
||||||
|
|
||||||
|
# Border
|
||||||
|
if self._selected:
|
||||||
|
pen = QPen(highlight, BORDER_WIDTH)
|
||||||
|
elif self._multi_selected:
|
||||||
|
pen = QPen(highlight.darker(150), BORDER_WIDTH)
|
||||||
|
else:
|
||||||
|
pen = QPen(mid, 1)
|
||||||
|
p.setPen(pen)
|
||||||
|
p.drawRect(self.rect().adjusted(0, 0, -1, -1))
|
||||||
|
|
||||||
|
# Thumbnail
|
||||||
|
if self._pixmap:
|
||||||
|
x = (self.width() - self._pixmap.width()) // 2
|
||||||
|
y = (self.height() - self._pixmap.height()) // 2
|
||||||
|
p.drawPixmap(x, y, self._pixmap)
|
||||||
|
|
||||||
|
# Favorite/saved indicator
|
||||||
|
if self._favorited:
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
if self._saved_locally:
|
||||||
|
p.setBrush(QColor("#22cc22"))
|
||||||
|
else:
|
||||||
|
p.setBrush(QColor("#ff4444"))
|
||||||
|
p.drawEllipse(self.width() - 14, 4, 10, 10)
|
||||||
|
|
||||||
|
# Multi-select checkmark
|
||||||
|
if self._multi_selected:
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(highlight)
|
||||||
|
p.drawEllipse(4, 4, 12, 12)
|
||||||
|
p.setPen(QPen(base, 2))
|
||||||
|
p.drawLine(7, 10, 9, 13)
|
||||||
|
p.drawLine(9, 13, 14, 7)
|
||||||
|
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
def enterEvent(self, event) -> None:
|
||||||
|
self._hover = True
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def leaveEvent(self, event) -> None:
|
||||||
|
self._hover = False
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def mousePressEvent(self, event) -> None:
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self._drag_start = event.position().toPoint()
|
||||||
|
self.clicked.emit(self.index, event)
|
||||||
|
elif event.button() == Qt.MouseButton.RightButton:
|
||||||
|
self.right_clicked.emit(self.index, event.globalPosition().toPoint())
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event) -> None:
|
||||||
|
if (self._drag_start and self._cached_path
|
||||||
|
and (event.position().toPoint() - self._drag_start).manhattanLength() > 10):
|
||||||
|
drag = QDrag(self)
|
||||||
|
mime = QMimeData()
|
||||||
|
mime.setUrls([QUrl.fromLocalFile(self._cached_path)])
|
||||||
|
drag.setMimeData(mime)
|
||||||
|
if self._pixmap:
|
||||||
|
drag.setPixmap(self._pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio))
|
||||||
|
drag.exec(Qt.DropAction.CopyAction)
|
||||||
|
self._drag_start = None
|
||||||
|
return
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event) -> None:
|
||||||
|
self._drag_start = None
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event) -> None:
|
||||||
|
self._drag_start = None
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self.double_clicked.emit(self.index)
|
||||||
|
|
||||||
|
|
||||||
|
class FlowLayout(QWidget):
|
||||||
|
"""A widget that arranges children in a wrapping flow."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._items: list[QWidget] = []
|
||||||
|
|
||||||
|
def add_widget(self, widget: QWidget) -> None:
|
||||||
|
widget.setParent(self)
|
||||||
|
self._items.append(widget)
|
||||||
|
self._do_layout()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for w in self._items:
|
||||||
|
w.setParent(None) # type: ignore
|
||||||
|
w.deleteLater()
|
||||||
|
self._items.clear()
|
||||||
|
self.setMinimumHeight(0)
|
||||||
|
|
||||||
|
def resizeEvent(self, event) -> None:
|
||||||
|
self._do_layout()
|
||||||
|
|
||||||
|
def _do_layout(self) -> None:
|
||||||
|
if not self._items:
|
||||||
|
return
|
||||||
|
x, y = THUMB_SPACING, THUMB_SPACING
|
||||||
|
row_height = 0
|
||||||
|
width = self.width() or 800
|
||||||
|
|
||||||
|
for widget in self._items:
|
||||||
|
item_w = widget.width() + THUMB_SPACING
|
||||||
|
item_h = widget.height() + THUMB_SPACING
|
||||||
|
if x + item_w > width and x > THUMB_SPACING:
|
||||||
|
x = THUMB_SPACING
|
||||||
|
y += row_height
|
||||||
|
row_height = 0
|
||||||
|
widget.move(x, y)
|
||||||
|
widget.show()
|
||||||
|
x += item_w
|
||||||
|
row_height = max(row_height, item_h)
|
||||||
|
|
||||||
|
self.setMinimumHeight(y + row_height + THUMB_SPACING)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def columns(self) -> int:
|
||||||
|
if not self._items:
|
||||||
|
return 1
|
||||||
|
w = self.width() or 800
|
||||||
|
return max(1, w // (THUMB_SIZE + THUMB_SPACING))
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailGrid(QScrollArea):
|
||||||
|
"""Scrollable grid of thumbnail widgets with keyboard nav, context menu, and multi-select."""
|
||||||
|
|
||||||
|
post_selected = Signal(int)
|
||||||
|
post_activated = Signal(int)
|
||||||
|
context_requested = Signal(int, object) # index, QPoint
|
||||||
|
multi_context_requested = Signal(list, object) # list[int], QPoint
|
||||||
|
reached_bottom = Signal() # emitted when scrolled to the bottom
|
||||||
|
reached_top = Signal() # emitted when scrolled to the top
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._flow = FlowLayout()
|
||||||
|
self.setWidget(self._flow)
|
||||||
|
self.setWidgetResizable(True)
|
||||||
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self._thumbs: list[ThumbnailWidget] = []
|
||||||
|
self._selected_index = -1
|
||||||
|
self._multi_selected: set[int] = set()
|
||||||
|
self._last_click_index = -1 # for shift-click range
|
||||||
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_index(self) -> int:
|
||||||
|
return self._selected_index
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_indices(self) -> list[int]:
|
||||||
|
"""Return all multi-selected indices, or just the single selected one."""
|
||||||
|
if self._multi_selected:
|
||||||
|
return sorted(self._multi_selected)
|
||||||
|
if self._selected_index >= 0:
|
||||||
|
return [self._selected_index]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def set_posts(self, count: int) -> list[ThumbnailWidget]:
|
||||||
|
self._flow.clear()
|
||||||
|
self._thumbs.clear()
|
||||||
|
self._selected_index = -1
|
||||||
|
self._multi_selected.clear()
|
||||||
|
self._last_click_index = -1
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
thumb = ThumbnailWidget(i)
|
||||||
|
thumb.clicked.connect(self._on_thumb_click)
|
||||||
|
thumb.double_clicked.connect(self._on_thumb_double_click)
|
||||||
|
thumb.right_clicked.connect(self._on_thumb_right_click)
|
||||||
|
self._flow.add_widget(thumb)
|
||||||
|
self._thumbs.append(thumb)
|
||||||
|
|
||||||
|
return self._thumbs
|
||||||
|
|
||||||
|
def _clear_multi(self) -> None:
|
||||||
|
for idx in self._multi_selected:
|
||||||
|
if 0 <= idx < len(self._thumbs):
|
||||||
|
self._thumbs[idx].set_multi_selected(False)
|
||||||
|
self._multi_selected.clear()
|
||||||
|
|
||||||
|
def _select(self, index: int) -> None:
|
||||||
|
if index < 0 or index >= len(self._thumbs):
|
||||||
|
return
|
||||||
|
self._clear_multi()
|
||||||
|
if 0 <= self._selected_index < len(self._thumbs):
|
||||||
|
self._thumbs[self._selected_index].set_selected(False)
|
||||||
|
self._selected_index = index
|
||||||
|
self._last_click_index = index
|
||||||
|
self._thumbs[index].set_selected(True)
|
||||||
|
self.ensureWidgetVisible(self._thumbs[index])
|
||||||
|
self.post_selected.emit(index)
|
||||||
|
|
||||||
|
def _toggle_multi(self, index: int) -> None:
|
||||||
|
"""Ctrl+click: toggle one item in/out of multi-selection."""
|
||||||
|
# First ctrl+click: add the currently single-selected item too
|
||||||
|
if not self._multi_selected and self._selected_index >= 0:
|
||||||
|
self._multi_selected.add(self._selected_index)
|
||||||
|
self._thumbs[self._selected_index].set_multi_selected(True)
|
||||||
|
|
||||||
|
if index in self._multi_selected:
|
||||||
|
self._multi_selected.discard(index)
|
||||||
|
self._thumbs[index].set_multi_selected(False)
|
||||||
|
else:
|
||||||
|
self._multi_selected.add(index)
|
||||||
|
self._thumbs[index].set_multi_selected(True)
|
||||||
|
self._last_click_index = index
|
||||||
|
|
||||||
|
def _range_select(self, index: int) -> None:
|
||||||
|
"""Shift+click: select range from last click to this one."""
|
||||||
|
start = self._last_click_index if self._last_click_index >= 0 else 0
|
||||||
|
lo, hi = min(start, index), max(start, index)
|
||||||
|
self._clear_multi()
|
||||||
|
for i in range(lo, hi + 1):
|
||||||
|
self._multi_selected.add(i)
|
||||||
|
self._thumbs[i].set_multi_selected(True)
|
||||||
|
|
||||||
|
def _on_thumb_click(self, index: int, event) -> None:
|
||||||
|
mods = event.modifiers()
|
||||||
|
if mods & Qt.KeyboardModifier.ControlModifier:
|
||||||
|
self._toggle_multi(index)
|
||||||
|
elif mods & Qt.KeyboardModifier.ShiftModifier:
|
||||||
|
self._range_select(index)
|
||||||
|
else:
|
||||||
|
self._select(index)
|
||||||
|
|
||||||
|
def _on_thumb_double_click(self, index: int) -> None:
|
||||||
|
self._select(index)
|
||||||
|
self.post_activated.emit(index)
|
||||||
|
|
||||||
|
def _on_thumb_right_click(self, index: int, pos) -> None:
|
||||||
|
if self._multi_selected and index in self._multi_selected:
|
||||||
|
# Right-click on multi-selected: bulk context menu
|
||||||
|
self.multi_context_requested.emit(sorted(self._multi_selected), pos)
|
||||||
|
else:
|
||||||
|
self._select(index)
|
||||||
|
self.context_requested.emit(index, pos)
|
||||||
|
|
||||||
|
def select_all(self) -> None:
|
||||||
|
self._clear_multi()
|
||||||
|
for i in range(len(self._thumbs)):
|
||||||
|
self._multi_selected.add(i)
|
||||||
|
self._thumbs[i].set_multi_selected(True)
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||||
|
cols = self._flow.columns
|
||||||
|
idx = self._selected_index
|
||||||
|
|
||||||
|
key = event.key()
|
||||||
|
mods = event.modifiers()
|
||||||
|
|
||||||
|
# Ctrl+A = select all
|
||||||
|
if key == Qt.Key.Key_A and mods & Qt.KeyboardModifier.ControlModifier:
|
||||||
|
self.select_all()
|
||||||
|
return
|
||||||
|
|
||||||
|
if key in (Qt.Key.Key_Right, Qt.Key.Key_L):
|
||||||
|
self._select(min(idx + 1, len(self._thumbs) - 1))
|
||||||
|
elif key in (Qt.Key.Key_Left, Qt.Key.Key_H):
|
||||||
|
self._select(max(idx - 1, 0))
|
||||||
|
elif key in (Qt.Key.Key_Down, Qt.Key.Key_J):
|
||||||
|
self._select(min(idx + cols, len(self._thumbs) - 1))
|
||||||
|
elif key in (Qt.Key.Key_Up, Qt.Key.Key_K):
|
||||||
|
self._select(max(idx - cols, 0))
|
||||||
|
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
||||||
|
if 0 <= idx < len(self._thumbs):
|
||||||
|
self.post_activated.emit(idx)
|
||||||
|
elif key == Qt.Key.Key_Home:
|
||||||
|
self._select(0)
|
||||||
|
elif key == Qt.Key.Key_End:
|
||||||
|
self._select(len(self._thumbs) - 1)
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
def scroll_to_top(self) -> None:
|
||||||
|
self.verticalScrollBar().setValue(0)
|
||||||
|
|
||||||
|
def scroll_to_bottom(self) -> None:
|
||||||
|
self.verticalScrollBar().setValue(self.verticalScrollBar().maximum())
|
||||||
|
|
||||||
|
def _check_scroll_bottom(self, value: int) -> None:
|
||||||
|
sb = self.verticalScrollBar()
|
||||||
|
if sb.maximum() > 0 and value >= sb.maximum() - 10:
|
||||||
|
self.reached_bottom.emit()
|
||||||
|
if value <= 0 and sb.maximum() > 0:
|
||||||
|
self.reached_top.emit()
|
||||||
|
|
||||||
|
def resizeEvent(self, event) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
|
if self._flow:
|
||||||
|
self._flow.resize(self.viewport().size().width(), self._flow.minimumHeight())
|
||||||
487
booru_viewer/gui/preview.py
Normal file
487
booru_viewer/gui/preview.py
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
"""Full media preview — image viewer with zoom/pan and video player."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QUrl
|
||||||
|
from PySide6.QtGui import QPixmap, QPainter, QWheelEvent, QMouseEvent, QKeyEvent, QColor, QMovie
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QMainWindow,
|
||||||
|
QStackedWidget, QPushButton, QSlider, QMenu, QInputDialog,
|
||||||
|
)
|
||||||
|
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||||
|
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||||
|
|
||||||
|
from ..core.config import MEDIA_EXTENSIONS
|
||||||
|
|
||||||
|
VIDEO_EXTENSIONS = (".mp4", ".webm", ".mkv", ".avi", ".mov")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_video(path: str) -> bool:
|
||||||
|
return Path(path).suffix.lower() in VIDEO_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
class FullscreenPreview(QMainWindow):
|
||||||
|
"""Fullscreen image viewer window."""
|
||||||
|
|
||||||
|
def __init__(self, pixmap: QPixmap, info: str = "", parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("booru-viewer — Fullscreen")
|
||||||
|
self._preview = ImageViewer()
|
||||||
|
self._preview.set_image(pixmap, info)
|
||||||
|
self._preview.close_requested.connect(self.close)
|
||||||
|
self.setCentralWidget(self._preview)
|
||||||
|
if parent:
|
||||||
|
screen = parent.screen()
|
||||||
|
else:
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
screen = QApplication.primaryScreen()
|
||||||
|
if screen:
|
||||||
|
self.setGeometry(screen.geometry())
|
||||||
|
self.showFullScreen()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||||
|
if event.key() in (Qt.Key.Key_Escape, Qt.Key.Key_Q, Qt.Key.Key_F):
|
||||||
|
self.close()
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Image Viewer (zoom/pan) --
|
||||||
|
|
||||||
|
class ImageViewer(QWidget):
|
||||||
|
"""Zoomable, pannable image viewer."""
|
||||||
|
|
||||||
|
close_requested = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._pixmap: QPixmap | None = None
|
||||||
|
self._movie: QMovie | None = None
|
||||||
|
self._zoom = 1.0
|
||||||
|
self._offset = QPointF(0, 0)
|
||||||
|
self._drag_start: QPointF | None = None
|
||||||
|
self._drag_offset = QPointF(0, 0)
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self._info_text = ""
|
||||||
|
|
||||||
|
def set_image(self, pixmap: QPixmap, info: str = "") -> None:
|
||||||
|
self._stop_movie()
|
||||||
|
self._pixmap = pixmap
|
||||||
|
self._zoom = 1.0
|
||||||
|
self._offset = QPointF(0, 0)
|
||||||
|
self._info_text = info
|
||||||
|
self._fit_to_view()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_gif(self, path: str, info: str = "") -> None:
|
||||||
|
self._stop_movie()
|
||||||
|
self._movie = QMovie(path)
|
||||||
|
self._movie.frameChanged.connect(self._on_gif_frame)
|
||||||
|
self._movie.start()
|
||||||
|
self._info_text = info
|
||||||
|
# Set initial pixmap from first frame
|
||||||
|
self._pixmap = self._movie.currentPixmap()
|
||||||
|
self._zoom = 1.0
|
||||||
|
self._offset = QPointF(0, 0)
|
||||||
|
self._fit_to_view()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _on_gif_frame(self) -> None:
|
||||||
|
if self._movie:
|
||||||
|
self._pixmap = self._movie.currentPixmap()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _stop_movie(self) -> None:
|
||||||
|
if self._movie:
|
||||||
|
self._movie.stop()
|
||||||
|
self._movie = None
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._stop_movie()
|
||||||
|
self._pixmap = None
|
||||||
|
self._info_text = ""
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _fit_to_view(self) -> None:
|
||||||
|
if not self._pixmap:
|
||||||
|
return
|
||||||
|
vw, vh = self.width(), self.height()
|
||||||
|
pw, ph = self._pixmap.width(), self._pixmap.height()
|
||||||
|
if pw == 0 or ph == 0:
|
||||||
|
return
|
||||||
|
scale_w = vw / pw
|
||||||
|
scale_h = vh / ph
|
||||||
|
self._zoom = min(scale_w, scale_h, 1.0)
|
||||||
|
self._offset = QPointF(
|
||||||
|
(vw - pw * self._zoom) / 2,
|
||||||
|
(vh - ph * self._zoom) / 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None:
|
||||||
|
p = QPainter(self)
|
||||||
|
pal = self.palette()
|
||||||
|
p.fillRect(self.rect(), pal.color(pal.ColorRole.Window))
|
||||||
|
if self._pixmap:
|
||||||
|
p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||||
|
p.translate(self._offset)
|
||||||
|
p.scale(self._zoom, self._zoom)
|
||||||
|
p.drawPixmap(0, 0, self._pixmap)
|
||||||
|
p.resetTransform()
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||||
|
if not self._pixmap:
|
||||||
|
return
|
||||||
|
mouse_pos = event.position()
|
||||||
|
old_zoom = self._zoom
|
||||||
|
delta = event.angleDelta().y()
|
||||||
|
factor = 1.15 if delta > 0 else 1 / 1.15
|
||||||
|
self._zoom = max(0.1, min(self._zoom * factor, 20.0))
|
||||||
|
ratio = self._zoom / old_zoom
|
||||||
|
self._offset = mouse_pos - ratio * (mouse_pos - self._offset)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||||
|
if event.button() == Qt.MouseButton.MiddleButton:
|
||||||
|
self._fit_to_view()
|
||||||
|
self.update()
|
||||||
|
elif event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self._drag_start = event.position()
|
||||||
|
self._drag_offset = QPointF(self._offset)
|
||||||
|
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
||||||
|
if self._drag_start is not None:
|
||||||
|
delta = event.position() - self._drag_start
|
||||||
|
self._offset = self._drag_offset + delta
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
||||||
|
self._drag_start = None
|
||||||
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||||
|
if event.key() in (Qt.Key.Key_Escape, Qt.Key.Key_Q):
|
||||||
|
self.close_requested.emit()
|
||||||
|
elif event.key() == Qt.Key.Key_0:
|
||||||
|
self._fit_to_view()
|
||||||
|
self.update()
|
||||||
|
elif event.key() in (Qt.Key.Key_Plus, Qt.Key.Key_Equal):
|
||||||
|
self._zoom = min(self._zoom * 1.2, 20.0)
|
||||||
|
self.update()
|
||||||
|
elif event.key() == Qt.Key.Key_Minus:
|
||||||
|
self._zoom = max(self._zoom / 1.2, 0.1)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def resizeEvent(self, event) -> None:
|
||||||
|
if self._pixmap:
|
||||||
|
self._fit_to_view()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Video Player --
|
||||||
|
|
||||||
|
class VideoPlayer(QWidget):
|
||||||
|
"""Video player with transport controls."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Video surface
|
||||||
|
self._video_widget = QVideoWidget()
|
||||||
|
self._video_widget.setAutoFillBackground(True)
|
||||||
|
layout.addWidget(self._video_widget, stretch=1)
|
||||||
|
|
||||||
|
# Player
|
||||||
|
self._player = QMediaPlayer()
|
||||||
|
self._audio = QAudioOutput()
|
||||||
|
self._player.setAudioOutput(self._audio)
|
||||||
|
self._player.setVideoOutput(self._video_widget)
|
||||||
|
self._audio.setVolume(0.5)
|
||||||
|
|
||||||
|
# Controls bar
|
||||||
|
controls = QHBoxLayout()
|
||||||
|
controls.setContentsMargins(4, 2, 4, 2)
|
||||||
|
|
||||||
|
self._play_btn = QPushButton("Play")
|
||||||
|
self._play_btn.setFixedWidth(65)
|
||||||
|
self._play_btn.clicked.connect(self._toggle_play)
|
||||||
|
controls.addWidget(self._play_btn)
|
||||||
|
|
||||||
|
self._time_label = QLabel("0:00")
|
||||||
|
self._time_label.setFixedWidth(45)
|
||||||
|
controls.addWidget(self._time_label)
|
||||||
|
|
||||||
|
self._seek_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
self._seek_slider.setRange(0, 0)
|
||||||
|
self._seek_slider.sliderMoved.connect(self._seek)
|
||||||
|
controls.addWidget(self._seek_slider, stretch=1)
|
||||||
|
|
||||||
|
self._duration_label = QLabel("0:00")
|
||||||
|
self._duration_label.setFixedWidth(45)
|
||||||
|
controls.addWidget(self._duration_label)
|
||||||
|
|
||||||
|
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
self._vol_slider.setRange(0, 100)
|
||||||
|
self._vol_slider.setValue(50)
|
||||||
|
self._vol_slider.setFixedWidth(80)
|
||||||
|
self._vol_slider.valueChanged.connect(self._set_volume)
|
||||||
|
controls.addWidget(self._vol_slider)
|
||||||
|
|
||||||
|
self._mute_btn = QPushButton("Mute")
|
||||||
|
self._mute_btn.setFixedWidth(80)
|
||||||
|
self._mute_btn.clicked.connect(self._toggle_mute)
|
||||||
|
controls.addWidget(self._mute_btn)
|
||||||
|
|
||||||
|
self._autoplay = True
|
||||||
|
self._autoplay_btn = QPushButton("Auto")
|
||||||
|
self._autoplay_btn.setFixedWidth(50)
|
||||||
|
self._autoplay_btn.setCheckable(True)
|
||||||
|
self._autoplay_btn.setChecked(True)
|
||||||
|
self._autoplay_btn.setToolTip("Auto-play videos when selected")
|
||||||
|
self._autoplay_btn.clicked.connect(self._toggle_autoplay)
|
||||||
|
controls.addWidget(self._autoplay_btn)
|
||||||
|
|
||||||
|
layout.addLayout(controls)
|
||||||
|
|
||||||
|
# Signals
|
||||||
|
self._player.positionChanged.connect(self._on_position)
|
||||||
|
self._player.durationChanged.connect(self._on_duration)
|
||||||
|
self._player.playbackStateChanged.connect(self._on_state)
|
||||||
|
self._player.mediaStatusChanged.connect(self._on_media_status)
|
||||||
|
self._player.errorOccurred.connect(self._on_error)
|
||||||
|
self._current_file: str | None = None
|
||||||
|
self._error_fired = False
|
||||||
|
|
||||||
|
def play_file(self, path: str, info: str = "") -> None:
|
||||||
|
self._current_file = path
|
||||||
|
self._error_fired = False
|
||||||
|
self._player.setSource(QUrl.fromLocalFile(path))
|
||||||
|
if self._autoplay:
|
||||||
|
self._player.play()
|
||||||
|
else:
|
||||||
|
self._player.pause()
|
||||||
|
|
||||||
|
def _toggle_autoplay(self, checked: bool = True) -> None:
|
||||||
|
self._autoplay = self._autoplay_btn.isChecked()
|
||||||
|
self._autoplay_btn.setText("Auto" if self._autoplay else "Man.")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._player.stop()
|
||||||
|
self._player.setSource(QUrl())
|
||||||
|
|
||||||
|
def _toggle_play(self) -> None:
|
||||||
|
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
self._player.pause()
|
||||||
|
else:
|
||||||
|
self._player.play()
|
||||||
|
|
||||||
|
def _seek(self, pos: int) -> None:
|
||||||
|
self._player.setPosition(pos)
|
||||||
|
|
||||||
|
def _set_volume(self, val: int) -> None:
|
||||||
|
self._audio.setVolume(val / 100.0)
|
||||||
|
|
||||||
|
def _toggle_mute(self) -> None:
|
||||||
|
self._audio.setMuted(not self._audio.isMuted())
|
||||||
|
self._mute_btn.setText("Unmute" if self._audio.isMuted() else "Mute")
|
||||||
|
|
||||||
|
def _on_position(self, pos: int) -> None:
|
||||||
|
if not self._seek_slider.isSliderDown():
|
||||||
|
self._seek_slider.setValue(pos)
|
||||||
|
self._time_label.setText(self._fmt(pos))
|
||||||
|
|
||||||
|
def _on_duration(self, dur: int) -> None:
|
||||||
|
self._seek_slider.setRange(0, dur)
|
||||||
|
self._duration_label.setText(self._fmt(dur))
|
||||||
|
|
||||||
|
def _on_state(self, state) -> None:
|
||||||
|
if state == QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
self._play_btn.setText("Pause")
|
||||||
|
else:
|
||||||
|
self._play_btn.setText("Play")
|
||||||
|
|
||||||
|
def _on_media_status(self, status) -> None:
|
||||||
|
# Manual loop: when video ends, restart it
|
||||||
|
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||||
|
self._player.setPosition(0)
|
||||||
|
self._player.play()
|
||||||
|
|
||||||
|
def _on_error(self, error, msg: str = "") -> None:
|
||||||
|
if self._current_file and not self._error_fired:
|
||||||
|
self._error_fired = True
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(self._current_file))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt(ms: int) -> str:
|
||||||
|
s = ms // 1000
|
||||||
|
m = s // 60
|
||||||
|
return f"{m}:{s % 60:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Combined Preview (image + video) --
|
||||||
|
|
||||||
|
class ImagePreview(QWidget):
|
||||||
|
"""Combined media preview — auto-switches between image and video."""
|
||||||
|
|
||||||
|
close_requested = Signal()
|
||||||
|
open_in_default = Signal()
|
||||||
|
open_in_browser = Signal()
|
||||||
|
save_to_folder = Signal(str)
|
||||||
|
favorite_requested = Signal()
|
||||||
|
navigate = Signal(int) # -1 = prev, +1 = next
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._folders_callback = None
|
||||||
|
self._current_path: str | None = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
self._stack = QStackedWidget()
|
||||||
|
layout.addWidget(self._stack)
|
||||||
|
|
||||||
|
# Image viewer (index 0)
|
||||||
|
self._image_viewer = ImageViewer()
|
||||||
|
self._image_viewer.close_requested.connect(self.close_requested)
|
||||||
|
self._stack.addWidget(self._image_viewer)
|
||||||
|
|
||||||
|
# Video player (index 1)
|
||||||
|
self._video_player = VideoPlayer()
|
||||||
|
self._stack.addWidget(self._video_player)
|
||||||
|
|
||||||
|
# Info label
|
||||||
|
self._info_label = QLabel()
|
||||||
|
self._info_label.setStyleSheet("padding: 2px 6px;")
|
||||||
|
layout.addWidget(self._info_label)
|
||||||
|
|
||||||
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
|
self.customContextMenuRequested.connect(self._on_context_menu)
|
||||||
|
|
||||||
|
# Keep these for compatibility with app.py accessing them
|
||||||
|
@property
|
||||||
|
def _pixmap(self):
|
||||||
|
return self._image_viewer._pixmap
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _info_text(self):
|
||||||
|
return self._image_viewer._info_text
|
||||||
|
|
||||||
|
def set_folders_callback(self, callback) -> None:
|
||||||
|
self._folders_callback = callback
|
||||||
|
|
||||||
|
def set_image(self, pixmap: QPixmap, info: str = "") -> None:
|
||||||
|
self._video_player.stop()
|
||||||
|
self._image_viewer.set_image(pixmap, info)
|
||||||
|
self._stack.setCurrentIndex(0)
|
||||||
|
self._info_label.setText(info)
|
||||||
|
self._current_path = None
|
||||||
|
|
||||||
|
def set_media(self, path: str, info: str = "") -> None:
|
||||||
|
"""Auto-detect and show image or video."""
|
||||||
|
self._current_path = path
|
||||||
|
ext = Path(path).suffix.lower()
|
||||||
|
if _is_video(path):
|
||||||
|
self._image_viewer.clear()
|
||||||
|
self._video_player.stop()
|
||||||
|
self._video_player.play_file(path, info)
|
||||||
|
self._stack.setCurrentIndex(1)
|
||||||
|
self._info_label.setText(info)
|
||||||
|
elif ext == ".gif":
|
||||||
|
self._video_player.stop()
|
||||||
|
self._image_viewer.set_gif(path, info)
|
||||||
|
self._stack.setCurrentIndex(0)
|
||||||
|
self._info_label.setText(info)
|
||||||
|
else:
|
||||||
|
self._video_player.stop()
|
||||||
|
pix = QPixmap(path)
|
||||||
|
if not pix.isNull():
|
||||||
|
self._image_viewer.set_image(pix, info)
|
||||||
|
self._stack.setCurrentIndex(0)
|
||||||
|
self._info_label.setText(info)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._video_player.stop()
|
||||||
|
self._image_viewer.clear()
|
||||||
|
self._info_label.setText("")
|
||||||
|
self._current_path = None
|
||||||
|
|
||||||
|
def _on_context_menu(self, pos) -> None:
|
||||||
|
menu = QMenu(self)
|
||||||
|
fav_action = menu.addAction("Favorite")
|
||||||
|
|
||||||
|
save_menu = menu.addMenu("Save to Library")
|
||||||
|
save_unsorted = save_menu.addAction("Unsorted")
|
||||||
|
save_menu.addSeparator()
|
||||||
|
save_folder_actions = {}
|
||||||
|
if self._folders_callback:
|
||||||
|
for folder in self._folders_callback():
|
||||||
|
a = save_menu.addAction(folder)
|
||||||
|
save_folder_actions[id(a)] = folder
|
||||||
|
save_menu.addSeparator()
|
||||||
|
save_new = save_menu.addAction("+ New Folder...")
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
copy_image = None
|
||||||
|
if self._stack.currentIndex() == 0 and self._image_viewer._pixmap:
|
||||||
|
copy_image = menu.addAction("Copy Image to Clipboard")
|
||||||
|
open_action = menu.addAction("Open in Default App")
|
||||||
|
browser_action = menu.addAction("Open in Browser")
|
||||||
|
|
||||||
|
# Image-specific
|
||||||
|
reset_action = None
|
||||||
|
if self._stack.currentIndex() == 0:
|
||||||
|
reset_action = menu.addAction("Reset View")
|
||||||
|
|
||||||
|
clear_action = menu.addAction("Clear Preview")
|
||||||
|
|
||||||
|
action = menu.exec(self.mapToGlobal(pos))
|
||||||
|
if not action:
|
||||||
|
return
|
||||||
|
if action == fav_action:
|
||||||
|
self.favorite_requested.emit()
|
||||||
|
elif action == save_unsorted:
|
||||||
|
self.save_to_folder.emit("")
|
||||||
|
elif action == save_new:
|
||||||
|
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||||||
|
if ok and name.strip():
|
||||||
|
self.save_to_folder.emit(name.strip())
|
||||||
|
elif id(action) in save_folder_actions:
|
||||||
|
self.save_to_folder.emit(save_folder_actions[id(action)])
|
||||||
|
elif action == copy_image:
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
QApplication.clipboard().setPixmap(self._image_viewer._pixmap)
|
||||||
|
elif action == open_action:
|
||||||
|
self.open_in_default.emit()
|
||||||
|
elif action == browser_action:
|
||||||
|
self.open_in_browser.emit()
|
||||||
|
elif action == reset_action:
|
||||||
|
self._image_viewer._fit_to_view()
|
||||||
|
self._image_viewer.update()
|
||||||
|
elif action == clear_action:
|
||||||
|
self.close_requested.emit()
|
||||||
|
|
||||||
|
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||||
|
if event.button() == Qt.MouseButton.RightButton:
|
||||||
|
event.ignore()
|
||||||
|
else:
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||||
|
if self._stack.currentIndex() == 0:
|
||||||
|
self._image_viewer.keyPressEvent(event)
|
||||||
|
elif event.key() == Qt.Key.Key_Space:
|
||||||
|
self._video_player._toggle_play()
|
||||||
|
|
||||||
|
def resizeEvent(self, event) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
157
booru_viewer/gui/search.py
Normal file
157
booru_viewer/gui/search.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""Search bar with tag autocomplete, history, and saved searches."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal, QTimer, QStringListModel
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QCompleter,
|
||||||
|
QMenu,
|
||||||
|
QInputDialog,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
class SearchBar(QWidget):
|
||||||
|
"""Tag search bar with autocomplete, history dropdown, and saved searches."""
|
||||||
|
|
||||||
|
search_requested = Signal(str)
|
||||||
|
autocomplete_requested = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, db: Database | None = None, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(6)
|
||||||
|
|
||||||
|
# History button
|
||||||
|
self._history_btn = QPushButton("v")
|
||||||
|
self._history_btn.setFixedWidth(30)
|
||||||
|
self._history_btn.setToolTip("Search history & saved searches")
|
||||||
|
self._history_btn.clicked.connect(self._show_history_menu)
|
||||||
|
layout.addWidget(self._history_btn)
|
||||||
|
|
||||||
|
self._input = QLineEdit()
|
||||||
|
self._input.setPlaceholderText("Search tags... (supports -negatives)")
|
||||||
|
self._input.returnPressed.connect(self._do_search)
|
||||||
|
layout.addWidget(self._input, stretch=1)
|
||||||
|
|
||||||
|
# Save search button
|
||||||
|
self._save_btn = QPushButton("Save")
|
||||||
|
self._save_btn.setFixedWidth(40)
|
||||||
|
self._save_btn.setToolTip("Save current search")
|
||||||
|
self._save_btn.clicked.connect(self._save_current_search)
|
||||||
|
layout.addWidget(self._save_btn)
|
||||||
|
|
||||||
|
self._btn = QPushButton("Search")
|
||||||
|
self._btn.clicked.connect(self._do_search)
|
||||||
|
layout.addWidget(self._btn)
|
||||||
|
|
||||||
|
# Autocomplete
|
||||||
|
self._completer_model = QStringListModel()
|
||||||
|
self._completer = QCompleter(self._completer_model)
|
||||||
|
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||||
|
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||||
|
self._input.setCompleter(self._completer)
|
||||||
|
|
||||||
|
# Debounce
|
||||||
|
self._ac_timer = QTimer()
|
||||||
|
self._ac_timer.setSingleShot(True)
|
||||||
|
self._ac_timer.setInterval(300)
|
||||||
|
self._ac_timer.timeout.connect(self._request_autocomplete)
|
||||||
|
self._input.textChanged.connect(self._on_text_changed)
|
||||||
|
|
||||||
|
def _on_text_changed(self, text: str) -> None:
|
||||||
|
self._ac_timer.start()
|
||||||
|
|
||||||
|
def _request_autocomplete(self) -> None:
|
||||||
|
text = self._input.text().strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
last_tag = text.split()[-1] if text.split() else ""
|
||||||
|
query = last_tag.lstrip("-")
|
||||||
|
if len(query) >= 2:
|
||||||
|
self.autocomplete_requested.emit(query)
|
||||||
|
|
||||||
|
def set_suggestions(self, suggestions: list[str]) -> None:
|
||||||
|
self._completer_model.setStringList(suggestions)
|
||||||
|
|
||||||
|
def _do_search(self) -> None:
|
||||||
|
query = self._input.text().strip()
|
||||||
|
if self._db and query:
|
||||||
|
self._db.add_search_history(query)
|
||||||
|
self.search_requested.emit(query)
|
||||||
|
|
||||||
|
def _show_history_menu(self) -> None:
|
||||||
|
if not self._db:
|
||||||
|
return
|
||||||
|
|
||||||
|
menu = QMenu(self)
|
||||||
|
|
||||||
|
# Saved searches
|
||||||
|
saved = self._db.get_saved_searches()
|
||||||
|
if saved:
|
||||||
|
saved_header = menu.addAction("-- Saved Searches --")
|
||||||
|
saved_header.setEnabled(False)
|
||||||
|
saved_actions = {}
|
||||||
|
for sid, name, query in saved:
|
||||||
|
a = menu.addAction(f" {name} ({query})")
|
||||||
|
saved_actions[id(a)] = (sid, query)
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
|
# History
|
||||||
|
history = self._db.get_search_history()
|
||||||
|
if history:
|
||||||
|
hist_header = menu.addAction("-- Recent --")
|
||||||
|
hist_header.setEnabled(False)
|
||||||
|
hist_actions = {}
|
||||||
|
for query in history:
|
||||||
|
a = menu.addAction(f" {query}")
|
||||||
|
hist_actions[id(a)] = query
|
||||||
|
menu.addSeparator()
|
||||||
|
clear_action = menu.addAction("Clear History")
|
||||||
|
else:
|
||||||
|
hist_actions = {}
|
||||||
|
clear_action = None
|
||||||
|
|
||||||
|
if not saved and not history:
|
||||||
|
empty = menu.addAction("No history yet")
|
||||||
|
empty.setEnabled(False)
|
||||||
|
|
||||||
|
action = menu.exec(self._history_btn.mapToGlobal(self._history_btn.rect().bottomLeft()))
|
||||||
|
if not action:
|
||||||
|
return
|
||||||
|
|
||||||
|
if clear_action and action == clear_action:
|
||||||
|
self._db.clear_search_history()
|
||||||
|
elif id(action) in hist_actions:
|
||||||
|
self._input.setText(hist_actions[id(action)])
|
||||||
|
self._do_search()
|
||||||
|
elif saved and id(action) in saved_actions:
|
||||||
|
_, query = saved_actions[id(action)]
|
||||||
|
self._input.setText(query)
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
|
def _save_current_search(self) -> None:
|
||||||
|
if not self._db:
|
||||||
|
return
|
||||||
|
query = self._input.text().strip()
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
name, ok = QInputDialog.getText(self, "Save Search", "Name:", text=query)
|
||||||
|
if ok and name.strip():
|
||||||
|
self._db.add_saved_search(name.strip(), query)
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
return self._input.text().strip()
|
||||||
|
|
||||||
|
def set_text(self, text: str) -> None:
|
||||||
|
self._input.setText(text)
|
||||||
|
|
||||||
|
def focus(self) -> None:
|
||||||
|
self._input.setFocus()
|
||||||
620
booru_viewer/gui/settings.py
Normal file
620
booru_viewer/gui/settings.py
Normal file
@ -0,0 +1,620 @@
|
|||||||
|
"""Settings dialog."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QFormLayout,
|
||||||
|
QTabWidget,
|
||||||
|
QWidget,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QComboBox,
|
||||||
|
QCheckBox,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QMessageBox,
|
||||||
|
QGroupBox,
|
||||||
|
QProgressBar,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.db import Database
|
||||||
|
from ..core.cache import cache_size_bytes, cache_file_count, clear_cache, evict_oldest
|
||||||
|
from ..core.config import (
|
||||||
|
data_dir, cache_dir, thumbnails_dir, db_path, IS_WINDOWS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsDialog(QDialog):
|
||||||
|
"""Full settings panel with tabs."""
|
||||||
|
|
||||||
|
settings_changed = Signal()
|
||||||
|
favorites_imported = Signal()
|
||||||
|
|
||||||
|
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
self.setWindowTitle("Settings")
|
||||||
|
self.setMinimumSize(550, 450)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
self._tabs = QTabWidget()
|
||||||
|
layout.addWidget(self._tabs)
|
||||||
|
|
||||||
|
self._tabs.addTab(self._build_general_tab(), "General")
|
||||||
|
self._tabs.addTab(self._build_cache_tab(), "Cache")
|
||||||
|
self._tabs.addTab(self._build_blacklist_tab(), "Blacklist")
|
||||||
|
self._tabs.addTab(self._build_paths_tab(), "Paths")
|
||||||
|
self._tabs.addTab(self._build_theme_tab(), "Theme")
|
||||||
|
|
||||||
|
# Bottom buttons
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
btns.addStretch()
|
||||||
|
|
||||||
|
save_btn = QPushButton("Save")
|
||||||
|
save_btn.clicked.connect(self._save_and_close)
|
||||||
|
btns.addWidget(save_btn)
|
||||||
|
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btns.addWidget(cancel_btn)
|
||||||
|
|
||||||
|
layout.addLayout(btns)
|
||||||
|
|
||||||
|
# -- General tab --
|
||||||
|
|
||||||
|
def _build_general_tab(self) -> QWidget:
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
# Page size
|
||||||
|
self._page_size = QSpinBox()
|
||||||
|
self._page_size.setRange(10, 100)
|
||||||
|
self._page_size.setValue(self._db.get_setting_int("page_size"))
|
||||||
|
form.addRow("Results per page:", self._page_size)
|
||||||
|
|
||||||
|
# Thumbnail size
|
||||||
|
self._thumb_size = QSpinBox()
|
||||||
|
self._thumb_size.setRange(100, 400)
|
||||||
|
self._thumb_size.setSingleStep(20)
|
||||||
|
self._thumb_size.setValue(self._db.get_setting_int("thumbnail_size"))
|
||||||
|
form.addRow("Thumbnail size (px):", self._thumb_size)
|
||||||
|
|
||||||
|
# Default rating
|
||||||
|
self._default_rating = QComboBox()
|
||||||
|
self._default_rating.addItems(["all", "general", "sensitive", "questionable", "explicit"])
|
||||||
|
current_rating = self._db.get_setting("default_rating")
|
||||||
|
idx = self._default_rating.findText(current_rating)
|
||||||
|
if idx >= 0:
|
||||||
|
self._default_rating.setCurrentIndex(idx)
|
||||||
|
form.addRow("Default rating filter:", self._default_rating)
|
||||||
|
|
||||||
|
# Default min score
|
||||||
|
self._default_score = QSpinBox()
|
||||||
|
self._default_score.setRange(0, 99999)
|
||||||
|
self._default_score.setValue(self._db.get_setting_int("default_score"))
|
||||||
|
form.addRow("Default minimum score:", self._default_score)
|
||||||
|
|
||||||
|
# Preload thumbnails
|
||||||
|
self._preload = QCheckBox("Load thumbnails automatically")
|
||||||
|
self._preload.setChecked(self._db.get_setting_bool("preload_thumbnails"))
|
||||||
|
form.addRow("", self._preload)
|
||||||
|
|
||||||
|
# File dialog platform (Linux only)
|
||||||
|
self._file_dialog_combo = None
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
self._file_dialog_combo = QComboBox()
|
||||||
|
self._file_dialog_combo.addItems(["qt", "gtk"])
|
||||||
|
current = self._db.get_setting("file_dialog_platform")
|
||||||
|
idx = self._file_dialog_combo.findText(current)
|
||||||
|
if idx >= 0:
|
||||||
|
self._file_dialog_combo.setCurrentIndex(idx)
|
||||||
|
form.addRow("File dialog (restart required):", self._file_dialog_combo)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
layout.addStretch()
|
||||||
|
return w
|
||||||
|
|
||||||
|
# -- Cache tab --
|
||||||
|
|
||||||
|
def _build_cache_tab(self) -> QWidget:
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
|
||||||
|
# Cache stats
|
||||||
|
stats_group = QGroupBox("Cache Statistics")
|
||||||
|
stats_layout = QFormLayout(stats_group)
|
||||||
|
|
||||||
|
images, thumbs = cache_file_count()
|
||||||
|
total_bytes = cache_size_bytes()
|
||||||
|
total_mb = total_bytes / (1024 * 1024)
|
||||||
|
|
||||||
|
self._cache_images_label = QLabel(f"{images}")
|
||||||
|
stats_layout.addRow("Cached images:", self._cache_images_label)
|
||||||
|
|
||||||
|
self._cache_thumbs_label = QLabel(f"{thumbs}")
|
||||||
|
stats_layout.addRow("Cached thumbnails:", self._cache_thumbs_label)
|
||||||
|
|
||||||
|
self._cache_size_label = QLabel(f"{total_mb:.1f} MB")
|
||||||
|
stats_layout.addRow("Total size:", self._cache_size_label)
|
||||||
|
|
||||||
|
self._fav_count_label = QLabel(f"{self._db.favorite_count()}")
|
||||||
|
stats_layout.addRow("Favorites:", self._fav_count_label)
|
||||||
|
|
||||||
|
layout.addWidget(stats_group)
|
||||||
|
|
||||||
|
# Cache limits
|
||||||
|
limits_group = QGroupBox("Cache Limits")
|
||||||
|
limits_layout = QFormLayout(limits_group)
|
||||||
|
|
||||||
|
self._max_cache = QSpinBox()
|
||||||
|
self._max_cache.setRange(100, 50000)
|
||||||
|
self._max_cache.setSuffix(" MB")
|
||||||
|
self._max_cache.setValue(self._db.get_setting_int("max_cache_mb"))
|
||||||
|
limits_layout.addRow("Max cache size:", self._max_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)
|
||||||
|
|
||||||
|
layout.addWidget(limits_group)
|
||||||
|
|
||||||
|
# Cache actions
|
||||||
|
actions_group = QGroupBox("Actions")
|
||||||
|
actions_layout = QVBoxLayout(actions_group)
|
||||||
|
|
||||||
|
btn_row1 = QHBoxLayout()
|
||||||
|
|
||||||
|
clear_thumbs_btn = QPushButton("Clear Thumbnails")
|
||||||
|
clear_thumbs_btn.clicked.connect(self._clear_thumbnails)
|
||||||
|
btn_row1.addWidget(clear_thumbs_btn)
|
||||||
|
|
||||||
|
clear_cache_btn = QPushButton("Clear Image Cache")
|
||||||
|
clear_cache_btn.clicked.connect(self._clear_image_cache)
|
||||||
|
btn_row1.addWidget(clear_cache_btn)
|
||||||
|
|
||||||
|
actions_layout.addLayout(btn_row1)
|
||||||
|
|
||||||
|
btn_row2 = QHBoxLayout()
|
||||||
|
|
||||||
|
clear_all_btn = QPushButton("Clear Everything")
|
||||||
|
clear_all_btn.setStyleSheet(f"QPushButton {{ color: #ff4444; }}")
|
||||||
|
clear_all_btn.clicked.connect(self._clear_all)
|
||||||
|
btn_row2.addWidget(clear_all_btn)
|
||||||
|
|
||||||
|
evict_btn = QPushButton("Evict to Limit Now")
|
||||||
|
evict_btn.clicked.connect(self._evict_now)
|
||||||
|
btn_row2.addWidget(evict_btn)
|
||||||
|
|
||||||
|
actions_layout.addLayout(btn_row2)
|
||||||
|
|
||||||
|
layout.addWidget(actions_group)
|
||||||
|
layout.addStretch()
|
||||||
|
return w
|
||||||
|
|
||||||
|
# -- Blacklist tab --
|
||||||
|
|
||||||
|
def _build_blacklist_tab(self) -> QWidget:
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
|
||||||
|
layout.addWidget(QLabel("Posts with these tags will be hidden from results:"))
|
||||||
|
|
||||||
|
self._bl_list = QListWidget()
|
||||||
|
self._refresh_blacklist()
|
||||||
|
layout.addWidget(self._bl_list)
|
||||||
|
|
||||||
|
add_row = QHBoxLayout()
|
||||||
|
self._bl_input = QLineEdit()
|
||||||
|
self._bl_input.setPlaceholderText("Tag to blacklist...")
|
||||||
|
self._bl_input.returnPressed.connect(self._bl_add)
|
||||||
|
add_row.addWidget(self._bl_input, stretch=1)
|
||||||
|
|
||||||
|
add_btn = QPushButton("Add")
|
||||||
|
add_btn.clicked.connect(self._bl_add)
|
||||||
|
add_row.addWidget(add_btn)
|
||||||
|
|
||||||
|
remove_btn = QPushButton("Remove")
|
||||||
|
remove_btn.clicked.connect(self._bl_remove)
|
||||||
|
add_row.addWidget(remove_btn)
|
||||||
|
|
||||||
|
layout.addLayout(add_row)
|
||||||
|
|
||||||
|
io_row = QHBoxLayout()
|
||||||
|
|
||||||
|
export_bl_btn = QPushButton("Export")
|
||||||
|
export_bl_btn.clicked.connect(self._bl_export)
|
||||||
|
io_row.addWidget(export_bl_btn)
|
||||||
|
|
||||||
|
import_bl_btn = QPushButton("Import")
|
||||||
|
import_bl_btn.clicked.connect(self._bl_import)
|
||||||
|
io_row.addWidget(import_bl_btn)
|
||||||
|
|
||||||
|
clear_bl_btn = QPushButton("Clear All")
|
||||||
|
clear_bl_btn.clicked.connect(self._bl_clear)
|
||||||
|
io_row.addWidget(clear_bl_btn)
|
||||||
|
|
||||||
|
layout.addLayout(io_row)
|
||||||
|
return w
|
||||||
|
|
||||||
|
# -- Paths tab --
|
||||||
|
|
||||||
|
def _build_paths_tab(self) -> QWidget:
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
data = QLineEdit(str(data_dir()))
|
||||||
|
data.setReadOnly(True)
|
||||||
|
form.addRow("Data directory:", data)
|
||||||
|
|
||||||
|
cache = QLineEdit(str(cache_dir()))
|
||||||
|
cache.setReadOnly(True)
|
||||||
|
form.addRow("Image cache:", cache)
|
||||||
|
|
||||||
|
thumbs = QLineEdit(str(thumbnails_dir()))
|
||||||
|
thumbs.setReadOnly(True)
|
||||||
|
form.addRow("Thumbnails:", thumbs)
|
||||||
|
|
||||||
|
db = QLineEdit(str(db_path()))
|
||||||
|
db.setReadOnly(True)
|
||||||
|
form.addRow("Database:", db)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
open_btn = QPushButton("Open Data Folder")
|
||||||
|
open_btn.clicked.connect(self._open_data_folder)
|
||||||
|
layout.addWidget(open_btn)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# Export / Import
|
||||||
|
exp_group = QGroupBox("Backup")
|
||||||
|
exp_layout = QHBoxLayout(exp_group)
|
||||||
|
|
||||||
|
export_btn = QPushButton("Export Favorites")
|
||||||
|
export_btn.clicked.connect(self._export_favorites)
|
||||||
|
exp_layout.addWidget(export_btn)
|
||||||
|
|
||||||
|
import_btn = QPushButton("Import Favorites")
|
||||||
|
import_btn.clicked.connect(self._import_favorites)
|
||||||
|
exp_layout.addWidget(import_btn)
|
||||||
|
|
||||||
|
layout.addWidget(exp_group)
|
||||||
|
|
||||||
|
return w
|
||||||
|
|
||||||
|
# -- Theme tab --
|
||||||
|
|
||||||
|
def _build_theme_tab(self) -> QWidget:
|
||||||
|
w = QWidget()
|
||||||
|
layout = QVBoxLayout(w)
|
||||||
|
|
||||||
|
layout.addWidget(QLabel(
|
||||||
|
"Customize the app's appearance with a Qt stylesheet (QSS).\n"
|
||||||
|
"Place a custom.qss file in your data directory.\n"
|
||||||
|
"Restart the app after editing."
|
||||||
|
))
|
||||||
|
|
||||||
|
css_path = data_dir() / "custom.qss"
|
||||||
|
path_label = QLineEdit(str(css_path))
|
||||||
|
path_label.setReadOnly(True)
|
||||||
|
layout.addWidget(path_label)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
|
||||||
|
edit_btn = QPushButton("Edit custom.qss")
|
||||||
|
edit_btn.clicked.connect(self._edit_custom_css)
|
||||||
|
btn_row.addWidget(edit_btn)
|
||||||
|
|
||||||
|
create_btn = QPushButton("Create from Template")
|
||||||
|
create_btn.clicked.connect(self._create_css_template)
|
||||||
|
btn_row.addWidget(create_btn)
|
||||||
|
|
||||||
|
guide_btn = QPushButton("View Guide")
|
||||||
|
guide_btn.clicked.connect(self._view_css_guide)
|
||||||
|
btn_row.addWidget(guide_btn)
|
||||||
|
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
delete_btn = QPushButton("Delete custom.qss (Reset to Default)")
|
||||||
|
delete_btn.clicked.connect(self._delete_custom_css)
|
||||||
|
layout.addWidget(delete_btn)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
return w
|
||||||
|
|
||||||
|
def _edit_custom_css(self) -> None:
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
css_path = data_dir() / "custom.qss"
|
||||||
|
if not css_path.exists():
|
||||||
|
css_path.write_text("/* booru-viewer custom stylesheet */\n\n")
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path)))
|
||||||
|
|
||||||
|
def _create_css_template(self) -> None:
|
||||||
|
css_path = data_dir() / "custom.qss"
|
||||||
|
if css_path.exists():
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Confirm", "Overwrite existing custom.qss with template?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
template = (
|
||||||
|
"/* booru-viewer custom stylesheet */\n"
|
||||||
|
"/* Edit and restart the app to apply changes */\n\n"
|
||||||
|
"/* -- Accent color -- */\n"
|
||||||
|
"/* QWidget { color: #00ff00; } */\n\n"
|
||||||
|
"/* -- Background -- */\n"
|
||||||
|
"/* QWidget { background-color: #000000; } */\n\n"
|
||||||
|
"/* -- Font -- */\n"
|
||||||
|
"/* QWidget { font-family: monospace; font-size: 13px; } */\n\n"
|
||||||
|
"/* -- Buttons -- */\n"
|
||||||
|
"/* QPushButton { padding: 6px 16px; border-radius: 4px; } */\n"
|
||||||
|
"/* QPushButton:hover { border-color: #00ff00; } */\n\n"
|
||||||
|
"/* -- Inputs -- */\n"
|
||||||
|
"/* QLineEdit { padding: 6px 10px; border-radius: 4px; } */\n"
|
||||||
|
"/* QLineEdit:focus { border-color: #00ff00; } */\n\n"
|
||||||
|
"/* -- Scrollbar -- */\n"
|
||||||
|
"/* QScrollBar:vertical { width: 10px; } */\n\n"
|
||||||
|
"/* -- Video seek bar -- */\n"
|
||||||
|
"/* QSlider::groove:horizontal { background: #333; height: 6px; } */\n"
|
||||||
|
"/* QSlider::handle:horizontal { background: #00ff00; width: 14px; } */\n"
|
||||||
|
)
|
||||||
|
css_path.write_text(template)
|
||||||
|
QMessageBox.information(self, "Done", f"Template created at:\n{css_path}")
|
||||||
|
|
||||||
|
def _view_css_guide(self) -> None:
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
dest = data_dir() / "custom_css_guide.txt"
|
||||||
|
# Copy guide to appdata if not already there
|
||||||
|
if not dest.exists():
|
||||||
|
import sys
|
||||||
|
# Try source tree, then PyInstaller bundle
|
||||||
|
for candidate in [
|
||||||
|
Path(__file__).parent / "custom_css_guide.txt",
|
||||||
|
Path(getattr(sys, '_MEIPASS', __file__)) / "booru_viewer" / "gui" / "custom_css_guide.txt",
|
||||||
|
]:
|
||||||
|
if candidate.is_file():
|
||||||
|
dest.write_text(candidate.read_text())
|
||||||
|
break
|
||||||
|
if dest.exists():
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(dest)))
|
||||||
|
else:
|
||||||
|
# Fallback: show in dialog
|
||||||
|
from PySide6.QtWidgets import QTextEdit, QDialog, QVBoxLayout
|
||||||
|
dlg = QDialog(self)
|
||||||
|
dlg.setWindowTitle("Custom CSS Guide")
|
||||||
|
dlg.resize(600, 500)
|
||||||
|
layout = QVBoxLayout(dlg)
|
||||||
|
text = QTextEdit()
|
||||||
|
text.setReadOnly(True)
|
||||||
|
text.setPlainText(
|
||||||
|
"booru-viewer Custom Stylesheet Guide\n"
|
||||||
|
"=====================================\n\n"
|
||||||
|
"Place a file named 'custom.qss' in your data directory.\n"
|
||||||
|
f"Path: {data_dir() / 'custom.qss'}\n\n"
|
||||||
|
"WIDGET REFERENCE\n"
|
||||||
|
"----------------\n"
|
||||||
|
"QMainWindow, QPushButton, QLineEdit, QComboBox, QScrollBar,\n"
|
||||||
|
"QLabel, QStatusBar, QTabWidget, QTabBar, QListWidget,\n"
|
||||||
|
"QMenu, QMenuBar, QToolTip, QDialog, QSplitter, QProgressBar,\n"
|
||||||
|
"QSpinBox, QCheckBox, QSlider\n\n"
|
||||||
|
"STATES: :hover, :pressed, :focus, :selected, :disabled\n\n"
|
||||||
|
"PROPERTIES: color, background-color, border, border-radius,\n"
|
||||||
|
"padding, margin, font-family, font-size\n\n"
|
||||||
|
"EXAMPLE\n"
|
||||||
|
"-------\n"
|
||||||
|
"QPushButton {\n"
|
||||||
|
" background: #333; color: white;\n"
|
||||||
|
" border: 1px solid #555; border-radius: 4px;\n"
|
||||||
|
" padding: 6px 16px;\n"
|
||||||
|
"}\n"
|
||||||
|
"QPushButton:hover { background: #555; }\n\n"
|
||||||
|
"Restart the app after editing custom.qss."
|
||||||
|
)
|
||||||
|
layout.addWidget(text)
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
|
def _delete_custom_css(self) -> None:
|
||||||
|
css_path = data_dir() / "custom.qss"
|
||||||
|
if css_path.exists():
|
||||||
|
css_path.unlink()
|
||||||
|
QMessageBox.information(self, "Done", "Deleted. Restart to use default theme.")
|
||||||
|
else:
|
||||||
|
QMessageBox.information(self, "Info", "No custom.qss found.")
|
||||||
|
|
||||||
|
# -- Actions --
|
||||||
|
|
||||||
|
def _refresh_stats(self) -> None:
|
||||||
|
images, thumbs = cache_file_count()
|
||||||
|
total_bytes = cache_size_bytes()
|
||||||
|
total_mb = total_bytes / (1024 * 1024)
|
||||||
|
self._cache_images_label.setText(f"{images}")
|
||||||
|
self._cache_thumbs_label.setText(f"{thumbs}")
|
||||||
|
self._cache_size_label.setText(f"{total_mb:.1f} MB")
|
||||||
|
|
||||||
|
def _clear_thumbnails(self) -> None:
|
||||||
|
count = clear_cache(clear_images=False, clear_thumbnails=True)
|
||||||
|
QMessageBox.information(self, "Done", f"Deleted {count} thumbnails.")
|
||||||
|
self._refresh_stats()
|
||||||
|
|
||||||
|
def _clear_image_cache(self) -> None:
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Confirm",
|
||||||
|
"Delete all cached images? (Favorites stay in the database but cached files are removed.)",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
count = clear_cache(clear_images=True, clear_thumbnails=False)
|
||||||
|
QMessageBox.information(self, "Done", f"Deleted {count} cached images.")
|
||||||
|
self._refresh_stats()
|
||||||
|
|
||||||
|
def _clear_all(self) -> None:
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Confirm",
|
||||||
|
"Delete ALL cached images and thumbnails?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
count = clear_cache(clear_images=True, clear_thumbnails=True)
|
||||||
|
QMessageBox.information(self, "Done", f"Deleted {count} files.")
|
||||||
|
self._refresh_stats()
|
||||||
|
|
||||||
|
def _evict_now(self) -> None:
|
||||||
|
max_bytes = self._max_cache.value() * 1024 * 1024
|
||||||
|
# Protect favorited file paths
|
||||||
|
protected = set()
|
||||||
|
for fav in self._db.get_favorites(limit=999999):
|
||||||
|
if fav.cached_path:
|
||||||
|
protected.add(fav.cached_path)
|
||||||
|
count = evict_oldest(max_bytes, protected)
|
||||||
|
QMessageBox.information(self, "Done", f"Evicted {count} files.")
|
||||||
|
self._refresh_stats()
|
||||||
|
|
||||||
|
def _refresh_blacklist(self) -> None:
|
||||||
|
self._bl_list.clear()
|
||||||
|
for tag in self._db.get_blacklisted_tags():
|
||||||
|
self._bl_list.addItem(tag)
|
||||||
|
|
||||||
|
def _bl_add(self) -> None:
|
||||||
|
tag = self._bl_input.text().strip()
|
||||||
|
if tag:
|
||||||
|
self._db.add_blacklisted_tag(tag)
|
||||||
|
self._bl_input.clear()
|
||||||
|
self._refresh_blacklist()
|
||||||
|
|
||||||
|
def _bl_remove(self) -> None:
|
||||||
|
item = self._bl_list.currentItem()
|
||||||
|
if item:
|
||||||
|
self._db.remove_blacklisted_tag(item.text())
|
||||||
|
self._refresh_blacklist()
|
||||||
|
|
||||||
|
def _bl_export(self) -> None:
|
||||||
|
from .dialogs import save_file
|
||||||
|
path = save_file(self, "Export Blacklist", "blacklist.txt", "Text (*.txt)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
tags = self._db.get_blacklisted_tags()
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("\n".join(tags))
|
||||||
|
QMessageBox.information(self, "Done", f"Exported {len(tags)} tags.")
|
||||||
|
|
||||||
|
def _bl_import(self) -> None:
|
||||||
|
from .dialogs import open_file
|
||||||
|
path = open_file(self, "Import Blacklist", "Text (*.txt)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
tags = [line.strip() for line in f if line.strip()]
|
||||||
|
count = 0
|
||||||
|
for tag in tags:
|
||||||
|
self._db.add_blacklisted_tag(tag)
|
||||||
|
count += 1
|
||||||
|
self._refresh_blacklist()
|
||||||
|
QMessageBox.information(self, "Done", f"Imported {count} tags.")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", str(e))
|
||||||
|
|
||||||
|
def _bl_clear(self) -> None:
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Confirm", "Remove all blacklisted tags?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
for tag in self._db.get_blacklisted_tags():
|
||||||
|
self._db.remove_blacklisted_tag(tag)
|
||||||
|
self._refresh_blacklist()
|
||||||
|
|
||||||
|
def _open_data_folder(self) -> None:
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_dir())))
|
||||||
|
|
||||||
|
def _export_favorites(self) -> None:
|
||||||
|
from .dialogs import save_file
|
||||||
|
import json
|
||||||
|
path = save_file(self, "Export Favorites", "favorites.json", "JSON (*.json)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
favs = self._db.get_favorites(limit=999999)
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"post_id": f.post_id,
|
||||||
|
"site_id": f.site_id,
|
||||||
|
"file_url": f.file_url,
|
||||||
|
"preview_url": f.preview_url,
|
||||||
|
"tags": f.tags,
|
||||||
|
"rating": f.rating,
|
||||||
|
"score": f.score,
|
||||||
|
"source": f.source,
|
||||||
|
"folder": f.folder,
|
||||||
|
"favorited_at": f.favorited_at,
|
||||||
|
}
|
||||||
|
for f in favs
|
||||||
|
]
|
||||||
|
with open(path, "w") as fp:
|
||||||
|
json.dump(data, fp, indent=2)
|
||||||
|
QMessageBox.information(self, "Done", f"Exported {len(data)} favorites.")
|
||||||
|
|
||||||
|
def _import_favorites(self) -> None:
|
||||||
|
from .dialogs import open_file
|
||||||
|
import json
|
||||||
|
path = open_file(self, "Import Favorites", "JSON (*.json)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(path) as fp:
|
||||||
|
data = json.load(fp)
|
||||||
|
count = 0
|
||||||
|
for item in data:
|
||||||
|
try:
|
||||||
|
folder = item.get("folder")
|
||||||
|
self._db.add_favorite(
|
||||||
|
site_id=item["site_id"],
|
||||||
|
post_id=item["post_id"],
|
||||||
|
file_url=item["file_url"],
|
||||||
|
preview_url=item.get("preview_url"),
|
||||||
|
tags=item.get("tags", ""),
|
||||||
|
rating=item.get("rating"),
|
||||||
|
score=item.get("score"),
|
||||||
|
source=item.get("source"),
|
||||||
|
folder=folder,
|
||||||
|
)
|
||||||
|
if folder:
|
||||||
|
self._db.add_folder(folder)
|
||||||
|
count += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
QMessageBox.information(self, "Done", f"Imported {count} favorites.")
|
||||||
|
self.favorites_imported.emit()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", str(e))
|
||||||
|
|
||||||
|
# -- Save --
|
||||||
|
|
||||||
|
def _save_and_close(self) -> None:
|
||||||
|
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_score", str(self._default_score.value()))
|
||||||
|
self._db.set_setting("preload_thumbnails", "1" if self._preload.isChecked() else "0")
|
||||||
|
self._db.set_setting("max_cache_mb", str(self._max_cache.value()))
|
||||||
|
self._db.set_setting("auto_evict", "1" if self._auto_evict.isChecked() else "0")
|
||||||
|
if self._file_dialog_combo is not None:
|
||||||
|
self._db.set_setting("file_dialog_platform", self._file_dialog_combo.currentText())
|
||||||
|
self.settings_changed.emit()
|
||||||
|
self.accept()
|
||||||
297
booru_viewer/gui/sites.py
Normal file
297
booru_viewer/gui/sites.py
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
"""Site manager dialog."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal, QMetaObject, Q_ARG, Qt as QtNS
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QFormLayout,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..core.db import Database, Site
|
||||||
|
from ..core.api.detect import detect_site_type
|
||||||
|
|
||||||
|
|
||||||
|
class SiteDialog(QDialog):
|
||||||
|
"""Dialog to add or edit a booru site."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None, site: Site | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._editing = site is not None
|
||||||
|
self.setWindowTitle("Edit Site" if self._editing else "Add Site")
|
||||||
|
self.setMinimumWidth(400)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
self._name_input = QLineEdit()
|
||||||
|
self._name_input.setPlaceholderText("e.g. Danbooru")
|
||||||
|
form.addRow("Name:", self._name_input)
|
||||||
|
|
||||||
|
self._url_input = QLineEdit()
|
||||||
|
self._url_input.setPlaceholderText("e.g. https://gelbooru.com or paste a full post URL")
|
||||||
|
self._url_input.textChanged.connect(self._try_parse_url)
|
||||||
|
form.addRow("URL:", self._url_input)
|
||||||
|
|
||||||
|
self._key_input = QLineEdit()
|
||||||
|
self._key_input.setPlaceholderText("(optional — or paste full &api_key=...&user_id=... string)")
|
||||||
|
self._key_input.textChanged.connect(self._try_parse_credentials)
|
||||||
|
form.addRow("API Key:", self._key_input)
|
||||||
|
|
||||||
|
self._user_input = QLineEdit()
|
||||||
|
self._user_input.setPlaceholderText("(optional)")
|
||||||
|
form.addRow("API User:", self._user_input)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
self._status_label = QLabel("")
|
||||||
|
layout.addWidget(self._status_label)
|
||||||
|
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
self._detect_btn = QPushButton("Auto-Detect")
|
||||||
|
self._detect_btn.clicked.connect(self._on_detect)
|
||||||
|
btns.addWidget(self._detect_btn)
|
||||||
|
|
||||||
|
self._test_btn = QPushButton("Test")
|
||||||
|
self._test_btn.clicked.connect(self._on_test)
|
||||||
|
btns.addWidget(self._test_btn)
|
||||||
|
|
||||||
|
btns.addStretch()
|
||||||
|
|
||||||
|
save_btn = QPushButton("Save" if self._editing else "Add")
|
||||||
|
save_btn.clicked.connect(self.accept)
|
||||||
|
btns.addWidget(save_btn)
|
||||||
|
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btns.addWidget(cancel_btn)
|
||||||
|
|
||||||
|
layout.addLayout(btns)
|
||||||
|
|
||||||
|
self._detected_type: str | None = None
|
||||||
|
|
||||||
|
# Populate fields if editing
|
||||||
|
if site:
|
||||||
|
self._name_input.setText(site.name)
|
||||||
|
self._url_input.setText(site.url)
|
||||||
|
self._key_input.setText(site.api_key or "")
|
||||||
|
self._user_input.setText(site.api_user or "")
|
||||||
|
self._detected_type = site.api_type
|
||||||
|
self._status_label.setText(f"Type: {site.api_type}")
|
||||||
|
|
||||||
|
def _on_detect(self) -> None:
|
||||||
|
url = self._url_input.text().strip()
|
||||||
|
if not url:
|
||||||
|
self._status_label.setText("Enter a URL first.")
|
||||||
|
return
|
||||||
|
self._status_label.setText("Detecting...")
|
||||||
|
self._detect_btn.setEnabled(False)
|
||||||
|
|
||||||
|
api_key = self._key_input.text().strip() or None
|
||||||
|
api_user = self._user_input.text().strip() or None
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
result = asyncio.run(detect_site_type(url, api_key=api_key, api_user=api_user))
|
||||||
|
self._detect_finished(result, None)
|
||||||
|
except Exception as e:
|
||||||
|
self._detect_finished(None, e)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
def _detect_finished(self, result: str | None, error: Exception | None) -> None:
|
||||||
|
self._detect_btn.setEnabled(True)
|
||||||
|
if error:
|
||||||
|
self._status_label.setText(f"Error: {error}")
|
||||||
|
elif result:
|
||||||
|
self._detected_type = result
|
||||||
|
self._status_label.setText(f"Detected: {result}")
|
||||||
|
else:
|
||||||
|
self._status_label.setText("Could not detect API type.")
|
||||||
|
|
||||||
|
def _on_test(self) -> None:
|
||||||
|
url = self._url_input.text().strip()
|
||||||
|
api_type = self._detected_type or "danbooru"
|
||||||
|
api_key = self._key_input.text().strip() or None
|
||||||
|
api_user = self._user_input.text().strip() or None
|
||||||
|
if not url:
|
||||||
|
self._status_label.setText("Enter a URL first.")
|
||||||
|
return
|
||||||
|
self._status_label.setText("Testing connection...")
|
||||||
|
self._test_btn.setEnabled(False)
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
import asyncio
|
||||||
|
from ..core.api.detect import client_for_type
|
||||||
|
try:
|
||||||
|
client = client_for_type(api_type, url, api_key=api_key, api_user=api_user)
|
||||||
|
ok, detail = asyncio.run(client.test_connection())
|
||||||
|
self._test_finished(ok, detail)
|
||||||
|
except Exception as e:
|
||||||
|
self._test_finished(False, str(e))
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
def _test_finished(self, ok: bool, detail: str) -> None:
|
||||||
|
self._test_btn.setEnabled(True)
|
||||||
|
if ok:
|
||||||
|
self._status_label.setText(f"Connected! {detail}")
|
||||||
|
else:
|
||||||
|
self._status_label.setText(f"Failed: {detail}")
|
||||||
|
|
||||||
|
def _try_parse_url(self, text: str) -> None:
|
||||||
|
"""Strip query params from pasted URLs like https://gelbooru.com/index.php?page=post&s=list&tags=all."""
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
text = text.strip()
|
||||||
|
if "?" not in text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parsed = urlparse(text)
|
||||||
|
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
return
|
||||||
|
self._url_input.blockSignals(True)
|
||||||
|
self._url_input.setText(base)
|
||||||
|
self._url_input.blockSignals(False)
|
||||||
|
self._status_label.setText(f"Extracted base URL: {base}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _try_parse_credentials(self, text: str) -> None:
|
||||||
|
"""Auto-parse combined credential strings like &api_key=XXX&user_id=123."""
|
||||||
|
import re
|
||||||
|
# Match user_id regardless of api_key being present
|
||||||
|
user_match = re.search(r'user_id=([^&\s]+)', text)
|
||||||
|
key_match = re.search(r'api_key=([^&\s]+)', text)
|
||||||
|
if user_match:
|
||||||
|
self._user_input.setText(user_match.group(1))
|
||||||
|
if key_match:
|
||||||
|
self._key_input.blockSignals(True)
|
||||||
|
self._key_input.setText(key_match.group(1))
|
||||||
|
self._key_input.blockSignals(False)
|
||||||
|
self._status_label.setText("Parsed api_key and user_id")
|
||||||
|
else:
|
||||||
|
# Clear the pasted junk, user needs to enter key separately
|
||||||
|
self._key_input.blockSignals(True)
|
||||||
|
self._key_input.clear()
|
||||||
|
self._key_input.blockSignals(False)
|
||||||
|
self._status_label.setText("Parsed user_id={}. Paste your API key above.".format(user_match.group(1)))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site_data(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self._name_input.text().strip(),
|
||||||
|
"url": self._url_input.text().strip(),
|
||||||
|
"api_type": self._detected_type or "danbooru",
|
||||||
|
"api_key": self._key_input.text().strip() or None,
|
||||||
|
"api_user": self._user_input.text().strip() or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SiteManagerDialog(QDialog):
|
||||||
|
"""Dialog to manage booru sites."""
|
||||||
|
|
||||||
|
sites_changed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
self.setWindowTitle("Manage Sites")
|
||||||
|
self.setMinimumSize(500, 350)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
self._list = QListWidget()
|
||||||
|
layout.addWidget(self._list)
|
||||||
|
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
add_btn = QPushButton("Add Site")
|
||||||
|
add_btn.clicked.connect(self._on_add)
|
||||||
|
btns.addWidget(add_btn)
|
||||||
|
|
||||||
|
edit_btn = QPushButton("Edit")
|
||||||
|
edit_btn.clicked.connect(self._on_edit)
|
||||||
|
btns.addWidget(edit_btn)
|
||||||
|
|
||||||
|
remove_btn = QPushButton("Remove")
|
||||||
|
remove_btn.clicked.connect(self._on_remove)
|
||||||
|
btns.addWidget(remove_btn)
|
||||||
|
|
||||||
|
btns.addStretch()
|
||||||
|
|
||||||
|
close_btn = QPushButton("Close")
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
btns.addWidget(close_btn)
|
||||||
|
|
||||||
|
layout.addLayout(btns)
|
||||||
|
self._list.itemDoubleClicked.connect(lambda _: self._on_edit())
|
||||||
|
self._refresh_list()
|
||||||
|
|
||||||
|
def _refresh_list(self) -> None:
|
||||||
|
self._list.clear()
|
||||||
|
for site in self._db.get_sites(enabled_only=False):
|
||||||
|
item = QListWidgetItem(f"{site.name} [{site.api_type}] {site.url}")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, site.id)
|
||||||
|
self._list.addItem(item)
|
||||||
|
|
||||||
|
def _on_add(self) -> None:
|
||||||
|
dlg = SiteDialog(self)
|
||||||
|
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
data = dlg.site_data
|
||||||
|
if not data["name"] or not data["url"]:
|
||||||
|
QMessageBox.warning(self, "Error", "Name and URL are required.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._db.add_site(**data)
|
||||||
|
self._refresh_list()
|
||||||
|
self.sites_changed.emit()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", str(e))
|
||||||
|
|
||||||
|
def _on_edit(self) -> None:
|
||||||
|
item = self._list.currentItem()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
site_id = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
sites = self._db.get_sites(enabled_only=False)
|
||||||
|
site = next((s for s in sites if s.id == site_id), None)
|
||||||
|
if not site:
|
||||||
|
return
|
||||||
|
dlg = SiteDialog(self, site=site)
|
||||||
|
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
data = dlg.site_data
|
||||||
|
if not data["name"] or not data["url"]:
|
||||||
|
QMessageBox.warning(self, "Error", "Name and URL are required.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._db.update_site(site_id, **data)
|
||||||
|
self._refresh_list()
|
||||||
|
self.sites_changed.emit()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", str(e))
|
||||||
|
|
||||||
|
def _on_remove(self) -> None:
|
||||||
|
item = self._list.currentItem()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
site_id = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Confirm", "Remove this site and all its favorites?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
self._db.delete_site(site_id)
|
||||||
|
self._refresh_list()
|
||||||
|
self.sites_changed.emit()
|
||||||
222
booru_viewer/gui/theme.py
Normal file
222
booru_viewer/gui/theme.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
"""Green-on-black Qt6 stylesheet."""
|
||||||
|
|
||||||
|
from ..core.config import GREEN, DARK_GREEN, DIM_GREEN, BG, BG_LIGHT, BG_LIGHTER, BORDER
|
||||||
|
|
||||||
|
STYLESHEET = f"""
|
||||||
|
QMainWindow, QDialog {{
|
||||||
|
background-color: {BG};
|
||||||
|
color: {GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QWidget {{
|
||||||
|
background-color: {BG};
|
||||||
|
color: {GREEN};
|
||||||
|
font-family: "Terminess Nerd Font Propo", "Hack Nerd Font", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QMenuBar {{
|
||||||
|
background-color: {BG};
|
||||||
|
color: {GREEN};
|
||||||
|
border-bottom: 1px solid {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QMenuBar::item:selected {{
|
||||||
|
background-color: {BG_LIGHTER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QMenu {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QMenu::item:selected {{
|
||||||
|
background-color: {BG_LIGHTER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QLineEdit {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
selection-background-color: {DIM_GREEN};
|
||||||
|
selection-color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QLineEdit:focus {{
|
||||||
|
border-color: {GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QPushButton {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
min-height: 28px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QPushButton:hover {{
|
||||||
|
background-color: {BG_LIGHTER};
|
||||||
|
border-color: {DIM_GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QPushButton:pressed {{
|
||||||
|
background-color: {DIM_GREEN};
|
||||||
|
color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QComboBox {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QComboBox:hover {{
|
||||||
|
border-color: {DIM_GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QComboBox QAbstractItemView {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
selection-background-color: {DIM_GREEN};
|
||||||
|
selection-color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QComboBox::drop-down {{
|
||||||
|
border: none;
|
||||||
|
width: 20px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QScrollBar:vertical {{
|
||||||
|
background: {BG};
|
||||||
|
width: 10px;
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QScrollBar::handle:vertical {{
|
||||||
|
background: {BORDER};
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QScrollBar::handle:vertical:hover {{
|
||||||
|
background: {DIM_GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
|
||||||
|
height: 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QScrollBar:horizontal {{
|
||||||
|
background: {BG};
|
||||||
|
height: 10px;
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QScrollBar::handle:horizontal {{
|
||||||
|
background: {BORDER};
|
||||||
|
min-width: 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QScrollBar::handle:horizontal:hover {{
|
||||||
|
background: {DIM_GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
|
||||||
|
width: 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QLabel {{
|
||||||
|
color: {GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QStatusBar {{
|
||||||
|
background-color: {BG};
|
||||||
|
color: {DIM_GREEN};
|
||||||
|
border-top: 1px solid {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QTabWidget::pane {{
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
background-color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QTabBar::tab {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
color: {DIM_GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QTabBar::tab:selected {{
|
||||||
|
color: {GREEN};
|
||||||
|
border-color: {GREEN};
|
||||||
|
background-color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QTabBar::tab:hover {{
|
||||||
|
color: {GREEN};
|
||||||
|
background-color: {BG_LIGHTER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QListWidget {{
|
||||||
|
background-color: {BG};
|
||||||
|
color: {GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
outline: none;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QListWidget::item:selected {{
|
||||||
|
background-color: {DIM_GREEN};
|
||||||
|
color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QListWidget::item:hover {{
|
||||||
|
background-color: {BG_LIGHTER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QDialogButtonBox QPushButton {{
|
||||||
|
min-width: 80px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QToolTip {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
padding: 4px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QCompleter QAbstractItemView {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
selection-background-color: {DIM_GREEN};
|
||||||
|
selection-color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QSplitter::handle {{
|
||||||
|
background-color: {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QProgressBar {{
|
||||||
|
background-color: {BG_LIGHT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
color: {GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QProgressBar::chunk {{
|
||||||
|
background-color: {DIM_GREEN};
|
||||||
|
border-radius: 3px;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
36
booru_viewer/main_gui.py
Normal file
36
booru_viewer/main_gui.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""GUI entry point."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# Windows: set App User Model ID so taskbar pinning works
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
||||||
|
u"pax.booru-viewer.gui.1"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply file dialog platform setting before Qt initializes
|
||||||
|
if sys.platform != "win32":
|
||||||
|
try:
|
||||||
|
from booru_viewer.core.db import Database
|
||||||
|
db = Database()
|
||||||
|
platform = db.get_setting("file_dialog_platform")
|
||||||
|
db.close()
|
||||||
|
if platform == "gtk":
|
||||||
|
# Use xdg-desktop-portal which routes to GTK portal (Thunar)
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORMTHEME", "xdgdesktopportal")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from booru_viewer.gui.app import run
|
||||||
|
run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
booru_viewer/main_tui.py
Normal file
10
booru_viewer/main_tui.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""TUI entry point."""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from .tui.app import run
|
||||||
|
run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
booru_viewer/tui/__init__.py
Normal file
0
booru_viewer/tui/__init__.py
Normal file
511
booru_viewer/tui/app.py
Normal file
511
booru_viewer/tui/app.py
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
"""Main Textual TUI application."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Horizontal, Vertical, ScrollableContainer
|
||||||
|
from textual.css.query import NoMatches
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.widgets import Header, Footer, Static, Input, Label, Button, ListView, ListItem
|
||||||
|
|
||||||
|
from ..core.db import Database
|
||||||
|
from ..core.api.base import Post
|
||||||
|
from ..core.api.detect import client_for_type
|
||||||
|
from ..core.cache import download_image
|
||||||
|
from ..core.config import GREEN, DIM_GREEN, BG, BG_LIGHT, BG_LIGHTER, BORDER
|
||||||
|
|
||||||
|
|
||||||
|
class PostList(ListView):
|
||||||
|
"""Scrollable list of posts with selection."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._post_count = 0
|
||||||
|
|
||||||
|
async def set_posts(self, posts: list[Post], db: Database | None = None, site_id: int | None = None) -> None:
|
||||||
|
self._post_count = len(posts)
|
||||||
|
await self.clear()
|
||||||
|
for i, post in enumerate(posts):
|
||||||
|
fav = ""
|
||||||
|
if db and site_id and db.is_favorited(site_id, post.id):
|
||||||
|
fav = " [*]"
|
||||||
|
rating = (post.rating or "?")[0].upper()
|
||||||
|
label = f"#{post.id}{fav} {rating} s:{post.score:>4} {post.width}x{post.height}"
|
||||||
|
item = ListItem(Label(label), id=f"post-{i}")
|
||||||
|
await self.append(item)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_index(item: ListItem) -> int:
|
||||||
|
if item and item.id and item.id.startswith("post-"):
|
||||||
|
return int(item.id.split("-")[1])
|
||||||
|
return -1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_index(self) -> int:
|
||||||
|
if self.highlighted_child and self.highlighted_child.id:
|
||||||
|
return self._get_index(self.highlighted_child)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
class InfoBar(Static):
|
||||||
|
"""Bottom info line showing selected post details."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BooruTUI(App):
|
||||||
|
"""Booru viewer TUI application."""
|
||||||
|
|
||||||
|
TITLE = "booru-viewer"
|
||||||
|
CSS = f"""
|
||||||
|
Screen {{
|
||||||
|
background: {BG};
|
||||||
|
color: {GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
Header {{
|
||||||
|
background: {BG};
|
||||||
|
color: {GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
Footer {{
|
||||||
|
background: {BG};
|
||||||
|
color: {DIM_GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#top-bar {{
|
||||||
|
height: 3;
|
||||||
|
layout: horizontal;
|
||||||
|
padding: 0 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#top-bar Label {{
|
||||||
|
width: auto;
|
||||||
|
padding: 1 1;
|
||||||
|
color: {DIM_GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#top-bar Input {{
|
||||||
|
width: 1fr;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#top-bar Button {{
|
||||||
|
width: auto;
|
||||||
|
min-width: 8;
|
||||||
|
margin-left: 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#nav-bar {{
|
||||||
|
height: 3;
|
||||||
|
layout: horizontal;
|
||||||
|
padding: 0 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#nav-bar Button {{
|
||||||
|
width: auto;
|
||||||
|
min-width: 10;
|
||||||
|
margin-right: 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#nav-bar .page-info {{
|
||||||
|
width: auto;
|
||||||
|
padding: 1 1;
|
||||||
|
color: {DIM_GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#main {{
|
||||||
|
height: 1fr;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#post-list {{
|
||||||
|
width: 1fr;
|
||||||
|
min-width: 40;
|
||||||
|
border-right: solid {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#right-panel {{
|
||||||
|
width: 1fr;
|
||||||
|
min-width: 30;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#preview {{
|
||||||
|
height: 1fr;
|
||||||
|
}}
|
||||||
|
|
||||||
|
#info-bar {{
|
||||||
|
height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
color: {DIM_GREEN};
|
||||||
|
border-top: solid {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
#status {{
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
color: {DIM_GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
ListView {{
|
||||||
|
background: {BG};
|
||||||
|
color: {GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
ListView > ListItem {{
|
||||||
|
background: {BG};
|
||||||
|
color: {DIM_GREEN};
|
||||||
|
padding: 0 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
ListView > ListItem.--highlight {{
|
||||||
|
background: {BG_LIGHTER};
|
||||||
|
color: {GREEN};
|
||||||
|
}}
|
||||||
|
|
||||||
|
ListItem:hover {{
|
||||||
|
background: {BG_LIGHTER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
Button {{
|
||||||
|
background: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: solid {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
Button:hover {{
|
||||||
|
background: {DIM_GREEN};
|
||||||
|
color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
Button.-active {{
|
||||||
|
background: {DIM_GREEN};
|
||||||
|
color: {BG};
|
||||||
|
}}
|
||||||
|
|
||||||
|
Input {{
|
||||||
|
background: {BG_LIGHT};
|
||||||
|
color: {GREEN};
|
||||||
|
border: solid {BORDER};
|
||||||
|
}}
|
||||||
|
|
||||||
|
Input:focus {{
|
||||||
|
border: solid {GREEN};
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("q", "quit", "Quit", show=True),
|
||||||
|
Binding("slash", "focus_search", "/Search", show=True),
|
||||||
|
Binding("f", "toggle_favorite", "Fav", show=True, priority=True),
|
||||||
|
Binding("escape", "unfocus", "Back", show=True),
|
||||||
|
Binding("n", "next_page", "Next", show=True, priority=True),
|
||||||
|
Binding("p", "prev_page", "Prev", show=True, priority=True),
|
||||||
|
Binding("o", "open_in_default", "Open", show=True, priority=True),
|
||||||
|
Binding("i", "show_info", "Info", show=True, priority=True),
|
||||||
|
Binding("s", "cycle_site", "Site", show=True, priority=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._db = Database()
|
||||||
|
self._posts: list[Post] = []
|
||||||
|
self._current_page = 1
|
||||||
|
self._current_tags = ""
|
||||||
|
self._current_site = None
|
||||||
|
self._show_info = False
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Horizontal(id="top-bar"):
|
||||||
|
yield Label("No site", id="site-label")
|
||||||
|
yield Input(placeholder="Search tags... (/)", id="search-input")
|
||||||
|
yield Button("Go", id="search-btn")
|
||||||
|
with Horizontal(id="nav-bar"):
|
||||||
|
yield Label("Page 1", classes="page-info", id="page-info")
|
||||||
|
yield Button("Prev", id="prev-btn")
|
||||||
|
yield Button("Next", id="next-btn")
|
||||||
|
with Horizontal(id="main"):
|
||||||
|
yield PostList(id="post-list")
|
||||||
|
with Vertical(id="right-panel"):
|
||||||
|
yield Static("", id="preview")
|
||||||
|
yield InfoBar("Select a post", id="info-bar")
|
||||||
|
yield Label("Ready", id="status")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
sites = self._db.get_sites()
|
||||||
|
if sites:
|
||||||
|
self._current_site = sites[0]
|
||||||
|
try:
|
||||||
|
self.query_one("#site-label", Label).update(f"[{self._current_site.name}]")
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
self._set_status(f"Connected to {self._current_site.name}")
|
||||||
|
|
||||||
|
def _set_status(self, msg: str) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#status", Label).update(msg)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _make_client(self):
|
||||||
|
if not self._current_site:
|
||||||
|
return None
|
||||||
|
s = self._current_site
|
||||||
|
return client_for_type(s.api_type, s.url, s.api_key, s.api_user)
|
||||||
|
|
||||||
|
# -- Events --
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
bid = event.button.id
|
||||||
|
if bid == "search-btn":
|
||||||
|
self._do_search_from_input()
|
||||||
|
elif bid == "prev-btn":
|
||||||
|
self.action_prev_page()
|
||||||
|
elif bid == "next-btn":
|
||||||
|
self.action_next_page()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
if event.input.id == "search-input":
|
||||||
|
self._do_search_from_input()
|
||||||
|
|
||||||
|
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
||||||
|
"""Update info when navigating the list."""
|
||||||
|
if event.item:
|
||||||
|
idx = PostList._get_index(event.item)
|
||||||
|
if idx >= 0:
|
||||||
|
self._update_info(idx)
|
||||||
|
|
||||||
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||||
|
"""Enter key on a list item = load preview."""
|
||||||
|
if event.item:
|
||||||
|
idx = PostList._get_index(event.item)
|
||||||
|
if 0 <= idx < len(self._posts):
|
||||||
|
self._load_preview(idx)
|
||||||
|
|
||||||
|
# -- Search --
|
||||||
|
|
||||||
|
def _do_search_from_input(self) -> None:
|
||||||
|
try:
|
||||||
|
inp = self.query_one("#search-input", Input)
|
||||||
|
self._current_tags = inp.value.strip()
|
||||||
|
except NoMatches:
|
||||||
|
return
|
||||||
|
self._current_page = 1
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
|
def _do_search(self) -> None:
|
||||||
|
if not self._current_site:
|
||||||
|
self._set_status("No site configured")
|
||||||
|
return
|
||||||
|
self._set_status("Searching...")
|
||||||
|
try:
|
||||||
|
self.query_one("#page-info", Label).update(f"Page {self._current_page}")
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tags = self._current_tags
|
||||||
|
page = self._current_page
|
||||||
|
blacklisted = self._db.get_blacklisted_tags()
|
||||||
|
search_tags = tags
|
||||||
|
for bt in blacklisted:
|
||||||
|
search_tags += f" -{bt}"
|
||||||
|
|
||||||
|
async def _search(self=self):
|
||||||
|
client = self._make_client()
|
||||||
|
if not client:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
posts = await client.search(tags=search_tags.strip(), page=page)
|
||||||
|
self._posts = posts
|
||||||
|
self._set_status(f"{len(posts)} results")
|
||||||
|
try:
|
||||||
|
post_list = self.query_one("#post-list", PostList)
|
||||||
|
await post_list.set_posts(posts, self._db, self._current_site.id if self._current_site else None)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self._set_status(f"Error: {e}")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
self.run_worker(_search(), exclusive=True)
|
||||||
|
|
||||||
|
# -- Info --
|
||||||
|
|
||||||
|
def _update_info(self, index: int) -> None:
|
||||||
|
if 0 <= index < len(self._posts):
|
||||||
|
post = self._posts[index]
|
||||||
|
status = f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}]"
|
||||||
|
self._set_status(status)
|
||||||
|
if self._show_info:
|
||||||
|
tags_preview = " ".join(post.tag_list[:15])
|
||||||
|
if len(post.tag_list) > 15:
|
||||||
|
tags_preview += "..."
|
||||||
|
info = (
|
||||||
|
f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}]\n"
|
||||||
|
f"Tags: {tags_preview}"
|
||||||
|
)
|
||||||
|
if post.source:
|
||||||
|
info += f"\nSource: {post.source}"
|
||||||
|
try:
|
||||||
|
self.query_one("#info-bar", InfoBar).update(info)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- Preview --
|
||||||
|
|
||||||
|
def _load_preview(self, index: int) -> None:
|
||||||
|
if index < 0 or index >= len(self._posts):
|
||||||
|
return
|
||||||
|
post = self._posts[index]
|
||||||
|
self._set_status(f"Loading #{post.id}...")
|
||||||
|
|
||||||
|
async def _load(self=self):
|
||||||
|
try:
|
||||||
|
path = await download_image(post.file_url)
|
||||||
|
try:
|
||||||
|
from .preview import ImagePreview
|
||||||
|
preview = self.query_one("#preview", Static)
|
||||||
|
# Show image info in the preview area
|
||||||
|
info = (
|
||||||
|
f" Post #{post.id}\n"
|
||||||
|
f" Size: {post.width}x{post.height}\n"
|
||||||
|
f" Score: {post.score}\n"
|
||||||
|
f" Rating: {post.rating or '?'}\n"
|
||||||
|
f" Cached: {path}\n"
|
||||||
|
)
|
||||||
|
if post.source:
|
||||||
|
info += f" Source: {post.source}\n"
|
||||||
|
info += f"\n Tags: {' '.join(post.tag_list[:20])}"
|
||||||
|
preview.update(info)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
self._set_status(f"Loaded #{post.id}")
|
||||||
|
except Exception as e:
|
||||||
|
self._set_status(f"Error: {e}")
|
||||||
|
|
||||||
|
self.run_worker(_load())
|
||||||
|
|
||||||
|
# -- Actions --
|
||||||
|
|
||||||
|
def action_focus_search(self) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#search-input", Input).focus()
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def action_unfocus(self) -> None:
|
||||||
|
try:
|
||||||
|
self.query_one("#post-list", PostList).focus()
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def action_next_page(self) -> None:
|
||||||
|
self._current_page += 1
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
|
def action_prev_page(self) -> None:
|
||||||
|
if self._current_page > 1:
|
||||||
|
self._current_page -= 1
|
||||||
|
self._do_search()
|
||||||
|
|
||||||
|
async def action_toggle_favorite(self) -> None:
|
||||||
|
post_list = self.query_one("#post-list", PostList)
|
||||||
|
idx = post_list.selected_index
|
||||||
|
if idx < 0 or idx >= len(self._posts) or not self._current_site:
|
||||||
|
return
|
||||||
|
post = self._posts[idx]
|
||||||
|
site_id = self._current_site.id
|
||||||
|
|
||||||
|
if self._db.is_favorited(site_id, post.id):
|
||||||
|
self._db.remove_favorite(site_id, post.id)
|
||||||
|
self._set_status(f"Unfavorited #{post.id}")
|
||||||
|
await post_list.set_posts(self._posts, self._db, site_id)
|
||||||
|
else:
|
||||||
|
self._set_status(f"Favoriting #{post.id}...")
|
||||||
|
|
||||||
|
async def _fav(self=self):
|
||||||
|
try:
|
||||||
|
path = await download_image(post.file_url)
|
||||||
|
self._db.add_favorite(
|
||||||
|
site_id=site_id,
|
||||||
|
post_id=post.id,
|
||||||
|
file_url=post.file_url,
|
||||||
|
preview_url=post.preview_url,
|
||||||
|
tags=post.tags,
|
||||||
|
rating=post.rating,
|
||||||
|
score=post.score,
|
||||||
|
source=post.source,
|
||||||
|
cached_path=str(path),
|
||||||
|
)
|
||||||
|
self._set_status(f"Favorited #{post.id}")
|
||||||
|
try:
|
||||||
|
post_list = self.query_one("#post-list", PostList)
|
||||||
|
await post_list.set_posts(self._posts, self._db, site_id)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self._set_status(f"Error: {e}")
|
||||||
|
|
||||||
|
self.run_worker(_fav())
|
||||||
|
|
||||||
|
def action_open_in_default(self) -> None:
|
||||||
|
post_list = self.query_one("#post-list", PostList)
|
||||||
|
idx = post_list.selected_index
|
||||||
|
if idx < 0 or idx >= len(self._posts):
|
||||||
|
return
|
||||||
|
post = self._posts[idx]
|
||||||
|
from ..core.cache import cached_path_for
|
||||||
|
path = cached_path_for(post.file_url)
|
||||||
|
if path.exists():
|
||||||
|
import subprocess, sys
|
||||||
|
if sys.platform == "linux":
|
||||||
|
subprocess.Popen(["xdg-open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", str(path)])
|
||||||
|
else:
|
||||||
|
import os
|
||||||
|
os.startfile(str(path))
|
||||||
|
self._set_status(f"Opened #{post.id}")
|
||||||
|
else:
|
||||||
|
self._set_status("Not cached — press Enter to download first")
|
||||||
|
|
||||||
|
def action_show_info(self) -> None:
|
||||||
|
self._show_info = not self._show_info
|
||||||
|
if self._show_info:
|
||||||
|
post_list = self.query_one("#post-list", PostList)
|
||||||
|
self._update_info(post_list.selected_index)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.query_one("#info-bar", InfoBar).update("Info hidden (press i)")
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def action_cycle_site(self) -> None:
|
||||||
|
sites = self._db.get_sites()
|
||||||
|
if not sites:
|
||||||
|
self._set_status("No sites configured")
|
||||||
|
return
|
||||||
|
if self._current_site:
|
||||||
|
ids = [s.id for s in sites]
|
||||||
|
try:
|
||||||
|
idx = ids.index(self._current_site.id)
|
||||||
|
next_site = sites[(idx + 1) % len(sites)]
|
||||||
|
except ValueError:
|
||||||
|
next_site = sites[0]
|
||||||
|
else:
|
||||||
|
next_site = sites[0]
|
||||||
|
self._current_site = next_site
|
||||||
|
try:
|
||||||
|
self.query_one("#site-label", Label).update(f"[{next_site.name}]")
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
self._set_status(f"Switched to {next_site.name}")
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
self._db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
app = BooruTUI()
|
||||||
|
app.run()
|
||||||
42
booru_viewer/tui/favorites.py
Normal file
42
booru_viewer/tui/favorites.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""Favorites browser panel for the TUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual.widgets import Static, Input
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
|
||||||
|
from ..core.db import Database, Favorite
|
||||||
|
from ..core.config import GREEN, DIM_GREEN
|
||||||
|
|
||||||
|
|
||||||
|
class FavoritesPanel(Static):
|
||||||
|
"""Browse local favorites."""
|
||||||
|
|
||||||
|
def __init__(self, db: Database, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._db = db
|
||||||
|
self._favorites: list[Favorite] = []
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.refresh_list()
|
||||||
|
|
||||||
|
def refresh_list(self, search: str | None = None) -> None:
|
||||||
|
self._favorites = self._db.get_favorites(search=search, limit=100)
|
||||||
|
total = self._db.favorite_count()
|
||||||
|
|
||||||
|
if not self._favorites:
|
||||||
|
self.update(" No favorites yet.\n Press 'f' on a post to favorite it.")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f" Favorites ({len(self._favorites)}/{total}):\n"]
|
||||||
|
for fav in self._favorites:
|
||||||
|
cached = "cached" if fav.cached_path and Path(fav.cached_path).exists() else "remote"
|
||||||
|
tags_preview = " ".join(fav.tags.split()[:5])
|
||||||
|
if len(fav.tags.split()) > 5:
|
||||||
|
tags_preview += "..."
|
||||||
|
lines.append(
|
||||||
|
f" #{fav.post_id} [{cached}] {tags_preview}"
|
||||||
|
)
|
||||||
|
self.update("\n".join(lines))
|
||||||
143
booru_viewer/tui/grid.py
Normal file
143
booru_viewer/tui/grid.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""Thumbnail grid widget for the Textual TUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.widgets import Static
|
||||||
|
from textual.reactive import reactive
|
||||||
|
|
||||||
|
from ..core.api.base import Post
|
||||||
|
from ..core.db import Database
|
||||||
|
from ..core.config import GREEN, DIM_GREEN, BG
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailCell(Static):
|
||||||
|
"""A single post cell in the grid."""
|
||||||
|
|
||||||
|
def __init__(self, index: int, post: Post, favorited: bool = False) -> None:
|
||||||
|
self._index = index
|
||||||
|
self._post = post
|
||||||
|
self._favorited = favorited
|
||||||
|
self._selected = False
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def compose_content(self) -> str:
|
||||||
|
fav = " *" if self._favorited else ""
|
||||||
|
rating = self._post.rating or "?"
|
||||||
|
return (
|
||||||
|
f"#{self._post.id}{fav}\n"
|
||||||
|
f"[{rating}] s:{self._post.score}\n"
|
||||||
|
f"{self._post.width}x{self._post.height}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.update(self.compose_content())
|
||||||
|
self._apply_style()
|
||||||
|
|
||||||
|
def set_selected(self, selected: bool) -> None:
|
||||||
|
self._selected = selected
|
||||||
|
self._apply_style()
|
||||||
|
|
||||||
|
def set_favorited(self, favorited: bool) -> None:
|
||||||
|
self._favorited = favorited
|
||||||
|
self.update(self.compose_content())
|
||||||
|
|
||||||
|
def _apply_style(self) -> None:
|
||||||
|
if self._selected:
|
||||||
|
self.styles.background = DIM_GREEN
|
||||||
|
self.styles.color = BG
|
||||||
|
self.styles.border = ("solid", GREEN)
|
||||||
|
else:
|
||||||
|
self.styles.background = BG
|
||||||
|
self.styles.color = GREEN if self._favorited else DIM_GREEN
|
||||||
|
self.styles.border = ("solid", DIM_GREEN)
|
||||||
|
|
||||||
|
def on_click(self) -> None:
|
||||||
|
self.post_message(CellClicked(self._index))
|
||||||
|
|
||||||
|
|
||||||
|
class CellClicked:
|
||||||
|
"""Message sent when a cell is clicked."""
|
||||||
|
def __init__(self, index: int) -> None:
|
||||||
|
self.index = index
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailGrid(Static):
|
||||||
|
"""Grid of post cells with keyboard navigation."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("j", "move_down", "Down", show=False),
|
||||||
|
Binding("k", "move_up", "Up", show=False),
|
||||||
|
Binding("h", "move_left", "Left", show=False),
|
||||||
|
Binding("l", "move_right", "Right", show=False),
|
||||||
|
Binding("down", "move_down", "Down", show=False),
|
||||||
|
Binding("up", "move_up", "Up", show=False),
|
||||||
|
Binding("left", "move_left", "Left", show=False),
|
||||||
|
Binding("right", "move_right", "Right", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
selected_index: int = reactive(-1, init=False)
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._cells: list[ThumbnailCell] = []
|
||||||
|
self._posts: list[Post] = []
|
||||||
|
self._cols = 4
|
||||||
|
self.can_focus = True
|
||||||
|
|
||||||
|
def set_posts(
|
||||||
|
self, posts: list[Post], db: Database | None = None, site_id: int | None = None
|
||||||
|
) -> None:
|
||||||
|
self._posts = posts
|
||||||
|
# Remove old cells
|
||||||
|
for cell in self._cells:
|
||||||
|
cell.remove()
|
||||||
|
self._cells.clear()
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for i, post in enumerate(posts):
|
||||||
|
fav = False
|
||||||
|
if db and site_id:
|
||||||
|
fav = db.is_favorited(site_id, post.id)
|
||||||
|
|
||||||
|
fav_marker = " *" if fav else ""
|
||||||
|
rating = post.rating or "?"
|
||||||
|
selected = " >> " if i == 0 else " "
|
||||||
|
lines.append(
|
||||||
|
f"{selected}#{post.id}{fav_marker} [{rating}] "
|
||||||
|
f"s:{post.score} {post.width}x{post.height}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.selected_index = 0 if posts else -1
|
||||||
|
self.update("\n".join(lines) if lines else "No results. Search for tags above.")
|
||||||
|
|
||||||
|
def update_favorite_status(self, index: int, favorited: bool) -> None:
|
||||||
|
"""Refresh the display for a single post's favorite status."""
|
||||||
|
if 0 <= index < len(self._posts):
|
||||||
|
self.set_posts(self._posts) # Simple refresh
|
||||||
|
|
||||||
|
def _render_list(self) -> None:
|
||||||
|
lines = []
|
||||||
|
for i, post in enumerate(self._posts):
|
||||||
|
selected = " >> " if i == self.selected_index else " "
|
||||||
|
lines.append(
|
||||||
|
f"{selected}#{post.id} [{post.rating or '?'}] "
|
||||||
|
f"s:{post.score} {post.width}x{post.height}"
|
||||||
|
)
|
||||||
|
self.update("\n".join(lines) if lines else "No results.")
|
||||||
|
|
||||||
|
def action_move_down(self) -> None:
|
||||||
|
if self._posts and self.selected_index < len(self._posts) - 1:
|
||||||
|
self.selected_index += 1
|
||||||
|
self._render_list()
|
||||||
|
|
||||||
|
def action_move_up(self) -> None:
|
||||||
|
if self._posts and self.selected_index > 0:
|
||||||
|
self.selected_index -= 1
|
||||||
|
self._render_list()
|
||||||
|
|
||||||
|
def action_move_right(self) -> None:
|
||||||
|
self.action_move_down()
|
||||||
|
|
||||||
|
def action_move_left(self) -> None:
|
||||||
|
self.action_move_up()
|
||||||
92
booru_viewer/tui/preview.py
Normal file
92
booru_viewer/tui/preview.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Image preview widget with Kitty graphics protocol support."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from ..core.config import GREEN, DIM_GREEN, BG
|
||||||
|
|
||||||
|
|
||||||
|
def _supports_kitty() -> bool:
|
||||||
|
"""Check if the terminal likely supports the Kitty graphics protocol."""
|
||||||
|
term = os.environ.get("TERM", "")
|
||||||
|
term_program = os.environ.get("TERM_PROGRAM", "")
|
||||||
|
return "kitty" in term or "kitty" in term_program
|
||||||
|
|
||||||
|
|
||||||
|
def _kitty_display(path: str, cols: int = 80, rows: int = 24) -> str:
|
||||||
|
"""Generate Kitty graphics protocol escape sequence for an image."""
|
||||||
|
try:
|
||||||
|
data = Path(path).read_bytes()
|
||||||
|
b64 = base64.standard_b64encode(data).decode("ascii")
|
||||||
|
|
||||||
|
# Send in chunks (Kitty protocol requires chunked transfer for large images)
|
||||||
|
chunks = [b64[i:i + 4096] for i in range(0, len(b64), 4096)]
|
||||||
|
output = ""
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
is_last = i == len(chunks) - 1
|
||||||
|
m = 0 if is_last else 1
|
||||||
|
if i == 0:
|
||||||
|
output += f"\033_Ga=T,f=100,m={m},c={cols},r={rows};{chunk}\033\\"
|
||||||
|
else:
|
||||||
|
output += f"\033_Gm={m};{chunk}\033\\"
|
||||||
|
return output
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePreview(Static):
|
||||||
|
"""Image preview panel. Uses Kitty graphics protocol on supported terminals,
|
||||||
|
otherwise shows image metadata."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._path: str | None = None
|
||||||
|
self._info: str = ""
|
||||||
|
self._use_kitty = _supports_kitty()
|
||||||
|
|
||||||
|
def show_image(self, path: str, info: str = "") -> None:
|
||||||
|
self._path = path
|
||||||
|
self._info = info
|
||||||
|
|
||||||
|
if self._use_kitty and self._path:
|
||||||
|
# Write Kitty escape directly to terminal, show info in widget
|
||||||
|
size = self.size
|
||||||
|
kitty_seq = _kitty_display(path, cols=size.width, rows=size.height - 2)
|
||||||
|
if kitty_seq:
|
||||||
|
sys.stdout.write(kitty_seq)
|
||||||
|
sys.stdout.flush()
|
||||||
|
self.update(f"\n{info}")
|
||||||
|
else:
|
||||||
|
# Fallback: show file info
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(path) as img:
|
||||||
|
w, h = img.size
|
||||||
|
fmt = img.format or "unknown"
|
||||||
|
size_kb = Path(path).stat().st_size / 1024
|
||||||
|
text = (
|
||||||
|
f" Image: {Path(path).name}\n"
|
||||||
|
f" Size: {w}x{h} ({size_kb:.0f} KB)\n"
|
||||||
|
f" Format: {fmt}\n"
|
||||||
|
f"\n {info}\n"
|
||||||
|
f"\n (Kitty graphics protocol not detected;\n"
|
||||||
|
f" run in Kitty terminal for inline preview)"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
text = f" {info}\n\n (Cannot read image)"
|
||||||
|
self.update(text)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._path = None
|
||||||
|
self._info = ""
|
||||||
|
if self._use_kitty:
|
||||||
|
# Clear Kitty images
|
||||||
|
sys.stdout.write("\033_Ga=d;\033\\")
|
||||||
|
sys.stdout.flush()
|
||||||
|
self.update("")
|
||||||
12
booru_viewer/tui/search.py
Normal file
12
booru_viewer/tui/search.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"""Search input widget for the TUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.widgets import Input
|
||||||
|
|
||||||
|
|
||||||
|
class SearchInput(Input):
|
||||||
|
"""Tag search input with styling."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
46
booru_viewer/tui/sites.py
Normal file
46
booru_viewer/tui/sites.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""Site manager panel for the TUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from textual.widgets import Static, Input, Button, Label
|
||||||
|
from textual.containers import Vertical
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
|
||||||
|
from ..core.db import Database
|
||||||
|
from ..core.api.detect import detect_site_type
|
||||||
|
from ..core.config import GREEN, DIM_GREEN, BG
|
||||||
|
|
||||||
|
|
||||||
|
class SitePanel(Static):
|
||||||
|
"""Site management panel."""
|
||||||
|
|
||||||
|
def __init__(self, db: Database, **kwargs) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._db = db
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.refresh_list()
|
||||||
|
|
||||||
|
def refresh_list(self) -> None:
|
||||||
|
sites = self._db.get_sites(enabled_only=False)
|
||||||
|
if not sites:
|
||||||
|
self.update(
|
||||||
|
" No sites configured.\n\n"
|
||||||
|
" Use the GUI (booru-gui) to add sites,\n"
|
||||||
|
" or add them via Python:\n\n"
|
||||||
|
" from booru_viewer.core.db import Database\n"
|
||||||
|
" db = Database()\n"
|
||||||
|
" db.add_site('Danbooru', 'https://danbooru.donmai.us', 'danbooru')\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [" Sites:\n"]
|
||||||
|
for site in sites:
|
||||||
|
status = "ON" if site.enabled else "OFF"
|
||||||
|
lines.append(
|
||||||
|
f" [{status}] {site.name} ({site.api_type}) {site.url}"
|
||||||
|
)
|
||||||
|
lines.append("\n (Manage sites via GUI or Python API)")
|
||||||
|
self.update("\n".join(lines))
|
||||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "booru-viewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Local booru image browser with Qt6 GUI and Textual TUI"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"httpx[http2]>=0.27",
|
||||||
|
"Pillow>=10.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
gui = ["PySide6>=6.6"]
|
||||||
|
tui = ["textual>=0.50"]
|
||||||
|
all = ["booru-viewer[gui,tui]"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
booru-gui = "booru_viewer.main_gui:main"
|
||||||
|
booru-tui = "booru_viewer.main_tui:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["booru_viewer"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = ["booru_viewer"]
|
||||||
Loading…
x
Reference in New Issue
Block a user