commit b10c00d6bf9f6dc8e8d471d0d3e0ec34ed2e744e Author: pax Date: Sat Apr 4 06:00:50 2026 -0500 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc20fdc --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.venv/ +venv/ +project.md +*.bak/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c08a73 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..63e1723 --- /dev/null +++ b/README.md @@ -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 diff --git a/booru-viewer.spec b/booru-viewer.spec new file mode 100644 index 0000000..8020404 --- /dev/null +++ b/booru-viewer.spec @@ -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', +) diff --git a/booru_viewer/__init__.py b/booru_viewer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/booru_viewer/core/__init__.py b/booru_viewer/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/booru_viewer/core/api/__init__.py b/booru_viewer/core/api/__init__.py new file mode 100644 index 0000000..1512d2a --- /dev/null +++ b/booru_viewer/core/api/__init__.py @@ -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", +] diff --git a/booru_viewer/core/api/base.py b/booru_viewer/core/api/base.py new file mode 100644 index 0000000..8ea114a --- /dev/null +++ b/booru_viewer/core/api/base.py @@ -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) diff --git a/booru_viewer/core/api/danbooru.py b/booru_viewer/core/api/danbooru.py new file mode 100644 index 0000000..4d4d832 --- /dev/null +++ b/booru_viewer/core/api/danbooru.py @@ -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 "" diff --git a/booru_viewer/core/api/detect.py b/booru_viewer/core/api/detect.py new file mode 100644 index 0000000..c34c995 --- /dev/null +++ b/booru_viewer/core/api/detect.py @@ -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) diff --git a/booru_viewer/core/api/e621.py b/booru_viewer/core/api/e621.py new file mode 100644 index 0000000..d0142a9 --- /dev/null +++ b/booru_viewer/core/api/e621.py @@ -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") diff --git a/booru_viewer/core/api/gelbooru.py b/booru_viewer/core/api/gelbooru.py new file mode 100644 index 0000000..6eb63f8 --- /dev/null +++ b/booru_viewer/core/api/gelbooru.py @@ -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 [] diff --git a/booru_viewer/core/api/moebooru.py b/booru_viewer/core/api/moebooru.py new file mode 100644 index 0000000..12fb19c --- /dev/null +++ b/booru_viewer/core/api/moebooru.py @@ -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 [] diff --git a/booru_viewer/core/cache.py b/booru_viewer/core/cache.py new file mode 100644 index 0000000..341f6cb --- /dev/null +++ b/booru_viewer/core/cache.py @@ -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' 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 diff --git a/booru_viewer/core/config.py b/booru_viewer/core/config.py new file mode 100644 index 0000000..c9852ca --- /dev/null +++ b/booru_viewer/core/config.py @@ -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") diff --git a/booru_viewer/core/db.py b/booru_viewer/core/db.py new file mode 100644 index 0000000..124595d --- /dev/null +++ b/booru_viewer/core/db.py @@ -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() diff --git a/booru_viewer/core/images.py b/booru_viewer/core/images.py new file mode 100644 index 0000000..3e12175 --- /dev/null +++ b/booru_viewer/core/images.py @@ -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 diff --git a/booru_viewer/gui/__init__.py b/booru_viewer/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/booru_viewer/gui/app.py b/booru_viewer/gui/app.py new file mode 100644 index 0000000..9f6569f --- /dev/null +++ b/booru_viewer/gui/app.py @@ -0,0 +1,1320 @@ +"""Main Qt6 application window.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import subprocess +import sys +import threading +from pathlib import Path + +from PySide6.QtCore import Qt, QTimer, Signal, Slot, QObject, QUrl +from PySide6.QtGui import QPixmap, QAction, QKeySequence, QDesktopServices, QShortcut +from PySide6.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, + QStackedWidget, + QComboBox, + QLabel, + QPushButton, + QStatusBar, + QSplitter, + QMessageBox, + QTextEdit, + QMenu, + QFileDialog, + QSpinBox, + QScrollArea, + QProgressBar, +) + +from ..core.db import Database, Site +from ..core.api.base import BooruClient, Post +from ..core.api.detect import client_for_type +from ..core.cache import download_image, download_thumbnail, cache_size_bytes, evict_oldest +from ..core.config import MEDIA_EXTENSIONS + +from .grid import ThumbnailGrid +from .preview import ImagePreview +from .search import SearchBar +from .sites import SiteManagerDialog +from .favorites import FavoritesView +from .settings import SettingsDialog + +log = logging.getLogger("booru") + + +class LogHandler(logging.Handler, QObject): + """Logging handler that emits to a QTextEdit.""" + + log_signal = Signal(str) + + def __init__(self, widget: QTextEdit) -> None: + logging.Handler.__init__(self) + QObject.__init__(self) + self._widget = widget + self.log_signal.connect(self._append) + self.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s %(message)s", datefmt="%H:%M:%S")) + + def emit(self, record: logging.LogRecord) -> None: + msg = self.format(record) + self.log_signal.emit(msg) + + def _append(self, msg: str) -> None: + self._widget.append(msg) + sb = self._widget.verticalScrollBar() + sb.setValue(sb.maximum()) + + +class AsyncSignals(QObject): + """Signals for async worker results.""" + search_done = Signal(list) + search_error = Signal(str) + thumb_done = Signal(int, str) + image_done = Signal(str, str) + image_error = Signal(str) + fav_done = Signal(int, str) + fav_error = Signal(str) + autocomplete_done = Signal(list) + batch_progress = Signal(int, int) # current, total + batch_done = Signal(str) + download_progress = Signal(int, int) # bytes_downloaded, total_bytes + + +# -- Info Panel -- + +class InfoPanel(QWidget): + """Toggleable panel showing post details.""" + + tag_clicked = Signal(str) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + + self._title = QLabel("No post selected") + self._title.setStyleSheet("font-weight: bold;") + layout.addWidget(self._title) + + self._details = QLabel() + self._details.setWordWrap(True) + self._details.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + layout.addWidget(self._details) + + self._tags_label = QLabel("Tags:") + self._tags_label.setStyleSheet("font-weight: bold; margin-top: 8px;") + layout.addWidget(self._tags_label) + + self._tags_scroll = QScrollArea() + self._tags_scroll.setWidgetResizable(True) + self._tags_scroll.setStyleSheet("QScrollArea { border: none; }") + self._tags_widget = QWidget() + self._tags_flow = QVBoxLayout(self._tags_widget) + self._tags_flow.setContentsMargins(0, 0, 0, 0) + self._tags_flow.setSpacing(2) + self._tags_scroll.setWidget(self._tags_widget) + layout.addWidget(self._tags_scroll, stretch=1) + + def set_post(self, post: Post) -> None: + self._title.setText(f"Post #{post.id}") + self._details.setText( + f"Size: {post.width}x{post.height}\n" + f"Score: {post.score}\n" + f"Rating: {post.rating or 'unknown'}\n" + f"Source: {post.source or 'none'}" + ) + # Clear old tags + while self._tags_flow.count(): + item = self._tags_flow.takeAt(0) + if item.widget(): + item.widget().deleteLater() + # Add clickable tags + for tag in post.tag_list[:100]: + btn = QPushButton(tag) + btn.setFlat(True) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet( + "QPushButton { text-align: left; padding: 1px 4px; border: none; }" + ) + btn.clicked.connect(lambda checked, t=tag: self.tag_clicked.emit(t)) + self._tags_flow.addWidget(btn) + self._tags_flow.addStretch() + + def clear(self) -> None: + self._title.setText("No post selected") + self._details.setText("") + while self._tags_flow.count(): + item = self._tags_flow.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + + +# -- Main App -- + +class BooruApp(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("booru-viewer") + self.setMinimumSize(900, 600) + self.resize(1200, 800) + + self._db = Database() + self._current_site: Site | None = None + self._posts: list[Post] = [] + self._current_page = 1 + self._current_tags = "" + self._current_rating = "all" + self._min_score = 0 + self._loading = False + self._last_scroll_page = 0 + self._signals = AsyncSignals() + + self._setup_signals() + self._setup_ui() + self._setup_menu() + self._load_sites() + + def _setup_signals(self) -> None: + Q = Qt.ConnectionType.QueuedConnection + s = self._signals + s.search_done.connect(self._on_search_done, Q) + s.search_error.connect(self._on_search_error, Q) + s.thumb_done.connect(self._on_thumb_done, Q) + s.image_done.connect(self._on_image_done, Q) + s.image_error.connect(self._on_image_error, Q) + s.fav_done.connect(self._on_fav_done, Q) + s.fav_error.connect(self._on_fav_error, Q) + s.autocomplete_done.connect(self._on_autocomplete_done, Q) + s.batch_progress.connect(self._on_batch_progress, Q) + s.batch_done.connect(lambda m: self._status.showMessage(m), Q) + s.download_progress.connect(self._on_download_progress, Q) + + def _clear_loading(self) -> None: + self._loading = False + + def _on_search_error(self, e: str) -> None: + self._loading = False + self._status.showMessage(f"Error: {e}") + + def _on_image_error(self, e: str) -> None: + self._dl_progress.hide() + self._status.showMessage(f"Error: {e}") + + def _on_fav_error(self, e: str) -> None: + self._status.showMessage(f"Error: {e}") + + def _run_async(self, coro_func, *args): + def _worker(): + try: + asyncio.run(coro_func(*args)) + except Exception as e: + log.error(f"Async worker failed: {e}") + threading.Thread(target=_worker, daemon=True).start() + + def _setup_ui(self) -> None: + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + + # Top bar: site selector + rating + search + top = QHBoxLayout() + + self._site_combo = QComboBox() + self._site_combo.setMinimumWidth(150) + self._site_combo.currentIndexChanged.connect(self._on_site_changed) + top.addWidget(self._site_combo) + + # Rating filter + self._rating_combo = QComboBox() + self._rating_combo.addItems(["All", "General", "Sensitive", "Questionable", "Explicit"]) + self._rating_combo.setMinimumWidth(100) + self._rating_combo.currentTextChanged.connect(self._on_rating_changed) + top.addWidget(self._rating_combo) + + # Score filter + score_label = QLabel("Score≥") + top.addWidget(score_label) + self._score_spin = QSpinBox() + self._score_spin.setRange(0, 99999) + self._score_spin.setValue(0) + self._score_spin.setFixedWidth(70) + top.addWidget(self._score_spin) + + self._search_bar = SearchBar(db=self._db) + self._search_bar.search_requested.connect(self._on_search) + self._search_bar.autocomplete_requested.connect(self._request_autocomplete) + top.addWidget(self._search_bar, stretch=1) + + layout.addLayout(top) + + # Nav bar + nav = QHBoxLayout() + self._browse_btn = QPushButton("Browse") + self._browse_btn.setCheckable(True) + self._browse_btn.setChecked(True) + self._browse_btn.clicked.connect(lambda: self._switch_view(0)) + nav.addWidget(self._browse_btn) + + self._fav_btn = QPushButton("Favorites") + self._fav_btn.setCheckable(True) + self._fav_btn.clicked.connect(lambda: self._switch_view(1)) + nav.addWidget(self._fav_btn) + + nav.addStretch() + + self._page_label = QLabel("Page 1") + nav.addWidget(self._page_label) + + prev_btn = QPushButton("Prev") + prev_btn.setFixedWidth(60) + prev_btn.clicked.connect(self._prev_page) + nav.addWidget(prev_btn) + + next_btn = QPushButton("Next") + next_btn.setFixedWidth(60) + next_btn.clicked.connect(self._next_page) + nav.addWidget(next_btn) + + layout.addLayout(nav) + + # Main content + self._splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left: stacked views + self._stack = QStackedWidget() + + self._grid = ThumbnailGrid() + self._grid.post_selected.connect(self._on_post_selected) + self._grid.post_activated.connect(self._on_post_activated) + self._grid.context_requested.connect(self._on_context_menu) + self._grid.multi_context_requested.connect(self._on_multi_context_menu) + self._grid.reached_bottom.connect(self._scroll_next_page) + self._grid.reached_top.connect(self._scroll_prev_page) + self._stack.addWidget(self._grid) + + self._favorites_view = FavoritesView(self._db) + self._favorites_view.favorite_activated.connect(self._on_favorite_activated) + self._stack.addWidget(self._favorites_view) + + self._splitter.addWidget(self._stack) + + # Right: preview + info (vertical split) + right = QSplitter(Qt.Orientation.Vertical) + + self._preview = ImagePreview() + self._preview.close_requested.connect(self._close_preview) + self._preview.open_in_default.connect(self._open_preview_in_default) + self._preview.open_in_browser.connect(self._open_preview_in_browser) + self._preview.favorite_requested.connect(self._favorite_from_preview) + self._preview.save_to_folder.connect(self._save_from_preview) + self._preview.navigate.connect(self._navigate_preview) + self._preview.set_folders_callback(self._db.get_folders) + self._preview.setMinimumWidth(300) + right.addWidget(self._preview) + + self._dl_progress = QProgressBar() + self._dl_progress.setMaximumHeight(6) + self._dl_progress.setTextVisible(False) + self._dl_progress.hide() + right.addWidget(self._dl_progress) + + self._info_panel = InfoPanel() + self._info_panel.tag_clicked.connect(self._on_tag_clicked) + self._info_panel.setMinimumHeight(100) + self._info_panel.hide() + right.addWidget(self._info_panel) + + right.setSizes([500, 0, 200]) + self._splitter.addWidget(right) + + self._splitter.setSizes([600, 500]) + layout.addWidget(self._splitter, stretch=1) + + # Log panel + self._log_text = QTextEdit() + self._log_text.setReadOnly(True) + self._log_text.setMaximumHeight(150) + self._log_text.setStyleSheet("font-family: monospace; font-size: 11px;") + self._log_text.hide() + layout.addWidget(self._log_text) + + # Hook up logging + self._log_handler = LogHandler(self._log_text) + self._log_handler.setLevel(logging.DEBUG) + logging.getLogger("booru").addHandler(self._log_handler) + logging.getLogger("booru").setLevel(logging.DEBUG) + + # Status bar + self._status = QStatusBar() + self.setStatusBar(self._status) + self._status.showMessage("Ready") + + # Global shortcuts for preview navigation + QShortcut(QKeySequence("Ctrl+Left"), self, lambda: self._navigate_preview(-1)) + QShortcut(QKeySequence("Ctrl+Right"), self, lambda: self._navigate_preview(1)) + + def _setup_menu(self) -> None: + menu = self.menuBar() + file_menu = menu.addMenu("&File") + + sites_action = QAction("&Manage Sites...", self) + sites_action.setShortcut(QKeySequence("Ctrl+S")) + sites_action.triggered.connect(self._open_site_manager) + file_menu.addAction(sites_action) + + settings_action = QAction("Se&ttings...", self) + settings_action.setShortcut(QKeySequence("Ctrl+,")) + settings_action.triggered.connect(self._open_settings) + file_menu.addAction(settings_action) + + file_menu.addSeparator() + + batch_action = QAction("Batch &Download Page...", self) + batch_action.setShortcut(QKeySequence("Ctrl+D")) + batch_action.triggered.connect(self._batch_download) + file_menu.addAction(batch_action) + + file_menu.addSeparator() + + quit_action = QAction("&Quit", self) + quit_action.setShortcut(QKeySequence("Ctrl+Q")) + quit_action.triggered.connect(self.close) + file_menu.addAction(quit_action) + + view_menu = menu.addMenu("&View") + + info_action = QAction("Toggle &Info Panel", self) + info_action.setShortcut(QKeySequence("Ctrl+I")) + info_action.triggered.connect(self._toggle_info) + view_menu.addAction(info_action) + + log_action = QAction("Toggle &Log", self) + log_action.setShortcut(QKeySequence("Ctrl+L")) + log_action.triggered.connect(self._toggle_log) + view_menu.addAction(log_action) + + view_menu.addSeparator() + + fullscreen_action = QAction("&Fullscreen", self) + fullscreen_action.setShortcut(QKeySequence("F11")) + fullscreen_action.triggered.connect(self._toggle_fullscreen) + view_menu.addAction(fullscreen_action) + + privacy_action = QAction("&Privacy Screen", self) + privacy_action.setShortcut(QKeySequence("Ctrl+P")) + privacy_action.triggered.connect(self._toggle_privacy) + view_menu.addAction(privacy_action) + + def _load_sites(self) -> None: + self._site_combo.clear() + for site in self._db.get_sites(): + self._site_combo.addItem(site.name, site.id) + + def _make_client(self) -> BooruClient | None: + 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) + + def _on_site_changed(self, index: int) -> None: + if index < 0: + self._current_site = None + return + site_id = self._site_combo.currentData() + sites = self._db.get_sites() + site = next((s for s in sites if s.id == site_id), None) + if not site: + return + self._current_site = site + self._status.showMessage(f"Connected to {site.name}") + + def _on_rating_changed(self, text: str) -> None: + self._current_rating = text.lower() + + def _switch_view(self, index: int) -> None: + self._stack.setCurrentIndex(index) + self._browse_btn.setChecked(index == 0) + self._fav_btn.setChecked(index == 1) + if index == 1: + self._favorites_view.refresh() + + def _on_tag_clicked(self, tag: str) -> None: + self._search_bar.set_text(tag) + self._on_search(tag) + + # -- Search -- + + def _on_search(self, tags: str) -> None: + self._current_tags = tags + self._current_page = 1 + self._min_score = self._score_spin.value() + self._do_search() + + def _prev_page(self) -> None: + if self._current_page > 1: + self._current_page -= 1 + self._do_search() + + def _next_page(self) -> None: + if self._loading: + return + self._current_page += 1 + self._do_search() + + def _scroll_next_page(self) -> None: + if self._loading: + return + self._current_page += 1 + self._do_search() + + def _scroll_prev_page(self) -> None: + if self._loading or self._current_page <= 1: + return + self._current_page -= 1 + self._do_search() + + def _build_search_tags(self) -> str: + """Build tag string with rating filter and negative tags.""" + parts = [] + if self._current_tags: + parts.append(self._current_tags) + + # Rating filter — site-specific syntax + # Danbooru/Gelbooru: 4-tier (general, sensitive, questionable, explicit) + # Moebooru/e621: 3-tier (safe, questionable, explicit) + rating = self._current_rating + if rating != "all" and self._current_site: + api = self._current_site.api_type + if api == "danbooru": + # Danbooru accepts both full words and single letters + danbooru_map = { + "general": "g", "sensitive": "s", + "questionable": "q", "explicit": "e", + } + if rating in danbooru_map: + parts.append(f"rating:{danbooru_map[rating]}") + elif api == "gelbooru": + # Gelbooru requires full words, no abbreviations + gelbooru_map = { + "general": "general", "sensitive": "sensitive", + "questionable": "questionable", "explicit": "explicit", + } + if rating in gelbooru_map: + parts.append(f"rating:{gelbooru_map[rating]}") + elif api == "e621": + # e621: 3-tier (s/q/e), accepts both full words and letters + e621_map = { + "general": "s", "sensitive": "s", + "questionable": "q", "explicit": "e", + } + if rating in e621_map: + parts.append(f"rating:{e621_map[rating]}") + else: + # Moebooru (yande.re, konachan) — 3-tier, full words work + # "general" and "sensitive" don't exist, map to "safe" + moebooru_map = { + "general": "safe", "sensitive": "safe", + "questionable": "questionable", "explicit": "explicit", + } + if rating in moebooru_map: + parts.append(f"rating:{moebooru_map[rating]}") + + # Append blacklisted tags as negatives + for tag in self._db.get_blacklisted_tags(): + parts.append(f"-{tag}") + + return " ".join(parts) + + def _do_search(self) -> None: + if not self._current_site: + self._status.showMessage("No site selected") + return + self._loading = True + self._page_label.setText(f"Page {self._current_page}") + self._status.showMessage("Searching...") + + search_tags = self._build_search_tags() + log.info(f"Search: tags='{search_tags}' rating={self._current_rating}") + page = self._current_page + min_score = self._min_score + limit = self._db.get_setting_int("page_size") or 40 + + async def _search(): + client = self._make_client() + try: + posts = await client.search(tags=search_tags, page=page, limit=limit) + # Client-side score filter + if min_score > 0: + posts = [p for p in posts if p.score >= min_score] + self._signals.search_done.emit(posts) + except Exception as e: + self._signals.search_error.emit(str(e)) + finally: + await client.close() + + self._run_async(_search) + + def _on_search_done(self, posts: list) -> None: + self._posts = posts + self._status.showMessage(f"{len(posts)} results") + thumbs = self._grid.set_posts(len(posts)) + self._grid.scroll_to_top() + # Clear loading after a brief delay so scroll signals don't re-trigger + QTimer.singleShot(100, self._clear_loading) + + from ..core.config import saved_dir, saved_folder_dir + site_id = self._site_combo.currentData() + for i, (post, thumb) in enumerate(zip(posts, thumbs)): + if site_id and self._db.is_favorited(site_id, post.id): + thumb.set_favorited(True) + # Check if saved to library (not just cached) + saved = any( + (saved_dir() / f"{post.id}{ext}").exists() + for ext in MEDIA_EXTENSIONS + ) + if not saved: + # Check folders + favs = self._db.get_favorites(site_id=site_id) + for f in favs: + if f.post_id == post.id and f.folder: + saved = any( + (saved_folder_dir(f.folder) / f"{post.id}{ext}").exists() + for ext in MEDIA_EXTENSIONS + ) + break + thumb.set_saved_locally(saved) + # Set drag path from cache + from ..core.cache import cached_path_for + cached = cached_path_for(post.file_url) + if cached.exists(): + thumb._cached_path = str(cached) + + if post.preview_url: + self._fetch_thumbnail(i, post.preview_url) + + self._grid.setFocus() + + def _fetch_thumbnail(self, index: int, url: str) -> None: + async def _download(): + try: + path = await download_thumbnail(url) + self._signals.thumb_done.emit(index, str(path)) + except Exception as e: + log.warning(f"Thumb #{index} failed: {e}") + self._run_async(_download) + + def _on_thumb_done(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) + + # -- Autocomplete -- + + def _request_autocomplete(self, query: str) -> None: + if not self._current_site or len(query) < 2: + return + + async def _ac(): + client = self._make_client() + try: + results = await client.autocomplete(query) + self._signals.autocomplete_done.emit(results) + except Exception: + pass + finally: + await client.close() + + self._run_async(_ac) + + def _on_autocomplete_done(self, suggestions: list) -> None: + self._search_bar.set_suggestions(suggestions) + + # -- Post selection / preview -- + + def _on_post_selected(self, index: int) -> None: + multi = self._grid.selected_indices + if len(multi) > 1: + self._status.showMessage(f"{len(multi)} posts selected") + return + if 0 <= index < len(self._posts): + post = self._posts[index] + self._status.showMessage( + f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}]" + ) + if self._info_panel.isVisible(): + self._info_panel.set_post(post) + self._on_post_activated(index) + + def _on_post_activated(self, index: int) -> None: + if 0 <= index < len(self._posts): + post = self._posts[index] + log.info(f"Preview: #{post.id} -> {post.file_url}") + self._status.showMessage(f"Loading #{post.id}...") + self._dl_progress.show() + self._dl_progress.setRange(0, 0) + + def _progress(downloaded, total): + self._signals.download_progress.emit(downloaded, total) + + async def _load(): + try: + path = await download_image(post.file_url, progress_callback=_progress) + info = f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}]" + self._signals.image_done.emit(str(path), info) + except Exception as e: + log.error(f"Image download failed: {e}") + self._signals.image_error.emit(str(e)) + + self._run_async(_load) + + def _on_download_progress(self, downloaded: int, total: int) -> None: + if total > 0: + self._dl_progress.setRange(0, total) + self._dl_progress.setValue(downloaded) + self._dl_progress.show() + mb = downloaded / (1024 * 1024) + total_mb = total / (1024 * 1024) + self._status.showMessage(f"Downloading... {mb:.1f}/{total_mb:.1f} MB") + else: + self._dl_progress.setRange(0, 0) # indeterminate + self._dl_progress.show() + + def _on_image_done(self, path: str, info: str) -> None: + self._dl_progress.hide() + self._preview.set_media(path, info) + self._status.showMessage("Loaded") + # Update drag path on the selected thumbnail + idx = self._grid.selected_index + if 0 <= idx < len(self._grid._thumbs): + self._grid._thumbs[idx]._cached_path = path + + def _on_favorite_activated(self, fav) -> None: + info = f"Favorite #{fav.post_id}" + + # Try local cache first + if fav.cached_path and Path(fav.cached_path).exists(): + self._preview.set_media(fav.cached_path, info) + return + + # Try saved library + from ..core.config import saved_dir, saved_folder_dir + search_dirs = [saved_dir()] + if fav.folder: + search_dirs.insert(0, saved_folder_dir(fav.folder)) + for d in search_dirs: + for ext in MEDIA_EXTENSIONS: + path = d / f"{fav.post_id}{ext}" + if path.exists(): + self._preview.set_media(str(path), info) + return + + # Download it + self._status.showMessage(f"Downloading #{fav.post_id}...") + + async def _dl(): + try: + path = await download_image(fav.file_url) + # Update cached_path in DB + self._db.update_favorite_cache_path(fav.id, str(path)) + info = f"Favorite #{fav.post_id}" + self._signals.image_done.emit(str(path), info) + except Exception as e: + self._signals.image_error.emit(str(e)) + + self._run_async(_dl) + + def _open_preview_in_default(self) -> None: + # Try the currently selected post first + idx = self._grid.selected_index + if 0 <= idx < len(self._posts): + self._open_in_default(self._posts[idx]) + return + # Fall back to finding any cached image that matches the preview + from ..core.cache import cache_dir + from PySide6.QtGui import QDesktopServices + # Open the most recently modified file in cache + cache = cache_dir() + files = sorted(cache.iterdir(), key=lambda f: f.stat().st_mtime, reverse=True) + for f in files: + if f.is_file() and f.suffix in ('.jpg', '.jpeg', '.png', '.gif', '.webp'): + QDesktopServices.openUrl(QUrl.fromLocalFile(str(f))) + return + + def _open_preview_in_browser(self) -> None: + idx = self._grid.selected_index + if 0 <= idx < len(self._posts): + self._open_in_browser(self._posts[idx]) + + def _navigate_preview(self, direction: int) -> None: + """Navigate to prev/next post in the preview. direction: -1 or +1.""" + idx = self._grid.selected_index + direction + log.info(f"Navigate: direction={direction} current={self._grid.selected_index} next={idx} total={len(self._posts)}") + if 0 <= idx < len(self._posts): + self._grid._select(idx) + self._on_post_activated(idx) + + def _favorite_from_preview(self) -> None: + idx = self._grid.selected_index + if 0 <= idx < len(self._posts): + self._toggle_favorite(idx) + + def _save_from_preview(self, folder: str) -> None: + idx = self._grid.selected_index + if 0 <= idx < len(self._posts): + target = folder if folder else None + if folder and folder not in self._db.get_folders(): + self._db.add_folder(folder) + self._save_to_library(self._posts[idx], target) + + def _close_preview(self) -> None: + self._preview.clear() + + # -- Context menu -- + + def _on_context_menu(self, index: int, pos) -> None: + if index < 0 or index >= len(self._posts): + return + post = self._posts[index] + menu = QMenu(self) + + open_browser = menu.addAction("Open in Browser") + 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") + menu.addSeparator() + fav_action = menu.addAction("Unfavorite" if self._is_current_favorited(index) else "Favorite") + menu.addSeparator() + bl_menu = menu.addMenu("Blacklist Tag") + for tag in post.tag_list[:20]: + bl_menu.addAction(tag) + + action = menu.exec(pos) + if not action: + return + + if action == open_browser: + self._open_in_browser(post) + elif action == open_default: + self._open_in_default(post) + elif action == save_as: + self._save_as(post) + elif action == save_lib_unsorted: + self._save_to_library(post, None) + elif action == save_lib_new: + from PySide6.QtWidgets import QInputDialog + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if ok and name.strip(): + self._db.add_folder(name.strip()) + self._save_to_library(post, name.strip()) + elif id(action) in save_lib_folders: + self._save_to_library(post, save_lib_folders[id(action)]) + elif action == copy_url: + QApplication.clipboard().setText(post.file_url) + self._status.showMessage("URL copied") + elif action == copy_tags: + QApplication.clipboard().setText(post.tags) + self._status.showMessage("Tags copied") + elif action == fav_action: + self._toggle_favorite(index) + elif action.parent() == bl_menu: + tag = action.text() + self._db.add_blacklisted_tag(tag) + self._status.showMessage(f"Blacklisted: {tag}") + + def _on_multi_context_menu(self, indices: list, pos) -> None: + """Context menu for multi-selected posts.""" + posts = [self._posts[i] for i in indices if 0 <= i < len(self._posts)] + if not posts: + return + count = len(posts) + + menu = QMenu(self) + fav_all = menu.addAction(f"Favorite All ({count})") + + save_menu = menu.addMenu(f"Save All to Library ({count})") + save_unsorted = save_menu.addAction("Unsorted") + save_folder_actions = {} + for folder in self._db.get_folders(): + a = save_menu.addAction(folder) + save_folder_actions[id(a)] = folder + save_menu.addSeparator() + save_new = save_menu.addAction("+ New Folder...") + + menu.addSeparator() + unfav_all = menu.addAction(f"Unfavorite All ({count})") + menu.addSeparator() + batch_dl = menu.addAction(f"Download All ({count})...") + copy_urls = menu.addAction("Copy All URLs") + + action = menu.exec(pos) + if not action: + return + + if action == fav_all: + self._bulk_favorite(indices, posts) + elif action == save_unsorted: + self._bulk_save(indices, posts, None) + elif action == save_new: + from PySide6.QtWidgets import QInputDialog + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if ok and name.strip(): + self._db.add_folder(name.strip()) + self._bulk_save(indices, posts, name.strip()) + elif id(action) in save_folder_actions: + self._bulk_save(indices, posts, save_folder_actions[id(action)]) + elif action == batch_dl: + from .dialogs import select_directory + dest = select_directory(self, "Download to folder") + if dest: + self._batch_download_posts(posts, dest) + elif action == unfav_all: + site_id = self._site_combo.currentData() + if site_id: + from ..core.cache import delete_from_library + from ..core.config import saved_dir, saved_folder_dir + for post in posts: + # Delete from unsorted library + delete_from_library(post.id, None) + # Delete from all folders + for folder in self._db.get_folders(): + delete_from_library(post.id, folder) + self._db.remove_favorite(site_id, post.id) + for idx in indices: + if 0 <= idx < len(self._grid._thumbs): + self._grid._thumbs[idx].set_favorited(False) + self._grid._thumbs[idx].set_saved_locally(False) + self._grid._clear_multi() + self._status.showMessage(f"Unfavorited {count} posts") + elif action == copy_urls: + urls = "\n".join(p.file_url for p in posts) + QApplication.clipboard().setText(urls) + self._status.showMessage(f"Copied {count} URLs") + + def _bulk_favorite(self, indices: list[int], posts: list[Post]) -> None: + site_id = self._site_combo.currentData() + if not site_id: + return + self._status.showMessage(f"Favoriting {len(posts)}...") + + async def _do(): + for i, (idx, post) in enumerate(zip(indices, posts)): + if self._db.is_favorited(site_id, post.id): + continue + 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._signals.fav_done.emit(idx, f"Favorited {i+1}/{len(posts)}") + except Exception: + pass + self._signals.batch_done.emit(f"Favorited {len(posts)} posts") + + self._run_async(_do) + + def _bulk_save(self, indices: list[int], posts: list[Post], folder: str | None) -> None: + site_id = self._site_combo.currentData() + where = folder or "Unsorted" + self._status.showMessage(f"Saving {len(posts)} to {where}...") + + async def _do(): + from ..core.config import saved_dir, saved_folder_dir + import shutil + for i, (idx, post) in enumerate(zip(indices, posts)): + try: + path = await download_image(post.file_url) + ext = Path(path).suffix + dest_dir = saved_folder_dir(folder) if folder else saved_dir() + dest = dest_dir / f"{post.id}{ext}" + if not dest.exists(): + shutil.copy2(path, dest) + if site_id and not self._db.is_favorited(site_id, post.id): + 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), folder=folder, + ) + self._signals.fav_done.emit(idx, f"Saved {i+1}/{len(posts)} to {where}") + except Exception: + pass + self._signals.batch_done.emit(f"Saved {len(posts)} to {where}") + + self._run_async(_do) + + def _toggle_favorite_if_not(self, post: Post) -> None: + """Favorite a post if not already favorited.""" + site_id = self._site_combo.currentData() + if not site_id or self._db.is_favorited(site_id, post.id): + return + + async def _fav(): + 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), + ) + except Exception: + pass + + self._run_async(_fav) + + def _batch_download_posts(self, posts: list, dest: str) -> None: + async def _batch(): + for i, post in enumerate(posts): + try: + path = await download_image(post.file_url) + ext = Path(path).suffix + target = Path(dest) / f"{post.id}{ext}" + if not target.exists(): + import shutil + shutil.copy2(path, target) + self._signals.batch_progress.emit(i + 1, len(posts)) + except Exception as e: + log.warning(f"Batch #{post.id} failed: {e}") + self._signals.batch_done.emit(f"Downloaded {len(posts)} images to {dest}") + + self._run_async(_batch) + + def _is_current_favorited(self, index: int) -> bool: + site_id = self._site_combo.currentData() + if not site_id or index < 0 or index >= len(self._posts): + return False + return self._db.is_favorited(site_id, self._posts[index].id) + + def _open_in_browser(self, post: Post) -> None: + if self._current_site: + base = self._current_site.url + api = self._current_site.api_type + if api == "danbooru" or api == "e621": + url = f"{base}/posts/{post.id}" + elif api == "gelbooru": + url = f"{base}/index.php?page=post&s=view&id={post.id}" + elif api == "moebooru": + url = f"{base}/post/show/{post.id}" + else: + url = f"{base}/posts/{post.id}" + QDesktopServices.openUrl(QUrl(url)) + + def _open_in_default(self, post: Post) -> None: + from ..core.cache import cached_path_for, is_cached + path = cached_path_for(post.file_url) + if path.exists(): + QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) + else: + self._status.showMessage("Image not cached yet — double-click to download first") + + def _save_to_library(self, post: Post, folder: str | None) -> None: + """Download and save image to the library folder structure.""" + from ..core.config import saved_dir, saved_folder_dir + + self._status.showMessage(f"Saving #{post.id} to library...") + + async def _save(): + try: + path = await download_image(post.file_url) + ext = Path(path).suffix + if folder: + dest_dir = saved_folder_dir(folder) + else: + dest_dir = saved_dir() + dest = dest_dir / f"{post.id}{ext}" + if not dest.exists(): + import shutil + shutil.copy2(path, dest) + + # Also favorite it with the folder + site_id = self._site_combo.currentData() + if site_id and not self._db.is_favorited(site_id, post.id): + 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), + folder=folder, + ) + elif site_id and folder: + # Already favorited, just update the folder + favs = self._db.get_favorites(site_id=site_id) + for f in favs: + if f.post_id == post.id: + self._db.move_favorite_to_folder(f.id, folder) + break + + where = folder or "Unsorted" + self._signals.fav_done.emit( + self._grid.selected_index, + f"Saved #{post.id} to {where}" + ) + except Exception as e: + self._signals.fav_error.emit(str(e)) + + self._run_async(_save) + + def _save_as(self, post: Post) -> None: + from ..core.cache import cached_path_for + from .dialogs import save_file + src = cached_path_for(post.file_url) + if not src.exists(): + self._status.showMessage("Image not cached — double-click to download first") + return + ext = src.suffix + dest = save_file(self, "Save Image", f"post_{post.id}{ext}", f"Images (*{ext})") + if dest: + import shutil + shutil.copy2(src, dest) + self._status.showMessage(f"Saved to {dest}") + + # -- Batch download -- + + def _batch_download(self) -> None: + if not self._posts: + self._status.showMessage("No posts to download") + return + from .dialogs import select_directory + dest = select_directory(self, "Download to folder") + if not dest: + return + + posts = list(self._posts) + self._status.showMessage(f"Downloading {len(posts)} images...") + + async def _batch(): + for i, post in enumerate(posts): + try: + path = await download_image(post.file_url) + ext = Path(path).suffix + target = Path(dest) / f"{post.id}{ext}" + if not target.exists(): + import shutil + shutil.copy2(path, target) + self._signals.batch_progress.emit(i + 1, len(posts)) + except Exception as e: + log.warning(f"Batch #{post.id} failed: {e}") + self._signals.batch_done.emit(f"Downloaded {len(posts)} images to {dest}") + + self._run_async(_batch) + + def _on_batch_progress(self, current: int, total: int) -> None: + self._status.showMessage(f"Downloading {current}/{total}...") + + # -- Toggles -- + + def _toggle_log(self) -> None: + self._log_text.setVisible(not self._log_text.isVisible()) + + def _toggle_info(self) -> None: + self._info_panel.setVisible(not self._info_panel.isVisible()) + if self._info_panel.isVisible() and 0 <= self._grid.selected_index < len(self._posts): + self._info_panel.set_post(self._posts[self._grid.selected_index]) + + def _open_site_manager(self) -> None: + dlg = SiteManagerDialog(self._db, self) + dlg.sites_changed.connect(self._load_sites) + dlg.exec() + + def _open_settings(self) -> None: + dlg = SettingsDialog(self._db, self) + dlg.settings_changed.connect(self._apply_settings) + self._favorites_imported = False + dlg.favorites_imported.connect(lambda: setattr(self, '_favorites_imported', True)) + dlg.exec() + if self._favorites_imported: + self._switch_view(1) + self._favorites_view.refresh() + + def _apply_settings(self) -> None: + """Re-read settings from DB and apply to UI.""" + rating = self._db.get_setting("default_rating") + idx = self._rating_combo.findText(rating.capitalize() if rating != "all" else "All") + if idx >= 0: + self._rating_combo.setCurrentIndex(idx) + self._score_spin.setValue(self._db.get_setting_int("default_score")) + self._favorites_view.refresh() + self._status.showMessage("Settings applied") + + # -- Fullscreen & Privacy -- + + def _toggle_fullscreen(self) -> None: + if self.isFullScreen(): + self.showNormal() + else: + self.showFullScreen() + + # -- Privacy screen -- + + def _toggle_privacy(self) -> None: + if not hasattr(self, '_privacy_on'): + self._privacy_on = False + self._privacy_overlay = QWidget(self) + self._privacy_overlay.setStyleSheet("background: black;") + self._privacy_overlay.hide() + + self._privacy_on = not self._privacy_on + if self._privacy_on: + self._privacy_overlay.setGeometry(self.rect()) + self._privacy_overlay.raise_() + self._privacy_overlay.show() + self.setWindowTitle("booru-viewer") + else: + self._privacy_overlay.hide() + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + if hasattr(self, '_privacy_overlay') and self._privacy_on: + self._privacy_overlay.setGeometry(self.rect()) + + # -- Keyboard shortcuts -- + + def keyPressEvent(self, event) -> None: + key = event.key() + # Privacy screen always works + if key == Qt.Key.Key_P and event.modifiers() == Qt.KeyboardModifier.ControlModifier: + self._toggle_privacy() + return + # If privacy is on, only allow toggling it off + if hasattr(self, '_privacy_on') and self._privacy_on: + return + if key == Qt.Key.Key_F and self._posts: + idx = self._grid.selected_index + if 0 <= idx < len(self._posts): + self._toggle_favorite(idx) + return + elif key == Qt.Key.Key_I: + self._toggle_info() + return + elif key == Qt.Key.Key_O and self._posts: + idx = self._grid.selected_index + if 0 <= idx < len(self._posts): + self._open_in_default(self._posts[idx]) + return + super().keyPressEvent(event) + + # -- Favorites -- + + def _toggle_favorite(self, index: int) -> None: + post = self._posts[index] + site_id = self._site_combo.currentData() + if not site_id: + return + + if self._db.is_favorited(site_id, post.id): + # Delete from library if saved + favs = self._db.get_favorites(site_id=site_id) + for f in favs: + if f.post_id == post.id: + from ..core.cache import delete_from_library + delete_from_library(post.id, f.folder) + break + self._db.remove_favorite(site_id, post.id) + self._status.showMessage(f"Unfavorited #{post.id}") + thumbs = self._grid._thumbs + if 0 <= index < len(thumbs): + thumbs[index].set_favorited(False) + thumbs[index].set_saved_locally(False) + else: + self._status.showMessage(f"Favoriting #{post.id}...") + + async def _fav(): + 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._signals.fav_done.emit(index, f"Favorited #{post.id}") + except Exception as e: + self._signals.fav_error.emit(str(e)) + + self._run_async(_fav) + + def _on_fav_done(self, index: int, msg: str) -> None: + self._status.showMessage(msg) + thumbs = self._grid._thumbs + if 0 <= index < len(thumbs): + thumbs[index].set_favorited(True) + # Only green if actually saved to library, not just cached + if "Saved" in msg: + thumbs[index].set_saved_locally(True) + + def closeEvent(self, event) -> None: + self._db.close() + super().closeEvent(event) + + +def run() -> None: + from ..core.config import data_dir + + app = QApplication(sys.argv) + + # Load user custom stylesheet if it exists + custom_css = data_dir() / "custom.qss" + if custom_css.exists(): + try: + app.setStyleSheet(custom_css.read_text()) + except Exception: + pass + + # Set app icon (works in taskbar on all platforms) + from PySide6.QtGui import QIcon + # PyInstaller sets _MEIPASS for bundled data + base_dir = Path(getattr(sys, '_MEIPASS', Path(__file__).parent.parent.parent)) + icon_path = base_dir / "icon.png" + if not icon_path.exists(): + icon_path = Path(__file__).parent.parent.parent / "icon.png" + if not icon_path.exists(): + icon_path = data_dir() / "icon.png" + if icon_path.exists(): + app.setWindowIcon(QIcon(str(icon_path))) + + window = BooruApp() + window.show() + sys.exit(app.exec()) diff --git a/booru_viewer/gui/custom_css_guide.txt b/booru_viewer/gui/custom_css_guide.txt new file mode 100644 index 0000000..7ba61d6 --- /dev/null +++ b/booru_viewer/gui/custom_css_guide.txt @@ -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 diff --git a/booru_viewer/gui/dialogs.py b/booru_viewer/gui/dialogs.py new file mode 100644 index 0000000..6a616af --- /dev/null +++ b/booru_viewer/gui/dialogs.py @@ -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 diff --git a/booru_viewer/gui/favorites.py b/booru_viewer/gui/favorites.py new file mode 100644 index 0000000..8e41108 --- /dev/null +++ b/booru_viewer/gui/favorites.py @@ -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() diff --git a/booru_viewer/gui/grid.py b/booru_viewer/gui/grid.py new file mode 100644 index 0000000..6e8ba17 --- /dev/null +++ b/booru_viewer/gui/grid.py @@ -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()) diff --git a/booru_viewer/gui/preview.py b/booru_viewer/gui/preview.py new file mode 100644 index 0000000..7de0426 --- /dev/null +++ b/booru_viewer/gui/preview.py @@ -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) diff --git a/booru_viewer/gui/search.py b/booru_viewer/gui/search.py new file mode 100644 index 0000000..6578014 --- /dev/null +++ b/booru_viewer/gui/search.py @@ -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() diff --git a/booru_viewer/gui/settings.py b/booru_viewer/gui/settings.py new file mode 100644 index 0000000..6f0b725 --- /dev/null +++ b/booru_viewer/gui/settings.py @@ -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() diff --git a/booru_viewer/gui/sites.py b/booru_viewer/gui/sites.py new file mode 100644 index 0000000..24d6035 --- /dev/null +++ b/booru_viewer/gui/sites.py @@ -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() diff --git a/booru_viewer/gui/theme.py b/booru_viewer/gui/theme.py new file mode 100644 index 0000000..f288f3a --- /dev/null +++ b/booru_viewer/gui/theme.py @@ -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; +}} +""" diff --git a/booru_viewer/main_gui.py b/booru_viewer/main_gui.py new file mode 100644 index 0000000..f8af79f --- /dev/null +++ b/booru_viewer/main_gui.py @@ -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() diff --git a/booru_viewer/main_tui.py b/booru_viewer/main_tui.py new file mode 100644 index 0000000..4b16e9e --- /dev/null +++ b/booru_viewer/main_tui.py @@ -0,0 +1,10 @@ +"""TUI entry point.""" + + +def main() -> None: + from .tui.app import run + run() + + +if __name__ == "__main__": + main() diff --git a/booru_viewer/tui/__init__.py b/booru_viewer/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/booru_viewer/tui/app.py b/booru_viewer/tui/app.py new file mode 100644 index 0000000..7ebafd3 --- /dev/null +++ b/booru_viewer/tui/app.py @@ -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() diff --git a/booru_viewer/tui/favorites.py b/booru_viewer/tui/favorites.py new file mode 100644 index 0000000..5ebb78f --- /dev/null +++ b/booru_viewer/tui/favorites.py @@ -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)) diff --git a/booru_viewer/tui/grid.py b/booru_viewer/tui/grid.py new file mode 100644 index 0000000..691160f --- /dev/null +++ b/booru_viewer/tui/grid.py @@ -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() diff --git a/booru_viewer/tui/preview.py b/booru_viewer/tui/preview.py new file mode 100644 index 0000000..0ac5ee2 --- /dev/null +++ b/booru_viewer/tui/preview.py @@ -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("") diff --git a/booru_viewer/tui/search.py b/booru_viewer/tui/search.py new file mode 100644 index 0000000..3c585aa --- /dev/null +++ b/booru_viewer/tui/search.py @@ -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) diff --git a/booru_viewer/tui/sites.py b/booru_viewer/tui/sites.py new file mode 100644 index 0000000..0b94572 --- /dev/null +++ b/booru_viewer/tui/sites.py @@ -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)) diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..43ac92e Binary files /dev/null and b/icon.ico differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..c88d3b6 Binary files /dev/null and b/icon.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8c1e3f2 --- /dev/null +++ b/pyproject.toml @@ -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"]