-
v0.3.1 Stable
released this
2026-08-28 23:10:24 -05:00 | 0 commits to main since this releasev0.3.1
Fixed
- Clicking a slow-loading post (typically a large video) and then another post could end with the first post taking over the preview once its download finished, because
image_donewas applied regardless of which post was selected. Each activation now issues a load ticket; a download that lands after a newer click is dropped (its progress bar too) instead of replacing the preview. The download still completes into the cache, so returning to that post is instant - With a custom.qss loaded, the preview and popout toolbar glyphs (☆ ↓ ⊘ ⊗ ⧉) rendered clipped, most visibly the bookmark star losing its points, and the four buttons came out at different widths. The buttons are fixed at 24x24 and every bundled theme gives
QPushButton6px of horizontal padding plus a 1px border, leaving a 10px content box; the popout toolbar additionally styles them 15px bold under an ID selector. The buttons now carry aniconBtnproperty and the base stylesheet pins their padding, geometry, and font size with attribute selectors that outrank the theme rules, in both the preview pane and the popout - On Windows and other floating window managers the main window crept upward and grew by one title-bar height on every launch until the title bar was off the top of the screen and the window could no longer be dragged. The non-Hyprland path saved
frameGeometry()(frame included) and restored it withsetGeometry()(client area only), so each round trip shifted the client rect to where the frame used to be. Save and restore now use the same client rect, a saved rect that is off-screen or larger than the display is discarded, and with nothing valid saved the window opens centered at up to 80% of the screen. Behavior change: first launch and recovered installs start centered and modest instead of edge-to-edge
Downloads
- Clicking a slow-loading post (typically a large video) and then another post could end with the first post taking over the preview once its download finished, because
-
v0.3.0 Stable
released this
2026-08-23 22:09:47 -05:00 | 8 commits to main since this releasev0.3.0
Fixed
- Every in-code Hyprland dispatch was a no-op on Hyprland 0.56+, which replaced the string dispatch API with a Lua one — the popout's aspect lock, resize, move, and un-float-on-reopen all silently stopped working, indistinguishable from running with both opt-out env vars permanently set.
hyprctlexits 0 on the resulting Lua parse error, so nothing could detect it by return code. The app now probes the compositor once with a read-onlyhyprctl evaland emits whichever dialect it speaks; pre-0.56 builds keep the legacy strings. Behavior change: popout geometry and aspect lock work again on current Hyprland - The saved-locally dot was cross-site wrong:
library_metawas keyed onpost_idalone, so a post saved from one booru showed as saved on any other booru with the same post id. The library is now keyed on(site_id, post_id), and auser_version2 migration recovers each existing row's site offline from itsfile_urlhost. Behavior change: the saved-dot is per site - Saving a post whose id matched one already in the library from a different booru was treated as a re-save: the file was never written and the UI reported success. The save flow's identity check now compares
(site_id, post_id), so the existing collision resolver writes12345_1.jpginstead of dropping the image. Behavior change: same-id posts from different boorus both save
Added
%site%filename template token, alongside%artist%,%character%,%copyright%,%general%,%meta%,%species%- The preview info panel shows which site a post came from
- Right-click menu on info-panel tags: Search this tag, Add this tag
to search, Blacklist this tag - Multi-site search: a Multi tickbox turns the site selector into a
checkable list; results from the ticked sites interleave round-robin,
the info panel names each post's source site, and a failing site is
dropped for that page and named in the status bar instead of sinking
the whole search.
Downloads
- Every in-code Hyprland dispatch was a no-op on Hyprland 0.56+, which replaced the string dispatch API with a Lua one — the popout's aspect lock, resize, move, and un-float-on-reopen all silently stopped working, indistinguishable from running with both opt-out env vars permanently set.
-
v0.2.9 Stable
released this
2026-08-19 23:03:04 -05:00 | 35 commits to main since this releasev0.2.9
Fixed
- Sites whose
__batch_api_probe__sentinel was poisoned tofalseby a pre-0.2.8 transient network error now recover automatically. v0.2.8 stopped new poisoning but left existing rows stuck —CategoryFetcher._do_ensuresends aFalsestraight to the HTML scrape and never re-probes, so Settings → Clear Tag Cache was a required manual step on upgrade that users had no way to know about. A one-shot migration (PRAGMA user_version0 → 1) drops only thefalsesentinels; confirmed-workingtruerows are kept. Gated rather than unconditional because a bareDELETEwould make a genuinely broken tag API re-probe on every launch
Downloads
- Sites whose
-
v0.2.8 Stable
released this
2026-08-19 22:26:14 -05:00 | 39 commits to main since this releasev0.2.8
Added
- Settings → Cache: Clear Tag Cache button — wipes the per-site
tag_typesrows (including the__batch_api_probe__sentinel) so Gelbooru/Moebooru backends re-probe and re-populate tag categories from scratch. Useful when a stale cache from an earlier build leaves some category types mis-labelled or missing
Changed
- Windows installer now ships
licenses\LGPL-2.1.txtandlicenses\THIRD-PARTY-NOTICES.txtalongside the app, naming the exact libmpv build, its source and checksum, and how to swap the DLL — the attribution and relink terms LGPL section 6 asks for when redistributing the library - Windows installer now bundles the LGPL build of libmpv instead of the GPL build. booru-viewer decodes video and never encodes it, so the GPL-only x264/x265 encoders were unused weight in an MIT-licensed app; H.264/HEVC/AV1/VP9 decoding is unaffected
- Thumbnail drag-start threshold raised from 10px to 30px to match the rubber band's gate — small mouse wobbles on a thumb no longer trigger a file drag
- Settings → Cache layout: Clear Tag Cache moved into row 1 alongside Clear Thumbnails and Clear Image Cache as a 3-wide non-destructive row; destructive Clear Everything + Evict stay in row 2
Fixed
- rule34.xxx misdetected as
danbooru, then failing Test Connection withHTTP 403 Forbidden. rule34 now serves a WAF HTML block page (403) at/posts.json, anddetect_site_typeread any 401/403 there as "Danbooru behind auth". The Danbooru and Moebooru probes now only treat 401/403 as conclusive when the body is actually JSON, so WAF-guarded Gelbooru-family hosts fall through to the Gelbooru probe. Behavior change: rule34.xxx detects asgelbooru - Gelbooru-shape sites answering HTTP 200 with a bare JSON string were reported as a search with zero results. rule34 now requires
api_key+user_idand replies"Missing authentication..."at 200, whichraise_for_status()cannot see;GelbooruClientnow raisesBooruAuthErrorinstead, and the Gelbooru probe treats that shape as a positive match. Behavior change: a missing or rejected API key surfaces as an error rather than an empty grid - Grid blanked out after splitter drag or tile/float toggle until the next scroll —
ThumbnailGrid.resizeEventnow re-runs_recycle_offscreenagainst the new geometry so thumbs whose pixmap was evicted by a column-count shift get refreshed into view. Behavior change: no more blank grid after resize - Status bar overwrote the per-post info set by
_on_post_selectedwith"N results — Loaded"the moment the image finished downloading, hiding tag counts / post ID until the user re-clicked;on_image_donenow preserves the incominginfostring category_fetcher._do_ensureno longer permanently flips_batch_api_worksto False when a transient network error drops a tag-API request mid-call; the unprobed path now routes through_probe_batch_api, which distinguishes clean 200-with-zero-matches (structurally broken, flip) from timeout/HTTP-error (transient, retry next call)- Bookmark→library save and bookmark Save As now plumb the active site's
CategoryFetcherthrough to the filename template, so%artist%/%character%tokens render correctly instead of silently dropping out when saving a post that wasn't previewed first - Info panel no longer silently drops tags that failed to land in a cached category — any tag from
post.tag_listnot rendered under a known category section now appears in an "Other" bucket, so partial cache coverage can't make individual tags invisible BooruClient._requestretries now coverhttpx.RemoteProtocolErrorandhttpx.ReadErrorin addition to the existing timeout/connect/network set — an overloaded booru that drops the TCP connection mid-response no longer fails the whole search on the first try- VRAM retained when no video is playing —
stop()now frees the GL render context (textures + FBOs) instead of just dropping the hwdec surface pool. Context is recreated lazily on nextplay_file()viaensure_gl_init()(~5ms, invisible behind network fetch)
Refactored
category_fetcherbatch tag-API params are now built by a shared_build_tag_api_paramshelper instead of duplicated acrossfetch_via_tag_apiand_probe_batch_apidetect.detect_site_type— removed the leftoverif True:indent marker; no behavior changecore.http.make_client— single constructor for the threehttpx.AsyncClientinstances (cache download pool, API pool, detect probe). Each call site still keeps its own singleton and connection pool; only the construction is shared- Silent
except: passsites inpopout/window,video_player, andwindow_statenow carry one-line comments naming the absorbed failure and the graceful fallback (or were downgraded tolog.debug(..., exc_info=True)). No behavior change - Popout docstrings purged of in-flight-refactor commit markers (
skeleton,14a,14b,future commit) that referred to now-landed state-machine extraction; load-bearing commit 14b reference kept in_dispatch_and_applyas it still protects against reintroducing the bug core/cache.pytempfile cleanup:BaseExceptioncatch now documents why it's intentionally broader thanExceptionapi/e621andapi/moebooruJSON parse guards narrowed from bareexcepttoValueErrorgui/media/video_player.py—import timehoisted to module topgui/post_actions.is_in_library— deadtry/exceptstripped
Removed
- Unused
Favoritealias incore/db.py— callers migrated toBookmarkin 0.2.5, nothing referenced the fallback anymore
Downloads
- Settings → Cache: Clear Tag Cache button — wipes the per-site
-
v0.2.7 Stable
released this
2026-04-14 19:03:22 -05:00 | 74 commits to main since this releasev0.2.7
Fixed
- Popout always reopened as floating even when tiled at close — Hyprland tiled state is now persisted and restored via
settiledon reopen - Video stutter on network streams —
cache_pause_initialwas blocking first frame, reverted cache_pause changes and kept larger demuxer buffer - Rubber band selection state getting stuck across interrupted drags
- LIKE wildcards in
search_library_metanot being escaped - Copy File to Clipboard broken in preview pane and popout; added Copy Image URL action
- Thumbnail cleanup and Post ID sort broken for templated filenames in library
- Save/unsave bookmark UX — no flash on toggle, correct dot indicators
- Autocomplete broken for multi-tag queries
- Search not resetting to page 1 on new query
- Fade animation cleanup crashing
FlowLayout.clear - Privacy toggle not preserving video pause state
- Bookmarks grid not refreshing on unsave
_cached_pathnot set for streaming videos- Standard icon column showing in QMessageBox dialogs
- Popout aspect lock for bookmarks now reads actual image dimensions instead of guessing
- GPU resource leak on Mesa/Intel drivers —
mpv_render_context_freenow runs with the owning GL context current (NVIDIA tolerated the bug, other drivers did not) - Popout teardown
AttributeErrorwhencentralWidget()orQApplication.instance()returnedNoneduring init/shutdown race - Category fetcher rejects XML responses containing
<!DOCTYPEor<!ENTITYbefore parsing, blocking XXE and billion-laughs payloads from user-configured sites - VRAM not released on popout close —
video_playernow drops the hwdec surface pool on stop and popout runs explicit mpv cleanup before teardown - Popout open animation was being suppressed by the
no_animaspect-lock workaround — first fit after open now lets Hyprland'swindowsIn/popinplay; subsequent navigation fits still suppress anim to avoid resize flicker - Thumbnail grid blanking out after Hyprland tiled resize until a scroll/click — viewport is now force-updated at the end of
ThumbnailGrid.resizeEventso the Qt Wayland buffer stays in sync with the new geometry - Library video thumbnails captured from a black opening frame — mpv now seeks to 10% before the first frame decode so title cards, fade-ins, and codec warmup no longer produce a black thumbnail (delete
~/.cache/booru-viewer/thumbnails/library/to regenerate existing entries)
Changed
- Uncached videos now download via httpx in parallel with mpv streaming — file is cached immediately for copy/paste without waiting for playback to finish
- Library video thumbnails use mpv instead of ffmpeg — drops the ffmpeg dependency entirely
- Save/Unsave from Library mutually exclusive in context menus, preview pane, and popout
- S key guard consistent with B/F behavior
- Tag count limits removed from info panel
- Ctrl+S and Ctrl+D menu shortcuts removed (conflict-prone)
- Thumbnail fade-in shortened from 200ms to 80ms
- Default demuxer buffer reduced to 50MiB; streaming URLs still get 150MiB
- Minimum width set on thumbnail grid
- Popout overlay hover zone enlarged
- Settings dialog gets an Apply button; thumbnail size and flip layout apply live
- Tab selection preserved on view switch
- Scroll delta accumulated for volume control and zoom (smoother with hi-res scroll wheels)
- Force Fusion widget style when no
custom.qssis present - Dark Fusion palette applied as fallback when no system Qt theme file (
Trolltech.conf) is detected; KDE/GNOME users keep their own palette - Behavior change: popout re-fits window to current content's aspect and resets zoom when leaving a tiled layout to a different-aspect image or video; previously restored the old floating geometry with the wrong aspect lock
Performance
- Thumbnails re-decoded from disk on size change instead of holding full pixmaps in memory
- Off-screen thumbnail pixmaps recycled (decoded on demand from cached path)
- Lookup sets cached across infinite scroll appends; invalidated on bookmark/save
auto_evict_cachethrottled to once per 30s- Stale prefetch spirals cancelled on new click
- Single-pass directory walk in cache eviction functions
- GTK dialog platform detection cached instead of recreating Database per call
Removed
- Dead code:
core/images.py TODO.md- Unused imports across
main_window,grid,settings,dialogs,sites,search_controller,video_player,info_panel - Dead
midvariable ingrid.paintEvent, deadget_connection_logimport insettings._build_network_tab
Downloads
- Popout always reopened as floating even when tiled at close — Hyprland tiled state is now persisted and restored via
-
v0.2.6 Stable
released this
2026-04-11 16:45:40 -05:00 | 166 commits to main since this releasev0.2.6
Security: 2026-04-10 audit remediation
Closes 12 of the 16 findings from the read-only audit at
docs/SECURITY_AUDIT.md. Two High, four Medium, four Low, and two Informational findings fixed; the four skipped Informational items are documented at the bottom. Each fix is its own commit on thesecurity/audit-2026-04-10branch with anAudit-Ref:trailer.- #1 SSRF (High): every httpx client now installs an event hook that resolves the target host and rejects loopback, RFC1918, link-local (including the 169.254.169.254 cloud-metadata endpoint), CGNAT, unique-local v6, and multicast. Hook fires on every redirect hop, not just the initial request. Behavior change: user-configured boorus pointing at private/loopback addresses now fail with
blocked request target ...instead of being probed. Test Connection on a local booru will be rejected. - #2 mpv (High): the embedded mpv instance is constructed with
ytdl=no,load_scripts=no, anddemuxer_lavf_o=protocol_whitelist=file,http,https,tls,tcp, plusinput_conf=/dev/nullon POSIX. Closes the yt-dlp delegation surface (CVE-prone extractors invoked on attacker-supplied URLs) and theconcat:/subfile:local-file-read gadget via ffmpeg's lavf demuxer. Behavior change: anyfile_urlwhose host is only handled by yt-dlp (youtube.com, reddit.com, ...) no longer plays. Boorus do not legitimately serve such URLs, so in practice this only affects hostile responses. - #3 Credential logging (Medium):
login,api_key,user_id, andpassword_hashare now stripped from URLs and params before any logging path emits them. Single redaction helper incore/api/_safety.py, called from the booru-base request hook and from each per-clientlog.debugline. - #4 DB + data dir permissions (Medium): on POSIX,
~/.local/share/booru-viewer/is now0o700andbooru.db(plus the-wal/-shmsidecars) is0o600. Behavior change: existing installs are tightened on next launch. Windows is unchanged — NTFS ACLs handle this separately. - #5 Lock leak (Medium): the per-URL coalesce lock table is capped at 4096 entries with LRU eviction. Eviction skips currently-held locks so a coroutine mid-
async withcan't be ripped out from under itself. - #6 HTML injection (Medium):
post.sourceis escaped before insertion into the info-panel rich text. Non-http(s) sources (includingjavascript:anddata:) render as plain escaped text without an<a>tag, so they can't become click targets. - #7 Windows reserved names (Low):
render_filename_templatenow prefixes filenames whose stem matches a reserved Windows device name (CON,PRN,AUX,NUL,COM1-9,LPT1-9) with_, regardless of host platform. Cross-OS library copies stay safe. - #8 PIL bomb cap (Low):
Image.MAX_IMAGE_PIXELS=256Mmoved fromcore/cache.py(where it was a side-effect of import order) tocore/__init__.py, so anybooru_viewer.core.*import installs the cap first. - #9 Dependency bounds (Low): upper bounds added to runtime deps in
pyproject.toml(httpx<1.0,Pillow<12.0,PySide6<7.0,python-mpv<2.0). Lock-file generation deferred — seeTODO.md. - #10 Early content validation (Low):
_do_downloadnow accumulates the first 16 bytes of the response and validates magic bytes before committing to writing the rest. A hostile server omitting Content-Type previously could burn up toMAX_DOWNLOAD_BYTES(500MB) of bandwidth before the post-download check rejected. - #14 Category fetcher body cap (Informational): HTML body the regex walks over in
CategoryFetcher.fetch_postis truncated at 2MB. Defense in depth — the regex is linear-bounded but a multi-MB hostile body still pegs CPU. - #16 Logging hook gap (Informational): e621 and detect_site_type clients now install the
_log_requesthook so their requests appear in the connection log alongside the base client. Absorbed into the #1 wiring commits since both files were already being touched.
Skipped (Wontfix), with reason:
- #11 64-bit hash truncation: not exploitable in practice (audit's own words). Fix would change every cache path and require a migration.
- #12 Referer leak through CDN redirects: intentional — booru CDNs gate downloads on Referer matching. Documented; not fixed.
- #13 hyprctl batch joining: user is trusted in the threat model and Hyprland controls the field. Informational only.
- #15 dead code in
core/images.py: code quality, not security. Out of scope under the no-refactor constraint. Logged inTODO.md.
Downloads
- #1 SSRF (High): every httpx client now installs an event hook that resolves the target host and rejects loopback, RFC1918, link-local (including the 169.254.169.254 cloud-metadata endpoint), CGNAT, unique-local v6, and multicast. Hook fires on every redirect hop, not just the initial request. Behavior change: user-configured boorus pointing at private/loopback addresses now fail with
-
v0.2.5 Stable
released this
2026-04-10 21:25:40 -05:00 | 205 commits to main since this releasev0.2.5
Full UI overhaul (icon buttons, compact top bar, responsive video controls), popout resize-pivot anchor, layout flip, and the main_window.py controller decomposition.
Changes since v0.2.4
Refactor: main_window.py controller decomposition
main_window.pywent from a 3,318-line god-class to a 1,164-line coordinator plus 7 controller modules. Every other subsystem in the codebase had already been decomposed (popout state machine, library save, category fetcher) — BooruApp was the last monolith. 11 commits, pure refactor, no behavior change. Design doc atdocs/MAIN_WINDOW_REFACTOR.md.- New
gui/window_state.py(293 lines) — geometry persistence, Hyprland IPC, splitter savers. - New
gui/privacy.py(66 lines) — privacy overlay toggle + popout coordination. - New
gui/search_controller.py(572 lines) — search orchestration, infinite scroll, backfill, blacklist filtering, tag building, autocomplete, thumbnail fetching. - New
gui/media_controller.py(273 lines) — image/video loading, prefetch, download progress, video streaming fast-path, cache eviction. - New
gui/popout_controller.py(204 lines) — popout lifecycle (open/close), state sync, geometry persistence, navigation delegation. - New
gui/post_actions.py(561 lines) — bookmarks, save/library, batch download, unsave, bulk ops, blacklist actions from popout. - New
gui/context_menus.py(246 lines) — single-post and multi-select context menu building + dispatch. - Controller-pattern: each takes
app: BooruAppvia constructor, accesses app internals as trusted collaborator viaself._app. No mixins, no ABC, no dependency injection — just plain classes with one reference each.TYPE_CHECKINGimport forBooruAppavoids circular imports at runtime. - Cleaned up 14 dead imports from
main_window.py. - The
_fullscreen_windowreference (52 sites across the codebase) was fully consolidated intoPopoutController.window. No file outsidepopout_controller.pytouches_fullscreen_windowdirectly anymore.
New: Phase 2 test suite (64 tests for extracted pure functions)
Each controller extraction also pulled decision-making code out into standalone module-level functions that take plain data in and return plain data out. Controllers call those functions; tests import them directly. Same structural forcing function as the popout state machine tests — the test files fail to collect if anyone adds a Qt import to a tested module.
tests/gui/test_search_controller.py(24 tests):build_search_tagsrating/score/media filter mapping per API type,filter_postsblacklist/dedup/seen-ids interaction,should_backfilltermination conditions.tests/gui/test_window_state.py(16 tests):parse_geometry/format_geometryround-trip,parse_splitter_sizesvalidation edge cases,build_hyprctl_restore_cmdsfor every floating/tiled permutation including the no_anim priming path.tests/gui/test_media_controller.py(9 tests):compute_prefetch_orderfor Nearby (cardinals) and Aggressive (ring expansion) modes, including bounds, cap, and dedup invariants.tests/gui/test_post_actions.py(10 tests):is_batch_messageprogress-pattern detection,is_in_librarypath-containment check.tests/gui/test_popout_controller.py(3 tests):build_video_sync_dictshape.- Total suite: 186 tests (57 core + 65 popout state machine + 64 new controller pure functions), ~0.3s runtime, all import-pure.
- PySide6 imports in controller modules were made lazy (inside method bodies) so the Phase 2 tests can collect on CI, which only installs
httpx,Pillow, andpytest.
UI overhaul: icon buttons and responsive layout
Toolbar and video controls moved from fixed-width text buttons to 24x24 icon buttons. Preview toolbar uses Unicode symbols (☆/★ bookmark, ↓/✕ save, ⊘ blacklist tag, ⊗ blacklist post, ⧉ popout) — both the embedded preview and the popout toolbar share the same object names (
#_tb_bookmark,#_tb_save,#_tb_bl_tag,#_tb_bl_post,#_tb_popout) so one QSS rule styles both. Video controls (play/pause, mute, loop, autoplay) render via QPainter using the palette'sbuttonTextcolor so they match any theme automatically, with1×as bold text for the Once loop state.- Responsive video controls bar: hides volume slider below 320px, duration label below 240px, current time label below 200px. Play/pause/seek/mute/loop always visible.
- Compact top bar: combos use
AdjustToContents, 3px spacing, top/nav bars wrapped in#_top_bar/#_nav_barnamed containers for theme targeting. - Main window minimum size dropped from 900x600 to 740x400 — the hard floor was blocking Hyprland's keyboard resize mode on narrow floating windows.
- Preview pane minimum width dropped from 380 to 200.
- Info panel title + details use
QSizePolicy.Ignoredhorizontally so long source URLs wrap within the splitter instead of pushing it wider.
New: popout anchor setting (resize pivot)
Combo in Settings > General. Controls which point of the popout window stays fixed across navigations as the aspect ratio changes:
Center(default, pins window center), or one of the four corners (pins that corner, window grows/shrinks from the opposite corner). The user can still drag the window anywhere — the anchor only controls the resize direction, not the screen position. Works on all platforms; on Hyprland the hyprctl dispatch path is used, elsewhere Qt'ssetGeometryfallback handles the same math.Viewport.center_x/center_yrepurposed as anchor point coordinates — in center mode it's the window center, in corner modes it's the pinned corner. Newanchor_point()helper inviewport.pyextracts the right point from a window rect based on mode._compute_window_rectbranches on anchor: center mode keeps the existing symmetric math, corner modes derive position from the anchor point + the new size.- Hyprland monitor reserved-area handling: reads
reservedfromhyprctl monitors -jso window positioning respects Waybar's exclusive zone (Qt'sscreen.availableGeometry()doesn't see layer-shell reservations on Wayland).
New: layout flip setting
Checkbox in Settings > General (restart required). Swaps the main splitter — preview+info panel on the left, grid on the right. Useful for left-handed workflows or multi-monitor setups where you want the preview closer to your other reference windows.
New: thumbnail fade-in animation
Thumbnails animate from 0 to 1 opacity over 200ms (OutCubic easing) as they load. Uses a
QPropertyAnimationon athumbOpacityQt Property applied inpaintEvent. The animation is stored on the widget instance to prevent Python garbage collection before the Qt event loop runs it.New: B / F / S keyboard shortcuts
BorF— toggle bookmark on the selected post (works in main grid and popout).S— toggle save to library (Unfiled). If already saved, unsaves. Works in main grid and popout.- The popout gained a new
toggle_save_requestedsignal that routes to a sharedPostActionsController.toggle_save_from_previewso both paths use the same toggle logic.
UX: grid click behavior
- Clicking empty grid space (blue area around thumbnails, cell padding outside the pixmap, or the 2px gaps between cells) deselects everything. Cell padding clicks work via a direct parent-walk from
ThumbnailWidget.mousePressEventto the grid — Qt event propagation throughQScrollAreaswallows events too aggressively to rely on. - Rubber band drag selection now works from any empty space — not just the 2px gaps. 30px manhattan threshold gates activation so single clicks on padding just deselect without flashing a zero-size rubber band.
- Hover highlight only appears when the cursor is actually over the pixmap, not the cell padding. Uses the same
_hit_pixmaphit-test as clicks. Cursor swaps between pointing-hand (over pixmap) and arrow (over padding) viamouseMoveEventtracking. - Clicking an already-showing post no longer restarts the video (fixes the click-to-drag case where the drag-start click was restarting mpv).
- Escape clears the grid selection.
- Stuck forbidden cursor after cancelled drag-and-drop is reset on mouse release. Stuck hover states on Wayland fast-exits are force-cleared in
ThumbnailGrid.leaveEvent.
Themes
All 12 bundled QSS themes were trimmed and regenerated:
- Removed 12 dead selector groups that the app never instantiates:
QRadioButton,QToolButton,QToolBar,QDockWidget,QTreeView/QTreeWidget,QTableView/QTableWidget,QHeaderView,QDoubleSpinBox,QPlainTextEdit,QFrame. - Popout overlay buttons now use
font-size: 15px; font-weight: boldso the icon symbols read well against the translucent-black overlay. themes/README.mddocuments the new#_tb_*toolbar button object names and the popout overlay styling. Removed the old Nerd Font remapping note — QSS can't change button text, so that claim was incorrect.
Downloads
- New
-
pre-release v0.2.4 Stable
released this
2026-04-09 21:41:15 -05:00 | 279 commits to main since this release0.2.4 (pre-release)
Library filename templates, tag category fetching for all backends, and a popout video streaming overhaul. 50+ commits since v0.2.3.
Changes since v0.2.3
New: library filename templates
Save files with custom names instead of bare post IDs. Templates use
%id%,%artist%,%character%,%copyright%,%general%,%meta%,%species%,%md5%,%rating%,%score%,%ext%tokens. Set in Settings > Paths.- New
core/library_save.pymodule with a singlesave_post_fileentry point. All eight save sites (Save to Library, Save As, Bulk Save, Batch Download, and their bookmarks-tab equivalents) route through it. - DB-backed
library_meta.filenamecolumn tracks the rendered name per post. Non-breaking migration for existing databases. - Sequential collision suffixes (
_1,_2,_3) when multiple posts render to the same filename (e.g. same artist). - Same-post idempotency via
get_library_post_id_by_filenamelookup. Re-saving a post that already exists under a different template returns the existing path. find_library_filesanddelete_from_libraryupdated to match templated filenames alongside legacy digit-stem files.is_post_in_library/get_saved_post_idsDB helpers replace filesystem walks for saved-dot indicators. Format-agnostic.reconcile_library_metacleans up orphan meta rows on startup.- Saved-dot indicators fixed across all tabs for templated filenames.
- Library tab single-delete and multi-delete now clean up
library_metarows (was leaking orphan rows for templated files). - Save As dialog default filename comes from the rendered template instead of the old hardcoded
post_prefix. - Batch downloads into library folders now register
library_meta(was silently skipping it). - Bookmark-to-library copies now register
library_meta(was invisible to Library tag search). - Cross-folder re-save is now copy, not move (the atomic rename was a workaround for not having a DB-backed filename column).
New: tag category fetching
Tag categories (Artist, Character, Copyright, General, Meta, Species) now work across all four backends, not just Danbooru and e621.
- New
CategoryFetchermodule with two strategies: batch tag API (Gelbooru proper with auth) and per-post HTML scrape (Rule34, Safebooru.org, Moebooru sites). - DB-backed
tag_typescache table. Tags are fetched once per site and cached across sessions.clear_tag_cachein Settings wipes it. - Batch API probe result persisted per site. First session probes once; subsequent sessions skip the probe.
- Background prefetch for Gelbooru batch API path only. search() fires
prefetch_batchin the background when_batch_api_worksis True, so the cache is warm before the user clicks. - Danbooru and e621
get_postnow populatestag_categoriesinline (latent bug: was returning empty categories on re-fetch). categories_updatedsignal re-renders the info panel when categories arrive asynchronously._categories_pendingflag on the info panel suppresses the flat-tag fallback flash when a fetch is in progress. Tags area stays empty until categories arrive and render in one pass.- HTML parser two-pass rewrite: Pass 1 finds tag-type elements by class, Pass 2 extracts tag names from
tags=NAMEURL parameters in search links. Works on Rule34, Safebooru.org, and Moebooru. save_post_fileensures categories before template render so%artist%/%character%tokens resolve on Gelbooru-style sites.- On-demand fetch model for Rule34 / Safebooru.org / Moebooru: ~200ms HTML scrape on first click, instant from cache on re-click.
Improved: popout video streaming
Click-to-first-frame latency on uncached video posts with the popout open is roughly halved. Single HTTP connection per video instead of two.
- Stream-record. mpv's
stream-recordper-file option tees the network stream to a.parttemp file as it plays. On clean EOF the.partis promoted to the real cache path. The parallel httpx download that used to race with mpv for the same bytes is eliminated. Seeks during playback invalidate the recording (mpv may skip byte ranges); the.partis discarded on seek, stop, popout close, or rapid click. - Redundant stops removed.
_on_video_streamno longer stops the embedded preview's mpv when the popout is the visible target (was wasting ~50-100ms of synchronouscommand('stop')time)._apply_load_videono longer callsstop()beforeplay_file(loadfile("replace")subsumes it). - Stack switch reordered.
_apply_load_videonow switches to the video surface before callingplay_file, so mpv's first frame lands on a visible widget instead of a cleared image viewer. - mpv network tuning.
cache_pause=no(stutter over pause for short clips), 50 MiB demuxer buffer cap, 20s read-ahead, 10s network timeout (down from ~60s). - Cache eviction safety.
evict_oldestskips.partfiles so eviction doesn't delete a temp file mpv is actively writing to.
Bug fixes
- Popout close preserves video position.
closeEventnow snapshotsposition_msbefore dispatchingCloseRequested(whoseStopMediaeffect destroys mpv'stime_pos). The embedded preview resumes at the correct position instead of restarting from 0. - Library popout aspect lock for images. Library items' Post objects were constructed without width/height, so the popout got 0/0 and
_fit_to_contentreturned early without settingkeep_aspect_ratio. Now reads actual pixel dimensions viaQPixmapbefore constructing the Post.
Other
- README updated, unused Windows screenshots dropped from the repo.
- Tightened thumbnail spacing in the grid from 8px to 2px.
- Max thumbnail size at 200px.
Downloads
- New
-
v0.2.3 Stable
released this
2026-04-09 00:15:16 -05:00 | 339 commits to main since this releaseA refactor + cleanup release. The two largest source files (
gui/app.py3608 lines +gui/preview.py2273 lines) are gone, replaced by a module-per-concern layout. The popout viewer's internal state was rebuilt as an explicit state machine with the historical race bugs locked out structurally instead of by suppression windows. The slider drag-back race that no one had named is finally fixed. A handful of latent bugs got caught and resolved on the way through.Changes since v0.2.2
Structural refactor: gui/app.py + gui/preview.py split
The two largest source files were doing too much.
gui/app.pywas 3608 lines mixing async dispatch, signal wiring, tab switching, popout coordination, splitter persistence, context menus, bulk actions, batch download, fullscreen, privacy, and a dozen other concerns.gui/preview.pywas 2273 lines holding the embedded preview, the popout, the image viewer, the video player, an OpenGL surface, and a click-to-seek slider. Both files had reached the point where almost every commit cited "the staging surface doesn't split cleanly" as the reason for bundling unrelated fixes.This release pays that cost down with a structural carve into 12 module-per-concern files plus 2 oversize-by-design god-class files. 14 commits, every commit byte-identical except for relative-import depth corrections, app runnable at every commit boundary.
gui/app.py(3608 lines) gone. Carved into:app_runtime.py:run(),_apply_windows_dark_mode(),_load_user_qss()(@palettepreprocessor),_BASE_POPOUT_OVERLAY_QSS. The QApplication setup, custom QSS load, icon resolution, BooruApp instantiation, and exec loop.main_window.py:BooruApp(QMainWindow), ~3200 lines. The class is one indivisible unit because every method shares instance attributes with every other method. Splitting it across files would have required either inheritance, composition, or method-as-attribute injection, and none of those were worth introducing for a refactor that was supposed to be a pure structural move with no logic changes.info_panel.py:InfoPanel(QWidget)toggleable info panel.log_handler.py:LogHandler(logging.Handler, QObject)Qt-aware logger adapter.async_signals.py:AsyncSignals(QObject)signal hub for async worker results.search_state.py:SearchStatedataclass.
gui/preview.py(2273 lines) gone. Carved into:preview_pane.py:ImagePreview(QWidget)embedded preview pane.popout/window.py:FullscreenPreview(QMainWindow)popout. Initially a single 1136-line file; further carved by the popout state machine refactor below.media/constants.py:VIDEO_EXTENSIONS,_is_video().media/image_viewer.py:ImageViewer(QWidget)zoom/pan image viewer.media/mpv_gl.py:_MpvGLWidget+_MpvOpenGLSurface.media/video_player.py:VideoPlayer(QWidget)+_ClickSeekSlider.popout/viewport.py:Viewport(NamedTuple)+_DRIFT_TOLERANCE.
- Re-export shim pattern. Each move added a
from .new_location import MovedClass # re-export for refactor compatline at the bottom of the old file so existing imports kept resolving the same class object during the migration. The final cleanup commit updated the importer call sites to canonical paths and deleted the now-emptyapp.pyandpreview.py.
Bug fixes surfaced by the refactor
The refactor's "manually verify after every commit" rule exposed 10 latent bugs that had been lurking in the original god-files. Every one of these is a preexisting issue, not something the refactor caused.
- Browse multi-select reshape. Split library and bookmark actions into four distinct entries (Save All / Unsave All / Bookmark All / Remove All Bookmarks), each shown only when the selection actually contains posts the action would affect. The original combined action did both library and bookmark operations under a misleading bookmark-only label, with no way to bulk-unsave without also stripping bookmarks. The reshape resolves the actual need.
- Infinite scroll page_size clamp. One-character fix at
_on_reached_bottom'ssearch_append.emitcall site (collectedbecomescollected[:limit]) to mirror the non-infinite path's slice in_do_search. The backfill loop's>=break condition allowed the last full batch to push collected past the configured page size. - Batch download: incremental saved-dot updates and browse-tab-only gating. Two-part fix. (1) Stash the chosen destination, light saved-dots incrementally as each file lands when the destination is inside
saved_dir(). (2) Disable the Batch Download menu and Ctrl+D shortcut on the Bookmarks and Library tabs, where it didn't make sense. - F11 round-trip preserves zoom and position. Two preservation bugs. (1)
ImageViewer.resizeEventno longer clobbers the user's explicit zoom and pan on F11 enter/exit; it usesevent.oldSize()to detect whether the user was at fit-to-view at the previous size and only re-fits in that case. (2) The popout's F11 enter writes the current Hyprland window state directly into its viewport tracking so F11 exit lands at the actual pre-fullscreen position regardless of how the user got there (drag, drag+nav, drag+F11). The previous drift detection only fired during a fit and missed the "drag then F11 with no nav between" sequence. - Remove O keybind for Open in Default App. Five-line block deleted from the main keypress handler. Right-click menu actions stay; only the keyboard shortcut is gone.
- Privacy screen resumes video on un-hide.
_toggle_privacynow callsresume()on the active video player on the privacy-off branch, mirroring the existingpause()calls on the privacy-on branch. The popout's privacy overlay also moved from "hide the popout window" to "raise an in-place black overlay over the popout's central widget" because Wayland's hide → show round-trip drops window position when the compositor unmaps and remaps; an in-place overlay sidesteps the issue. - VideoPlayer mute state preservation. When the popout opens, the embedded preview's mute state was synced into the popout's
VideoPlayerbefore the popout's mpv instance was created (mpv is wired lazily on firstset_media). The sync silently disappeared because theis_mutedsetter only forwarded to mpv if mpv existed. Now there's a_pending_mutefield that the setter writes to unconditionally;_ensure_mpvreplays it into the freshly-created mpv. Same pattern as the existing volume-from-slider replay. - Search count + end-of-results instrumentation.
_do_searchand_on_reached_bottomnow log per-filter drop counts (bl_tags,bl_posts,dedup),api_returned,kept, and theat_enddecision at DEBUG level. Distinguishes "API ran out of posts" from "client-side filters trimmed the page" for the next reproduction. This is instrumentation, not a fix; the underlying intermittent end-of-results bug is still under investigation.
Popout state machine refactor
In the past two weeks, five popout race fixes had landed (
baa910a,5a44593,7d19555,fda3b10,31d02d3), each correct in isolation but fitting the same pattern: a perf round shifted timing, a latent race surfaced, a defensive layer was added. The pattern was emergent from the popout's signal-and-callback architecture, not from any one specific bug. Every defensive layer added a timestamp-based suppression window that the next race fix would have to navigate around.This release rebuilds the popout's internal state as an explicit state machine. The 1136-line
FullscreenPreviewgod-class became a thin Qt adapter on top of a pure-Python state machine, with the historical race fixes enforced structurally instead of by suppression windows. 16 commits.The state machine has 6 states (
AwaitingContent,DisplayingImage,LoadingVideo,PlayingVideo,SeekingVideo,Closing), 17 events, and 14 effects. The pure-Python core lives inpopout/state.pyandpopout/effects.pyand imports nothing from PySide6, mpv, or httpx. The Qt-side adapter inpopout/window.pytranslates Qt events into state machine events and applies the returned effects to widgets; it never makes decisions about what to do.The race fixes that were timestamp windows in the previous code are now structural transitions:
- EOF race.
VideoEofReachedis only legal inPlayingVideo. In every other state (most importantlyLoadingVideo, where the stale-eof race lived), the event is dropped at the dispatch boundary without changing state or emitting effects. Replaces the 250ms_eof_ignore_untiltimestamp window that the previous code used to suppress stale eof events from a previous video's stop. - Double-load race.
NavigateRequestedfrom a media-bearing state transitions toAwaitingContentonce. A secondNavigateRequestedwhile still inAwaitingContentre-emits the navigate signal but does not re-stop or re-load. The state machine never produces twoLoadVideo/LoadImageeffects for the same navigation cycle, regardless of how manyNavigateRequestedevents the eventFilter dispatches. - Persistent viewport. The viewport (center + long_side) is a state machine field, only mutated by user-action events (
WindowMoved,WindowResized, orHyprlandDriftDetected). Never overwritten by reading the previous fit's output. Replaces the per-nav drift accumulation that the previous "recompute viewport from current state" shortcut produced. - F11 round-trip. Entering fullscreen snapshots the current viewport into a separate
pre_fullscreen_viewportfield. Exiting restores from the snapshot. The pre-fullscreen viewport is the captured value at the moment of entering, regardless of how the user got there. - Seek slider pin.
SeekingVideostate holds the user's click target. The slider rendering reads from the state machine: while inSeekingVideo, the displayed value is the click target; otherwise it's mpv's actualtime_pos.SeekCompleted(from mpv'splayback-restartevent) transitions back toPlayingVideo. No timestamp window. - Pending mute. The mute / volume / loop_mode values are state machine fields.
MuteToggleRequestedflips the field regardless of which state the machine is in. ThePlayingVideoentry handler emits[ApplyMute, ApplyVolume, ApplyLoopMode]so the persistent values land in the freshly-loaded video on every load cycle.
The Qt adapter's interface to
main_window.pywas also cleaned up. Previouslymain_window.pyreached into_fullscreen_window._video.X,_fullscreen_window._stack.currentIndex(),_fullscreen_window._bookmark_btn.setVisible(...), and similar private-attribute access at ~25 sites. Those are gone. Nine new public methods onFullscreenPreviewreplace them:is_video_active,set_toolbar_visibility,sync_video_state,get_video_state,seek_video_to,connect_media_ready_once,pause_media,force_mpv_pause,stop_media. Existing methods (set_media,update_state,set_post_tags,privacy_hide,privacy_show) are preserved unchanged.A new debug environment variable
BOORU_VIEWER_STRICT_STATE=1raises anInvalidTransitionexception on illegal (state, event) pairs in the state machine. Default release mode drops + logs at debug.Slider drag-back race fixed
The slider's
_seekmethod usedmpv.seek(pos / 1000.0, 'absolute')(keyframe-only seek). On videos with sparse keyframes (typical 1-5s GOP), mpv lands on the nearest keyframe at-or-before the click position, which is up to 5 seconds behind where the user actually clicked. The 500ms pin window from the earlier fix sweep papered over this for half a second, but afterwards the slider visibly dragged back to mpv's keyframe-rounded position and crawled forward.'absolute' → 'absolute+exact'inVideoPlayer._seek. Aligns the slider withseek_to_msand_seek_relative, which were already using exact seek. mpv decodes from the previous keyframe forward to the EXACT target position before reporting it viatime_pos. Costs 30-100ms more per seek but lands at the exact click position. No more drag-back. Affects both the embedded preview and the popout because they share theVideoPlayerclass.- Legacy 500ms pin window removed. Now redundant after the exact-seek fix. The supporting fields (
_seek_target_ms,_seek_pending_until,_seek_pin_window_secs) are gone,_seekis one line,_poll's slider write is unconditional after theisSliderDown()check.
Grid layout fix
The grid was collapsing by a column when switching to a post in some scenarios. Two compounding issues.
- The flow layout's wrap loop was vulnerable to per-cell width drift. Walked each thumb summing
widget.width() + THUMB_SPACINGand wrapped onx + item_w > self.width(). IfTHUMB_SIZEwas changed at runtime via Settings, existing thumbs kept their oldsetFixedSizevalue while new ones from infinite-scroll backfill got the new value. Mixed widths break a width-summing wrap loop. - The
columnsproperty had an off-by-one at column boundaries because it omitted the leading margin fromw // (THUMB_SIZE + THUMB_SPACING). A row that fits N thumbs needsTHUMB_SPACING + N * steppixels, notN * step. The visible symptom was that keyboard Up/Down navigation step was off-by-one in the boundary range. - Fix. The flow layout now computes column count once via
(width - THUMB_SPACING) // stepand positions thumbs by(col, row)index, with no per-widgetwidget.width()reads. Thecolumnsproperty uses the EXACT same formula so keyboard nav matches the visual layout at every window width. Affects all three tabs (Browse / Bookmarks / Library) since they all use the sameThumbnailGrid.
Other fixes
These two landed right after v0.2.2 was tagged but before the structural refactor started.
- Popout video load performance. mpv URL streaming for uncached videos via a new
video_streamsignal that hands the remote URL to mpv directly instead of waiting for the cache download to finish. mpv fast-load optionsvd_lavc_fastandvd_lavc_skiploopfilter=nonkey. GL pre-warm at popout open via ashowEventcallingensure_gl_initso the first video click doesn't pay for context creation. Identical-rect skip in_fit_to_contentso back-to-back same-aspect navigation doesn't redundantly dispatch hyprctl. Plus three race-defense layers: pause-on-activate at the top of_on_post_activated, the 250ms stale-eof suppression window in VideoPlayer that the state machine refactor later subsumed, and removed redundant_update_fullscreencalls from_navigate_fullscreenand_on_video_end_nextthat were re-loading the previous post's path with a stale value. - Double-activation race fix in
_navigate_preview. Removed a redundant_on_post_activatedcall from all five view types (browse, bookmarks normal, bookmarks wrap-edge, library normal, library wrap-edge)._select(idx)already chains throughpost_selectedwhich already calls_on_post_activated, so calling it explicitly again was a duplicate that fired the activation handler twice per keyboard nav.
Downloads
-
v0.2.2 Stable
released this
2026-04-08 00:29:53 -05:00 | 392 commits to main since this release0.2.2
A hardening + decoupling release. Bookmark folders and library folders are no longer the same thing under the hood, the
core/layers get a defensive hardening pass, the async/DB layers get a real concurrency refactor, and the README finally articulates what this project is.Changes since v0.2.1
Bookmarks ↔ Library decoupling
- Bookmark folders and library folders are now independent name spaces. Used to share identity through
_db.get_folders()— the same string was both a row infavorite_foldersand a directory undersaved_dir. The cross-bleed produced a duplicate-on-move bug and made "Save to Library" silently re-file the bookmark. Now they're two stores: bookmark folders are DB-backed labels for organizing your bookmark list, library folders are real subdirectories ofsaved/for organizing files on disk. library_folders()incore.configis the new source of truth for every Save-to-Library menu — reads filesystem subdirs ofsaved_dirdirectly.find_library_files(post_id)is the new "is this saved?" / delete primitive — walks the library shallowly by post id.- Move-aware Save to Library. If the post is already in another library folder, atomic
Path.rename()into the destination instead of re-copying from cache. Also fixes the duplicate-on-move bug. - Library tab right-click: Move to Folder submenu for both single and multi-select, using
Path.renamefor atomic moves. - Bookmarks tab: − Folder button next to + Folder for deleting the selected bookmark folder. DB-only, library filesystem untouched.
- Browse tab right-click: "Bookmark as" submenu when a post is not yet bookmarked (Unfiled / your bookmark folders / + New); flat "Remove Bookmark" when already bookmarked.
- Embedded preview Bookmark button got the same submenu shape via a new
bookmark_to_foldersignal +set_bookmark_folders_callback. - Popout Bookmark and Save buttons both got the submenu treatment; works in both Browse and Bookmarks tab modes.
- Popout in library mode keeps the Save button visible as Unsave; the rest of the toolbar (Bookmark / BL Tag / BL Post) is hidden since they don't apply.
- Popout state drift fixed.
_update_fullscreen_statenow mirrors the embedded preview's_is_bookmarked/_is_savedinstead of re-querying DB+filesystem, eliminating a state race during async bookmark adds. - "Unsorted" renamed to "Unfiled" everywhere user-facing. Library Unfiled and bookmarks Unfiled now share one label.
favorite_folderstable preserved for backward compatibility — no migration required.
Concurrency refactor
The earlier worker pattern of
threading.Thread + asyncio.runwas a real loop-affinity bug. The first throwaway loop a worker constructed would bind the shared httpx clients, and the next call from the persistent loop would fail with "Event loop is closed". This release routes everything through one loop and adds the locking and cleanup that should have been there from the start.core/concurrency.pyis a new module:set_app_loop()/get_app_loop()/run_on_app_loop(). Every async piece of work in the GUI now schedules through one persistent loop, registered at startup byBooruApp.gui/sites.pySiteDialog Detect and Test buttons now route throughrun_on_app_loopinstead of spawning a daemon thread. Results marshal back via Qt Signals withQueuedConnection. The dialog tracks in-flight futures and cancels them on close so a mid-detect dialog dismissal doesn't poke a destroyed QObject.gui/bookmarks.pythumbnail loader got the same swap. The existingthumb_readysignal already marshaled correctly.- Lazy-init lock on shared httpx clients.
BooruClient._shared_client,E621Client._e621_client, andcache._shared_clientall use a fast-path / locked-slow-path lazy init. Concurrent first-callers can no longer both build a client and leak one. E621ClientUA-change leftover tracking. When the User-Agent changes (api_user edit) and a new client is built, the old one is stashed in_e621_to_closeand drained at shutdown instead of leaking.aclose_sharedon shutdown.BooruApp.closeEventnow runs an_close_allcoroutine viarun_coroutine_threadsafe(...).result(timeout=5)before stopping the loop. Connection pools, keepalive sockets, and TLS state release cleanly instead of being abandoned.Database._write_lock(RLock) + new_write()context manager. Every write method now serializes through one lock so the asyncio thread and the Qt main thread can't interleave multi-statement writes. RLock so a writing method can call another writing method on the same thread without self-deadlocking. Reads stay lock-free under WAL.
Defensive hardening
- DB transactions.
delete_site,add_search_history,remove_folder,rename_folder, and_migratenow wrap their multi-statement bodies inwith self.conn:so a crash mid-method can't leave orphan rows. add_bookmarklastrowid fix. WhenINSERT OR IGNOREcollides on(site_id, post_id),lastrowidis stale; the method now re-SELECTs the existing id. Was returningBookmark(id=0)silently, which then no-op'dupdate_bookmark_cache_pathon the next bookmark.- LIKE wildcard escape.
get_bookmarksLIKE clauses nowESCAPE '\\'so user search literals stop acting as SQL wildcards (cat_earno longer matchescatear). - Path traversal guard on folder names. New
_validate_folder_namerejects.., path separators, and leading./~at write time.saved_folder_dir()resolves the candidate and refuses anything that doesn'trelative_tothe saved-images base. - Download size cap and streaming.
download_imageenforces a 500 MB hard cap against the advertised Content-Length and the running total inside the chunk loop (servers can lie). Payloads ≥ 50 MB stream to a tempfile and atomicos.replaceinstead of buffering in RAM. - Per-URL coalesce lock.
defaultdict[str, asyncio.Lock]keyed by URL hash so concurrent callers downloading the same URL don't racewrite_bytes. Image.MAX_IMAGE_PIXELS = 256MwithDecompressionBombErrorhandling in both PIL converters.- Ugoira zip-bomb caps. Frame count and cumulative uncompressed size checked from
ZipInfoheaders before any decompression. _convert_animated_to_giffailure cache. Writes a.convfailedsentinel sibling on failure to break the re-decode-every-paint loop for malformed animated PNGs/WebPs._is_valid_mediadistinguishes IO errors from "definitely invalid". ReturnsTrue(don't delete) onOSErrorso a transient EBUSY/permissions hiccup no longer triggers a delete + re-download loop.- Hostname suffix matching for Referer. Was using substring
inmatching, which meantimgblahgelbooru.attacker.comfalsely mapped togelbooru.com. Now uses proper suffix check. _requestretries onhttpx.NetworkErrorandhttpx.ConnectErrorin addition toTimeoutException. A single DNS hiccup or RST no longer blows up the whole search.test_connectionno longer echoes the response body in error strings. It was a body-leak gadget when used viadetect_site_type's redirect-following client.- Exception logging across
detect,search, andautocompletein every API client. Previously every failure was a silentreturn []; now every swallowed exception logs at WARNING with type, message, and (where relevant) the response body prefix. main_gui.pyfile_dialog_platformDB probe failure now prints to stderr instead of vanishing.- Folder name validation surfaced as
QMessageBox.warningingui/bookmarks.pyandgui/app.pyinstead of crashing when a user types something the validator rejects.
Popout overlay fix
WA_StyledBackgroundset on_slideshow_toolbarand_slideshow_controls. PlainQWidgetparents silently ignore QSSbackground:declarations without this attribute, which is why the popout overlay strip was rendering fully transparent (buttons styled, but the bar behind them showing the letterbox color).- Base popout overlay style baked into the QSS loader.
_BASE_POPOUT_OVERLAY_QSSis prepended before the user'scustom.qssso themes that don't define overlay rules still get a usable translucent black bar with white text. Bundled themes still override on the same selectors.
Popout aspect-ratio handling
The popout viewer's aspect handling had been patch-thrashing for ~20 commits since 0.2.0. A cold-context audit mapped 13 distinct failure modes still live in the code; this release closes the four highest-impact ones.
- Width-anchor ratchet broken. The previous
_fit_to_contentwas width-anchored:start_w = self.width()read the current window width and derived height from aspect, with a back-derive if height exceeded the cap. Width was the only stable reference, and because portrait content has aspect < 1 and the height cap (90% of screen) was tighter than the width cap (100%), every portrait visit ran the back-derive and permanently shrunk the window. Going P→L→P→L→P on a 1080p screen produced a visibly smaller landscape on each loop. - New
Viewport(center_x, center_y, long_side)model. Three numbers, no aspect. Aspect is recomputed from content on every nav. The new_compute_window_rect(viewport, content_aspect, screen)is a pure static method: symmetric across portrait/landscape (long_sidebecomes width for landscape and height for portrait), proportional clamp shrinks both edges by the same factor when either would exceed its 0.90 ceiling, no asymmetric clamp constants, no back-derive step. - Viewport derived per-call from existing state. No persistent field, no
moveEvent/resizeEventhooks needed for the basic ratchet fix. Three priority sources: pending one-shots (first fit after open or F11 exit) → current Hyprland window position+size → current Qt geometry. The Hyprland-current source captures whatever the user has dragged the popout to, so the next nav respects manual resizes. - First-fit aspect-lock race fixed.
_fit_to_contentused to call_is_hypr_floatingwhich returnedNonefor both "not Hyprland" and "Hyprland but the window isn't visible to hyprctl yet". The latter happens on the very first popout open because thewm:openWindowevent hasn't been processed whenset_mediafires. The method then fell through to a plain Qt resize and skipped thekeep_aspect_ratiosetprop, so the first image always opened unlocked and only subsequent navigations got the right shape. Now inlines the env-var check, distinguishes the twoNonecases, and retries on Hyprland with a 40ms backoff (capped at 5 attempts / 200ms total) when the window isn't registered yet. - Non-Hyprland top-left drift fixed. The Qt fallback branch used to call
self.resize(w, h), which anchors top-left and lets bottom-right drift. The popout center walked toward the upper-left of the screen across navigations on Qt-driven WMs. Now usesself.setGeometry(QRect(x, y, w, h))with the computed top-left so the center stays put.
Image fill in popout and embedded preview
ImageViewer._fit_to_viewno longer caps zoom at native pixel size. Usedmin(scale_w, scale_h, 1.0)so a smaller image in a larger window centered with letterbox space around it. The1.0cap is gone — images scale up to fill the available view, matching how the video player fills its widget. Combined with the popout'skeep_aspect_ratio, the window matches the image's aspect AND the image fills it cleanly. Tiled popouts with mismatched aspect still letterbox (intentional — the layout owns the window shape).
Main app flash and popout resize speed
- Suppress dl_progress widget when the popout is open. The download progress bar at the bottom of the right splitter was unconditionally
show()'d on every grid click, including when the popout was open and the right splitter had been collapsed to give the grid full width. The show/hide pulse forced a layout pass on the right splitter that briefly compressed the main grid before the download finished andhide()fired. Visible flash on every click in the main app, even when clicking the same post that was already loaded (becausedownload_imagestill runs against the cache). Three callsites now skip the widget entirely when the popout is visible. The status bar still updates withLoading #X...so the user has feedback in the main window. - Cache
_hyprctl_get_windowacross one fit call._fit_to_contentused to callhyprctl clients -jthree times per popout navigation: once at the top for the floating check, once inside_derive_viewport_for_fitfor the position/size read, and once inside_hyprctl_resize_and_movefor the address lookup. Each call is a ~3mssubprocess.runthat blocks the Qt event loop, totalling ~9ms of UI freeze per nav. The two helpers now accept an optionalwin=Noneparameter;_fit_to_contentfetches the window dict once and threads it down. Per-fit subprocess count drops from 3 to 1 (~6ms saved per navigation), making rapid clicking and aspect-flip transitions feel snappier. - Show download progress on the active thumbnail when the embedded preview is hidden. After the dl_progress suppression above landed, the user lost all visible download feedback in the main app whenever the popout was open.
_on_post_activatednow decides per call whether to use the dl_progress widget at the bottom of the right splitter or fall back to drawing the download progress on the active thumbnail in the main grid via the existing prefetch-progress paint path (set_prefetch_progress(0.0..1.0)to fill,set_prefetch_progress(-1)to clear). The decision is captured at function entry aspreview_hidden = not (self._preview.isVisible() and self._preview.width() > 0)and closed over by the_progresscallback and the_loadcoroutine, so the indicator that starts on a download stays on the same target even if the user opens or closes the popout mid-download. Generalizes to any reason the preview is hidden, not just popout-open: a user who has dragged the main splitter to collapse the preview gets the thumbnail indicator now too.
Popout overlay stays hidden across navigation
- Stop auto-showing the popout overlay on every
set_media.FullscreenPreview.set_mediaended with an unconditionalself._show_overlay()call, which meant the floating toolbar and video controls bar popped back into view on every left/right/hjkl navigation between posts. Visually noisy and not what the overlay is for — it's supposed to be a hover-triggered surface, not a per-post popup. Removed the call. The overlay is still shown by__init__default state (_ui_visible = True, so the user sees it for ~2 seconds on first popout open and the auto-hide timer hides it after that), byeventFiltermouse-move-into-top/bottom-edge-zone (the intended hover trigger, unchanged), by volume scroll on the video stack (unchanged), and byCtrl+Htoggle (unchanged). After this, the only way the overlay appears mid-session is hover orCtrl+H— navigation through posts no longer flashes it back into view.
Discord screen-share audio capture
ao=pulsein the mpv constructor. mpv defaults toao=pipewire(native PipeWire audio output) on Linux. Discord's screen-share-with-audio capture on Linux only enumerates clients connected via the libpulse API; native PipeWire clients are invisible to it. Visible symptom: video plays locally fine but audio is silently dropped from any Discord screen share. Firefox works because Firefox uses libpulse to talk to PipeWire's pulseaudio compat layer. Settingao="pulse,wasapi,"in the MPV constructor (comma-separated priority list, mpv tries each in order) routes mpv through the same pulseaudio compat layer Firefox uses.pulseworks on Linux;wasapiis the Windows fallback; trailing empty falls through to mpv's compiled-in default. No platform branch needed — mpv silently skips audio outputs that aren't available. Verified by inspection: with the fix, mpv's sink-input hasmodule-stream-restore.id = "sink-input-by-application-name:booru-viewer"(the pulse-protocol form, identical to Firefox) instead of"sink-input-by-application-id:booru-viewer"(the native-pipewire form). References: mpv #11100, edisionnano/Screenshare-with-audio-on-Discord-with-Linux.audio_client_name="booru-viewer"in the mpv constructor. mpv now registers in pulseaudio/pipewire introspection asbooru-viewerinstead of the default "mpv Media Player". Setsapplication.name,application.id,application.icon_name,node.name, anddevice.descriptiontobooru-viewerso capture tools group mpv's audio under the same identity as the Qt application.
Docs
- README repositioning. New "Why booru-viewer" section between Screenshots and Features that names ahoviewer, Grabber, and Hydrus, lays out the labor axis (who does the filing) and the desktop axis (Hyprland/Wayland targeting), and explains the bookmark/library two-tier model with the browser-bookmark analogy.
- New tagline that does positioning instead of category description.
- Bookmarks and Library Features sections split to remove the previous intertwining; each now describes its own folder concept clearly.
- Backup recipe in Data Locations explaining the
saved/+booru.dbsplit and the recovery path. - Theming section notes that each bundled theme ships in
*-rounded.qssand*-square.qssvariants.
Fixes & polish
- Drop the unused "Size: WxH" line from the InfoPanel — bookmarks and library never had width/height plumbed and the field just showed 0×0.
- Tighter combo and button padding across all 12 bundled themes.
QPushButtonpadding 2px 8px → 2px 6px,QComboBoxpadding 2px 6px → 2px 4px,QComboBox::drop-downwidth 18px → 14px. Saves 8px non-text width per combo and 4px per button. - Library sort combo: new "Post ID" entry with a numeric stem sort that handles non-digit stems gracefully. Fits in 75px instead of needing 90px after the padding tightening.
- Score and page spinboxes 50px → 40px in the top toolbar to recover horizontal space. The internal range (0–99999) is unchanged; values >9999 will visually clip at the right edge but the stored value is preserved.
Downloads
- Bookmark folders and library folders are now independent name spaces. Used to share identity through