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:
pax 2026-04-04 06:00:50 -05:00
commit b10c00d6bf
40 changed files with 6613 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.eggs/
*.egg
.venv/
venv/
project.md
*.bak/

21
LICENSE Normal file
View 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
View 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
View 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
View File

View File

View 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",
]

View 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)

View 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 ""

View 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)

View 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")

View 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 []

View 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
View 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

View 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
View 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()

View 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

View File

1320
booru_viewer/gui/app.py Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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

View 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
View 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
View 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
View 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()

View 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
View 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
View 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
View 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
View File

@ -0,0 +1,10 @@
"""TUI entry point."""
def main() -> None:
from .tui.app import run
run()
if __name__ == "__main__":
main()

View File

511
booru_viewer/tui/app.py Normal file
View 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()

View 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
View 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()

View 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("")

View 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
View 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))

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

28
pyproject.toml Normal file
View 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"]