Three fixes that all surfaced from the bookmark/library decoupling
shake-out:
- Popout first-image aspect-lock race: _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
we inline the env-var check, distinguish the two None cases, and
retry on Hyprland with a 40ms backoff (capped at 5 attempts /
200ms total) when the window isn't registered yet.
- Image fill in popout (and embedded preview): ImageViewer._fit_to_view
used min(scale_w, scale_h, 1.0) which clamped the zoom at native
pixel size, so a smaller image in a larger window centered with
letterbox space around it. Dropped the 1.0 cap so 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).
- Combo + button padding tightening across all 12 bundled themes
and Library sort combo: 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, so the new "Post ID" sort entry fits in 75px instead of
needing 90. Library sort combo bumped from "Name" (lexicographic)
to "Post ID" with a numeric stem sort that handles non-digit
stems gracefully.
Bookmark folders and library folders used to share identity through
_db.get_folders() — the same string was both a row in favorite_folders
and a directory under saved_dir. They look like one concept but they're
two stores, and the cross-bleed produced a duplicate-on-move bug and
made "Save to Library" silently re-file the bookmark too.
Now they're independent name spaces:
- library_folders() in core.config reads filesystem subdirs of
saved_dir; the source of truth for every Save-to-Library menu
- find_library_files(post_id) walks the library shallowly and is the
new "is this saved?" / delete primitive
- bookmark folders stay DB-backed and are only used for bookmark
organization (filter combo, Move to Folder)
- delete_from_library no longer takes a folder hint — walks every
library folder by post id and deletes every match (also cleans up
duplicates left by the old save-to-folder copy bug)
- _save_to_library is move-aware: if the post is already in another
library folder, atomic Path.rename() into the destination instead
of re-copying from cache (the duplicate bug fix)
- bookmark "Move to Folder" no longer also calls _copy_to_library;
Save to Library no longer also calls move_bookmark_to_folder
- settings export/import unchanged; favorite_folders table preserved
so no migration
UI additions:
- Library tab right-click: Move to Folder submenu (single + multi),
uses 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" replaced with "Bookmark as"
submenu when not yet bookmarked (Unfiled / folders / + New); flat
"Remove Bookmark" when already bookmarked
- Embedded preview Bookmark button: same submenu shape via new
bookmark_to_folder signal + set_bookmark_folders_callback
- Popout Bookmark button: same shape — works in both browse and
bookmarks tab modes
- Popout Save button: Save-to-Library submenu via new save_to_folder
+ unsave_requested signals (drops save_toggle_requested + the
_save_toggle_from_popout indirection)
- Popout in library mode: Save button stays visible as Unsave; the
rest of the toolbar (Bookmark / BL Tag / BL Post) is hidden
State plumbing:
- _update_fullscreen_state mirrors the embedded preview's
_is_bookmarked / _is_saved instead of re-querying DB+filesystem,
eliminating the popout state drift during async bookmark adds
- Library tab Save button reads "Unsave" the entire time; Save
button width bumped 60→75 so the label doesn't clip on tight themes
- Embedded preview tracks _is_bookmarked alongside _is_saved so the
new Bookmark-as submenu can flip to a flat unbookmark when active
Naming:
- "Unsorted" renamed to "Unfiled" everywhere user-facing — library
Unfiled and bookmarks Unfiled now share one label. Internal
comparison in library.py:_scan_files updated to match the combo.
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.
Let KeepAspectRatio handle sizing (centered by default).
Set video widget palette to match theme background so
letterbox bars blend with the UI instead of showing black.
Compares video aspect ratio to container ratio. Wider videos
get height constrained, taller videos get width constrained.
Works for both preview and slideshow automatically.
Preview constrains height (eliminates top/bottom bars).
Slideshow constrains width (eliminates side bars).
Both use video aspect ratio from first frame.
- Slideshow video player: auto_size_video=False, no height constraint
- Revert video widget hide/show (caused info panel issues)
- Preview video still auto-sizes to aspect ratio
Detects video dimensions from first frame via QVideoSink,
sets max height on the video widget to eliminate black bars.
Resets on each new video. Uses KeepAspectRatio mode.
- Renamed "Copy Image to Clipboard" to "Copy File to Clipboard"
- Works for images AND videos via wl-copy with correct MIME types
- Added to grid, preview, bookmarks, and library context menus
- Ctrl+C shortcut works globally
- Qt fallback for non-Wayland systems
- Ctrl+C tries pixmap then cached file path as fallback
- Preview right-click always shows "Copy Image to Clipboard"
- Works for images and loads from disk for videos
- Status bar shows result count with copy confirmation
- BL Tag button in slideshow: opens categorized tag menu
- BL Post button in slideshow: blacklists current post
- Ctrl+C copies preview image to clipboard
- "Copy Image to Clipboard" in grid right-click menu
- Fix README code block formatting (missing closing backticks)
- Add ffmpeg back to Linux install deps
- Mute, volume, autoplay, loop state synced on slideshow open/close
- Loop restart detection requires position > 80% of duration to
prevent false triggers on new video loads
- Use codicon codepoints (eb7c etc.) which exist in Terminess/Nerd Fonts
- All icons overridable via QSS: qproperty-playIcon, qproperty-pauseIcon,
qproperty-muteIcon, qproperty-unmuteIcon on VideoPlayer
- Lazy detection after QSS is applied