436 Commits

Author SHA1 Message Date
pax
57a19f87ba gelbooru: re-add background prefetch for batch API fast path only
When _batch_api_works is True (Gelbooru proper with auth, persisted
from a prior session's probe), search() fires prefetch_batch in the
background. The batch tag API covers the entire page's tags in 1-2
requests during the time between grid render and user click — the
cache is warm before the info panel opens, so categories appear
instantly with no flash of flat tags.

Gated on _batch_api_works is True (not None, not False):
  - Gelbooru proper: prefetches (batch API known good)
  - Rule34: skips (batch_api_works = False, persisted)
  - Safebooru.org: skips (no auth → fetcher skips batch capability)

Rule34 / Safebooru.org / Moebooru stay on-demand: the ~200ms
per-click HTML scrape is unavoidable for those sites because their
only path is per-post page fetching, which can't be batched.
2026-04-09 20:01:34 -05:00
pax
403c099bed library: clean up library_meta on delete (templated + digit-stem)
The Library tab's single-delete and multi-delete context menu
actions called .unlink() directly, bypassing delete_from_library
entirely. They only extracted post_id from digit-stem filenames
(int(stem) if stem.isdigit()), so templated files like
artist_12345.jpg got deleted from disk but left orphan
library_meta rows that made get_saved_post_ids lie forever.

Fix: resolve post_id via db.get_library_post_id_by_filename first
(handles templated filenames), fall back to int(stem) for legacy
digit-stem files, then call db.remove_library_meta(post_id) after
unlinking. Both single-delete and multi-delete paths are fixed.

This was the last source of orphan library_meta rows. With this
fix + the earlier delete_from_library cleanup, every deletion
path in the app now cleans up its meta row:
  - Library tab single delete (this commit)
  - Library tab multi delete (this commit)
  - Browse/preview "Unsave from Library" (via delete_from_library)
  - Browse multi-select "Unsave All" (via delete_from_library)
  - Bookmarks "Unsave from Library" (via delete_from_library)
  - Bookmarks multi-select "Unsave All" (via delete_from_library)
2026-04-09 19:58:28 -05:00
pax
912be0bc80 main_window: fix last digit-stem _saved_ids in _on_search_done
The primary search result handler (_on_search_done) was still using
the old filesystem walk + stem.isdigit() filter to build the saved-
post-id set. The two other call sites (_on_load_more and the
blacklist rebuild) were fixed in the earlier saved-dot sweep but
this one was missed. Templated filenames like artist_12345.jpg
were invisible, so the saved-dot disappeared after any grid
rebuild (new search, page change, etc).

Fix: use self._db.get_saved_post_ids() (one indexed SELECT,
format-agnostic) like the other two sites already do. Also drops
the saved_dir import that was only needed for the filesystem walk.
2026-04-09 19:56:55 -05:00
pax
f168bece00 category_fetcher: fix _do_ensure to try batch API when not yet probed
_do_ensure only tried the batch API when _batch_api_works was True,
but after removing the search-time prefetch (where the probe used
to run), _batch_api_works stayed None forever. Gelbooru's only
viable path IS the batch API (its post-view HTML has no tag links),
so clicks on Gelbooru posts produced zero categories.

Fix: _do_ensure now tries the batch API when _batch_api_works is
not False (i.e., both True and None). When None, the call doubles
as an inline probe: if the batch produced categories, save True;
if nothing useful came back, save False and fall to HTML.

This is simpler than the old prefetch_batch probe because it runs
on ONE post at a time — no batch/HTML mixing concerns, no "single
path per invocation" rule. The probe result is persisted to DB so
it only fires once per site ever.

Dispatch matrix in _do_ensure:
  _batch_api_works True  + auth → batch API (Gelbooru proper)
  _batch_api_works None  + auth → batch as probe → True or False
  _batch_api_works False        → HTML scrape (Rule34)
  no auth                       → HTML scrape (Safebooru.org)
  transient error               → stays None, retry next click

Verified all three sites from clean cache: Gelbooru 55/56+49/50
(batch), Rule34 40/40+38/38 (HTML), Safebooru.org 47/47+47/47
(HTML).
2026-04-09 19:53:20 -05:00
pax
35424ff89d gelbooru+moebooru: drop background prefetch from search, fetch on demand
Removes the asyncio.create_task(prefetch_batch) calls from
search() and get_post() in both clients. Tags are now fetched
ONLY when the user actually clicks a post (via ensure_categories
in the info panel path) or saves with a category-token template.

The background prefetch was the source of most of the complexity:
probe timing, early-exit bugs from partial composes racing with
on-click ensures, Rule34's slow probe blocking the prefetch
window. All gone.

New flow:
  search() → fast, returns posts with flat tags only
  click    → ensure_categories fires, ~200ms HTML scrape or
             batch API, categories arrive, signal re-renders
  re-click → instant (cache compose, no HTTP)
  save     → ensure in save_post_file, same path

The ~200ms per first-click is invisible during the image load.
The cache compounds across posts and sessions. The prefetch_batch
method stays in CategoryFetcher for potential future use but
nothing calls it from the hot path anymore.
2026-04-09 19:48:04 -05:00
pax
7d11aeab06 category_fetcher: persist batch API probe result across sessions
The probe that detects whether a site's batch tag API works
(Gelbooru proper: yes, Rule34: no) now persists its result in the
tag_types table using a sentinel key (__batch_api_probe__). On
subsequent app launches, the fetcher reads the saved result at
construction time and skips the probe entirely.

Before: every session with Rule34 wasted ~0.6s on a probe request
that always fails (Rule34 returns garbage for names=). During that
time the background prefetch couldn't start HTML scraping, so the
first few post clicks paid ~0.3s each.

After: first ever session probes Rule34 once, stores False. Every
subsequent session reads False from DB, skips the probe, and the
background prefetch immediately starts HTML scraping. By the time
the user clicks any post, the scrape is usually done.

Gelbooru proper: probe succeeds on first session, stores True.
Future sessions use the batch API without probing. No change in
speed (already fast), just saves the probe roundtrip.

Persisted per site_id so different Gelbooru-shaped sites get their
own probe result. The clear_tag_cache method wipes probe results
along with tag data (the sentinel key lives in the same table).
2026-04-09 19:46:20 -05:00
pax
1547cbe55a fix: remove early-exit on non-empty tag_categories in ensure path
Two places checked `if post.tag_categories: return` before doing
a full cache-coverage check, causing posts with partial cache
composes (e.g. 5/40 tags from the background prefetch) to get
stuck at low coverage forever:

  ensure_categories: removed the post.tag_categories early exit.
    Now ALWAYS runs try_compose_from_cache first. Only the 100%
    coverage return (True) is trusted as "done." Partial composes
    return False and fall through to the fetch path.

  _ensure_post_categories_async: removed the post.tag_categories
    guard. Danbooru/e621 are filtered by the client.category_fetcher
    is None check instead (they categorize inline, no fetcher).
    For Gelbooru-style sites, always schedules ensure_categories
    regardless of current post state.

Root cause: the partial-compose fix (try_compose_from_cache
populates tag_categories even when cache coverage is <100%)
conflicted with the early-exit guards that assumed non-empty
tag_categories = fully categorized. Now the only "fully done"
signal is try_compose_from_cache returning True (100% coverage).
2026-04-09 19:40:09 -05:00
pax
762d73dc4f category_fetcher: fix partial-compose vs ensure_categories interaction
try_compose_from_cache was returning True on ANY partial cache hit
(even 1/38 tags). ensure_categories then saw non-empty
tag_categories and returned immediately, leaving the post stuck at
1/38 coverage. The bug showed on Rule34: post 1 got fully scraped
(40/40), its tags got cached, then post 2's compose found one
matching tag and declared victory.

Fix: try_compose_from_cache now returns True ONLY when 100% of
unique tags have cached labels (no fetch needed). It STILL
populates post.tag_categories with whatever IS cached (for
immediate partial display), but returning False signals
ensure_categories to continue to the fetch path.

This is the correct semantic split:
  - populate → always (for display)
  - return True → only when complete (for dispatch)

Verified:
  Rule34:       40/40 + 38/38 (was 40/40 + 1/38)
  Gelbooru:     55/56 + 49/50 (batch API, one rare tag)
  Safebooru.org: 47/47 + 47/47 (HTML scrape, full)
2026-04-09 19:36:58 -05:00
pax
f0fe52c886 fix: HTML parser two-pass rewrite + fire-and-forget prefetch
Three fixes:

1. HTML parser completely rewritten with two-pass approach:
   - Pass 1: regex finds each tag-type element and its full inner
     content (up to closing </li|span|td|div>)
   - Pass 2: within the content, extracts the tag name from the
     tags=NAME URL parameter in the search link
   The old single-pass regex captured the ? wiki-link (first <a>)
   instead of the tag name (second <a>). The URL-param extraction
   works on Rule34 (40 tags), Safebooru.org (47 tags), and
   yande.re (3 tags). Gelbooru proper returns 0 (post page only
   has ? links with no tags= param) which is correct — Gelbooru
   uses the batch tag API instead.

2. prefetch_batch is now truly fire-and-forget:
   gelbooru.py and moebooru.py use asyncio.create_task instead of
   await for prefetch_batch. search() returns immediately. The
   probe + batch/HTML fetch runs in the background. Previously
   search() blocked on the probe, which made Rule34 searches take
   5+ seconds (slow/broken Rule34 API response time).

3. Partial cache compose already fixed in the previous commit
   complements this: posts with 49/50 cached tags now show all
   available categories instead of nothing.
2026-04-09 19:31:43 -05:00
pax
165733c6e0 category_fetcher: compose from partial cache coverage
try_compose_from_cache previously required 100% cache coverage —
every tag in the post had to have a cached label or it returned
False and populated nothing. One rare uncached tag out of 50
blocked the entire composition, leaving the post with zero
categories even though 49/50 labels were available.

Fix: compose whatever IS cached, return True when at least one
tag got categorized. Tags not in the cache are simply absent from
the categories dict (they stay in the flat tags string). The
return value now means "the post has usable categories" rather
than "the post has complete categories." This distinction matters
because the dispatch logic uses the return value to decide
whether to skip the fetch path — partial coverage is better than
no coverage, and the missing tags get cached eventually when
other posts that contain them get fetched.

Verified against Gelbooru: post with 50 tags where 49 were cached
now gets 49/50 categorized (Artist, Character, Copyright, General,
Meta) instead of 0/50.
2026-04-09 19:23:57 -05:00
pax
af9b68273c bookmarks: await save_post_file (now async) via run_on_app_loop
Two bookmark save sites updated for save_post_file's sync→async
signature change:

  _save_bookmark_to_library: wraps the save in an async closure
    and schedules via run_on_app_loop (already imported for the
    thumbnail download path). Fire-and-forget; the source file is
    already cached so the save is near-instant.

  Save As action: same async wrapper pattern. The dialog runs
    synchronously (user picks destination), then the actual file
    copy is scheduled on the async loop.

Neither site passes a category_fetcher — bookmarks don't have a
direct reference to the active BooruClient. The save flow's
ensure_categories check in library_save.py short-circuits (the
fetcher is None), so template rendering uses whatever categories
are already on the post object. For bookmark→library saves, the
user typically hasn't clicked the post in the browse grid, so
categories may be empty — the template falls back to %id% for
category tokens, same as before. Full categorization on the
bookmark save path is a future enhancement (would require passing
the client through from main_window).
2026-04-09 19:21:57 -05:00
pax
e2a666885f main_window: pass category_fetcher to all save_post_file call sites
Four save call sites updated to await save_post_file (now async)
and pass category_fetcher so the template-render ensure check can
fire when needed.

  _bulk_save: creates fetcher once at the top of the async closure,
    shared across all posts in the batch. Probe state persists
    within the batch.
  _save_to_library: creates fetcher per invocation (single post).
  _save_as: wrapped in an async closure (was sync before) since
    save_post_file is now async. Uses bookmark_done/error signals
    for status instead of direct showMessage.
  _batch_download_to: creates fetcher once at the top, shared
    across the batch.

New _get_category_fetcher helper returns the fetcher from a fresh
client (lightweight — shares the global httpx pool) or None if no
site is active.
2026-04-09 19:20:31 -05:00
pax
8f8db62a5a library_save: ensure categories before template render
save_post_file is now async and gains an optional
category_fetcher parameter. When the template uses any category
token (%artist%, %character%, %copyright%, %general%, %meta%,
%species%) AND the post's tag_categories is empty AND a fetcher
is available, it awaits ensure_categories(post) before calling
render_filename_template. This guarantees the filename is
correct even when saving a post the user hasn't clicked
(bypassing the info panel's on-display trigger).

When the template uses only non-category tokens (%id%, %md5%,
%score%, %rating%, %ext%) or is empty, the ensure check is
skipped entirely — no HTTP overhead for the common case.

Every existing caller already runs from _run_async closures,
so the sync→async signature change is mechanical. The callers
are updated in the next two commits to pass category_fetcher.
2026-04-09 19:18:13 -05:00
pax
fa1222a774 main_window: pass db+site_id + ensure categories on info panel display
Three changes:

1. _make_client passes db=self._db, site_id=s.id so Gelbooru and
   Moebooru clients get a CategoryFetcher attached via the factory.

2. _on_post_activated calls _ensure_post_categories_async(post)
   after setting up the preview. If the post has empty categories
   (background prefetch hasn't reached it yet, or cache miss),
   this schedules ensure_categories on the async loop. When it
   completes, it emits categories_updated via the Qt signal.

3. _on_categories_updated slot re-renders the info panel and
   preview pane tag display when the currently-selected post's
   categories arrive. Stale updates (user clicked a different post
   before the fill completed) are silently dropped by the post.id
   check.
2026-04-09 19:17:34 -05:00
pax
9a05286f06 signals: add categories_updated carrying a Post 2026-04-09 19:16:16 -05:00
pax
f5954d1387 api: factory constructs CategoryFetcher for Gelbooru + Moebooru sites
client_for_type gains optional db + site_id kwargs. When both are
passed and api_type is gelbooru or moebooru, a CategoryFetcher is
constructed and assigned to client.category_fetcher. The fetcher
owns the per-tag cache, the batch tag API fast path, and the
per-post HTML scrape fallback.

Danbooru and e621 never get a fetcher — their inline JSON
categorization is already optimal.

Test Connection dialog and scripts don't pass db/site_id, so they
get fetcher-less clients with the existing search behavior.
2026-04-09 19:15:57 -05:00
pax
834deecf57 moebooru: implement _post_view_url + prefetch wiring
Override _post_view_url to return /post/show/{id} for the per-post
HTML scrape path. No _tag_api_url override — Moebooru has no batch
tag DAPI; the CategoryFetcher dispatch goes straight to per-post
HTML for these sites.

search() and get_post() now call prefetch_batch when a fetcher is
attached, same fire-and-forget pattern as gelbooru.py.
2026-04-09 19:15:34 -05:00
pax
7f897df4b2 gelbooru: implement _post_view_url + _tag_api_url + prefetch wiring
Overrides both URL methods from the base class:
  _post_view_url(post) -> /index.php?page=post&s=view&id={id}
    Universal HTML scrape path — works on Gelbooru proper, Rule34,
    Safebooru.org without auth.
  _tag_api_url() -> {base_url}/index.php
    Batch tag DAPI fast path. The CategoryFetcher's probe-and-cache
    determines at runtime whether the endpoint actually honors
    names=. Gelbooru proper: probe succeeds. Rule34: probe fails
    (garbage response), falls back to HTML. Safebooru.org: no auth,
    dispatch skips batch entirely.

search() and get_post() now call
    await self.category_fetcher.prefetch_batch(posts)
after building the post list, when a fetcher is attached. The
prefetch is fire-and-forget — search returns immediately and the
background tasks fill categories as the user reads. When no
fetcher is attached (Test Connection dialog, scripts), this is a
no-op and behavior is unchanged.
2026-04-09 19:15:02 -05:00
pax
5ba0441be7 e621: populate categories in get_post (latent bug fix) 2026-04-09 19:14:19 -05:00
pax
9001808951 danbooru: populate categories in get_post (latent bug fix) 2026-04-09 19:13:52 -05:00
pax
8f298e51fc api: BooruClient virtual _post_view_url + _tag_api_url + category_fetcher attr
Three additions to the base class, all default-inactive:

  _post_view_url(post) -> str | None
    Override to provide the post-view HTML URL for the per-post
    category scrape path. Default None (Danbooru/e621 skip it).

  _tag_api_url() -> str | None
    Override to provide the batch tag DAPI base URL for the fast
    path in CategoryFetcher. Default None. Only Gelbooru proper
    benefits — the fetcher's probe-and-cache determines at runtime
    whether the endpoint actually honors the names= parameter.

  self.category_fetcher = None
    Set externally by the factory (client_for_type) when db and
    site_id are available. Gelbooru-shape and Moebooru clients use
    it; Danbooru/e621 leave it None.

No behavior change at this commit. Existing clients inherit the
defaults and continue working identically.
2026-04-09 19:13:21 -05:00
pax
e00d88e1ec api: CategoryFetcher module with HTML scrape + batch tag API + cache
New module core/api/category_fetcher.py — the unified tag-category
fetcher for boorus that don't return categories inline.

Public surface:
  try_compose_from_cache(post) — instant, no HTTP. Builds
    post.tag_categories from cached (site_id, name) -> label
    entries. Returns True if every tag in the post is cached.
  fetch_via_tag_api(posts) — batch fast path. Collects uncached
    tags across posts, chunks into 500-name batches, GETs the
    tag DAPI. Only available when the client declares _tag_api_url
    AND has credentials (Gelbooru proper). Includes JSON/XML
    sniffing parser ported from the reverted code.
  fetch_post(post) — universal fallback. HTTP GETs the post-view
    HTML page, regex-extracts class="tag-type-X">name</a>
    markup. Works on every Gelbooru fork and every Moebooru
    deployment. Does NOT require auth.
  ensure_categories(post) — idempotent dispatch: cache compose ->
    batch API (if available) -> HTML scrape. Coalesces concurrent
    calls for the same post.id via an in-flight task dict.
  prefetch_batch(posts) — fire-and-forget background prefetch.
    ONE fetch path per invocation (no mixing batch + HTML).

Probe-and-cache for the batch tag API:
  _batch_api_works = None -> not yet probed OR transient error
                              (retry next call)
  _batch_api_works = True -> batch works (Gelbooru proper)
  _batch_api_works = False -> clean 200 + zero matching names
                               (Rule34's broken names= filter)
  Transition to True/False is permanent per instance. Transient
  errors (HTTP error, timeout, parse exception) leave None so the
  next search retries the probe.

HTML regex handles both standard tag-type-artist and combined-
class forms like tag-link tag-type-artist (Konachan). Tag names
normalized to underscore-separated lowercase.

Canonical category order: Artist > Character > Copyright >
Species > General > Meta > Lore (matches danbooru/e621 inline).

Dead code at this commit — no integration yet.
2026-04-09 19:12:43 -05:00
pax
5395569213 db: re-add tag_types cache table with string labels + auto-prune
Per-site tag-type cache for boorus that don't return categories
inline. Uses string labels ("Artist", "Character", "Copyright",
"General", "Meta") instead of the integer codes the reverted
version used — the labels come directly from HTML class names,
no mapping step needed.

Schema: tag_types(site_id, name, label TEXT, fetched_at)
        PRIMARY KEY (site_id, name)

Methods:
  get_tag_labels(site_id, names) — chunked 500-name SELECT
  set_tag_labels(site_id, mapping) — bulk INSERT OR REPLACE,
    auto-prunes oldest entries when the table exceeds 50k rows
  clear_tag_cache(site_id=None) — manual wipe, for future
    Settings UI "Clear tag cache" button

The 50k row cap prevents unbounded growth over months of
browsing multiple boorus. Normal usage (a few thousand unique
tags per site) never reaches it. When exceeded, the oldest
entries by fetched_at are pruned first — these are the tags the
user hasn't encountered recently and would be re-fetched cheaply
if needed.

Migration: CREATE TABLE IF NOT EXISTS in _migrate(), non-breaking
for existing databases.
2026-04-09 19:10:37 -05:00
pax
81fc4d93eb main_window: library tab info panel + preview work for templated files
Two more digit-stem-only callsites I missed in the saved-dot fix
sweep. _set_library_info and _show_library_post both did
'if not stem.isdigit(): return' before consulting library_meta or
building the toolbar Post. Templated files (post-template-refactor
saves like 12345_hatsune_miku.jpg) bailed out silently — clicking
one in the Library tab left the info panel showing the previous
selection's data and the toolbar actions did nothing.

Extracted a small helper _post_id_from_library_path that resolves
either layout: look up library_meta.filename first (templated),
fall back to int(stem) for legacy digit-stem files. Both call sites
go through the helper now.

Same pattern as the find_library_files / _is_post_in_library
fixes from the earlier saved-dot bug. With this commit there are
no remaining "is templated file in the library?" callsites that
fall back to digit-stem matching alone — every check is
format-agnostic via the DB.
2026-04-09 18:25:21 -05:00
pax
a27672b95e main_window: fix browse-side saved-dot indicator + delete cleanup
The browse grid had the same digit-stem-only bug as the bookmark
grid: _saved_ids in two places used a root-only iterdir + isdigit
filter, missing both subfolder saves and templated filenames. The
user only reported the bookmark side, but this side has been
silently broken for any save into a subfolder for a while.

Six changes, all driven by the new db-backed helpers:

  _on_load_more (browse grid append):
    _saved_ids = self._db.get_saved_post_ids()
  After-blacklist rebuild:
    _saved_ids = self._db.get_saved_post_ids()
  _is_post_saved:
    return self._db.is_post_in_library(post_id)
  Bookmark preview lookup find_library_files:
    pass db=self._db so templated names also match
  _unsave_from_preview delete_from_library:
    pass db=self._db so templated names get unlinked AND meta cleaned
  _bulk_unsave delete_from_library:
    same fix
2026-04-09 18:25:21 -05:00
pax
3ef1a0bbd3 bookmarks: fix saved-dot indicator for templated/folder library saves
The dot on bookmark thumbnails uses set_saved_locally(...) and was
driven by find_library_files(post_id) — a digit-stem filesystem
walk that silently failed for any save with a templated filename
(e.g. 12345_hatsune_miku.jpg). The user reported it broken right
after templating landed.

Switch to db.get_saved_post_ids() for the grid refresh: one indexed
SELECT, set membership in O(1) per thumb. Format-agnostic, sees
both digit-stem and templated saves.

The "Unsave from Library" context menu used the same broken
find_library_files check for visibility. Switched to
db.is_post_in_library(post_id), which is the same idea via a
single-row SELECT 1.

Both delete_from_library call sites (single + bulk Unsave All)
now pass db so templated filenames are matched and the meta row
gets cleaned up. Refresh always runs after Unsave so the dot
clears whether the file was on disk or just an orphan meta row.
2026-04-09 18:25:21 -05:00
pax
150970b56f cache: delete_from_library cleans up library_meta + matches templated names
Two related fixes that the old delete flow was missing:

1. delete_from_library now accepts an optional `db` parameter which
   it forwards to find_library_files. Without `db`, only digit-stem
   files match (the old behavior — preserved as a fallback). With
   `db`, templated filenames stored in library_meta also match,
   so post-refactor saves like 12345_hatsune_miku.jpg get unlinked
   too. Without this fix, "Unsave from Library" on a templated
   save was a silent no-op.

2. Always cleans up the library_meta row when called with `db`, not
   just when files were unlinked. Two cases this matters for:
     a. Files were on disk and unlinked → meta is now stale.
     b. Files were already gone but the meta lingered (orphan from
        a previous broken delete) → user asked to "unsave," meta
        should reflect that.
   This is the missing half of the cleanup that left some libraries
   with pathologically more meta rows than actual files.
2026-04-09 18:25:21 -05:00
pax
5976a81bb6 db: add reconcile_library_meta to clean up orphan meta rows
The old delete_from_library deleted files from disk but never
cleaned up the matching library_meta row. Result: pathologically
the meta table can have many more rows than there are files on
disk. This was harmless when the only consumer was tag-search (the
meta would just match nothing useful), but it becomes a real
problem the moment is_post_in_library / get_saved_post_ids start
driving UI state — the saved-dot indicator would light up for
posts whose files have been gone for ages.

reconcile_library_meta() walks saved_dir() shallowly (root + one
level of subdirs), collects every present post_id (digit-stem
files plus templated filenames looked up via library_meta.filename),
and DELETEs every meta row whose post_id isn't in that set.
Returns the count of removed rows.

Defensive: if saved_dir() exists but has zero files (e.g. removable
drive temporarily unmounted), the method refuses to reconcile and
returns 0. The cost of a false positive — wiping every meta row
for a perfectly intact library — is higher than the cost of
leaving stale rows around for one more session.

The cache.py fix in the next commit makes future delete_from_library
calls clean up after themselves. This method is the one-time
catch-up for libraries that were already polluted before that fix.
2026-04-09 18:25:21 -05:00
pax
6f59de0c64 config: find_library_files now matches templated filenames
When given an optional db handle, find_library_files queries
library_meta for templated filenames belonging to the post and
matches them alongside the legacy digit-stem stem == str(post_id)
heuristic. Without db it degrades to the legacy-only behavior, so
existing callers don't break — but every caller in the gui layer
has a Database instance and will be updated to pass it.

This is the foundation for the bookmark/browse saved-dot indicator
fix and the delete_from_library fix in the next three commits.
2026-04-09 18:25:21 -05:00
pax
28348fa9ab db: add is_post_in_library / get_saved_post_ids helpers
The pre-template world used find_library_files(post_id) — a
filesystem walk matching files whose stem equals str(post_id) — for
"is this post saved?" checks across the bookmark dot indicator,
browse dot indicator, Unsave menu visibility, etc. With templated
filenames (e.g. 12345_hatsune_miku.jpg) the stem no longer equals
the post id and the dots silently stop lighting up.

Two new helpers, both indexed:
- is_post_in_library(post_id) -> bool   single check, SELECT 1
- get_saved_post_ids() -> set[int]      batch fetch for grid scans

Both go through library_meta which is keyed by post_id, so they're
format-agnostic — they don't care whether the on-disk filename is
12345.jpg, mon3tr_(arknights).jpg, or anything else, as long as the
save flow wrote a meta row. Every save site does this since the
unified save_post_file refactor landed.
2026-04-09 18:25:21 -05:00
pax
f0b1fc9052 config: render_filename_template now matches the API client key casing
The danbooru and e621 API clients store tag_categories with
Capitalized keys ("Artist", "Character", "Copyright", "General",
"Meta", "Species") — that's the convention info_panel and
preview_pane already iterate against. render_filename_template was
looking up lowercase keys, so every category token rendered empty
even on Danbooru posts where the data was right there. Templates
like "%id%_%character%" silently collapsed back to "{id}.{ext}".

Fix: look up the Capitalized form, with a fallback chain (exact ->
.lower() -> .capitalize()) so future drift between API clients in
either direction won't silently break templates again.

Verified against a real Danbooru save in the user's library: post
11122211 with tag_categories containing Artist=["yun_ze"],
Character=["mon3tr_(arknights)"], etc. now renders
"%id%_%character%" -> "11122211_mon3tr_(arknights).jpg" instead of
"11122211.jpg".
2026-04-09 18:25:21 -05:00
pax
98ac31079a bookmarks: route Save As action through save_post_file
Sixth and final Phase 2 site migration. The bookmarks context-menu
Save As action now mirrors main_window._save_as: render the template
to populate the dialog default name, then route the actual save
through save_post_file with explicit_name set to whatever the user
typed. Same behavior change as the browse-side Save As — Save As
into saved_dir() now registers library_meta where v0.2.3 didn't.

After this commit the eight save sites in main_window.py and
bookmarks.py all share one implementation. The net diff of Phase 1 +
Phase 2 (excluding the Phase 0 scaffolding) is a deletion in
main_window.py + bookmarks.py even after adding library_save.py,
which is the test for whether the refactor was the right call.
2026-04-09 18:25:21 -05:00
pax
d05a9cd368 bookmarks: route library copy through save_post_file
Fifth Phase 2 site migration. _copy_to_library_unsorted and
_copy_to_library now both delegate to a private
_save_bookmark_to_library helper that walks through save_post_file.
A small _bookmark_to_post adapter constructs a Post from a Bookmark
for the renderer — Bookmark already carries every field the renderer
reads, this is just one place to maintain if Post's shape drifts.

Fixes the latent v0.2.3 bug where bookmark→library copies wrote
files but never registered library_meta rows — those files were on
disk but invisible to Library tag-search until you also re-saved
from the browse side.

Picks up filename templates and sequential collision suffixes for
bookmark→library saves for free, same as the browse-side migrations.

Net add (+32 lines) is from the new helper docstrings + the explicit
_bookmark_to_post adapter; the actual save logic shrinks to a one-
liner per public method.
2026-04-09 18:25:21 -05:00
pax
f6c5c6780d main_window: route batch download paths through save_post_file
Fourth Phase 2 site migration. Extracts a shared _batch_download_to
helper that owns the async loop with a per-batch in_flight set, then
makes both _batch_download (the dialog-driven entry) and
_batch_download_posts (the multi-select entry) thin wrappers that
delegate to it.

Fixes the latent v0.2.3 bug where batch downloads landing inside
saved_dir() never wrote library_meta rows — _on_batch_done painted
saved-dots from disk but the search index stayed empty. The
library_meta write is now automatic via save_post_file's
is_relative_to(saved_dir()) check, so any batch into a library folder
gets indexed for free.

Also picks up filename templates and sequential collision suffixes
across batch downloads — collision-prone templates like %artist% on a
page of same-artist posts now produce someartist.jpg, someartist_1.jpg,
someartist_2.jpg instead of clobbering.
2026-04-09 18:25:21 -05:00
pax
b7cb021d1b main_window: route _save_as through save_post_file
Third Phase 2 site migration. Default filename in the dialog now
comes from rendering the library_filename_template against the post,
so users see their templated name and can edit if they want. Drops
the legacy hardcoded "post_" prefix on the default — anyone who wants
the prefix can put it in the template.

The actual save still routes through save_post_file with
explicit_name set to whatever the user typed, so collision resolution
runs even on user-chosen filenames (sequential _1/_2 if the picked
name already belongs to a different post in the library).

behavior change from v0.2.3: Save As into saved_dir() now registers
library_meta. Previously Save As never wrote meta regardless of
destination. If a file is in the library it should be searchable —
this fixes that.
2026-04-09 18:25:21 -05:00
pax
b72f3a54c0 main_window: route _bulk_save through save_post_file
Second Phase 2 site migration. Hoists destination resolution out of
the per-iteration loop, uses a shared in_flight set so collision-prone
templates (%artist% on a page of same-artist posts) get sequential
suffixes instead of clobbering each other, and finally calls
_copy_library_thumb so multi-select bulk saves get library thumbnails
just like single-post saves do.

Drops the dead site_id assignment that nothing read.

Fixes the latent bug where _bulk_save left library thumbnails uncopied
even though _save_to_library always copied them — multi-select saves
were missing thumbnails in the Library tab until you re-saved one at
a time.
2026-04-09 18:25:21 -05:00
pax
38937528ef main_window: route _save_to_library through save_post_file
First Phase 2 site migration. _save_to_library shrinks from ~80 lines
to ~30 by delegating to core.library_save.save_post_file. The
"find existing copy and rename across folders" block is gone — same-
post idempotency is now handled by the DB-backed filename column via
_same_post_on_disk inside save_post_file. The thumbnail-copy block is
extracted as a new _copy_library_thumb helper so _bulk_save (Phase
2.2) can call it too.

behavior change from v0.2.3: cross-folder re-save is now copy, not
move. Old folder's copy is preserved. The atomic-rename-move was a
workaround for not having a DB-backed filename column; with
_same_post_on_disk the workaround is unnecessary. Users who want
move semantics can manually delete the old copy.

Net diff: -52 lines.
2026-04-09 18:25:21 -05:00
pax
9248dd77aa library: add unified save_post_file for the upcoming refactor
New module core/library_save.py with one public function and two
private helpers. Dead code at this commit — Phase 2 commits route the
eight save sites through it one at a time.

save_post_file(src, post, dest_dir, db, in_flight=None, explicit_name=None)
- Renders the basename from library_filename_template, or uses
  explicit_name when set (Save As path).
- Resolves collisions: same-post-on-disk hits return the basename
  unchanged so re-saves are idempotent; different-post collisions get
  sequential _1, _2, _3 suffixes. in_flight is consulted alongside
  on-disk state for batch members claimed earlier in the same call.
- Conditionally writes library_meta when the resolved destination is
  inside saved_dir(), regardless of which save path called us.
- Returns the resolved Path so callers can build status messages.

_same_post_on_disk uses get_library_post_id_by_filename, falling back
to the legacy v0.2.3 digit-stem heuristic for rows whose filename
column is empty. Mirrors the digit-stem checks already in gui/library.py.

Boundary rule: imports core.cache, core.config, core.db only. No gui/
imports — that's how main_window.py and bookmarks.py will both call in
without circular imports.
2026-04-09 18:25:21 -05:00
pax
6075f31917 library: scaffold filename templates + DB column
Adds the foundation that the unified save flow refactor builds on. No
behavior change at this commit — empty default template means every save
site still produces {id}{ext} like v0.2.3.

- core/db.py: library_meta.filename column with non-breaking migration
  for legacy databases. Index on filename. New
  get_library_post_id_by_filename() lookup. filename kwarg on
  save_library_meta (defaults to "" for legacy callers).
  library_filename_template added to _DEFAULTS.
- core/config.py: render_filename_template() with %id% %md5% %ext%
  %rating% %score% %artist% %character% %copyright% %general% %meta%
  %species% tokens. Sanitizes filesystem-reserved chars, collapses
  whitespace, strips leading dots/.., caps the rendered stem at 200
  characters, falls back to post id when sanitization yields empty.
- gui/settings.py: Library filename template input field next to the
  Library directory row, with a help label listing tokens and noting
  that Gelbooru/Moebooru can only resolve the basic ones.
2026-04-09 18:25:21 -05:00
pax
77a53a42c9 grid: standardize cell width in FlowLayout (fix column collapse)
The previous FlowLayout._do_layout walked each thumb summing
`widget.width() + THUMB_SPACING` and wrapped on `x + item_w >
self.width()`. This was vulnerable to two issues that conspired to
produce the "grid collapses by a column when switching to a post"
bug:

1. **Per-widget width drift**: ThumbnailWidget calls
   `setFixedSize(THUMB_SIZE, THUMB_SIZE)` in __init__, capturing the
   constant at construction time. If `THUMB_SIZE` is later mutated
   via `_apply_settings` (main_window.py:2953 writes
   `grid_mod.THUMB_SIZE = new_size`), existing thumbs keep their old
   fixed size while new ones (e.g. from infinite-scroll backfill via
   `append_posts`) get the new value. Mixed widths break the
   width-summing wrap loop.

2. **Off-by-one in the columns property**: `w // (THUMB_SIZE +
   THUMB_SPACING)` overcounted by 1 at column boundaries because it
   omitted the leading THUMB_SPACING margin. A row that fits N
   thumbs needs `THUMB_SPACING + N * step` pixels, not `N * step`.
   For width=1135 with step=188, the formula returned 6 columns
   while `_do_layout` only fit 5 — the two diverged whenever the
   grid sat in the boundary range.

Both are fixed by using a deterministic position formula:

  cols = max(1, (width - THUMB_SPACING) // step)
  for each thumb i:
      col = i % cols
      row = i // cols
      x = THUMB_SPACING + col * step
      y = THUMB_SPACING + row * step

The layout is now a function of `self.width()` and the constants
only — no per-widget reads, no width-summing accumulator. The
columns property uses the EXACT same formula so callers (e.g.
main_window's keyboard Up/Down nav step) always get the value the
visual layout actually used.

Standardizing on the constant means existing thumbs that were
created with an old `THUMB_SIZE` value still position correctly
(they sit in the cells positioned by the new step), and any future
mutation of THUMB_SIZE only affects newly-created thumbs without
breaking the layout of the surviving ones.

Affects all three tabs (Browse / Bookmarks / Library) since they
all use ThumbnailGrid from grid.py.

Verification:
- Phase A test suite (16 tests) still passes
- Popout state machine tests (65 tests) still pass
- Total: 81 / 81 automated tests green
- Imports clean
- Manual: open the popout to a column boundary (resize window
  width such that the grid is exactly N columns wide), switch
  between posts — column count should NOT flip to N-1 anymore.
  Also verify keyboard Up/Down nav steps by exactly the column
  count visible on screen (was off-by-one before at boundaries).
2026-04-08 21:29:55 -05:00
pax
af265c6077 Revert "grid: force vertical scrollbar AlwaysOn to fix column-collapse race"
This reverts commit 69f75fc98fa2817f9ccfd4809475299a1e41f89a.
2026-04-08 21:26:01 -05:00
pax
69f75fc98f grid: force vertical scrollbar AlwaysOn to fix column-collapse race
The ThumbnailGrid was setting horizontal scrollbar to AlwaysOff
explicitly but leaving the vertical scrollbar at the default
AsNeeded. When content first overflowed enough to summon the
vertical scrollbar, the viewport width dropped by ~14-16px
(scrollbar width), and FlowLayout's column count flipped down by 1
because the integer-division formula sat right at a boundary.

  columns = max(1, w // (THUMB_SIZE + THUMB_SPACING))

For THUMB_SIZE=180 + THUMB_SPACING=6 (per-column step = 186):
  - viewport 1122 → 6 columns
  - viewport 1108 (1122 - 14 scrollbar) → 5 columns

If the popout/main window happened to sit anywhere in the range
where `viewport_width % 186 < scrollbar_width`, the column count
flipped when the scrollbar appeared. The user saw "the grid
collapses by a column when switching to a post" — the actual
trigger isn't post selection, it's the grid scrolling enough to
bring the selected thumbnail into view, which makes content
visibly overflow and summons the scrollbar. From the user's
perspective the two events looked correlated.

Fix: setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn). The
scrollbar is now always visible, its width is always reserved in
the viewport, and FlowLayout's column count is stable across the
scrollbar visibility transition.

Trade-off: a slim grey scrollbar strip is always visible on the
right edge of the grid, even when content fits on one screen and
would otherwise have no scrollbar. For an image grid that almost
always overflows in practice, this is the standard behavior (most
file browsers / image viewers do the same) and the cost is
invisible after the first few thumbnails load.

Affects all three tabs (Browse / Bookmarks / Library) since they
all use ThumbnailGrid from grid.py.

Verification:
- Phase A test suite (16 tests) still passes
- Popout state machine tests (65 tests) still pass
- Total: 81 / 81 automated tests green
- Imports clean
- Manual: open the popout to a column boundary (resize window
  width such that the grid is exactly N columns wide before any
  scrolling), then scroll down — column count should NOT flip to
  N-1 anymore.
2026-04-08 21:23:12 -05:00
pax
0ef3643b32 popout/window: fix dispatch lambdas dropping effects (video auto-fit + Loop=Next)
The signal-connection lambdas in __init__ added by commit 14a only
called _fsm_dispatch — they never followed up with _apply_effects.
Commit 14b added the apply layer and updated the keyboard event
handlers in eventFilter to dispatch+apply, but missed the lambdas.
Result: every effect produced by an mpv-driven signal was silently
dropped.

Two user-visible regressions:

  1. Video auto-fit (and aspect ratio lock) broken in popout. The
     mpv `video-params` observer fires when mpv reports video
     dimensions, and the chain is:
       _on_video_params (mpv thread) → _pending_video_size set
       → _poll → video_size.emit(w, h)
       → connected lambda → dispatch VideoSizeKnown(w, h)
       → state machine emits FitWindowToContent(w, h)
       → adapter SHOULD apply by calling _fit_to_content
     The lambda dropped the effects, so _fit_to_content never ran
     for video loads. Image loads were unaffected because they go
     through set_media's ContentArrived dispatch (which DOES apply
     via _dispatch_and_apply in this commit) with API-known
     dimensions.

  2. Loop=Next play_next broken. The mpv eof → VideoPlayer.play_next
     → connected lambda → dispatch VideoEofReached chain produces an
     EmitPlayNextRequested effect in PlayingVideo + Loop=Next, but
     the lambda dropped the effect, so self.play_next_requested was
     never emitted, and main_window's _on_video_end_next never fired.
     The user reported the auto-fit breakage; the play_next breakage
     was the silent twin that no one noticed because Loop=Next isn't
     the default.

Both bugs landed in commit 14b. The seek pin removal in d48435d
didn't cause them but exposed the auto-fit one because the user
was paying attention to popout sizing during the slider verification.

Fix:

- Add `_dispatch_and_apply(event)` helper. The single line of
  documentation in its docstring tells future-pax: "if you're
  going to dispatch an event, go through this helper, not bare
  _fsm_dispatch." This makes the apply step impossible to forget
  for any new wire-point.

- Update all 6 signal-connection lambdas to call _dispatch_and_apply:
    play_next → VideoEofReached
    video_size → VideoSizeKnown
    clicked_position → SeekRequested
    _mute_btn.clicked → MuteToggleRequested
    _vol_slider.valueChanged → VolumeSet
    _loop_btn.clicked → LoopModeSet

- Update the rest of the dispatch sites (keyboard event handlers in
  eventFilter, the wheel-tilt navigation, the wheel-vertical volume
  scroll, _on_video_playback_restart, set_media, closeEvent, the
  Open dispatch in __init__, and the WindowResized/WindowMoved
  dispatches in resizeEvent/moveEvent) to use _dispatch_and_apply
  for consistency. The keyboard handlers were already calling
  dispatch+apply via the two-line `effects = ...; self._apply_effects(effects)`
  pattern; switching to the helper is just deduplication. The
  Open / Window* dispatches were bare _fsm_dispatch but their
  handlers return [] anyway so the apply was a no-op.

After this commit, every dispatch site in the popout adapter goes
through one helper. The only remaining `self._fsm_dispatch(...)` call
is inside the helper itself (line 437) and one reference in the
helper's docstring.

Verification:
- Phase A test suite (16 tests) still passes
- State machine tests (65 tests) still pass — none of them touch
  the adapter wiring
- 81 / 81 tests green at HEAD

Manual verification needed:
- Click an uncached video in browse → popout opens, video loads,
  popout auto-fits to video aspect, Hyprland aspect lock applies
- Click cached video → same
- Loop=Next mode + video reaches EOF → popout advances to next post
  (was silently broken since 14b)
- Image load still auto-fits (regression check — image path was
  already working via ContentArrived's immediate FitWindowToContent)
2026-04-08 21:00:27 -05:00
pax
d48435db1c VideoPlayer: remove legacy _seek_pending_until pin window
The 500ms `_seek_pending_until` pin window in `VideoPlayer._poll`
became redundant after `609066c` switched the slider seek from
`'absolute'` to `'absolute+exact'`. With exact seek, mpv decodes
from the previous keyframe forward to the click position before
reporting it via `time_pos`, so `_poll`'s read-and-write loop
naturally lands the slider at the click position without any
pinning. The pin was defense in depth for keyframe-rounding latency
that no longer exists.

Removed:
  - `_seek_target_ms`, `_seek_pending_until`, `_seek_pin_window_secs`
    fields from `__init__`
  - The `_time.monotonic() < _seek_pending_until` branch in `_poll`
    (now unconditionally `setValue(pos_ms)` after the isSliderDown
    check)
  - The pin-arming logic from `_seek` (now just calls `mpv.seek`
    directly)

Net diff: ~30 lines removed, ~10 lines of explanatory comments
added pointing future-pax at the `609066c` commit body for the
"why" of the cleanup.

The popout's state machine SeekingVideo state continues to track
seeks via the dispatch path (seek_target_ms is held on the state
machine, not on VideoPlayer) for the future when the adapter's
SeekVideoTo apply handler grows past its current no-op. The
removal here doesn't affect that — it only drops dead defense-in-
depth code from the legacy slider rendering path.

Verification:
- Phase A test suite (16 tests) still passes
- State machine tests (65 tests) still pass — none of them touch
  VideoPlayer fields
- Both surfaces (embedded preview + popout) still seek correctly
  per the post-609066c verification (commit 14a/14b sweep)

Followup target from docs/POPOUT_FINAL.md "What's NOT done"
section. The other listed followup (replace self._viewport with
self._state_machine.viewport in popout/window.py) is bigger and
filed for a future session.
2026-04-08 20:52:58 -05:00
pax
a2b759be90 popout/window: drop refactor shims (final cleanup)
Removes the last vestiges of the legacy compatibility layer that
commits 13-15 left in place to keep the app runnable across the
authority transfer:

1. Three `_hyprctl_*` shim methods on FullscreenPreview that
   delegated to the popout/hyprland module-level functions. Commit
   13 added them to preserve byte-for-byte call-site compatibility
   while window.py still had its old imperative event handling.
   After commit 14b switched authority to the dispatch+apply path
   and commit 15 cleaned up main_window's interface, every remaining
   call site in window.py is updated to call hyprland.* directly:

     self._hyprctl_get_window()        → hyprland.get_window(self.windowTitle())
     self._hyprctl_resize(0, 0)        → hyprland.resize(self.windowTitle(), 0, 0)
     self._hyprctl_resize_and_move(...) → hyprland.resize_and_move(self.windowTitle(), ...)

   8 internal call sites updated, 3 shim methods removed.

2. The legacy `self._video.video_size.connect(self._on_video_size)`
   parallel-path connection plus the dead `_on_video_size` method.
   The dispatch lambda wired in __init__ already handles
   VideoSizeKnown → FitWindowToContent → _fit_to_content via the
   apply path. The legacy direct connection was a duplicate that
   the same-rect skip in _fit_to_content made harmless, but it
   muddied the dispatch trace and was dead weight after 14b.

A new `from . import hyprland` at the top of window.py imports the
module once at load time instead of inline-importing on every shim
call (the legacy shims used `from . import hyprland` inside each
method body to avoid import order issues during the commit-13
extraction).

After this commit, FullscreenPreview's interaction with Hyprland is:
  - Single import: `from . import hyprland`
  - Direct calls: `hyprland.get_window(self.windowTitle())` etc
  - No shim layer
  - The popout/hyprland module is the single source of Hyprland IPC
    for the popout

Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green.

Final state of the popout state machine refactor:

- 6 states / 17 events / 14 effects (within budget 10/20/15)
- 6 race-fix invariants enforced structurally (no timestamp windows
  in state.py, no guards, no fall-throughs)
- popout/state.py + popout/effects.py: pure Python, no PySide6, no
  mpv, no httpx — verifiable via the meta_path import blocker
- popout/hyprland.py: isolated subprocess wrappers
- popout/window.py: thin Qt adapter — translates Qt events into
  state machine dispatches, applies returned effects to widgets via
  the existing private helpers
- main_window.py: zero direct popout._underscore access; all
  interaction goes through the public method surface defined in
  commit 15

Test cases / followups: none. The refactor is complete.
2026-04-08 20:35:36 -05:00
pax
ec238f3aa4 gui/main_window: replace popout internal access with public methods
Drops every direct popout._underscore access from main_window in favor
of nine new public methods on FullscreenPreview. The legacy private
fields (_video, _viewer, _stack, _bookmark_btn, etc.) stay in place —
this is a clean public wrapper layer, not a re-architecture. Going
through public methods makes the popout's interface explicit and
prevents future code from reaching into popout internals.

New public methods on FullscreenPreview:

  is_video_active() -> bool
    Replaces popout._stack.currentIndex() == 1 checks. Used to gate
    video-only operations.

  set_toolbar_visibility(*, bookmark, save, bl_tag, bl_post)
    Replaces 4-line popout._bookmark_btn.setVisible(...) etc block.
    Per-tab toolbar gating.

  sync_video_state(*, volume, mute, autoplay, loop_state)
    Replaces 4-line popout._video.volume = ... etc block. Called by
    main_window's _open_fullscreen_preview to push embedded preview
    state into the popout.

  get_video_state() -> dict
    Returns volume / mute / autoplay / loop_state / position_ms in
    one read. Replaces 5 separate popout._video.* attribute reads
    in main_window's _on_fullscreen_closed reverse sync.

  seek_video_to(ms)
    Wraps VideoPlayer.seek_to_ms (which uses 'absolute+exact' since
    the 609066c drag-back fix). Used by the seek-after-load pattern.

  connect_media_ready_once(callback)
    One-shot callback wiring with auto-disconnect. Replaces the
    manual lambda + try/except disconnect dance in main_window.

  pause_media()
    Wraps VideoPlayer.pause(). Replaces 3 sites of direct
    popout._video.pause() calls in privacy-screen / external-open
    paths.

  force_mpv_pause()
    Direct mpv.pause = True without button text update. Replaces
    the legacy popout._video._mpv.pause = True deep attribute access
    in main_window's _on_post_activated. Used to prevent the OLD
    video from reaching natural EOF during the new post's async
    download.

  stop_media()
    Stops the video and clears the image viewer. Replaces 2 sites
    of the popout._viewer.clear() + popout._video.stop() sequence
    in blacklist-removal flow.

main_window.py call sites updated:

  Line 1122-1130 (_on_post_activated):
    popout._video._mpv.pause = True → popout.force_mpv_pause()

  Line 1339-1342 (_update_fullscreen):
    4 popout._*.setVisible(...) → popout.set_toolbar_visibility(...)

  Line 1798, 1811, 2731:
    popout._video.pause() → popout.pause_media()

  Line 2151-2166 (_open_fullscreen_preview sync block):
    sv = popout._video; sv.volume = ...; ...
    + manual seek-when-ready closure
    → popout.sync_video_state(...) + popout.connect_media_ready_once(...)

  Line 2196-2207 (_on_fullscreen_closed reverse sync):
    sv = popout._video; pv.volume = sv.volume; ...; popout._stack.currentIndex...
    → popout.get_video_state() returning a dict

  Line 2393-2394, 2421-2423 (blacklist removal):
    popout._viewer.clear() + popout._video.stop()
    → popout.stop_media()

After this commit, main_window has ZERO direct popout._underscore
accesses. The popout's public method surface is the only way for
main_window to interact with the popout's internals.

The popout's public method surface is now:

  Lifecycle:
    - set_media (existing — keeps the kind, info, width, height contract)
    - update_state (existing — bookmarked/saved button labels)
    - close (Qt builtin — triggers closeEvent)

  Wiring:
    - set_post_tags
    - set_bookmark_folders_callback
    - set_folders_callback

  Privacy:
    - privacy_hide / privacy_show (existing)

  New in commit 15:
    - 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

  Outbound signals (unchanged from refactor start):
    - navigate / play_next_requested / closed
    - bookmark_requested / bookmark_to_folder
    - save_to_folder / unsave_requested
    - blacklist_tag_requested / blacklist_post_requested
    - privacy_requested

Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green.

Verification:
- Imports clean
- Pure-Python state machine + tests unchanged
- main_window's popout interaction goes through public methods only

Test cases for commit 16 (final shim cleanup):
- Drop the hyprland re-export shim methods from popout/window.py
- Have callers use popout.hyprland directly
2026-04-08 20:33:12 -05:00
pax
69d25b325e popout/window: apply effects from StateMachine, remove duplicate emits
**Commit 14b of the pre-emptive 14a/14b split.**

Adds the effect application path. The state machine becomes the
single source of truth for the popout's media transitions, navigation,
fullscreen toggle, and close lifecycle. The legacy imperative paths
that 14a left in place are removed where the dispatch+apply chain
now produces the same side effects.

Architectural shape:

  Qt event → _fsm_dispatch(Event) → list[Effect] → _apply_effects()
                                                      ↓
                                              pattern-match by type
                                                      ↓
                                       calls existing private helpers
                                       (_fit_to_content, _enter_fullscreen,
                                        _video.play_file, etc.)

The state machine doesn't try to reach into Qt or mpv directly; it
returns descriptors and the adapter dispatches them to the existing
implementation methods. The private helpers stay in place as the
implementation; the state machine becomes their official caller.

What's fully authoritative via dispatch+apply:
  - Navigate keys + wheel tilt → NavigateRequested → EmitNavigate
  - F11 → FullscreenToggled → EnterFullscreen / ExitFullscreen
  - Space → TogglePlayRequested → TogglePlay
  - closeEvent → CloseRequested → StopMedia + EmitClosed
  - set_media → ContentArrived → LoadImage|LoadVideo + FitWindowToContent
  - mpv playback-restart → VideoStarted | SeekCompleted (state-aware)
  - mpv eof-reached + Loop=Next → VideoEofReached → EmitPlayNextRequested
  - mpv video-params → VideoSizeKnown → FitWindowToContent

What's deliberately no-op apply in 14b (state machine TRACKS but
doesn't drive):
  - ApplyMute / ApplyVolume / ApplyLoopMode: legacy slot connections
    on the popout's VideoPlayer still handle the user-facing toggles.
    Pushing state.mute/volume/loop_mode would create a sync hazard
    with the embedded preview's mute state, which main_window pushes
    via direct attribute writes at popout open. The state machine
    fields are still updated for the upcoming SyncFromEmbedded path
    in a future commit; the apply handlers are intentionally empty.
  - SeekVideoTo: the legacy `_ClickSeekSlider.clicked_position →
    VideoPlayer._seek` connection still handles both the mpv.seek
    call (now exact, per the 609066c drag-back fix) and the legacy
    500ms `_seek_pending_until` pin window. Replacing this requires
    modifying VideoPlayer._poll which is forbidden by the state
    machine refactor's no-touch rule on media/video_player.py.

Removed duplicate legacy emits (would have caused real bugs):
  - self.navigate.emit(±N) in eventFilter arrow keys + wheel tilt
    → EmitNavigate effect
  - self.closed.emit() and self._video.stop() in closeEvent
    → StopMedia + EmitClosed effects
  - self._video.play_next.connect(self.play_next_requested)
    signal-to-signal forwarding → EmitPlayNextRequested effect
  - self._enter_fullscreen() / self._exit_fullscreen() direct calls
    → EnterFullscreen / ExitFullscreen effects
  - self._video._toggle_play() direct call → TogglePlay effect
  - set_media body's load logic → LoadImage / LoadVideo effects

The Esc/Q handler now only calls self.close() and lets closeEvent
do the dispatch + apply. Two reasons:

1. Geometry persistence (FullscreenPreview._saved_geometry /
   _saved_fullscreen) is adapter-side concern and must run BEFORE
   self.closed is emitted, because main_window's
   _on_fullscreen_closed handler reads those class fields. Saving
   geometry inside closeEvent before dispatching CloseRequested
   gets the order right.
2. The state machine sees the close exactly once. Two-paths
   (Esc/Q → dispatch + close() → closeEvent → re-dispatch) would
   require the dispatch entry's CLOSING-state guard to silently
   absorb the second event — works but more confusing than just
   having one dispatch site.

The closeEvent flow now is:
  1. Save FullscreenPreview._saved_fullscreen and _saved_geometry
     (adapter-side, before dispatch)
  2. Remove the QApplication event filter
  3. Dispatch CloseRequested → effects = [StopMedia, EmitClosed]
  4. Apply effects → stop media, emit self.closed
  5. super().closeEvent(event) → Qt window close

Verification:

- Phase A test suite (16 tests in tests/core/) still passes
- State machine tests (65 in tests/gui/popout/test_state.py) still pass
- Total: 81 / 81 automated tests green
- Imports clean

**The 11 manual scenarios are NOT verified by automated tests.**
The user must run the popout interactively and walk through each
scenario before this commit can be considered fully verified:

  1. P↔L navigation cycles drift toward corner
  2. Super+drag externally then nav
  3. Corner-resize externally then nav
  4. F11 same-aspect round-trip
  5. F11 across-aspect round-trip
  6. First-open from saved geometry
  7. Restart persistence across app sessions
  8. Rapid Right-arrow spam
  9. Uncached video click
  10. Mute toggle before mpv exists
  11. Seek mid-playback (already verified by the 14a + drag-back-fix
      sweep)

**If ANY scenario fails after this commit:** immediate `git revert
HEAD`, do not fix in place. The 14b apply layer is bounded enough
that any regression can be diagnosed by inspecting the apply handler
for the relevant effect type, but the in-place-fix temptation should
be resisted — bisect-safety requires a clean revert.

Test cases for commit 15:
  - main_window.popout calls become method calls instead of direct
    underscore access (open_post / sync_video_state / get_video_state /
    set_toolbar_visibility)
  - Method-cluster sweep from REFACTOR_INVENTORY.md still passes
2026-04-08 20:25:24 -05:00
pax
609066cf87 VideoPlayer: use absolute+exact for slider seek (fix drag-back race)
The slider's _seek used plain 'absolute' (keyframe seek), which made
mpv land on the nearest keyframe at-or-before the click position.
For sparse-keyframe videos (1-5s GOP) the actual position landed
1-5s behind where the user clicked. The 500ms _seek_pending_until
pin window from c4061b0 papered over this for half a second, but
afterwards the slider visibly dragged back to mpv's keyframe-rounded
position and crawled forward. Observed in BOTH the embedded preview
and the popout slider — the bug lives in the shared VideoPlayer
class, so fixing it once fixes both surfaces.

Empirically verified by the pre-commit-1 mpv probe in
docs/POPOUT_REFACTOR_PLAN.md: seek(7.0, 'absolute') landed at
time_pos=5.000 (2s back); seek(12.0, 'absolute') landed at 10.033
(also 2s back). Both match the user's reported drag-back symptom.

The other seek paths in VideoPlayer already use exact mode:
  - seek_to_ms (line 318): 'absolute+exact'
  - _seek_relative (line 430): 'relative+exact'

The slider's _seek was the only outlier. The original c4061b0 commit
chose plain 'absolute' for "responsiveness" and added the pin window
to hide the keyframe rounding. This commit removes the underlying
cause: the seek now decodes from the previous keyframe forward to
the EXACT target position before mpv emits playback-restart, costing
~30-100ms more per seek depending on GOP density (well under the
500ms pin window) but landing time_pos at the click position
exactly. The slider doesn't need any pin window to mask a
discrepancy that no longer exists.

The _seek_pending_until pin remains in place as defense in depth —
it's now redundant for keyframe rounding but still smooths over the
sub-100ms decode latency between the click and the first _poll
tick that reads the new time_pos. Commit 14b will remove the legacy
pin code as part of the imperative-path cleanup.

This also unblocks the popout state machine's design (commits 6, 11,
14a). The state machine's SeekingVideo state lasts until mpv's
playback-restart event arrives — empirically 14-34ms in the user's
verification log of commit 14a. Without exact seek, commit 14b
would visibly REGRESS slider behavior because compute_slider_display_ms
returns mpv.time_pos after 30ms instead of the legacy 500ms — the
drag-back would surface immediately on every seek. With exact seek,
mpv.time_pos == seek_target_ms after the seek completes, so the
state machine's slider pin is correct without needing any extra
window.

Found during commit 14a verification gate. Pre-existing bug — the
state machine refactor revealed it but didn't introduce it.

Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green. Phase B regression target met (the dispatch
trace from the verification run shows correct SeekRequested →
SeekCompleted round-trips with no spurious state transitions).

Verification:
- Click slider mid-playback in embedded preview → no drag-back
- Click slider mid-playback in popout → no drag-back
- Drag the slider continuously → still works (isSliderDown path
  unchanged)
- Period/Comma keys (relative seek) → still work (already use
  'relative+exact')
2026-04-08 20:09:49 -05:00
pax
35d80c32f2 popout/window: route adapter logger to stderr for terminal capture
Follow-up to commit 14a. The booru logger has only the in-app
QTextEdit LogHandler attached (main_window.py:436-440), so the
POPOUT_FSM dispatch trace from the state machine adapter only
reaches the Ctrl+L log panel — invisible from the shell.

Adds a stderr StreamHandler attached directly to the
`booru.popout.adapter` logger so:

  python -m booru_viewer.main_gui 2>&1 | grep POPOUT_FSM

works during the commit-14a verification gate. The user can capture
the dispatch trace per scenario and compare it to the legacy path's
actions before commit 14b switches authority.

The handler is tagged with a `_is_popout_fsm_stderr` sentinel
attribute so re-imports of window.py don't stack duplicate
handlers (defensive — module-level code only runs once per process,
but the check costs nothing).

Format: `[HH:MM:SS.mmm] POPOUT_FSM <event> | <old> -> <new> | effects=[...]`
The millisecond precision matters for the seek scenario where the
race window is sub-100ms.

Propagation to the parent booru logger is left enabled, so dispatch
trace lines also continue to land in the in-app log panel for the
user who prefers Ctrl+L.

Tests still pass (81 / 81). No behavior change to widgets — this
only affects log output routing.
2026-04-08 20:01:16 -05:00
pax
45e6042ebb popout/window: wire eventFilter to StateMachine.dispatch (parallel)
**Commit 14a of the pre-emptive 14a/14b split.**

Adds the popout's pure-Python state machine as a parallel side
channel to the legacy imperative event handling. The state machine
runs alongside the existing code: every Qt event handler / mpv
signal / button click below dispatches a state machine event AND
continues to run the existing imperative action. The state machine's
returned effects are LOGGED at DEBUG, not applied to widgets.

**The legacy path stays authoritative through commit 14a; commit
14b switches the authority to the dispatch path.**

This is the bisect-safe-by-construction split the refactor plan
called for. 197 lines added, 0 removed. No widget side effects from
the dispatch path. App is byte-identical from the user's perspective.

Wired wire-points (every Qt event the state machine cares about):

  __init__:
    - Constructs StateMachine, sets grid_cols
    - Dispatches Open(saved_geo, saved_fullscreen, monitor) using
      the class-level cross-popout-session state
    - Connects VideoPlayer.playback_restart Signal (added in
      commit 1) to _on_video_playback_restart, which routes to
      VideoStarted (LoadingVideo) or SeekCompleted (SeekingVideo)
      based on current state machine state
    - Connects VideoPlayer.play_next → VideoEofReached dispatch
    - Connects VideoPlayer.video_size → VideoSizeKnown dispatch
    - Connects VideoPlayer._seek_slider.clicked_position → SeekRequested
    - Connects VideoPlayer._mute_btn.clicked → MuteToggleRequested
    - Connects VideoPlayer._vol_slider.valueChanged → VolumeSet
    - Connects VideoPlayer._loop_btn.clicked → LoopModeSet

  set_media:
    - Detects MediaKind from is_video / .gif suffix
    - Builds referer for streaming URLs
    - Dispatches ContentArrived(path, info, kind, width, height, referer)
      BEFORE the legacy imperative load path runs

  eventFilter (key + wheel):
    - Esc/Q → CloseRequested
    - Left/H → NavigateRequested(-1)
    - Right/L → NavigateRequested(+1)
    - Up/K → NavigateRequested(-grid_cols)
    - Down/J → NavigateRequested(+grid_cols)
    - F11 → FullscreenToggled
    - Space (video) → TogglePlayRequested
    - Wheel horizontal tilt → NavigateRequested(±1)
    - Wheel vertical (video) → VolumeSet(new_value)
    - Period/Comma keys (relative seek) explicitly NOT dispatched —
      they go straight to mpv via the legacy path. The state
      machine's SeekRequested is for slider-driven seeks; commit 14b
      will route the relative-seek keys through SeekRequested with
      a target_ms computed from current position.

  resizeEvent (non-Hyprland branch):
    - WindowResized(rect) dispatched after the legacy viewport update

  moveEvent (non-Hyprland branch):
    - WindowMoved(rect) dispatched after the legacy viewport update

  closeEvent:
    - CloseRequested dispatched at entry

The _fsm_dispatch helper centralizes the dispatch + log path so every
wire-point is one line. Logs at DEBUG level via a new
`booru.popout.adapter` logger:

    POPOUT_FSM <event_name> | <old_state> -> <new_state> | effects=[...]

Filter the log output by `POPOUT_FSM` substring to see only the
state machine activity during the manual sweep.

The _on_video_playback_restart helper is the ONE place the adapter
peeks at state machine state to choose between two event types
(VideoStarted vs SeekCompleted from the same mpv playback-restart
event). It's a read, not a write — the state machine's dispatch
remains the only mutation point.

Tests passing after this commit: 81 / 81 (16 Phase A + 65 state).
Phase A still green.

**Verification gate (next):**

Before commit 14b lands, the user runs the popout in their own
interactive Hyprland session and walks through the 11 race scenarios:

  1. P↔L navigation cycles drift toward corner
  2. Super+drag externally then nav
  3. Corner-resize externally then nav
  4. F11 same-aspect round-trip
  5. F11 across-aspect round-trip
  6. First-open from saved geometry
  7. Restart persistence across app sessions
  8. Rapid Right-arrow spam
  9. Uncached video click
  10. Mute toggle before mpv exists
  11. Seek mid-playback

For each scenario, capture the POPOUT_FSM log lines and verify the
state machine's dispatch sequence matches what the legacy path
actually did. Any discrepancy is a state machine logic bug that
must be fixed in state.py BEFORE 14b lands and switches authority
to the dispatch path. Fix in state.py, not in window.py — state.py
is still the source of truth.

The bisect-safe property: even if the user finds a discrepancy
during the sweep, this commit DOES NOT change app behavior. App is
fully functional through the legacy path. The dispatch path is
diagnostic-only.

Test cases for commit 14b:
  - Each effect type pattern-matches to a real widget action
  - Manual 11-scenario sweep with the dispatch path authoritative
2026-04-08 19:50:40 -05:00