332 Commits

Author SHA1 Message Date
pax
eb58d76bc0 Route async work through one persistent loop, lock shared httpx + DB writes
Mixing `threading.Thread + asyncio.run` workers with the long-lived
asyncio loop in gui/app.py is a real loop-affinity bug: the first worker
thread to call `asyncio.run` constructs a throwaway loop, which the
shared httpx clients then attach to, and the next call from the
persistent loop fails with "Event loop is closed" / "attached to a
different loop". This commit eliminates the pattern across the GUI and
adds the locking + cleanup that should have been there from the start.

Persistent loop accessor (core/concurrency.py — new)
- set_app_loop / get_app_loop / run_on_app_loop. BooruApp registers the
  one persistent loop at startup; everything that wants to schedule
  async work calls run_on_app_loop instead of spawning a thread that
  builds its own loop. Three functions, ~30 lines, single source of
  truth for "the loop".

Lazy-init lock + cleanup on shared httpx clients (core/api/base.py,
core/api/e621.py, core/cache.py)
- Each shared singleton (BooruClient._shared_client, E621Client._e621_client,
  cache._shared_client) now uses fast-path / locked-slow-path lazy init.
  Concurrent first-callers from the same loop can no longer both build
  a client and leak one (verified: 10 racing callers => 1 httpx instance).
- Each module exposes an aclose helper that BooruApp.closeEvent runs via
  run_coroutine_threadsafe(...).result(timeout=5) BEFORE stopping the
  loop. The connection pool, keepalive sockets, and TLS state finally
  release cleanly instead of being abandoned at process exit.
- E621Client tracks UA-change leftovers in _e621_to_close so the old
  client doesn't leak when api_user changes — drained in aclose_shared.

GUI workers routed through the persistent loop (gui/sites.py,
gui/bookmarks.py)
- SiteDialog._on_detect / _on_test: replaced
  `threading.Thread(target=lambda: asyncio.run(...))` with
  run_on_app_loop. Results marshaled back through Qt Signals connected
  with QueuedConnection. Added _closed flag + _inflight futures list:
  closeEvent cancels pending coroutines and shorts out the result emit
  if the user closes the dialog mid-detect (no use-after-free on
  destroyed QObject).
- BookmarksView._load_thumb_async: same swap. The existing thumb_ready
  signal already used QueuedConnection so the marshaling side was
  already correct.

DB write serialization (core/db.py)
- Database._write_lock = threading.RLock() — RLock not Lock so a
  writing method can call another writing method on the same thread
  without self-deadlocking.
- New _write() context manager composes the lock + sqlite3's connection
  context manager (the latter handles BEGIN / COMMIT / ROLLBACK
  atomically). Every write method converted: add_site, update_site,
  delete_site, add_bookmark, add_bookmarks_batch, remove_bookmark,
  update_bookmark_cache_path, add_folder, remove_folder, rename_folder,
  move_bookmark_to_folder, add/remove_blacklisted_tag,
  add/remove_blacklisted_post, save_library_meta, remove_library_meta,
  set_setting, add_search_history, clear_search_history,
  remove_search_history, add_saved_search, remove_saved_search.
- _migrate keeps using the lock + raw _conn context manager because
  it runs from inside the conn property's lazy init (where _write()
  would re-enter conn).
- Reads stay lock-free and rely on WAL for reader concurrency. Verified
  under contention: 5 threads × 50 add_bookmark calls => 250 rows,
  zero corruption, zero "database is locked" errors.

Smoke-tested with seven scenarios: get_app_loop raises before set,
run_on_app_loop round-trips, lazy init creates exactly one client,
10 concurrent first-callers => 1 httpx, aclose_shared cleans up,
RLock allows nested re-acquire, multi-threaded write contention.
2026-04-07 17:24:23 -05:00
pax
54ccc40477 Defensive hardening across core/* and popout overlay fix
Sweep of defensive hardening across the core layers plus a related popout
overlay regression that surfaced during verification.

Database integrity (core/db.py)
- Wrap delete_site, add_search_history, remove_folder, rename_folder,
  and _migrate in `with self.conn:` so partial commits can't leave
  orphan rows on a crash mid-method.
- add_bookmark re-SELECTs the existing id when INSERT OR IGNORE
  collides on (site_id, post_id). Was returning Bookmark(id=0)
  silently, which then no-op'd update_bookmark_cache_path the next
  time the post was bookmarked.
- get_bookmarks LIKE clauses now ESCAPE '%', '_', '\\' so user search
  literals stop acting as SQL wildcards (cat_ear no longer matches
  catear).

Path traversal (core/db.py + core/config.py)
- Validate folder names at write time via _validate_folder_name —
  rejects '..', os.sep, leading '.' / '~'. Permits Unicode/spaces/
  parens so existing folders keep working.
- saved_folder_dir() resolves the candidate path and refuses anything
  that doesn't relative_to the saved-images base. Defense in depth
  against folder strings that bypass the write-time validator.
- gui/bookmarks.py and gui/app.py wrap add_folder calls in try/except
  ValueError and surface a QMessageBox.warning instead of crashing.

Download safety (core/cache.py)
- New _do_download(): payloads >=50MB stream to a tempfile in the
  destination dir and atomically os.replace into place; smaller
  payloads keep the existing buffer-then-write fast path. Both
  enforce a 500MB hard cap against the advertised Content-Length AND
  the running total inside the chunk loop (servers can lie).
- Per-URL asyncio.Lock coalesces concurrent downloads of the same
  URL so two callers don't race write_bytes on the same path.
- Image.MAX_IMAGE_PIXELS = 256M with DecompressionBombError handling
  in both converters.
- _convert_ugoira_to_gif checks frame count + cumulative uncompressed
  size against UGOIRA_MAX_FRAMES / UGOIRA_MAX_UNCOMPRESSED_BYTES from
  ZipInfo headers BEFORE decompressing — defends against zip bombs.
- _convert_animated_to_gif writes a .convfailed sentinel sibling on
  failure to break the re-decode-on-every-paint loop for malformed
  animated PNGs/WebPs.
- _is_valid_media returns True (don't delete) on OSError so a
  transient EBUSY/permissions hiccup no longer triggers a delete +
  re-download loop on every access.
- _referer_for() uses proper hostname suffix matching, not substring
  `in` (imgblahgelbooru.attacker.com no longer maps to gelbooru.com).
- PIL handles wrapped in `with` blocks for deterministic cleanup.

API client retry + visibility (core/api/*)
- base.py: _request retries on httpx.NetworkError + ConnectError in
  addition to TimeoutException. test_connection no longer echoes the
  HTTP response body in the error string (it was an SSRF body-leak
  gadget when used via detect_site_type's redirect-following client).
- detect.py + danbooru.py + e621.py + gelbooru.py + moebooru.py:
  every previously-swallowed exception in search/autocomplete/probe
  paths now logs at WARNING with type, message, and (where relevant)
  the response body prefix. Debugging "the site isn't working" used
  to be a total blackout.

main_gui.py
- file_dialog_platform DB probe failure prints to stderr instead of
  vanishing.

Popout overlay (gui/preview.py + gui/app.py)
- preview.py:79,141 — setAttribute(WA_StyledBackground, True) on
  _slideshow_toolbar and _slideshow_controls. Plain QWidget parents
  silently ignore QSS `background:` declarations without this
  attribute, which is why the popout overlay strip was rendering
  fully transparent (buttons styled, bar behind them showing the
  letterbox color).
- app.py: bake _BASE_POPOUT_OVERLAY_QSS as a fallback prepended
  before the user's custom.qss in the loader. Custom themes that
  don't define overlay rules now still get a translucent black
  bar with white text + hairline borders. Bundled themes win on
  tie because their identical-specificity rules come last in the
  prepended string.
2026-04-07 17:24:19 -05:00
pax
7d02aa8588 Drop parenthetical hints from search placeholder text — live search and -negative tags can be inferred 2026-04-07 15:46:35 -05:00
pax
d501ccf69a Match native Qt+Fusion sizing across themed widgets (~23px uniform toolbar row), drop score +/- buttons, force score/page spinbox height to match 2026-04-07 15:42:36 -05:00
pax
81ce926c88 Live search in bookmarks/library (debounced) + 3-state library count label with QSS-targetable libraryCountState property 2026-04-07 15:26:00 -05:00
pax
6d68652e61 Bookmarks/library/preview toolbars: compact button padding, 4px splitter pad, uniform 30px row height; library drops unreachable set_missing call 2026-04-07 15:19:24 -05:00
pax
3824d382c3 Square thumbnail selection border, Qt-targetable selection colors, indicator row swap, drop dead missing-indicator code 2026-04-07 15:19:16 -05:00
pax
0eab860088 Persist info panel visibility and right splitter sizes across sessions 2026-04-07 14:41:28 -05:00
pax
6c1a98a827 QSS @palette/${} preprocessor + theme overhaul: themable popout overlays, slider square, mpv letterbox via QPalette, embedded controls under media, compact toolbar buttons 2026-04-07 14:41:00 -05:00
pax
1712fc5836 Side-by-side +/- spinbox buttons + auto-derived dialog min height so cache fields can't clip 2026-04-07 14:40:22 -05:00
pax
dded54c435 Selection border on thumbnails uses pen-aware QRectF + rounded corners (smooth, even, no off-by-one) 2026-04-07 14:40:16 -05:00
pax
463f77d8bb Make info panel tag colors QSS-targetable, delete dead theme.py + green palette constants 2026-04-07 13:15:31 -05:00
pax
72150fc98b Add BOORU_VIEWER_NO_HYPR_RULES + BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK env vars for ricers with their own windowrules 2026-04-07 12:27:22 -05:00
pax
33293dfbae Wrap video Next loop to start of bookmarks/library list at end of media 2026-04-07 11:41:26 -05:00
pax
6d3d27d9d5 Persist main window state — splitter sizes, geometry, floating, maximized; flush on close 2026-04-07 11:40:19 -05:00
pax
389e455ac0 Fix Open in Browser/Default App on bookmarks and library tabs (route per active tab, drop random-cache fallback) 2026-04-07 11:37:24 -05:00
pax
74f948a3e8 Speed up page loads — pre-fetch bookmarks/cache as sets, off-load PIL conversion to a worker 2026-04-07 11:36:23 -05:00
pax
2b9bf22249 Refresh popout BL Tag menu when navigating between bookmarked posts 2026-04-07 11:13:46 -05:00
pax
8ef40dc0fe Restore popout windowed position on F11 exit (defer fit, disable Hyprland anim, dedupe video-params) 2026-04-07 11:13:43 -05:00
pax
56cb5ce1df Scroll tilt navigates one cell/post in grid, preview, and popout 2026-04-07 08:50:13 -05:00
pax
92b7a16ab2 Restore popout position via hyprctl on first fit (Wayland ignores Qt setGeometry for child windows) 2026-04-06 21:36:40 -05:00
pax
2f3161f974 Save popout position from hyprctl on close (Wayland can't report position to Qt) 2026-04-06 19:51:35 -05:00
pax
7004f38668 Popout max height 90% of screen 2026-04-06 19:25:04 -05:00
pax
37082a55c1 Consolidate popout sizing into single _fit_to_content function 2026-04-06 19:23:42 -05:00
pax
f2a85bb634 Scale up landscape content if window too narrow (min 250px height) 2026-04-06 19:17:40 -05:00
pax
803b5f5b24 Remove landscape minimum, respect user's saved window width with 85% height cap 2026-04-06 19:15:17 -05:00
pax
4cf094f517 Revert user-resize tracking, keep simple min/max constraints 2026-04-06 19:14:08 -05:00
pax
c0a189192e Popout respects user resize per session, resets when stretched to minimum 2026-04-06 19:12:34 -05:00
pax
1de6f02ed0 Bump landscape popout minimum to 45% of screen 2026-04-06 19:03:06 -05:00
pax
0a1fbb7906 Landscape popout minimum width 35% of screen 2026-04-06 19:02:07 -05:00
pax
aaf33dd7c7 Limit popout height to 85% of screen for portrait content 2026-04-06 19:00:22 -05:00
pax
924e065e65 Set cached_path on bookmark thumbnails for drag and copy 2026-04-06 15:20:50 -05:00
pax
0e6e7090ff Unset keep_aspect_ratio before resize to allow aspect ratio changes 2026-04-06 15:15:10 -05:00
pax
f295e51d59 Clamp popout to both screen width and height on aspect change 2026-04-06 15:07:43 -05:00
pax
5e91e7ebb9 Fix popout overlay zone detection: map cursor to window coordinates 2026-04-06 14:16:23 -05:00
pax
c6c4df1e77 Tighten popout overlay trigger zones to 40px 2026-04-06 14:14:51 -05:00
pax
e01aa86063 Popout overlay: toolbar shows near top edge, controls near bottom 2026-04-06 14:13:40 -05:00
pax
84726f9677 Clamp popout height to screen bounds on landscape-to-portrait transition 2026-04-06 14:00:32 -05:00
pax
2fbf2f6472 0.2.0: mpv backend, popout viewer, preview toolbar, API retry, SearchState refactor
Video:
- Replace Qt Multimedia with mpv via python-mpv + OpenGL render API
- Hardware-accelerated decoding, frame-accurate seeking, proper EOF detection
- Translucent overlay controls in both preview and popout
- LC_NUMERIC=C for mpv locale compatibility

Popout viewer (renamed from slideshow):
- Floating toolbar + controls overlay with auto-hide (2s)
- Window auto-resizes to content aspect ratio on navigation
- Hyprland: hyprctl resizewindowpixel + keep_aspect_ratio prop
- Window geometry persisted to DB across sessions
- Smart F11 exit sizing (60% monitor, centered)

Preview toolbar:
- Bookmark, Save, BL Tag, BL Post, Popout buttons above preview
- Save opens folder picker menu, shows Save/Unsave state
- Blacklist actions have confirmation dialogs
- Per-tab button visibility (Library: Save + Popout only)
- Cross-tab state management with grid selection clearing

Search & pagination:
- SearchState dataclass replaces 8 scattered attrs + defensive getattr
- Media type filter dropdown (All/Animated/Video/GIF/Audio)
- API retry with backoff on 429/503/timeout
- Infinite scroll dedup fix (local seen set per backfill round)
- Prev/Next buttons hide at boundaries, "(end)" status indicator

Grid:
- Rubber band drag selection
- Saved/bookmarked dots update instantly across all tabs
- Library/bookmarks emit signals on file deletion for cross-tab sync

Settings & misc:
- Default site option
- Max thumbnail cache setting (500MB default)
- Source URLs clickable in info panel
- Long URLs truncated to prevent splitter blowout
- Bulk save no longer auto-bookmarks
2026-04-06 13:43:46 -05:00
pax
b30a469dde Slideshow defaults to fullscreen, remembers windowed size on F11 2026-04-06 01:27:17 -05:00
pax
bda92a0a87 Instant infinite scroll drain, trigger 3 rows early
Old staggered drain (50ms per post) was added for visual polish but
made infinite scroll painfully slow — a 40-post page took 2 seconds
just to add to the grid. Thumbnails already load async via _fetch_thumbnail,
so the stagger was just delaying grid population for no real benefit.

Now all posts are added instantly in one pass with thumbnails filling in
as they arrive. Scroll trigger widened from 1 row to 3 rows from bottom
so the next page starts loading before you reach the end.
2026-04-06 01:01:53 -05:00
pax
a93a8bc70f Pause video when opening in external application 2026-04-05 23:40:17 -05:00
pax
5f4af78e91 Apply saved thumbnail size on startup 2026-04-05 22:17:13 -05:00
pax
1ac3706e96 Trigger infinite scroll when splitter/resize removes scrollbar 2026-04-05 22:15:21 -05:00
pax
57475098e2 Fix infinite scroll not triggering when results don't fill viewport 2026-04-05 22:12:32 -05:00
pax
56b802b745 Simplify combobox dropdown styling — let Fusion draw its own arrow 2026-04-05 21:55:47 -05:00
pax
76acb8bb67 Fix combobox dropdown arrow styling on Windows dark mode 2026-04-05 21:48:58 -05:00
pax
1a5dbff1bb Clean up dead code and unused imports 2026-04-05 21:30:47 -05:00
pax
8467c0696b Add post date to info line 2026-04-05 21:15:22 -05:00
pax
e22cde2a27 Add start-from-page field in top bar 2026-04-05 21:11:34 -05:00