• v0.2.2 7d195558f6

    v0.2.2 Stable

    pax released this 2026-04-08 05:29:53 +00:00 | 345 commits to main since this release

    0.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 in favorite_folders and a directory under saved_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 of saved/ for organizing files on disk.
    • library_folders() in core.config is the new source of truth for every Save-to-Library menu — reads filesystem subdirs of saved_dir directly.
    • 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.rename for 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_folder signal + 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_state now mirrors the embedded preview's _is_bookmarked / _is_saved instead 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_folders table preserved for backward compatibility — no migration required.

    Concurrency refactor

    The earlier worker pattern of threading.Thread + asyncio.run was 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.py is 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 by BooruApp.
    • gui/sites.py SiteDialog Detect and Test buttons now route through run_on_app_loop instead of spawning a daemon thread. Results marshal back via Qt Signals with QueuedConnection. 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.py thumbnail loader got the same swap. The existing thumb_ready signal already marshaled correctly.
    • Lazy-init lock on shared httpx clients. BooruClient._shared_client, E621Client._e621_client, and cache._shared_client all use a fast-path / locked-slow-path lazy init. Concurrent first-callers can no longer both build a client and leak one.
    • E621Client UA-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_close and drained at shutdown instead of leaking.
    • aclose_shared on shutdown. BooruApp.closeEvent now runs an _close_all coroutine via run_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 _migrate now wrap their multi-statement bodies in with self.conn: so a crash mid-method can't leave orphan rows.
    • add_bookmark lastrowid fix. When INSERT OR IGNORE collides on (site_id, post_id), lastrowid is stale; the method now re-SELECTs the existing id. Was returning Bookmark(id=0) silently, which then no-op'd update_bookmark_cache_path on the next bookmark.
    • LIKE wildcard escape. get_bookmarks LIKE clauses now ESCAPE '\\' so user search literals stop acting as SQL wildcards (cat_ear no longer matches catear).
    • Path traversal guard on folder names. New _validate_folder_name rejects .., path separators, and leading ./~ at write time. saved_folder_dir() resolves the candidate and refuses anything that doesn't relative_to the saved-images base.
    • Download size cap and streaming. download_image enforces 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 atomic os.replace instead 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 race write_bytes.
    • Image.MAX_IMAGE_PIXELS = 256M with DecompressionBombError handling in both PIL converters.
    • Ugoira zip-bomb caps. Frame count and cumulative uncompressed size checked from ZipInfo headers before any decompression.
    • _convert_animated_to_gif failure cache. Writes a .convfailed sentinel sibling on failure to break the re-decode-every-paint loop for malformed animated PNGs/WebPs.
    • _is_valid_media distinguishes IO errors from "definitely invalid". Returns True (don't delete) on OSError so a transient EBUSY/permissions hiccup no longer triggers a delete + re-download loop.
    • Hostname suffix matching for Referer. Was using substring in matching, which meant imgblahgelbooru.attacker.com falsely mapped to gelbooru.com. Now uses proper suffix check.
    • _request retries on httpx.NetworkError and httpx.ConnectError in addition to TimeoutException. A single DNS hiccup or RST no longer blows up the whole search.
    • test_connection no longer echoes the response body in error strings. It was a body-leak gadget when used via detect_site_type's redirect-following client.
    • Exception logging across detect, search, and autocomplete in every API client. Previously every failure was a silent return []; now every swallowed exception logs at WARNING with type, message, and (where relevant) the response body prefix.
    • main_gui.py file_dialog_platform DB probe failure now prints to stderr instead of vanishing.
    • Folder name validation surfaced as QMessageBox.warning in gui/bookmarks.py and gui/app.py instead of crashing when a user types something the validator rejects.

    Popout overlay fix

    • WA_StyledBackground set 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, but the bar behind them showing the letterbox color).
    • Base popout overlay style baked into the QSS loader. _BASE_POPOUT_OVERLAY_QSS is prepended before the user's custom.qss so 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_content was 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_side becomes 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/resizeEvent hooks 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_content used to call _is_hypr_floating which returned None for both "not Hyprland" and "Hyprland but the window isn't visible to hyprctl yet". The latter happens on the very first popout open because the wm:openWindow event hasn't been processed when set_media fires. The method then fell through to a plain Qt resize and skipped the keep_aspect_ratio setprop, so the first image always opened unlocked and only subsequent navigations got the right shape. Now inlines the env-var check, distinguishes the two None cases, 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 uses self.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_view no longer caps zoom at native pixel size. Used min(scale_w, scale_h, 1.0) so a smaller image in a larger window centered with letterbox space around it. The 1.0 cap is gone — images scale up to fill the available view, matching how the video player fills its widget. Combined with the popout's keep_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 and hide() fired. Visible flash on every click in the main app, even when clicking the same post that was already loaded (because download_image still runs against the cache). Three callsites now skip the widget entirely when the popout is visible. The status bar still updates with Loading #X... so the user has feedback in the main window.
    • Cache _hyprctl_get_window across one fit call. _fit_to_content used to call hyprctl clients -j three times per popout navigation: once at the top for the floating check, once inside _derive_viewport_for_fit for the position/size read, and once inside _hyprctl_resize_and_move for the address lookup. Each call is a ~3ms subprocess.run that blocks the Qt event loop, totalling ~9ms of UI freeze per nav. The two helpers now accept an optional win=None parameter; _fit_to_content fetches 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_activated now 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 as preview_hidden = not (self._preview.isVisible() and self._preview.width() > 0) and closed over by the _progress callback and the _load coroutine, 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_media ended with an unconditional self._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), by eventFilter mouse-move-into-top/bottom-edge-zone (the intended hover trigger, unchanged), by volume scroll on the video stack (unchanged), and by Ctrl+H toggle (unchanged). After this, the only way the overlay appears mid-session is hover or Ctrl+H — navigation through posts no longer flashes it back into view.

    Discord screen-share audio capture

    • ao=pulse in the mpv constructor. mpv defaults to ao=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. Setting ao="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. pulse works on Linux; wasapi is 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 has module-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 as booru-viewer instead of the default "mpv Media Player". Sets application.name, application.id, application.icon_name, node.name, and device.description to booru-viewer so 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.db split and the recovery path.
    • Theming section notes that each bundled theme ships in *-rounded.qss and *-square.qss variants.

    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. QPushButton padding 2px 8px → 2px 6px, QComboBox padding 2px 6px → 2px 4px, QComboBox::drop-down width 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