Compare commits

...
36 Commits
Author SHA1 Message Date
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusenandClaude Opus 4.8 86efbf7f2c fix(modal): kebab menus open via explicit v-model, not activator click
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m56s
Operator-confirmed on a fresh build: both the tag-chip and suggestion
kebabs still never opened. The prior 8326e54 'fix' only wrapped them in a
<span @click.stop> — inert for SuggestionItem (no parent capture) — and
never addressed why the `#activator`/`v-bind="props"` click failed to
toggle the menu inside the teleported ImageViewer modal. The dialogs in
that same modal open via v-model and work, so drive the menus the same way:

- The activator (v-btn / v-icon) toggles a reactive flag with @click.stop
  (which also shields the chip's close button / any parent).
- The v-menu binds that flag (v-model / :model-value) and uses
  activator="parent" with :open-on-click="false" purely for positioning,
  so opening no longer depends on Vuetify's activator-click path.
- TagPanel tracks a single openTagId (one chip menu open at a time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:01:55 -04:00
bvandeusenandClaude Opus 4.8 3a0cca5aca fix(tags): allow creating a character with no fandom
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m4s
Not all characters belong to a fandom (original characters, unsorted).
The create flow forced every new character through FandomPicker, whose
only outcomes were 'Use this fandom' (disabled until one is picked) or
Cancel (which aborts the whole creation) — there was no way to confirm a
character with no fandom.

- FandomPicker: add a 'No fandom' action that emits confirm(null).
- TagAutocomplete.onFandomChosen: pass fandom_id: null when null is
  emitted.

Backend already supported this end to end (Tag.fandom_id nullable, the
CHECK only forbids fandom_id on non-character kinds, tag_service
find_or_create defaults fandom_id=None, API reads body.get). A fandom can
still be assigned later from the chip kebab's 'Set fandom…'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:50:19 -04:00
bvandeusen 83f8af8090 Merge pull request 'dev→main: surface near-duplicate (pHash) control + reorder import tab' (#68) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 2m56s
2026-06-04 21:39:44 -04:00
bvandeusenandClaude Opus 4.8 a5b3702863 feat(settings): surface the near-duplicate (pHash) control + reorder import tab
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 10s
CI / integration (push) Successful in 3m6s
The phash_threshold knob (controls whether edits/variants of an image are
dropped as near-duplicates on import) was buried at the bottom of the import
filters form and labelled opaquely, so it read as 'missing'. Hoist it to the
TOP of the form as a 'Near-duplicate sensitivity' section: a labelled slider
(Exact / Strict / Default / Loose stops, 0-16) for the gist + the precise
number field, both bound to phash_threshold, with copy that says plainly to
lower it if variants are being dropped.

Also swap the import-tab order to filters → trigger → recent-tasks (filters on
top per operator); the task list stays directly under the trigger for hit/miss
feedback adjacency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:42:32 -04:00
bvandeusen 9a2617c1a2 Merge pull request 'dev→main: post-card redesign (images→modal, in-place text expand)' (#67) from dev into main
CI / lint (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m57s
2026-06-04 17:32:50 -04:00
bvandeusenandClaude Opus 4.8 509a7958cf feat(posts): images open the modal; only text expands in place
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m2s
Post cards no longer expand the whole card on click (the old two-click path
to the images). The card is compact-only now:
- Hero / rail thumbs / the +N tile are buttons that open the post-scoped image
  modal (modal.open(id, { postImageIds })) so you view big + arrow through ALL
  the post's images. The feed caps thumbnails at 6, so for posts with more we
  lazily getPostFull to get the complete id list; +N opens at the first hidden
  image.
- The description is the ONLY in-place expansion: a Show more / Show less toggle
  shown only when the text is actually truncated (server description_truncated
  flag OR a measured CSS-clamp overflow, ResizeObserver-guarded). Expanding
  loads description_full when server-truncated and renders it unclamped.
- Attachments: download chips now render inline in the compact card (the feed
  already carries download_url), since the expanded view is gone.

Removes PostImageGrid.vue (the mosaic, now unused). Tests cover show-more
visibility + image-click opening the scoped modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:29:19 -04:00
bvandeusen 81688815a0 Merge pull request 'dev→main: similar-search render fix + reset-content-tagging + scan persistence' (#66) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
2026-06-04 16:59:52 -04:00
bvandeusenandClaude Opus 4.8 5a6a95682d fix(cleanup): library scans survive navigation, reconnect on return
CI / frontend-build (push) Successful in 24s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / integration (push) Successful in 2m56s
The transparency / single-color audit cards held the run + poll timer in
local component state, so navigating away destroyed both and onMounted never
reconnected — the Celery scan kept running and writing LibraryAuditRun, but
the UI forgot it. Now each card, on mount, fetches its rule's latest run
(GET /api/cleanup/audit?rule=<rule>&limit=1) and rehydrates: shows progress +
resumes polling if still running, or shows the completed result (ready/applied/
error) so the operator can act on it after returning. Adds the ?rule= filter
to the audit-history endpoint + cleanup store latestAuditForRule().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:55:20 -04:00
bvandeusenandClaude Opus 4.8 91b0145bc8 feat(tags): 'Reset content tagging' admin action
CI / backend-lint-and-test (push) Successful in 11s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 30s
CI / integration (push) Successful in 2m57s
Wipe every general + character tag so the operator can re-tag from scratch via
the Camie auto-suggest, while PRESERVING fandoms, series (+ series_page order),
and each image's stored tagger_predictions (so suggestions repopulate
immediately). One set-based DELETE FROM tag WHERE kind IN ('general','character')
— the five tag-referencing tables all cascade, so applications + aliases +
allowlist + rejections + centroids clear automatically; series tags aren't
deleted so series survive; Tag.fandom_id is SET NULL so fandoms are untouched.

Reuses the established dry-run-preview -> confirm pattern: cleanup_service.
reset_content_tagging() + POST /api/admin/tags/reset-content +
TagMaintenanceCard section with a backup-first warning and a red confirm
showing exact counts (tags by kind + image applications). Irreversible except
via DB backup restore; the wipe only fires when the operator confirms.

Tests: service dry-run counts + live delete preserves fandom/series/series_page
while content tags + their image_tag cascade away; API dry-run wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:47:36 -04:00
bvandeusenandClaude Opus 4.8 26e47a86cb fix(gallery): render similar-mode results (flat list when no date groups)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
The 'See all similar' takeover fetched /api/gallery/similar fine (200, ~100
results) but the grid showed nothing: GalleryGrid renders ONLY by iterating
store.dateGroups, and similar-mode returns date_groups=[] (results are ranked
by cosine distance, not chronological). Zero groups → zero tiles despite
store.images being full. Add a flat fallback: when there are no date groups
but images exist, render them as one ungrouped list in ranked order (no date
headers). The modal Related strip was unaffected (it renders its images
directly). Test locks both the flat and grouped paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:24:52 -04:00
bvandeusen 773128c3bf Merge pull request 'dev→main: purpose-built mobile layout for subscriptions hub' (#65) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 38s
CI / integration (push) Successful in 2m56s
2026-06-04 13:43:56 -04:00
bvandeusenandClaude Opus 4.8 928e3037f0 fix(ui): purpose-built mobile layout for subscriptions hub
CI / frontend-build (push) Successful in 59s
CI / integration (push) Successful in 2m56s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 10s
Vuetify's auto card-stack was too verbose (one subscription filled the whole
phone screen) and the expanded sources still needed lateral scroll. Replace it
below 600px (useDisplay) with a custom compact-card list: each subscription is
a 2-line card (name + health + expand chevron, then platform chips + sources
count + last activity) so several fit per screen. Expanding shows the action
row + each source as a STACKED SourceCard (new) — platform/url/enabled/last/
next/errors/actions laid out vertically, no horizontal scroll. The mobile
cards drive the same selected/expanded key arrays as the desktop data-table,
so selection and bulk actions are unchanged. Desktop keeps the v-data-table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:40:25 -04:00
bvandeusen ce7b154ae9 Merge pull request 'dev→main: subscriptions table mobile card layout' (#64) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
2026-06-04 12:56:58 -04:00
bvandeusenandClaude Opus 4.8 b08b12eb8f fix(ui): subscriptions table → card layout on mobile
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 1m8s
CI / integration (push) Successful in 2m56s
The subscriptions v-data-table (select + expand + 6 cols + a nested 8-col
sources sub-table) horizontally-scrolled on phones. Set mobile-breakpoint=600
so Vuetify stacks each subscription row into a label:value card below 600px;
the custom item slots (platform chips, health dot, action buttons) render as
card rows. The expanded sources detail reclaims its desktop indent on mobile
and keeps its own horizontal scroll for the wide source columns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:53:40 -04:00
bvandeusen 9430a9d9c3 Merge pull request 'dev→main: gallery similarity search (Phase 3) + UI/mobile polish' (#63) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
2026-06-04 11:21:26 -04:00
bvandeusenandClaude Opus 4.8 4fd6d4cc29 fix(ui): mobile pass 2 — Posts & Subscriptions filter bars
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 34s
CI / integration (push) Successful in 2m58s
PostsFilterBar didn't wrap and its artist/platform fields had inline
min-widths (240/180px) a media query can't override → horizontal overflow on
phones. Moved widths to classes, added flex-wrap, and <600px each field takes
a full-width row. SubscriptionsTab's status/search inline max-widths likewise
moved to classes; <600px they go full-width and the v-spacer is dropped so the
search isn't shoved around.

Verified as already-fine (sweep false positives, no change): PostCard (default
body is a stacked column; only goes row at container >=800px), SeriesReaderView
(already has a <=768px block: nav drawer 150px, quick-nav stacks). The
subscriptions v-data-table scrolls horizontally within its own wrapper, so it
doesn't widen the page — a true mobile card layout is a larger follow-up if
wanted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:26:56 -04:00
bvandeusenandClaude Opus 4.8 304e8aa878 fix(ui): mobile responsiveness — nav hamburger + primary-path fixes
CI / backend-lint-and-test (push) Successful in 12s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 29s
CI / integration (push) Successful in 2m57s
The top nav packed brand + health + pipeline chip + ~7 inline links + an
action slot into one flex row, colliding/overflowing on phones (operator:
'almost unusable'). Below 768px the links now fold into a hamburger v-menu;
below 480px the brand text hides (glyph still brands). Plus the primary
browsing path:
- BulkEditorPanel: fixed 320px -> min(320px, 90vw) so it can't swallow the screen.
- GalleryFilterBar: <600px gives search its own full-width row (its 200px
  min-width was jamming the wrapping bar); sort grows.
- GalleryFacetPanel: <480px wraps groups + lets the side-by-side date inputs
  grow full-width.
- ArtistsView grid: minmax(min(440px,100%),1fr) so a card never overflows
  (single column on phones).
- GalleryView: hide the year/month timeline strip <600px.
ImageViewer already stacks its side panel below the image <900px (left as-is).

Secondary surfaces (Posts/Subscriptions filter bars, SubscriptionsTab table,
SeriesReader, PostCard) still need a mobile pass — follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:06:33 -04:00
bvandeusenandClaude Opus 4.8 0497394710 feat(ui): double showcase cadence + filter bar matches TopNav frost
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m56s
- Showcase reveal cadence 80ms -> 160ms (slower, more deliberate one-at-a-time
  cascade per operator). Bump showcase.spec timer advances to cover 60x160ms.
- Gallery filter bar now uses the EXACT gradiated-obsidian frost + blur as
  TopNav (was a flat rgba(...,0.55) block), so the two read as one continuous
  piece of chrome with images visibly scrolling under both; the nav's
  transparent bottom edge against the bar's opaque top leaves a faint seam that
  separates them at the very top of scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:02:21 -04:00
bvandeusenandClaude Opus 4.8 21a73cd1dc feat(gallery): visual 'more like this' UI (Phase 3 frontend)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
Modal 'Related' strip (RelatedStrip.vue) — top-12 similar thumbs, fetched on
its own DEFERRED, single-flighted path (200ms after the modal is up) so it
never blocks or slows the modal; collapses silently on empty/slow/error and is
hidden when the image has no embedding (has_embedding flag). 'See all similar'
closes the modal and navigates the gallery to ?similar_to=<id>.

Gallery store: similar_to filter field + loadSimilar() (ranked, hasMore=false,
no timeline); applyFilterFromQuery routes similar-mode to /similar with the
scope filters composed; cloneFilter/filterToQuery carry similar_to. Filter bar:
clearable 'Similar to #id' chip, sort hidden in similar-mode; timeline sidebar
hidden too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:52:42 -04:00
bvandeusenandClaude Opus 4.8 79cd1234e2 feat(gallery): visual 'more like this' search (Phase 3 backend)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 28s
CI / integration (push) Successful in 2m57s
GalleryService.similar() ranks images by pgvector cosine distance to a source
image's precomputed SigLIP embedding — no query-time ML inference. Composes
with the Phase-1/2 scope filters (AND) but replaces the date sort (always
nearest-first, bounded top-N, no cursor). Returns None for a missing source
(→404), [] for a source with no embedding (video / pending ML); excludes self
and NULL-embedding rows. New GET /api/gallery/similar?similar_to=<id>&limit=N.
Image-detail payload gains has_embedding so the UI can hide the surface.

Alembic 0036 adds an HNSW vector_cosine_ops index on siglip_embedding (1152<2000
dims) so the search is sub-50ms ANN instead of a full scan; one-time ~30-60s
build over existing embeddings on deploy. Shared _gallery_images/_image_json
helpers de-dup the scroll/similar builders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:47:43 -04:00
bvandeusen 23aee56ce3 Merge pull request 'dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf' (#62) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m0s
2026-06-04 08:27:47 -04:00
bvandeusenandClaude Opus 4.8 3f6ea601f8 perf(ci): collapse the 3 integration shards into one job
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m55s
With fsync-off the whole integration suite runs in ~45s (was ~13min across
shards), so the 3-way split only triplicated the ~2min fixed overhead
(container + install + migrate) and consumed 3 of 6 runner slots for no
wall-clock gain. Merge intapi/intimp/intcore into one `integration` job:
spin up once, install once, migrate once, run `pytest -m integration` over
the whole suite. Frees 2 runner slots (6 jobs -> 4) and drops ~140 lines of
near-duplicate YAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:17:50 -04:00
bvandeusenandClaude Opus 4.8 6a25db4b8b perf(ci): relax Postgres durability in integration shards (fsync off)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 2m15s
CI / intcore (push) Successful in 2m28s
CI / intapi (push) Successful in 2m34s
Option 1 (pooling the teardown connection) left teardowns at ~1.5-2s/test, so
the cost is the per-test TRUNCATE's commit forcing an fsync, not the connect
handshake. Each shard now ALTER SYSTEM SETs fsync/synchronous_commit/
full_page_writes off + pg_reload_conf() right after deps install, before
alembic — sighup/user-context GUCs apply with no restart. The DB is ephemeral
(rebuilt per run) so fsync-off is safe; the step is non-fatal so a perms
surprise can't red a shard. Speeds up every test's commit (setup inserts +
the teardown TRUNCATE), stacking on the pooled engine from the prior commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:45:21 -04:00
bvandeusenandClaude Opus 4.8 c802b26406 perf(ci): reuse a session-scoped engine for the per-test DB reset
CI / intimp (push) Successful in 3m35s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m33s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 14s
CI / frontend-build (push) Successful in 20s
The autouse integration teardown created a fresh SQLAlchemy engine + Postgres
connection for EVERY test, then disposed it — --durations showed the 15
slowest ops in both long shards were all ~1.5-2s teardowns (the connect+SCRAM
handshake, not test logic). Hoist the truncate engine to a session-scoped,
pool_pre_ping'd fixture so the pooled connection is reused across teardowns;
the TRUNCATE+restore still runs per test, so isolation is unchanged. Lazy
create_engine means the no-DB unit job instantiates but never connects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:35:28 -04:00
bvandeusenandClaude Opus 4.8 3a4270e6be fix(showcase): reveal each tile only once its image is fully decoded
CI / backend-lint-and-test (push) Successful in 13s
CI / intapi (push) Successful in 7m41s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / intimp (push) Successful in 3m33s
CI / intcore (push) Successful in 8m23s
The buffered cascade revealed tiles on an 80ms timer regardless of image
load, so the flip-in animation played on a gray placeholder and the thumbnail
popped in afterward. Worse, MasonryGrid ALSO applied a per-index
animation-delay (index×70ms) that compounded on top of the insert cadence,
so the cascade visibly dragged and desynced as it grew.

Now the producer preloads each queued thumbnail (decode pipelined ahead) and
the consumer awaits that decode before pushing the item — every tile animates
in fully loaded, strictly one at a time. Drop the compounding CSS stagger;
the store's one-item-at-a-time push is the sole pacer, so each tile animates
the instant it mounts. New utils/preloadImage.js (load+decode+timeout gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:19:44 -04:00
bvandeusenandClaude Opus 4.8 ae569c0f9a fix(gallery): ruff C408 (dict literal) + panel auto-open on deep-link
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m29s
CI / intapi (push) Successful in 7m35s
CI / intcore (push) Successful in 9m2s
Rewrite facets() common/plat_scope as dict literals (C408). Open the refine
panel via a watch on hasRefineFilters rather than reading filter state at
bar-setup time — the parent applies the URL query in its onMounted, after the
bar child has set up, so the initial read was always the default (empty) state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:14:15 -04:00
bvandeusenandClaude Opus 4.8 1adc47f59c feat(gallery): faceted refine panel UI (Phase 2 frontend)
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m34s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m30s
Add a 'Refine ▾' toggle to the gallery filter bar that expands a full-width
GalleryFacetPanel below it, inside the same sticky hazey chrome. The panel
offers platform chips (with live counts + a 'No platform' unsourced bucket),
two count-badged curation-flag toggles (Untagged / No artist), and a from/to
date range bounded by the facet min/max.

Store gains the platform/untagged/no_artist/date_from/date_to filter params
(URL-mirrored, AND-composed) and a panel-gated, single-flighted loadFacets()
that fetches /api/gallery/facets scoped to the active filter. Shared
cloneFilter/filterToQuery helpers keep the bar and panel writing one URL
format. The panel auto-opens on deep-link when refine filters are present and
refetches counts (debounced) on every filter change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:06:51 -04:00
bvandeusenandClaude Opus 4.8 9fe534139a feat(gallery): faceted filter params + /facets counts endpoint (Phase 2 backend)
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 18s
CI / intimp (push) Successful in 3m34s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m50s
Extend the composable gallery filter with platform / untagged / no_artist /
date_from / date_to, AND-composed with the existing tag/artist/media/sort
params and threaded through scroll, timeline, and jump_cursor.

Add GalleryService.facets() + GET /api/gallery/facets returning live counts
scoped to the current filter with per-group minus-self semantics: platform
counts (COUNT(DISTINCT image) incl. a null unsourced bucket), curation-flag
counts (untagged / no_artist), and effective_date min/max bounds. The
UNSOURCED_PLATFORM sentinel makes filesystem-imported content reachable via
the platform facet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:01:07 -04:00
bvandeusenandClaude Opus 4.8 16e0268da7 fix(modal): next arrow clears metadata panel + arrows work in empty tag input
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 25s
CI / intimp (push) Successful in 3m35s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m23s
Image viewer (#609):
- The next (▶) arrow was offset from the viewport edge (right:16px) so it
  floated over the 320px metadata side panel. Offset it off a shared
  --fc-side-w var so it sits at the image's right edge instead; full-width
  again below 900px when the panel stacks under the image.
- Arrow nav was fully disabled whenever a text field was focused. Now it
  yields to the caret ONLY when the field has text; an empty tag-entry field
  still navigates ←/→. Extracted to utils/textEntry.js (arrowNavAllowed).
  ESC behaviour unchanged (already closes the modal, overlay-aware).

Test: arrowNavAllowed — empty/non-text → navigate, text present → don't.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:37:16 -04:00
bvandeusenandClaude Opus 4.8 1f4ce8513b style: ruff I001 — keep _sync_engine as-import on its own line (combine-as-imports=false)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 22s
CI / frontend-build (push) Successful in 19s
CI / intapi (push) Successful in 8m17s
CI / intcore (push) Successful in 8m38s
CI / intimp (push) Successful in 4m6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:59:26 -04:00
bvandeusenandClaude Opus 4.8 5a116ca9d0 style: ruff I001 — aliased _sync_session_factory sorts before get_sync_engine
CI / lint (push) Failing after 4s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m49s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 9m37s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:55:18 -04:00
bvandeusenandClaude Opus 4.8 ef3ee5aceb feat(maintenance): DB maintenance UI card + fix ruff I001
CI / lint (push) Failing after 2s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 18s
CI / intimp (push) Successful in 4m10s
CI / intapi (push) Successful in 8m26s
CI / intcore (push) Successful in 9m54s
- Settings → Maintenance gains a "Database maintenance" card: a "Run VACUUM
  ANALYZE now" button (enqueues the maintenance task) plus a per-table bloat
  readout (live/dead/dead%/last vacuum) from /api/admin/maintenance/db-stats.
- dbMaintenance store (loadStats / runVacuum) + test.
- Fix ruff I001: combine the two _sync_engine imports onto one line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:52:36 -04:00
bvandeusenandClaude Opus 4.8 914033db29 feat(maintenance): scheduled + manual DB VACUUM ANALYZE + bloat readout
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 22s
CI / frontend-build (push) Successful in 23s
CI / intimp (push) Successful in 3m35s
CI / intapi (push) Successful in 7m53s
CI / intcore (push) Successful in 9m15s
The TABLESAMPLE showcase reads physical blocks (bloat-sensitive), and the
periodic prune/backfill/recovery tasks churn dead tuples faster than
autovacuum always keeps up — so explicit maintenance earns its keep here.

- tasks.maintenance.vacuum_analyze: VACUUM (ANALYZE) over high-churn tables
  (VACUUM_TABLES) on an AUTOCOMMIT connection (VACUUM can't run in a txn).
  Scheduled weekly via Beat; also operator-triggerable.
- _sync_engine.get_sync_engine(): expose the process engine for the
  autocommit connection.
- GET  /api/admin/maintenance/db-stats: per-table n_live/n_dead/dead_pct +
  last (auto)vacuum/analyze from pg_stat_user_tables — visibility, not a
  black box.
- POST /api/admin/maintenance/vacuum: enqueue the task on demand.

Tests: vacuum task runs + reports tables; db-stats shape; trigger queues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:49:39 -04:00
bvandeusenandClaude Opus 4.8 d495605c12 style(gallery): hazey filter bar attached to the TopNav
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m43s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m49s
- Filter bar gets the same obsidian translucent + backdrop-blur as the
  TopNav so the two read as one piece of chrome.
- margin-top:-8px cancels the v-container's pt-2 so the bar sits flush at
  64px even at scroll 0 — fixes the gap/separation when scrolled to top.
- Inputs/toggles get a more-opaque backing so they stay legible on the haze.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:40:32 -04:00
bvandeusenandClaude Opus 4.8 76d8ad42a8 fix(showcase): buffered producer/consumer for a steady cascade
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m32s
CI / intapi (push) Successful in 7m35s
CI / intcore (push) Successful in 9m8s
The cascade "burped" — chunks appeared unevenly — because the old pipeline
coupled display to fetch timing: it trickled each batch right after its
fetch and assumed the next round-trip would land inside the ~240ms trickle
window. When a fetch ran long (TABLESAMPLE hits random, sometimes-cold
blocks; RTT jitter) the animation starved, then a clump burst in.

Decouple the two:
- Producer (_fill) races ahead fetching batches into a buffer up to a
  target depth, refilling when it dips below BUFFER_MIN.
- Consumer (_drain) reveals one item every CADENCE_MS regardless of when
  fetches land; it only waits if the buffer genuinely starves.

A small PRIME buffer precedes the drain so it doesn't starve at the front;
the buffer (BUFFER_MIN×CADENCE runway) absorbs per-fetch jitter so images
appear at an even pace. Public store API (loadInitial/shuffle/fetchPage/
images/loading/hasMore/isEmpty) unchanged — ShowcaseView/MasonryGrid need
no change.

Test (fake timers): fire-order + dedup, one-item-per-cadence rate limit,
empty-library flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:35:25 -04:00
60 changed files with 3204 additions and 721 deletions
+30 -149
View File
@@ -92,28 +92,25 @@ jobs:
- run: npm run test:unit - run: npm run test:unit
- run: npm run build - run: npm run build
# Integration suite split into THREE parallel shards (2026-05-25, runner # Single integration job — collapsed from a 3-way shard split on 2026-06-04.
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service # The shards existed to parallelize ~8.5min of integration tests; once the
# set and runs alembic + a disjoint subset of integration tests. Shards # throwaway Postgres runs with fsync OFF (the durability step below) the whole
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py # suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
# stays single-threaded per shard but multiple shards run in parallel # (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
# wall-clock. Approximate split — rebalance once --durations=15 output # runner slots for no wall-clock gain. One job now: spin up once, install
# reveals which shard is the long pole. # once, migrate once, run every integration test.
# #
# Each shard's docker-ps filter uses its own unique job name to scope # The docker-ps filter scopes to THIS job's own Postgres/Redis service
# service-container resolution. act_runner appears to strip underscores # containers by job name. act_runner strips underscores from job names when
# from job names when building container labels — `int_api` yielded # labelling containers (`int_api` matched nothing on 2026-05-25), so the name
# zero matches on 2026-05-25 — so shards use no-separator names # stays separator-free (`integration`). The step prints `docker ps -a` first
# (`intapi`, `intimp`, `intcore`) instead. Each step prints # so a future naming-convention shift surfaces in the log without a
# `docker ps -a` first so a future naming-convention shift surfaces in # guess-and-push cycle.
# the log without another guess-and-push cycle.
# #
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT # Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
# done — per ci-requirements.md, FC is the only Python consumer of that # per ci-requirements.md, FC is the only Python consumer of that image and the
# image and the CI-Runner project's "add deps to image when used by >1 # CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
# project" rule keeps the install per-job. integration:
intapi:
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -144,14 +141,14 @@ jobs:
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: API integration shard (resolve service IPs, migrate, test) - name: Integration suite (resolve service IPs, migrate, test)
run: | run: |
set -eux set -eux
echo "=== container landscape (diagnostic for filter scoping) ===" echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ===" echo "=== end landscape ==="
PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1) PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1) RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD" test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD") RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
@@ -168,130 +165,14 @@ jobs:
else else
pip install -r requirements.txt pytest pytest-asyncio pip install -r requirements.txt pytest pytest-asyncio
fi fi
# Relax durability on the throwaway CI Postgres so the per-test
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
# surprise can't red the job; fabledcurator is the postgres image's
# bootstrap superuser.
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
alembic upgrade head alembic upgrade head
pytest tests/test_api_*.py -v -m integration --durations=15 pytest tests/ -v -m integration --durations=15
intimp:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
DB_USER: fabledcurator
DB_PASSWORD: ci_integration
DB_PORT: "5432"
DB_NAME: fabledcurator_test
SECRET_KEY: ci_integration_placeholder
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: fabledcurator
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: fabledcurator_test
options: >-
--health-cmd "pg_isready -U fabledcurator"
--health-interval 10s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Importer integration shard (resolve service IPs, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ==="
PG=$(docker ps --filter "name=intimp" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=intimp" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
test -n "$PG_IP" && test -n "$RD_IP"
export DB_HOST="$PG_IP"
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
for i in $(seq 1 60); do
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
sleep 2
done
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio
else
pip install -r requirements.txt pytest pytest-asyncio
fi
alembic upgrade head
pytest tests/test_importer*.py tests/test_import_*.py tests/test_migration_*.py tests/test_phash_*.py tests/test_sidecar_*.py tests/test_scan_*.py tests/test_archive_extractor.py tests/test_backfill_phash.py -v -m integration --durations=15
intcore:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
DB_USER: fabledcurator
DB_PASSWORD: ci_integration
DB_PORT: "5432"
DB_NAME: fabledcurator_test
SECRET_KEY: ci_integration_placeholder
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: fabledcurator
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: fabledcurator_test
options: >-
--health-cmd "pg_isready -U fabledcurator"
--health-interval 10s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
run: |
set -eux
echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ==="
PG=$(docker ps --filter "name=intcore" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=intcore" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
test -n "$PG_IP" && test -n "$RD_IP"
export DB_HOST="$PG_IP"
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
for i in $(seq 1 60); do
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
sleep 2
done
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio
else
pip install -r requirements.txt pytest pytest-asyncio
fi
alembic upgrade head
pytest tests/ -v -m integration --durations=15 \
--ignore-glob='tests/test_api_*.py' \
--ignore-glob='tests/test_importer*.py' \
--ignore-glob='tests/test_import_*.py' \
--ignore-glob='tests/test_migration_*.py' \
--ignore-glob='tests/test_phash_*.py' \
--ignore-glob='tests/test_sidecar_*.py' \
--ignore-glob='tests/test_scan_*.py' \
--ignore-glob='tests/test_archive_extractor.py' \
--ignore-glob='tests/test_backfill_phash.py'
@@ -0,0 +1,41 @@
"""image_record.siglip_embedding: HNSW cosine index for "more like this"
Revision ID: 0036
Revises: 0035
Create Date: 2026-06-04
Gallery Phase 3 (visual similarity search) ranks images by
`siglip_embedding.cosine_distance(source_embedding)`. Without an index that's
a sequential scan computing a 1152-dim distance for every row — fine at small
scale, but it grows linearly with the library. Add an HNSW index with
`vector_cosine_ops` so the top-N nearest search is sub-50ms ANN.
1152 dims is under pgvector's 2000-dim HNSW limit, so HNSW (no training,
better recall than IVFFlat) is the right choice. ONE-TIME COST: building the
index over the existing embeddings (~57k vectors on the operator's library)
locks image_record for ~30-60s during this migration on deploy — acceptable
for a single-operator homelab. NULL embeddings (videos / not-yet-embedded
rows) are simply not indexed.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0036"
down_revision: Union[str, None] = "0035"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Raw SQL: alembic's create_index doesn't express the `USING hnsw (...
# vector_cosine_ops)` access-method + opclass cleanly. Must match the
# query's cosine_distance operator class to be usable by the planner.
op.execute(
"CREATE INDEX ix_image_record_siglip_hnsw "
"ON image_record USING hnsw (siglip_embedding vector_cosine_ops)"
)
def downgrade() -> None:
op.drop_index("ix_image_record_siglip_hnsw", table_name="image_record")
+68 -1
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import hashlib import hashlib
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import select from sqlalchemy import select, text
from ..extensions import get_session from ..extensions import get_session
from ..models import Artist from ..models import Artist
@@ -222,3 +222,70 @@ async def tags_purge_legacy():
lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run) lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run)
) )
return jsonify(result) return jsonify(result)
@admin_bp.route("/tags/reset-content", methods=["POST"])
async def tags_reset_content():
"""Tier-A: delete ALL general + character tags (the Camie-suggestable
content vocabulary) so the operator can re-tag from scratch via
auto-suggest. fandom + series tags + series_page ordering are preserved,
and image tagger_predictions are untouched so suggestions repopulate.
dry-run preview returns per-kind counts + applications + a sample so the
UI shows exactly what'll go before the operator confirms (dry_run=false).
Irreversible except via DB backup restore."""
from ..services.cleanup_service import reset_content_tagging
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
async with get_session() as session:
result = await session.run_sync(
lambda sync_sess: reset_content_tagging(sync_sess, dry_run=dry_run)
)
return jsonify(result)
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
async def db_stats():
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
so the operator can see when a VACUUM is worth running."""
from ..tasks.maintenance import VACUUM_TABLES
wanted = set(VACUUM_TABLES)
async with get_session() as session:
rows = (await session.execute(text(
"SELECT relname, n_live_tup, n_dead_tup, last_vacuum, "
"last_autovacuum, last_analyze FROM pg_stat_user_tables"
))).all()
def _iso(v):
return v.isoformat() if v is not None else None
out = []
for r in rows:
if r.relname not in wanted:
continue
live = r.n_live_tup or 0
dead = r.n_dead_tup or 0
total = live + dead
out.append({
"table": r.relname,
"live": live,
"dead": dead,
"dead_pct": round(100 * dead / total, 1) if total else 0.0,
"last_vacuum": _iso(r.last_vacuum),
"last_autovacuum": _iso(r.last_autovacuum),
"last_analyze": _iso(r.last_analyze),
})
out.sort(key=lambda t: t["dead"], reverse=True)
return jsonify({"tables": out})
@admin_bp.route("/maintenance/vacuum", methods=["POST"])
async def trigger_vacuum():
"""Operator-triggered VACUUM (ANALYZE) over the high-churn tables — the
same maintenance-queue task the weekly Beat schedule runs."""
from ..tasks.maintenance import vacuum_analyze
vacuum_analyze.delay()
return jsonify({"status": "queued"}), 202
+8 -5
View File
@@ -154,12 +154,15 @@ async def audit_history():
limit = min(int(request.args.get("limit", "20")), 100) limit = min(int(request.args.get("limit", "20")), 100)
except ValueError: except ValueError:
return _bad("invalid_limit") return _bad("invalid_limit")
# Optional rule filter so a card can reconnect to ITS latest run on mount
# (?rule=transparency&limit=1) — the audit survives navigation; the UI
# rehydrates from this rather than losing the in-flight scan.
rule = request.args.get("rule") or None
async with get_session() as session: async with get_session() as session:
rows = (await session.execute( stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
select(LibraryAuditRun) if rule is not None:
.order_by(LibraryAuditRun.id.desc()) stmt = stmt.where(LibraryAuditRun.rule == rule)
.limit(limit) rows = (await session.execute(stmt.limit(limit))).scalars().all()
)).scalars().all()
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]}) return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
+110 -33
View File
@@ -1,4 +1,6 @@
"""Gallery API: cursor scroll, timeline, jump, image detail.""" """Gallery API: cursor scroll, timeline, jump, image detail, facets."""
from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
@@ -8,11 +10,39 @@ from ..services.gallery_service import GalleryService
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery") gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
def _image_json(i):
"""Serialize a GalleryImage for the scroll/similar list responses."""
return {
"id": i.id,
"sha256": i.sha256,
"mime": i.mime,
"width": i.width,
"height": i.height,
"created_at": i.created_at.isoformat(),
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
"thumbnail_url": i.thumbnail_url,
"artist": i.artist,
}
def _parse_date(raw):
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
Raises ValueError (→ 400) on a malformed value."""
if not raw:
return None
return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=UTC)
def _parse_filters(): def _parse_filters():
"""Parse the composable gallery filters from query args. Raises """Parse the composable gallery filters from query args, returning
ValueError (→ 400) on malformed ids. `tag_id` accepts a single id or a ``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
comma-separated list (AND); `media` is image|video; `sort` is
newest|oldest.""" `tag_id` accepts a single id or a comma-separated list (AND); `media` is
image|video; `sort` is newest|oldest; `platform` selects one platform
(or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are boolean
flags; `date_from`/`date_to` are inclusive calendar-day bounds (date_to is
widened by a day so the whole day is covered by the service's half-open
`< date_to`)."""
tag_raw = request.args.get("tag_id") tag_raw = request.args.get("tag_id")
tag_ids = ( tag_ids = (
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None [int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
@@ -25,7 +55,20 @@ def _parse_filters():
media_type = media if media in ("image", "video") else None media_type = media if media in ("image", "video") else None
sort = request.args.get("sort") sort = request.args.get("sort")
sort = sort if sort in ("newest", "oldest") else "newest" sort = sort if sort in ("newest", "oldest") else "newest"
return tag_ids, post_id, artist_id, media_type, sort platform = request.args.get("platform") or None
untagged = request.args.get("untagged") in ("1", "true", "yes")
no_artist = request.args.get("no_artist") in ("1", "true", "yes")
date_from = _parse_date(request.args.get("date_from"))
date_to = _parse_date(request.args.get("date_to"))
if date_to is not None:
date_to += timedelta(days=1) # inclusive of the date_to calendar day
filters = {
"tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id,
"media_type": media_type, "platform": platform,
"untagged": untagged, "no_artist": no_artist,
"date_from": date_from, "date_to": date_to,
}
return filters, sort
@gallery_bp.route("/scroll", methods=["GET"]) @gallery_bp.route("/scroll", methods=["GET"])
@@ -33,7 +76,7 @@ async def scroll():
cursor = request.args.get("cursor") or None cursor = request.args.get("cursor") or None
try: try:
limit = int(request.args.get("limit", "50")) limit = int(request.args.get("limit", "50"))
tag_ids, post_id, artist_id, media_type, sort = _parse_filters() filters, sort = _parse_filters()
except ValueError: except ValueError:
return jsonify({"error": "invalid filter or limit parameter"}), 400 return jsonify({"error": "invalid filter or limit parameter"}), 400
@@ -41,29 +84,14 @@ async def scroll():
svc = GalleryService(session) svc = GalleryService(session)
try: try:
page = await svc.scroll( page = await svc.scroll(
cursor=cursor, limit=limit, tag_ids=tag_ids, cursor=cursor, limit=limit, sort=sort, **filters,
post_id=post_id, artist_id=artist_id,
media_type=media_type, sort=sort,
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
return jsonify( return jsonify(
{ {
"images": [ "images": [_image_json(i) for i in page.images],
{
"id": i.id,
"sha256": i.sha256,
"mime": i.mime,
"width": i.width,
"height": i.height,
"created_at": i.created_at.isoformat(),
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
"thumbnail_url": i.thumbnail_url,
"artist": i.artist,
}
for i in page.images
],
"next_cursor": page.next_cursor, "next_cursor": page.next_cursor,
"date_groups": [ "date_groups": [
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups {"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
@@ -72,19 +100,46 @@ async def scroll():
) )
@gallery_bp.route("/similar", methods=["GET"])
async def similar():
"""Visual "more like this": images ranked by cosine distance to the
`similar_to` image's embedding. Composes with the scope filters (AND) but
ignores post_id and sort. Bounded top-N, no cursor."""
try:
similar_to = int(request.args["similar_to"])
limit = int(request.args.get("limit", "100"))
filters, _sort = _parse_filters()
except (KeyError, ValueError):
return jsonify({"error": "similar_to query param required"}), 400
# post_id is the exclusive post-detail view — not a similarity scope.
scope = {k: v for k, v in filters.items() if k != "post_id"}
async with get_session() as session:
svc = GalleryService(session)
try:
images = await svc.similar(image_id=similar_to, limit=limit, **scope)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if images is None:
return jsonify({"error": "not found"}), 404
return jsonify(
{
"images": [_image_json(i) for i in images],
"next_cursor": None,
"date_groups": [],
}
)
@gallery_bp.route("/timeline", methods=["GET"]) @gallery_bp.route("/timeline", methods=["GET"])
async def timeline(): async def timeline():
try: try:
tag_ids, post_id, artist_id, media_type, _sort = _parse_filters() filters, _sort = _parse_filters()
except ValueError: except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400 return jsonify({"error": "invalid filter parameter"}), 400
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
buckets = await svc.timeline( buckets = await svc.timeline(**filters)
tag_ids=tag_ids, post_id=post_id, artist_id=artist_id,
media_type=media_type,
)
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
return jsonify( return jsonify(
@@ -92,21 +147,43 @@ async def timeline():
) )
@gallery_bp.route("/facets", methods=["GET"])
async def facets():
try:
filters, _sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400
async with get_session() as session:
svc = GalleryService(session)
try:
f = await svc.facets(**filters)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
{
"total": f.total,
"platforms": f.platforms,
"untagged": f.untagged,
"no_artist": f.no_artist,
"date_min": f.date_min.isoformat() if f.date_min else None,
"date_max": f.date_max.isoformat() if f.date_max else None,
}
)
@gallery_bp.route("/jump", methods=["GET"]) @gallery_bp.route("/jump", methods=["GET"])
async def jump(): async def jump():
try: try:
year = int(request.args["year"]) year = int(request.args["year"])
month = int(request.args["month"]) month = int(request.args["month"])
tag_ids, post_id, artist_id, media_type, sort = _parse_filters() filters, sort = _parse_filters()
except (KeyError, ValueError): except (KeyError, ValueError):
return jsonify({"error": "year and month query params required"}), 400 return jsonify({"error": "year and month query params required"}), 400
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
cursor = await svc.jump_cursor( cursor = await svc.jump_cursor(
year=year, month=month, tag_ids=tag_ids, year=year, month=month, sort=sort, **filters,
post_id=post_id, artist_id=artist_id,
media_type=media_type, sort=sort,
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
+4
View File
@@ -97,6 +97,10 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.prune_task_runs", "task": "backend.app.tasks.maintenance.prune_task_runs",
"schedule": 86400.0, # daily "schedule": 86400.0, # daily
}, },
"vacuum-analyze": {
"task": "backend.app.tasks.maintenance.vacuum_analyze",
"schedule": 604800.0, # weekly — reclaim dead-tuple bloat + refresh stats
},
"fc3h-backup-db-nightly": { "fc3h-backup-db-nightly": {
"task": "backend.app.tasks.backup.backup_db_nightly", "task": "backend.app.tasks.backup.backup_db_nightly",
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour "schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
+56
View File
@@ -455,6 +455,62 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
return result return result
# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes
# these so the operator can re-tag from scratch via auto-suggest. fandom +
# series (and series_page ordering) are deliberately NOT here — they're kept.
RESETTABLE_TAG_KINDS = ("general", "character")
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
"""Count (dry_run) or DELETE every general + character tag so the operator
can re-tag from scratch via the Camie auto-suggest.
PRESERVED: fandom + series tags and their series_page ordering, plus every
image's image_record.tagger_predictions (untouched) so suggestions
repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
tag_reference_embedding / tag_suggestion_rejection clears each deleted
tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting
character tags never touches the fandom rows. Irreversible except via DB
backup restore.
Returns:
{"by_kind": {"general": N, "character": M},
"count": total tags,
"applications": image_tag rows that will be / were removed,
"sample_names": [first 50],
and on live runs "deleted": total}
"""
predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS)
rows = session.execute(
select(Tag.id, Tag.name, Tag.kind).where(predicate)
).all()
by_kind: dict[str, int] = {}
for _id, _name, kind in rows:
key = kind.value if hasattr(kind, "value") else str(kind)
by_kind[key] = by_kind.get(key, 0) + 1
# Headline impact: applications (image_tag rows) that vanish via cascade.
applications = session.execute(
select(func.count())
.select_from(image_tag)
.where(image_tag.c.tag_id.in_(select(Tag.id).where(predicate)))
).scalar_one()
sample = [name for _id, name, _kind in rows[:50]]
total = len(rows)
result = {
"by_kind": by_kind,
"count": total,
"applications": applications,
"sample_names": sample,
}
if dry_run:
return result
if total:
session.execute(Tag.__table__.delete().where(predicate))
session.commit()
result["deleted"] = total
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules. # FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+241 -23
View File
@@ -18,15 +18,22 @@ import base64
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from sqlalchemy import Select, and_, exists, func, or_, select from sqlalchemy import Select, and_, distinct, exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from ..models import Artist, ImageProvenance, ImageRecord, Post, Tag from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
from ..models.tag import image_tag from ..models.tag import image_tag
CURSOR_SEPARATOR = "|" CURSOR_SEPARATOR = "|"
# Reserved `platform` filter value selecting images with NO platformed
# provenance (filesystem imports). Returned by facets() as a null-valued
# bucket; the frontend maps that null back to this sentinel in the URL so the
# bucket is selectable. Underscore-wrapped so it can't collide with a real
# gallery-dl platform name (patreon/pixiv/...).
UNSOURCED_PLATFORM = "__unsourced__"
def encode_cursor(effective_date: datetime, image_id: int) -> str: def encode_cursor(effective_date: datetime, image_id: int) -> str:
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}" raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
@@ -92,6 +99,16 @@ class TimelineBucket:
count: int count: int
@dataclass(frozen=True)
class GalleryFacets:
total: int # images matching the FULL active filter
platforms: list[dict] # [{"value": str|None, "count": int}], null = unsourced
untagged: int # how many the Untagged flag would isolate
no_artist: int # how many the No-artist flag would isolate
date_min: datetime | None
date_max: datetime | None
def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str: def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str:
"""Return the URL to fetch a thumbnail. """Return the URL to fetch a thumbnail.
@@ -128,15 +145,27 @@ def _require_single_filter(tag_ids, post_id, artist_id) -> None:
) )
def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type): def _apply_scope(
"""Apply the composable gallery filters to a statement already joined stmt, *, tag_ids, post_id, artist_id, media_type,
to Post via _outer_join_primary_post. platform=None, untagged=False, no_artist=False,
date_from=None, date_to=None,
):
"""Apply the composable gallery filters to a statement.
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag All clauses are correlated EXISTS / scalar predicates on ImageRecord, so
(AND), which avoids the row-multiplication a multi-join would cause. they AND together without row-multiplication and don't require any join to
be present on `stmt` (the artist/platform paths alias Post/Source inside
their own EXISTS).
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag.
- post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded - post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
by _require_single_filter). by _require_single_filter).
- media_type: 'image' | 'video' narrows by mime prefix. - media_type: 'image' | 'video' narrows by mime prefix.
- platform: EXISTS a provenance→source with that platform; the
UNSOURCED_PLATFORM sentinel inverts it (NO platformed provenance).
- untagged: NOT EXISTS any image_tag row.
- no_artist: ImageRecord.artist_id IS NULL.
- date_from / date_to: half-open [from, to) bounds on effective_date.
""" """
for tid in tag_ids or []: for tid in tag_ids or []:
stmt = stmt.where( stmt = stmt.where(
@@ -152,9 +181,39 @@ def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type):
stmt = stmt.where(ImageRecord.mime.like("image/%")) stmt = stmt.where(ImageRecord.mime.like("image/%"))
elif media_type == "video": elif media_type == "video":
stmt = stmt.where(ImageRecord.mime.like("video/%")) stmt = stmt.where(ImageRecord.mime.like("video/%"))
if platform is not None:
stmt = stmt.where(_platform_clause(platform))
if untagged:
stmt = stmt.where(
~exists().where(image_tag.c.image_record_id == ImageRecord.id)
)
if no_artist:
stmt = stmt.where(ImageRecord.artist_id.is_(None))
eff = _effective_date_col()
if date_from is not None:
stmt = stmt.where(eff >= date_from)
if date_to is not None:
stmt = stmt.where(eff < date_to)
return stmt return stmt
def _platform_clause(platform):
"""Correlated EXISTS on a provenance row whose Source carries `platform`.
The UNSOURCED_PLATFORM sentinel inverts to NOT EXISTS(any sourced
provenance) — i.e. filesystem-imported content with no platform."""
src = aliased(Source)
if platform == UNSOURCED_PLATFORM:
return ~exists().where(
ImageProvenance.image_record_id == ImageRecord.id,
ImageProvenance.source_id == src.id,
)
return exists().where(
ImageProvenance.image_record_id == ImageRecord.id,
ImageProvenance.source_id == src.id,
src.platform == platform,
)
def _provenance_clause(post_id, artist_id): def _provenance_clause(post_id, artist_id):
"""Correlated EXISTS clause (NOT a join) so an image with multiple """Correlated EXISTS clause (NOT a join) so an image with multiple
matching provenance rows is returned exactly once and the matching provenance rows is returned exactly once and the
@@ -186,6 +245,27 @@ def _provenance_clause(post_id, artist_id):
return None return None
def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
"""Build GalleryImage list from (record, posted_at, eff_date) rows + the
artist hydration map. Shared by scroll() and similar()."""
return [
GalleryImage(
id=record.id,
path=record.path,
sha256=record.sha256,
mime=record.mime,
width=record.width,
height=record.height,
created_at=record.created_at,
effective_date=eff_date,
posted_at=posted_at,
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
artist=artists.get(record.id),
)
for record, posted_at, eff_date in rows
]
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]: async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
"""Map image_id -> {"name","slug"} via the canonical """Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size.""" image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
@@ -215,6 +295,11 @@ class GalleryService:
artist_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, media_type: str | None = None,
sort: str = "newest", sort: str = "newest",
platform: str | None = None,
untagged: bool = False,
no_artist: bool = False,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> GalleryPage: ) -> GalleryPage:
if limit < 1 or limit > 200: if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200") raise ValueError("limit must be between 1 and 200")
@@ -226,6 +311,8 @@ class GalleryService:
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
) )
descending = sort != "oldest" descending = sort != "oldest"
@@ -258,22 +345,7 @@ class GalleryService:
artists = await _artists_for( artists = await _artists_for(
self.session, [r[0].id for r in rows] self.session, [r[0].id for r in rows]
) )
images = [ images = _gallery_images(rows, artists)
GalleryImage(
id=record.id,
path=record.path,
sha256=record.sha256,
mime=record.mime,
width=record.width,
height=record.height,
created_at=record.created_at,
effective_date=eff_date,
posted_at=posted_at,
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
artist=artists.get(record.id),
)
for record, posted_at, eff_date in rows
]
return GalleryPage( return GalleryPage(
images=images, images=images,
next_cursor=next_cursor, next_cursor=next_cursor,
@@ -286,6 +358,11 @@ class GalleryService:
post_id: int | None = None, post_id: int | None = None,
artist_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, media_type: str | None = None,
platform: str | None = None,
untagged: bool = False,
no_artist: bool = False,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[TimelineBucket]: ) -> list[TimelineBucket]:
eff = _effective_date_col() eff = _effective_date_col()
year_col = func.date_part("year", eff).label("yr") year_col = func.date_part("year", eff).label("yr")
@@ -298,6 +375,8 @@ class GalleryService:
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
) )
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc()) stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
rows = (await self.session.execute(stmt)).all() rows = (await self.session.execute(stmt)).all()
@@ -307,6 +386,9 @@ class GalleryService:
self, year: int, month: int, tag_ids: list[int] | None = None, self, year: int, month: int, tag_ids: list[int] | None = None,
post_id: int | None = None, artist_id: int | None = None, post_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, sort: str = "newest", media_type: str | None = None, sort: str = "newest",
platform: str | None = None, untagged: bool = False,
no_artist: bool = False, date_from: datetime | None = None,
date_to: datetime | None = None,
) -> str | None: ) -> str | None:
"""Returns a cursor that, when passed to scroll() with the same sort, """Returns a cursor that, when passed to scroll() with the same sort,
positions at the first image of the given year-month. None if the positions at the first image of the given year-month. None if the
@@ -324,6 +406,8 @@ class GalleryService:
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
) )
descending = sort != "oldest" descending = sort != "oldest"
if descending: if descending:
@@ -339,6 +423,137 @@ class GalleryService:
boundary = record.id + 1 if descending else record.id - 1 boundary = record.id + 1 if descending else record.id - 1
return encode_cursor(eff_date, boundary) return encode_cursor(eff_date, boundary)
async def facets(
self, *, tag_ids: list[int] | None = None,
post_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, platform: str | None = None,
untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None,
) -> GalleryFacets:
"""Live facet counts scoped to the current filter. Each facet GROUP is
computed with all OTHER active filters applied but its OWN selection
ignored ("minus-self"), so sibling options stay visible/switchable.
No outer join is needed — every clause is a correlated EXISTS or a
column predicate on ImageRecord.
"""
_require_single_filter(tag_ids, post_id, artist_id)
common = {
"tag_ids": tag_ids, "post_id": post_id,
"artist_id": artist_id, "media_type": media_type,
}
# total — the full active filter (the headline result count).
total = (await self.session.execute(
_apply_scope(
select(func.count(ImageRecord.id)), **common,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
)
)).scalar_one()
# platforms — scope minus the platform selection. Inner-join
# provenance→source and COUNT(DISTINCT image) per platform (a
# cross-posted image counts under each of its platforms).
plat_scope = {
**common, "untagged": untagged, "no_artist": no_artist,
"date_from": date_from, "date_to": date_to,
}
src = aliased(Source)
plat_stmt = (
select(src.platform, func.count(distinct(ImageRecord.id)))
.select_from(ImageRecord)
.join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id)
.join(src, src.id == ImageProvenance.source_id)
)
plat_stmt = _apply_scope(plat_stmt, **plat_scope).group_by(src.platform)
platforms = [
{"value": p, "count": c}
for p, c in (await self.session.execute(plat_stmt)).all()
]
# Unsourced (filesystem) bucket — same minus-platform scope.
unsourced = (await self.session.execute(
_apply_scope(
select(func.count(ImageRecord.id)), **plat_scope,
platform=UNSOURCED_PLATFORM,
)
)).scalar_one()
if unsourced:
platforms.append({"value": None, "count": unsourced})
# curation flags — each minus its OWN flag.
untagged_count = (await self.session.execute(
_apply_scope(
select(func.count(ImageRecord.id)), **common,
platform=platform, no_artist=no_artist,
date_from=date_from, date_to=date_to, untagged=True,
)
)).scalar_one()
no_artist_count = (await self.session.execute(
_apply_scope(
select(func.count(ImageRecord.id)), **common,
platform=platform, untagged=untagged,
date_from=date_from, date_to=date_to, no_artist=True,
)
)).scalar_one()
# date bounds — scope minus the date params (those drive the picker).
eff = _effective_date_col()
dmin, dmax = (await self.session.execute(
_apply_scope(
select(func.min(eff), func.max(eff)), **common,
platform=platform, untagged=untagged, no_artist=no_artist,
)
)).one()
return GalleryFacets(
total=total, platforms=platforms,
untagged=untagged_count, no_artist=no_artist_count,
date_min=dmin, date_max=dmax,
)
async def similar(
self, image_id: int, limit: int = 100, *,
tag_ids: list[int] | None = None, artist_id: int | None = None,
media_type: str | None = None, platform: str | None = None,
untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None,
) -> list[GalleryImage] | None:
"""Visual "more like this": images ranked by cosine distance to
`image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
No ML inference here; the embedding was computed at import.
Returns None if the source image doesn't exist (→ 404), [] if it has
no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
scope filters (AND) but REPLACES the date sort — always nearest-first,
bounded to `limit` (no cursor; distance-ranking has no date cursor).
"""
if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200")
src = await self.session.get(ImageRecord, image_id)
if src is None:
return None
if src.siglip_embedding is None:
return []
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt)
stmt = stmt.where(
ImageRecord.siglip_embedding.is_not(None),
ImageRecord.id != image_id,
)
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=None,
artist_id=artist_id, media_type=media_type,
platform=platform, untagged=untagged, no_artist=no_artist,
date_from=date_from, date_to=date_to,
)
stmt = stmt.order_by(distance.asc()).limit(limit)
rows = (await self.session.execute(stmt)).all()
artists = await _artists_for(self.session, [r[0].id for r in rows])
return _gallery_images(rows, artists)
async def get_image_with_tags(self, image_id: int) -> dict | None: async def get_image_with_tags(self, image_id: int) -> dict | None:
record = await self.session.get(ImageRecord, image_id) record = await self.session.get(ImageRecord, image_id)
if record is None: if record is None:
@@ -376,6 +591,9 @@ class GalleryService:
"height": record.height, "height": record.height,
"size_bytes": record.size_bytes, "size_bytes": record.size_bytes,
"integrity_status": record.integrity_status, "integrity_status": record.integrity_status,
# Phase 3: lets the modal hide the "Related"/find-similar surface
# for images that have no embedding yet (videos / pending ML).
"has_embedding": record.siglip_embedding is not None,
"created_at": record.created_at.isoformat(), "created_at": record.created_at.isoformat(),
"posted_at": posted_at.isoformat() if posted_at else None, "posted_at": posted_at.isoformat() if posted_at else None,
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime), "thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
+7
View File
@@ -34,3 +34,10 @@ def sync_session_factory():
) )
_SESSIONMAKER = sessionmaker(_ENGINE, expire_on_commit=False) _SESSIONMAKER = sessionmaker(_ENGINE, expire_on_commit=False)
return _SESSIONMAKER return _SESSIONMAKER
def get_sync_engine():
"""The process-wide sync Engine — for raw work that needs a connection
directly (e.g. AUTOCOMMIT VACUUM, which can't run inside a transaction)."""
sync_session_factory() # ensure _ENGINE is initialized
return _ENGINE
+34
View File
@@ -22,10 +22,27 @@ from ..models import (
TaskRun, TaskRun,
) )
from ..utils.phash import compute_phash from ..utils.phash import compute_phash
from ._sync_engine import get_sync_engine
from ._sync_engine import sync_session_factory as _sync_session_factory from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# High-churn tables whose dead-tuple bloat matters: the TABLESAMPLE showcase
# reads physical blocks (bloat slows it directly), and the periodic
# prune/backfill/recovery tasks generate dead tuples faster than autovacuum
# always keeps up with. VACUUM reclaims them; ANALYZE refreshes planner stats.
# Allowlist ONLY — names are interpolated into VACUUM, so they must never come
# from request input.
VACUUM_TABLES = (
"image_record",
"image_provenance",
"post_attachment",
"download_event",
"task_run",
"import_task",
"import_batch",
)
STUCK_THRESHOLD_MINUTES = 5 STUCK_THRESHOLD_MINUTES = 5
# Archive ImportTasks run the per-member pipeline inline for every # Archive ImportTasks run the per-member pipeline inline for every
# member (import_archive_file: soft=30min/hard=35min). The ImportTask # member (import_archive_file: soft=30min/hard=35min). The ImportTask
@@ -761,3 +778,20 @@ def cleanup_old_download_events() -> int:
) )
session.commit() session.commit()
return result.rowcount or 0 return result.rowcount or 0
@celery.task(name="backend.app.tasks.maintenance.vacuum_analyze")
def vacuum_analyze() -> dict:
"""Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to
reclaim dead-tuple bloat and refresh planner statistics. VACUUM cannot run
inside a transaction block, so it runs on an AUTOCOMMIT connection.
Scheduled weekly; also operator-triggerable from Settings → Maintenance.
"""
engine = get_sync_engine()
done = []
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
for table in VACUUM_TABLES:
conn.exec_driver_sql(f"VACUUM (ANALYZE) {table}")
done.append(table)
log.info("vacuum_analyze complete: %s", done)
return {"vacuumed": done}
+46 -4
View File
@@ -11,6 +11,8 @@
<PipelineStatusChip /> <PipelineStatusChip />
</div> </div>
<!-- Desktop: inline links, centered. Hidden on mobile (see media query),
where they fold into the hamburger menu on the right. -->
<nav class="fc-links"> <nav class="fc-links">
<RouterLink <RouterLink
v-for="r in navRoutes" v-for="r in navRoutes"
@@ -20,9 +22,31 @@
>{{ r.meta.title }}</RouterLink> >{{ r.meta.title }}</RouterLink>
</nav> </nav>
<!-- Per-view contextual actions teleport here (Showcase: Shuffle, <div class="fc-nav-right">
Gallery: Select). TopNav owns the slot, not its contents. --> <!-- Per-view contextual actions teleport here (Showcase: Shuffle,
<div id="fc-nav-actions" class="fc-nav-actions" /> Gallery: Select). TopNav owns the slot, not its contents. -->
<div id="fc-nav-actions" class="fc-nav-actions" />
<!-- Mobile nav: the link row collides with brand + actions below ~768px
(7+ links in one flex row), so collapse it into a menu. -->
<v-menu location="bottom end">
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-menu" variant="text" size="small"
class="fc-nav-burger" aria-label="Menu"
/>
</template>
<v-list density="compact" min-width="180">
<v-list-item
v-for="r in navRoutes"
:key="r.name"
:to="{ name: r.name }"
:title="r.meta.title"
/>
</v-list>
</v-menu>
</div>
</header> </header>
</template> </template>
@@ -137,7 +161,7 @@ const health = computed(() => {
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
} }
.fc-nav-actions { .fc-nav-right {
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -145,4 +169,22 @@ const health = computed(() => {
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
} }
.fc-nav-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* The hamburger only exists on mobile; the inline links carry desktop. */
.fc-nav-burger { display: none; }
@media (max-width: 768px) {
.fc-topnav { gap: 0.5rem; padding: 0.6rem 0.75rem; }
/* Fold the link row into the hamburger menu. */
.fc-links { display: none; }
.fc-nav-burger { display: inline-flex; }
}
@media (max-width: 480px) {
/* Reclaim width on the smallest phones — the glyph alone still brands. */
.fc-brand__text { display: none; }
}
</style> </style>
@@ -112,6 +112,16 @@ onMounted(async () => {
await store.loadDefaults() await store.loadDefaults()
threshold.value = store.defaults.single_color_threshold threshold.value = store.defaults.single_color_threshold
tolerance.value = store.defaults.single_color_tolerance tolerance.value = store.defaults.single_color_tolerance
// Reconnect to this rule's latest run so a scan started before navigating
// away keeps showing progress / its result on return (the scan itself runs
// backend-side regardless).
try {
const latest = await store.latestAuditForRule('single_color')
if (latest) {
audit.value = latest
if (latest.status === 'running') startPoll(latest.id)
}
} catch { /* non-fatal — card still works for a fresh scan */ }
}) })
onUnmounted(() => stopPoll()) onUnmounted(() => stopPoll())
@@ -97,6 +97,16 @@ let pollTimer = null
onMounted(async () => { onMounted(async () => {
await store.loadDefaults() await store.loadDefaults()
threshold.value = store.defaults.transparency_threshold threshold.value = store.defaults.transparency_threshold
// Reconnect to this rule's latest run so a scan started before navigating
// away keeps showing progress / its result on return (the scan itself runs
// backend-side regardless).
try {
const latest = await store.latestAuditForRule('transparency')
if (latest) {
audit.value = latest
if (latest.status === 'running') startPoll(latest.id)
}
} catch { /* non-fatal — card still works for a fresh scan */ }
}) })
onUnmounted(() => stopPoll()) onUnmounted(() => stopPoll())
@@ -6,7 +6,6 @@
v-for="item in col" :key="item.id" v-for="item in col" :key="item.id"
class="fc-masonry__item" class="fc-masonry__item"
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }" :class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
:style="itemStyle(item)"
type="button" type="button"
@click="$emit('open', item.id)" @click="$emit('open', item.id)"
> >
@@ -66,12 +65,6 @@ function shouldAnimate(item) {
return idx !== undefined && idx >= props.animateFromIndex return idx !== undefined && idx >= props.animateFromIndex
} }
function itemStyle(item) {
if (!shouldAnimate(item)) return {}
const idx = idxById.value.get(item.id) - props.animateFromIndex
return { '--stagger-index': idx }
}
function aspectStyle(item) { function aspectStyle(item) {
const w = Number(item.width) const w = Number(item.width)
const h = Number(item.height) const h = Number(item.height)
@@ -117,12 +110,15 @@ useInfiniteScroll(sentinelEl, () => {
.fc-masonry__end { text-align: center; padding: 32px 0; } .fc-masonry__end { text-align: center; padding: 32px 0; }
/* Cascade entry: each tile flips up out of a backward tilt and settles /* Cascade entry: each tile flips up out of a backward tilt and settles
into place, one at a time — more pronounced than a plain fade so the into place — more pronounced than a plain fade so the showcase reads as an
showcase reads as an "experience" (operator-flagged 2026-05-28). The "experience" (operator-flagged 2026-05-28). The reveal is paced entirely by
`both` fill holds the hidden/tilted 0% state until each tile's staggered the store, which pushes one fully-decoded item at a time (showcase.js); each
turn; the cubic-bezier overshoots slightly past flat then settles. tile therefore animates the instant it mounts, with NO per-index CSS delay —
Honors prefers-reduced-motion. Tunables: tilt (-28deg), stagger (70ms), the old `animation-delay: index×70ms` compounded on top of the insert
duration (0.6s). */ cadence and made the cascade drag and desync as it grew (operator-flagged
2026-06-04). The `both` fill holds the hidden/tilted 0% state until mount;
the cubic-bezier overshoots slightly past flat then settles. Honors
prefers-reduced-motion. Tunables: tilt (-28deg), duration (0.6s). */
@keyframes fc-masonry-item-in { @keyframes fc-masonry-item-in {
0% { 0% {
opacity: 0; opacity: 0;
@@ -138,7 +134,6 @@ useInfiniteScroll(sentinelEl, () => {
transform-origin: center top; transform-origin: center top;
backface-visibility: hidden; backface-visibility: hidden;
animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both; animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both;
animation-delay: calc(var(--stagger-index, 0) * 70ms);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.fc-masonry__item--anim { .fc-masonry__item--anim {
@@ -186,7 +186,9 @@ async function onDeleteConfirm(token) {
<style scoped> <style scoped>
.fc-bulk-panel { .fc-bulk-panel {
position: fixed; top: 0; right: 0; position: fixed; top: 0; right: 0;
width: 320px; height: 100vh; z-index: 1100; /* min(320px, 90vw): on phones the panel never swallows the whole screen —
leaves a sliver of the gallery visible behind it. */
width: min(320px, 90vw); height: 100vh; z-index: 1100;
background: rgb(var(--v-theme-surface)); background: rgb(var(--v-theme-surface));
border-left: 1px solid rgb(var(--v-theme-surface-light)); border-left: 1px solid rgb(var(--v-theme-surface-light));
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.4); box-shadow: -2px 0 16px rgba(0, 0, 0, 0.4);
@@ -0,0 +1,153 @@
<template>
<div class="fc-facets">
<v-progress-linear
v-if="store.facetsLoading" indeterminate color="accent"
class="fc-facets__bar" height="2"
/>
<div class="fc-facets__group">
<span class="fc-facets__label">Platform</span>
<div class="fc-facets__chips">
<v-chip
v-for="p in platformOptions" :key="p.key"
size="small" label
:variant="p.key === store.filter.platform ? 'flat' : 'tonal'"
:color="p.key === store.filter.platform ? 'accent' : undefined"
@click="selectPlatform(p.key)"
>{{ p.label }}<span class="fc-facets__count">{{ p.count }}</span></v-chip>
<span v-if="!platformOptions.length" class="fc-facets__empty">none</span>
</div>
</div>
<div class="fc-facets__group">
<span class="fc-facets__label">Curation</span>
<div class="fc-facets__chips">
<v-chip
size="small" label
:variant="store.filter.untagged ? 'flat' : 'tonal'"
:color="store.filter.untagged ? 'accent' : undefined"
@click="toggleFlag('untagged')"
>Untagged<span class="fc-facets__count">{{ facetCount('untagged') }}</span></v-chip>
<v-chip
size="small" label
:variant="store.filter.no_artist ? 'flat' : 'tonal'"
:color="store.filter.no_artist ? 'accent' : undefined"
@click="toggleFlag('no_artist')"
>No artist<span class="fc-facets__count">{{ facetCount('no_artist') }}</span></v-chip>
</div>
</div>
<div class="fc-facets__group">
<span class="fc-facets__label">Date</span>
<v-text-field
type="date" density="compact" hide-details variant="outlined"
:model-value="store.filter.date_from" :min="dateMin" :max="dateMax"
class="fc-facets__date"
@update:model-value="setDate('date_from', $event)"
/>
<span class="fc-facets__dash"></span>
<v-text-field
type="date" density="compact" hide-details variant="outlined"
:model-value="store.filter.date_to" :min="dateMin" :max="dateMax"
class="fc-facets__date"
@update:model-value="setDate('date_to', $event)"
/>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
// Mirrors backend gallery_service.UNSOURCED_PLATFORM — the sentinel that
// selects filesystem-imported content (the null/"no platform" bucket).
const UNSOURCED = '__unsourced__'
const store = useGalleryStore()
const router = useRouter()
const platformOptions = computed(() =>
(store.facets?.platforms || []).map((p) => ({
key: p.value === null ? UNSOURCED : p.value,
label: p.value === null ? 'No platform' : p.value,
count: p.count,
}))
)
function facetCount(flag) {
const v = store.facets?.[flag]
return v == null ? '' : v
}
const dateMin = computed(() => (store.facets?.date_min || '').slice(0, 10) || undefined)
const dateMax = computed(() => (store.facets?.date_max || '').slice(0, 10) || undefined)
// Single write path, shared format with the bar: clone → patch → URL. The
// route watcher in GalleryView reloads the store (and our filter-watch below
// refetches the facet counts for the new scope).
function pushPatch(patch) {
const n = cloneFilter(store.filter)
Object.assign(n, patch)
router.push({ name: 'gallery', query: filterToQuery(n) })
}
function selectPlatform(key) {
// Single-select, click-active-to-clear (the platform param is one value).
pushPatch({ platform: store.filter.platform === key ? null : key })
}
function toggleFlag(name) {
pushPatch({ [name]: !store.filter[name] })
}
function setDate(field, val) {
pushPatch({ [field]: val || null })
}
// Fetch when the panel opens; refetch (debounced) whenever the active filter
// changes so the live counts track the current scope. Never fires on scroll —
// the panel is the only caller of loadFacets.
onMounted(() => store.loadFacets())
let debounce = null
watch(() => store.filter, () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => store.loadFacets(), 250)
})
onBeforeUnmount(() => { if (debounce) clearTimeout(debounce) })
</script>
<style scoped>
.fc-facets {
position: relative;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 4px 12px 12px;
}
.fc-facets__bar { position: absolute; top: 0; left: 0; right: 0; }
.fc-facets__group { display: flex; align-items: center; gap: 8px; }
.fc-facets__label {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-facets__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The count rides inside the chip as a dimmer trailing number. */
.fc-facets__count {
margin-left: 6px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
font-size: 0.78em;
}
.fc-facets__empty { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem; }
.fc-facets__date { max-width: 160px; }
.fc-facets__dash { color: rgb(var(--v-theme-on-surface-variant)); }
/* Phones: let each facet group wrap and the side-by-side date inputs grow to
full width so they don't overflow a ~360px viewport. */
@media (max-width: 480px) {
.fc-facets__group { flex-wrap: wrap; }
.fc-facets__date { max-width: none; flex: 1 1 9rem; }
}
</style>
@@ -1,5 +1,6 @@
<template> <template>
<div class="fc-filterbar"> <div class="fc-filterbar-wrap">
<div class="fc-filterbar">
<v-autocomplete <v-autocomplete
v-model="selected" v-model="selected"
:items="searchItems" :items="searchItems"
@@ -37,6 +38,12 @@
prepend-icon="mdi-account" prepend-icon="mdi-account"
@click:close="clearArtist" @click:close="clearArtist"
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip> >{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip>
<v-chip
v-if="store.filter.similar_to"
size="small" closable color="accent" variant="tonal"
prepend-icon="mdi-image-multiple"
@click:close="clearSimilar"
>Similar to #{{ store.filter.similar_to }}</v-chip>
</div> </div>
<v-spacer /> <v-spacer />
@@ -51,7 +58,9 @@
<v-btn value="video" size="small">Videos</v-btn> <v-btn value="video" size="small">Videos</v-btn>
</v-btn-toggle> </v-btn-toggle>
<!-- Sort is meaningless in similar-mode (results are distance-ranked). -->
<v-select <v-select
v-if="!store.filter.similar_to"
:model-value="store.filter.sort" :model-value="store.filter.sort"
:items="SORTS" :items="SORTS"
density="compact" hide-details variant="outlined" density="compact" hide-details variant="outlined"
@@ -59,19 +68,31 @@
@update:model-value="setSort" @update:model-value="setSort"
/> />
<v-btn
:color="refineOpen ? 'accent' : undefined"
:variant="refineOpen || hasRefineFilters ? 'tonal' : 'text'"
size="small"
:append-icon="refineOpen ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="toggleRefine"
>Refine{{ refineCount ? ` (${refineCount})` : '' }}</v-btn>
<v-btn <v-btn
v-if="hasActiveFilters" variant="text" size="small" v-if="hasActiveFilters" variant="text" size="small"
prepend-icon="mdi-close" @click="clearAll" prepend-icon="mdi-close" @click="clearAll"
>Clear</v-btn> >Clear</v-btn>
</div>
<GalleryFacetPanel v-if="refineOpen" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import { useGalleryStore } from '../../stores/gallery.js' import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
import { useTagStore } from '../../stores/tags.js' import { useTagStore } from '../../stores/tags.js'
import GalleryFacetPanel from './GalleryFacetPanel.vue'
const store = useGalleryStore() const store = useGalleryStore()
const tagStore = useTagStore() const tagStore = useTagStore()
@@ -88,13 +109,35 @@ const searchItems = ref([])
const searchLoading = ref(false) const searchLoading = ref(false)
let debounce = null let debounce = null
// The faceted-refine sub-filters (platform / curation flags / date range).
const refineCount = computed(() => {
const f = store.filter
return (f.platform ? 1 : 0) + (f.untagged ? 1 : 0) + (f.no_artist ? 1 : 0)
+ (f.date_from ? 1 : 0) + (f.date_to ? 1 : 0)
})
const hasRefineFilters = computed(() => refineCount.value > 0)
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
store.filter.tag_ids.length > 0 || store.filter.tag_ids.length > 0 ||
store.filter.artist_id != null || store.filter.artist_id != null ||
store.filter.media_type != null || store.filter.media_type != null ||
store.filter.sort !== 'newest' store.filter.sort !== 'newest' ||
store.filter.similar_to != null ||
hasRefineFilters.value
) )
// Auto-open the panel when refine filters are present in the URL (deep-link /
// back button). The parent applies the query in its onMounted — after this
// child has set up — so watch for the transition rather than reading the
// initial (still-default) filter state.
const refineOpen = ref(false)
watch(hasRefineFilters, (v) => { if (v) refineOpen.value = true })
function toggleRefine() {
// The panel fetches facets itself on mount (and refetches on filter change);
// opening it is enough.
refineOpen.value = !refineOpen.value
}
function iconFor(raw) { function iconFor(raw) {
if (raw.kind === 'artist') return 'mdi-account' if (raw.kind === 'artist') return 'mdi-account'
return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant', return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant',
@@ -156,47 +199,71 @@ function clearArtist() {
store.noteArtistLabel(null) store.noteArtistLabel(null)
pushFilter((n) => { n.artist_id = null }) pushFilter((n) => { n.artist_id = null })
} }
function clearSimilar() { pushFilter((n) => { n.similar_to = null }) }
function setMedia(m) { pushFilter((n) => { n.media_type = m }) } function setMedia(m) { pushFilter((n) => { n.media_type = m }) }
function setSort(s) { pushFilter((n) => { n.sort = s }) } function setSort(s) { pushFilter((n) => { n.sort = s }) }
function clearAll() { router.push({ name: 'gallery', query: {} }) } function clearAll() { router.push({ name: 'gallery', query: {} }) }
// Single write path: clone the current filter, mutate, serialize to the URL. // Single write path: clone the current filter, mutate, serialize to the URL.
// The route watcher in GalleryView applies it to the store and reloads. // The route watcher in GalleryView applies it to the store and reloads.
// cloneFilter/filterToQuery are shared with GalleryFacetPanel so the refine
// sub-filters survive a bar push and vice versa.
function pushFilter(mutate) { function pushFilter(mutate) {
const f = store.filter const n = cloneFilter(store.filter)
const n = {
tag_ids: [...f.tag_ids],
artist_id: f.artist_id,
media_type: f.media_type,
sort: f.sort,
}
mutate(n) mutate(n)
const q = {} router.push({ name: 'gallery', query: filterToQuery(n) })
if (n.tag_ids.length) q.tag_id = n.tag_ids.join(',')
if (n.artist_id) q.artist_id = String(n.artist_id)
if (n.media_type) q.media = n.media_type
if (n.sort && n.sort !== 'newest') q.sort = n.sort
router.push({ name: 'gallery', query: q })
} }
</script> </script>
<style scoped> <style scoped>
/* Pinned under the 64px TopNav, matching the app's sticky v-tabs chrome /* The whole chrome (bar row + expandable refine panel) is one sticky,
(SettingsView / ArtistHeader / SubscriptionsView). */ frosted block pinned directly under the 64px TopNav and continuous with it. */
.fc-filterbar { .fc-filterbar-wrap {
position: sticky; position: sticky;
top: 64px; top: 64px;
z-index: 4; z-index: 5;
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
so the bar sits flush at 64px even at scroll 0 — without this it detaches
and a gap shows through when scrolled to the top. */
margin-top: -8px;
margin-bottom: 12px;
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
the two read as one continuous piece of chrome — images scroll visibly
under both. The nav's gradient fades to transparent at ITS bottom; this
bar re-darkens at its top, so a faint seam (the page/image showing through
the nav's transparent edge) separates them when scrolled to the very top,
while under-scroll they frost as one. */
background: linear-gradient(
to bottom,
rgba(20, 23, 26, 0.92) 0%,
rgba(20, 23, 26, 0.65) 60%,
rgba(20, 23, 26, 0) 100%
);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.fc-filterbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 8px 4px; padding: 10px 12px;
margin-bottom: 12px; }
background: rgb(var(--v-theme-surface)); /* The inputs/toggles float on the haze: still translucent, but a touch more
border-bottom: 1px solid rgb(var(--v-theme-surface-light)); opaque than the bar/nav so the controls stay legible. */
.fc-filterbar-wrap :deep(.v-field),
.fc-filterbar-wrap :deep(.v-btn-group) {
background-color: rgba(20, 23, 26, 0.72);
} }
.fc-filterbar__search { max-width: 320px; min-width: 200px; } .fc-filterbar__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.fc-filterbar__sort { max-width: 150px; } .fc-filterbar__sort { max-width: 150px; }
/* Phones: the search's 200px min-width jams the wrapping bar. Give search its
own full-width row and let sort grow; everything else wraps under it. */
@media (max-width: 600px) {
.fc-filterbar { gap: 8px; }
.fc-filterbar__search { min-width: 100%; max-width: none; }
.fc-filterbar__sort { max-width: none; flex: 1 1 auto; }
}
</style> </style>
@@ -15,6 +15,19 @@
</div> </div>
</template> </template>
<!-- Similar-mode (and any non-date-grouped result set) returns no date
groups the results are ranked, not chronological. Render them as a
single flat list in their given order rather than nothing. -->
<div
v-if="!store.dateGroups.length && store.images.length"
class="fc-gallery-grid__items"
>
<GalleryItem
v-for="img in store.images"
:key="img.id" :image="img" @open="$emit('open', img.id)"
/>
</div>
<div v-if="store.loading" class="fc-gallery-grid__sentinel"> <div v-if="store.loading" class="fc-gallery-grid__sentinel">
<v-progress-circular indeterminate color="accent" size="28" /> <v-progress-circular indeterminate color="accent" size="28" />
</div> </div>
@@ -17,6 +17,10 @@
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<!-- Not every character belongs to a fandom (original characters,
unsorted, etc.). "No fandom" creates the character unassigned;
a fandom can still be set later from the chip's kebab menu. -->
<v-btn variant="text" @click="onNoFandom">No fandom</v-btn>
<v-spacer /> <v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn> <v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn> <v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn>
@@ -45,4 +49,9 @@ function onConfirm() {
const f = store.fandomCache.find(x => x.id === selectedId.value) const f = store.fandomCache.find(x => x.id === selectedId.value)
if (f) emit('confirm', f) if (f) emit('confirm', f)
} }
// Create the character with no fandom. Emits null so the caller knows this
// was a deliberate "unassigned", not a cancel.
function onNoFandom() {
emit('confirm', null)
}
</script> </script>
+21 -10
View File
@@ -51,6 +51,9 @@
<aside v-if="modal.current" class="fc-viewer__side"> <aside v-if="modal.current" class="fc-viewer__side">
<ProvenancePanel /> <ProvenancePanel />
<TagPanel /> <TagPanel />
<!-- Non-blocking: fetches its own similar set after the modal is up;
collapses silently if empty/slow/failed (see RelatedStrip). -->
<RelatedStrip />
</aside> </aside>
</div> </div>
</div> </div>
@@ -60,10 +63,12 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useModalStore } from '../../stores/modal.js' import { useModalStore } from '../../stores/modal.js'
import { arrowNavAllowed } from '../../utils/textEntry.js'
import ImageCanvas from './ImageCanvas.vue' import ImageCanvas from './ImageCanvas.vue'
import VideoCanvas from './VideoCanvas.vue' import VideoCanvas from './VideoCanvas.vue'
import TagPanel from './TagPanel.vue' import TagPanel from './TagPanel.vue'
import ProvenancePanel from './ProvenancePanel.vue' import ProvenancePanel from './ProvenancePanel.vue'
import RelatedStrip from './RelatedStrip.vue'
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
@@ -105,11 +110,13 @@ function onKeyDown(ev) {
ev.preventDefault() ev.preventDefault()
emit('close') emit('close')
} else if (ev.key === 'ArrowLeft') { } else if (ev.key === 'ArrowLeft') {
if (isTextEntry(ev.target)) return // Navigate unless the caret is in a non-empty text field (then let it move
// through the text). An empty tag-entry field still navigates.
if (!arrowNavAllowed(ev.target)) return
ev.preventDefault() ev.preventDefault()
modal.goPrev() modal.goPrev()
} else if (ev.key === 'ArrowRight') { } else if (ev.key === 'ArrowRight') {
if (isTextEntry(ev.target)) return if (!arrowNavAllowed(ev.target)) return
ev.preventDefault() ev.preventDefault()
modal.goNext() modal.goNext()
} }
@@ -135,17 +142,14 @@ watch(() => modal.currentImageId, async () => {
function nextFrame() { function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve)) return new Promise(resolve => requestAnimationFrame(resolve))
} }
function isTextEntry(el) {
if (!el) return false
const tag = el.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
}
</script> </script>
<style scoped> <style scoped>
.fc-viewer { .fc-viewer {
position: fixed; inset: 0; z-index: 2000; position: fixed; inset: 0; z-index: 2000;
/* Single source of truth for the metadata side-panel width — the next
arrow offsets off it so it never overlaps the panel. */
--fc-side-w: 320px;
/* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav, /* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
mid-opacity + blur so the page behind shows through faintly. */ mid-opacity + blur so the page behind shows through faintly. */
background: rgba(20, 23, 26, 0.65); background: rgba(20, 23, 26, 0.65);
@@ -174,7 +178,12 @@ function isTextEntry(el) {
position: absolute; top: 72px; right: 16px; z-index: 3; position: absolute; top: 72px; right: 16px; z-index: 3;
} }
.fc-viewer__nav--prev { left: 16px; transform: translateY(-50%); } .fc-viewer__nav--prev { left: 16px; transform: translateY(-50%); }
.fc-viewer__nav--next { right: 16px; transform: translateY(-50%); } /* Sit just inside the image area, clear of the metadata side panel —
not floating over it (operator-flagged 2026-06-04). */
.fc-viewer__nav--next {
right: calc(var(--fc-side-w) + 16px);
transform: translateY(-50%);
}
.fc-viewer__body { .fc-viewer__body {
flex: 1; display: flex; min-height: 0; flex: 1; display: flex; min-height: 0;
} }
@@ -187,7 +196,7 @@ function isTextEntry(el) {
min-width: 0; min-height: 0; min-width: 0; min-height: 0;
} }
.fc-viewer__side { .fc-viewer__side {
width: 320px; flex-shrink: 0; width: var(--fc-side-w); flex-shrink: 0;
background: rgb(var(--v-theme-surface)); background: rgb(var(--v-theme-surface));
border-left: 1px solid rgb(var(--v-theme-surface-light)); border-left: 1px solid rgb(var(--v-theme-surface-light));
overflow-y: auto; overflow-y: auto;
@@ -195,6 +204,8 @@ function isTextEntry(el) {
@media (max-width: 900px) { @media (max-width: 900px) {
.fc-viewer__body { flex-direction: column; } .fc-viewer__body { flex-direction: column; }
/* Side panel drops below the image — the next arrow uses the full width. */
.fc-viewer__nav--next { right: 16px; }
.fc-viewer__side { .fc-viewer__side {
width: 100%; width: 100%;
max-height: 40vh; max-height: 40vh;
@@ -0,0 +1,133 @@
<template>
<!-- Collapses entirely unless the source has an embedding AND we're loading
or have results — so a slow/empty/failed fetch leaves no trace and never
affects the modal. -->
<div v-if="show" class="fc-related">
<div class="fc-related__head">
<span class="fc-related__title">Related</span>
<v-btn
size="x-small" variant="text" color="accent"
:disabled="loading || !results.length"
@click="seeAll"
>See all similar</v-btn>
</div>
<div class="fc-related__row">
<template v-if="loading">
<div v-for="n in 6" :key="n" class="fc-related__skel" />
</template>
<button
v-for="img in results" :key="img.id"
class="fc-related__item" type="button"
@click="openImage(img.id)"
>
<img :src="img.thumbnail_url" :alt="`image ${img.id}`" loading="lazy" />
</button>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { useModalStore } from '../../stores/modal.js'
// Deferred so the modal's main image gets network/decode priority the strip
// is a nice-to-have and must never block or slow the modal load.
const DEFER_MS = 200
const STRIP_LIMIT = 12
const api = useApi()
const router = useRouter()
const modal = useModalStore()
const results = ref([])
const loading = ref(false)
const hasEmbedding = computed(() => modal.current?.has_embedding === true)
const show = computed(() => hasEmbedding.value && (loading.value || results.value.length > 0))
let seq = 0
let timer = null
async function fetchSimilar(id) {
const mine = ++seq
loading.value = true
results.value = []
try {
const body = await api.get('/api/gallery/similar', {
params: { similar_to: id, limit: STRIP_LIMIT },
})
if (mine !== seq) return
results.value = body.images || []
} catch {
if (mine === seq) results.value = [] // quietly collapse on error
} finally {
if (mine === seq) loading.value = false
}
}
// Re-fetch whenever the modal lands on a new embedded image. modal.current is
// null while the next image loads, so this also clears the strip during
// prev/next nav and repopulates once the new payload arrives.
watch(
() => (hasEmbedding.value ? modal.current?.id : null),
(id) => {
if (timer) { clearTimeout(timer); timer = null }
seq++ // cancel any in-flight fetch
results.value = []
loading.value = false
if (!id) return
timer = setTimeout(() => fetchSimilar(id), DEFER_MS)
},
{ immediate: true },
)
onBeforeUnmount(() => { if (timer) clearTimeout(timer) })
function openImage(id) {
modal.open(id)
}
function seeAll() {
const id = modal.current?.id
if (!id) return
modal.close()
router.push({ name: 'gallery', query: { similar_to: String(id) } })
}
</script>
<style scoped>
.fc-related {
padding: 12px;
border-top: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-related__head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.fc-related__title {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-related__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.fc-related__item {
display: block; padding: 0; border: 0; background: none;
cursor: pointer; border-radius: 4px; overflow: hidden;
aspect-ratio: 1; width: 100%;
}
.fc-related__item img {
width: 100%; height: 100%; object-fit: cover; display: block;
background: rgb(var(--v-theme-surface-light));
transition: transform 0.2s ease, filter 0.2s ease;
}
.fc-related__item:hover img { transform: scale(1.05); filter: brightness(1.1); }
.fc-related__skel {
aspect-ratio: 1; border-radius: 4px;
background: rgb(var(--v-theme-surface-light));
opacity: 0.5;
}
</style>
@@ -19,24 +19,25 @@
> >
Accept Accept
</v-btn> </v-btn>
<!-- Operator-flagged 2026-06-02: the kebab menu wasn't opening. <!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
Wrapping in a <span @click.stop> matches the TagPanel chip prior `#activator` + `v-bind="props"` path never toggled the menu
fix — even though there's no parent click capture here today, inside this teleported modal, while v-model-driven overlays (the
the wrap is harmless and keeps both kebabs on the same dialogs in this modal) work fine. So drive the menu explicitly:
pattern. Click bubbles from the v-btn opens menu via the button toggles `menuOpen` with @click.stop (also shields any
activator props bubble continues to span stopPropagation parent), and `activator="parent"` anchors the menu for positioning
halts it. --> only — `:open-on-click="false"` keeps Vuetify's activator-click out
<span class="fc-suggestion__menu-wrap" @click.stop> of it, so there's a single, reliable opener. -->
<v-menu> <span class="fc-suggestion__menu-wrap">
<template #activator="{ props }"> <v-btn
<v-btn class="fc-suggestion__menu"
class="fc-suggestion__menu" icon="mdi-dots-vertical" size="small"
icon="mdi-dots-vertical" size="small" variant="outlined" density="compact"
variant="outlined" density="compact" :aria-label="`More actions for ${suggestion.display_name}`"
:aria-label="`More actions for ${suggestion.display_name}`" @click.stop="menuOpen = !menuOpen"
v-bind="props" />
/> <v-menu
</template> v-model="menuOpen" activator="parent" :open-on-click="false"
>
<v-list density="compact"> <v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)"> <v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for…</v-list-item-title> <v-list-item-title>Treat as alias for…</v-list-item-title>
@@ -51,11 +52,12 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
const props = defineProps({ suggestion: { type: Object, required: true } }) const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'dismiss']) defineEmits(['accept', 'alias', 'dismiss'])
const menuOpen = ref(false)
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
</script> </script>
@@ -147,10 +147,14 @@ function onCreate () {
reset() reset()
} }
// fandom is null when the user picked "No fandom" — characters don't all
// belong to a fandom. The backend already accepts fandom_id: null for the
// character kind (tag.kind check + nullable fandom_id), and a fandom can be
// assigned later from the chip kebab's "Set fandom…".
function onFandomChosen (fandom) { function onFandomChosen (fandom) {
fandomDialog.value = false fandomDialog.value = false
emit('pick-new', { emit('pick-new', {
name: pendingNewName, kind: 'character', fandom_id: fandom.id, name: pendingNewName, kind: 'character', fandom_id: fandom ? fandom.id : null,
}) })
pendingNewName = null pendingNewName = null
reset() reset()
+22 -14
View File
@@ -10,20 +10,24 @@
> >
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon> <v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span> {{ tag.name }}<span v-if="tag.fandom_id"></span>
<!-- Operator-flagged 2026-06-02: the previous activator had <!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
`@click.stop` directly on the v-icon, which silently never opened inside this teleported modal. Drive it explicitly
overrode Vuetify's onClick from `v-bind="mp"` — the menu instead (same mechanism as the dialogs below, which work): the
never opened. Now the v-icon receives the activator icon toggles `openTagId` with @click.stop (shielding the chip's
onClick cleanly, and the wrapping span absorbs the close button), and `activator="parent"` + `:open-on-click=false`
bubbled click so the chip's close button isn't tripped. --> anchors the menu for positioning only. One tag's menu open at a
<span class="kebab-wrap" @click.stop> time, so a single id is enough. -->
<v-menu> <span class="kebab-wrap">
<template #activator="{ props: mp }"> <v-icon
<v-icon size="x-small" class="ml-1 kebab-icon"
v-bind="mp" size="x-small" class="ml-1" icon="mdi-dots-vertical"
icon="mdi-dots-vertical" @click.stop="openTagId = openTagId === tag.id ? null : tag.id"
/> />
</template> <v-menu
:model-value="openTagId === tag.id"
activator="parent" :open-on-click="false"
@update:model-value="v => { if (!v) openTagId = null }"
>
<v-list density="compact"> <v-list density="compact">
<v-list-item @click="openRename(tag)"> <v-list-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title> <v-list-item-title>Rename…</v-list-item-title>
@@ -84,6 +88,9 @@ import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore() const modal = useModalStore()
const store = useTagStore() const store = useTagStore()
const errorMsg = ref(null) const errorMsg = ref(null)
// Which tag chip's kebab menu is open (only one at a time). Drives each
// chip menu's v-model so opening never depends on Vuetify's activator click.
const openTagId = ref(null)
const KIND_ICONS = { const KIND_ICONS = {
general: 'mdi-tag', character: 'mdi-account-circle', general: 'mdi-tag', character: 'mdi-account-circle',
@@ -147,4 +154,5 @@ async function onFandomUpdated() {
} }
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; } .fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.kebab-wrap { display: inline-flex; align-items: center; } .kebab-wrap { display: inline-flex; align-items: center; }
.kebab-icon { cursor: pointer; }
</style> </style>
+156 -202
View File
@@ -1,29 +1,18 @@
<template> <template>
<v-card <v-card class="fc-post-card" variant="outlined">
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
variant="outlined"
:tabindex="expanded ? -1 : 0"
@click="onCardClick"
@keydown.enter="onCardClick"
>
<div class="fc-post-card__head"> <div class="fc-post-card__head">
<!-- Posts with no live subscription have source=null (alembic <!-- Posts with no live subscription have source=null (alembic 0030);
0030); show a "filesystem import" affordance instead of a show a "filesystem import" affordance instead of a platform chip. -->
platform chip. -->
<v-chip size="x-small" variant="tonal"> <v-chip size="x-small" variant="tonal">
{{ post.source?.platform ?? 'filesystem import' }} {{ post.source?.platform ?? 'filesystem import' }}
</v-chip> </v-chip>
<RouterLink <RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }" :to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-card__artist" class="fc-post-card__artist"
@click.stop
>{{ post.artist.name }}</RouterLink> >{{ post.artist.name }}</RouterLink>
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span> <span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
<span v-if="expanded && images.length" class="fc-post-card__meta"> <span v-if="totalImages" class="fc-post-card__meta">
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }} · {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
</span>
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
</span> </span>
<v-spacer /> <v-spacer />
<v-btn <v-btn
@@ -31,140 +20,108 @@
:href="post.post_url" target="_blank" rel="noopener" :href="post.post_url" target="_blank" rel="noopener"
icon="mdi-open-in-new" size="x-small" variant="text" icon="mdi-open-in-new" size="x-small" variant="text"
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`" :aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
@click.stop
/>
<v-btn
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
size="x-small" variant="text"
:aria-label="expanded ? 'Collapse post' : 'Expand post'"
@click.stop="toggleExpanded"
/> />
</div> </div>
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. --> <div class="fc-post-card__body">
<div v-if="!expanded" class="fc-post-card__body">
<div class="fc-post-card__media"> <div class="fc-post-card__media">
<template v-if="images.length"> <template v-if="images.length">
<div class="fc-post-card__hero"> <!-- Images open the post-scoped image modal (look bigger + arrow
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" /> through ALL the post's images) the card never expands. -->
</div> <button
<div v-if="rail.length" class="fc-post-card__rail"> type="button" class="fc-post-card__hero"
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell"> aria-label="Open images" @click="openModal(hero.image_id)"
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" /> >
</div> <img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
<div v-if="moreCount > 0" class="fc-post-card__rail-more"> </button>
+{{ moreCount }} <div v-if="rail.length || moreCount" class="fc-post-card__rail">
</div> <button
v-for="t in rail" :key="t.image_id" type="button"
class="fc-post-card__rail-cell"
aria-label="Open image" @click="openModal(t.image_id)"
>
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
</button>
<button
v-if="moreCount > 0" type="button"
class="fc-post-card__rail-more"
:aria-label="`Open ${moreCount} more images`"
@click="openModalAtMore"
>+{{ moreCount }}</button>
</div> </div>
</template> </template>
<PostEmptyThumbs v-else /> <PostEmptyThumbs v-else />
</div> </div>
<div class="fc-post-card__text"> <div class="fc-post-card__text">
<h3 v-if="plainTitle" class="fc-post-card__title"> <h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
{{ plainTitle }}
</h3>
<h3 v-else class="fc-post-card__title fc-post-card__title--missing"> <h3 v-else class="fc-post-card__title fc-post-card__title--missing">
Post {{ post.external_post_id }} Post {{ post.external_post_id }}
</h3> </h3>
<p v-if="post.description_plain" class="fc-post-card__desc"> <p
{{ post.description_plain }} v-if="hasDescription" ref="descEl"
</p> class="fc-post-card__desc"
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
>{{ descText }}</p>
<p v-else class="fc-post-card__desc fc-post-card__desc--missing"> <p v-else class="fc-post-card__desc fc-post-card__desc--missing">
(no description) (no description)
</p> </p>
<div v-if="post.attachments?.length" class="fc-post-card__atts"> <!-- The ONLY in-place expansion: the post text, and only when it's
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon> actually truncated (server flag or a CSS-clamp overflow). -->
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }} <button
</div> v-if="canExpand" type="button" class="fc-post-card__more"
</div> @click="toggleDesc"
</div> >{{ descExpanded ? 'Show less' : 'Show more' }}</button>
<!-- Expanded body: title, full mosaic, full sanitized HTML description, <div v-if="attachments.length" class="fc-post-card__atts">
attachments. Lazy-loaded detail via getPostFull. -->
<div v-else class="fc-post-card__expanded">
<h2 v-if="plainTitle" class="fc-post-card__title-full">
{{ plainTitle }}
</h2>
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h2>
<section v-if="images.length" class="fc-post-card__sec">
<PostImageGrid :thumbnails="images" />
<div v-if="!detailLoaded" class="fc-post-card__loading-hint">
Loading full image list
</div>
</section>
<section v-if="descriptionHtml" class="fc-post-card__sec">
<div class="fc-post-card__desc-full" v-html="descriptionHtml" />
</section>
<section v-else-if="detailLoaded" class="fc-post-card__sec">
<p class="fc-post-card__desc fc-post-card__desc--missing">(no description)</p>
</section>
<section v-if="attachments.length" class="fc-post-card__sec">
<h3 class="fc-post-card__h3">Attachments</h3>
<div class="fc-post-card__atts-full">
<a <a
v-for="att in attachments" :key="att.id" v-for="att in attachments" :key="att.id"
:href="att.download_url" download :href="att.download_url" download class="fc-post-card__att"
class="fc-post-card__att"
@click.stop
> >
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon> <v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
<span>{{ att.original_filename }}</span> <span>{{ att.original_filename }}</span>
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span> <span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a> </a>
</div> </div>
</section> </div>
</div> </div>
</v-card> </v-card>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { usePostsStore } from '../../stores/posts.js' import { usePostsStore } from '../../stores/posts.js'
import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js' import { toPlainText } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue' import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostImageGrid from './PostImageGrid.vue'
const props = defineProps({ const props = defineProps({
post: { type: Object, required: true }, post: { type: Object, required: true },
}) })
const postsStore = usePostsStore() const postsStore = usePostsStore()
const modal = useModalStore()
// Per-card expand state. No global modal — each PostCard owns its own // Full detail (uncapped thumbnails + full description), fetched lazily — only
// view-mode and lazy-loaded detail. // when opening the modal for a post with >6 images, or expanding a
const expanded = ref(false) // server-truncated description.
const detail = ref(null) const detail = ref(null)
const detailLoaded = ref(false)
const detailError = ref(null)
// When expanded + detail loaded, prefer the uncapped detail thumbnails + const attachments = computed(() => props.post.attachments || [])
// full description. Falls back to feed shape if detail fetch is in flight const images = computed(() => props.post.thumbnails || [])
// or failed. const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
const merged = computed(() => detail.value || props.post)
const images = computed(() => merged.value.thumbnails || [])
const attachments = computed(() => merged.value.attachments || [])
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render as
// plain text — the CSS makes the title bold.
const plainTitle = computed(() => toPlainText(props.post.post_title)) const plainTitle = computed(() => toPlainText(props.post.post_title))
// Compact-view hero+rail derived from the feed-shape (capped 6). const hero = computed(() => images.value[0])
const hero = computed(() => props.post.thumbnails?.[0]) const rail = computed(() => images.value.slice(1, 4))
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4)) const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
const moreCount = computed(() => { const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0 const more = props.post.thumbnails_more || 0
const railLen = rail.value.length const extraShown = Math.max(0, images.value.length - visibleCount.value)
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
return more + extraShown return more + extraShown
}) })
@@ -180,49 +137,77 @@ const relativeDate = computed(() => {
return new Date(sortDateIso.value).toLocaleDateString() return new Date(sortDateIso.value).toLocaleDateString()
}) })
const descriptionHtml = computed(() => { // --- images → post-scoped modal ---------------------------------------
// Detail endpoint returns description_full as plain text (the service async function fullImageIds () {
// uses html_to_plain on the stored description). Render plain text in // Feed caps thumbnails at 6; load detail for the complete id list only when
// <p> wrappers; sanitize defensively in case the backend ever returns // there are more, so the modal can arrow through ALL of the post's images.
// raw HTML. if ((props.post.thumbnails_more || 0) === 0) {
const raw = merged.value.description_full || merged.value.description_plain return images.value.map((t) => t.image_id)
if (!raw) return ''
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
const esc = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return esc
.split(/\n\s*\n/)
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
})
async function loadDetailIfNeeded () {
if (detailLoaded.value || detail.value) return
try {
detail.value = await postsStore.getPostFull(props.post.id)
detailLoaded.value = true
} catch (e) {
detailError.value = e.message
// Leave merged on feed-shape; the card still renders the truncated
// body so the operator isn't staring at a blank panel.
} }
if (!detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* fall back to the capped feed list */ }
}
return (detail.value?.thumbnails || images.value).map((t) => t.image_id)
} }
function toggleExpanded () { async function openModal (imageId) {
expanded.value = !expanded.value modal.open(imageId, { postImageIds: await fullImageIds() })
if (expanded.value) loadDetailIfNeeded()
} }
function onCardClick (e) { async function openModalAtMore () {
// Inner interactive elements use @click.stop so they never reach here. const ids = await fullImageIds()
// Whole-card click expands a collapsed card; collapsing is chevron-only const first = ids[visibleCount.value] ?? ids[0]
// so a mosaic-image click on an expanded card can never accidentally if (first != null) modal.open(first, { postImageIds: ids })
// collapse the surrounding card. }
if (expanded.value) return
expanded.value = true // --- description "Show more" (text-only, in place, only when truncated) ----
loadDetailIfNeeded() const descExpanded = ref(false)
const cssOverflow = ref(false)
const descEl = ref(null)
const hasDescription = computed(() => !!props.post.description_plain)
const fullDescription = computed(() => detail.value?.description_full || null)
const descText = computed(() =>
descExpanded.value
? (fullDescription.value || props.post.description_plain)
: props.post.description_plain,
)
// Show the toggle iff the server truncated the text OR the clamp is cutting it.
const canExpand = computed(
() => props.post.description_truncated === true || cssOverflow.value,
)
function measureOverflow () {
const el = descEl.value
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
}
let ro = null
onMounted(() => {
nextTick(measureOverflow)
// Re-measure when the card resizes (the container-query clamp differs by
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
ro = new ResizeObserver(() => { if (!descExpanded.value) measureOverflow() })
ro.observe(descEl.value)
}
})
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
async function toggleDesc () {
if (!descExpanded.value) {
if (props.post.description_truncated && !fullDescription.value && !detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* render the truncated text rather than nothing */ }
}
descExpanded.value = true
} else {
descExpanded.value = false
nextTick(measureOverflow)
}
} }
function formatBytes (n) { function formatBytes (n) {
@@ -239,20 +224,6 @@ function formatBytes (n) {
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
container-type: inline-size; container-type: inline-size;
transition: border-color 0.15s ease;
}
.fc-post-card:not(.fc-post-card--expanded) {
cursor: pointer;
}
.fc-post-card:not(.fc-post-card--expanded):hover {
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 2px;
}
.fc-post-card--expanded {
border-color: rgb(var(--v-theme-accent) / 0.6);
} }
.fc-post-card__head { .fc-post-card__head {
@@ -273,7 +244,6 @@ function formatBytes (n) {
.fc-post-card__date, .fc-post-card__date,
.fc-post-card__meta { white-space: nowrap; } .fc-post-card__meta { white-space: nowrap; }
/* ---- COMPACT BODY ---- */
.fc-post-card__body { .fc-post-card__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -288,6 +258,13 @@ function formatBytes (n) {
.fc-post-card__text { flex: 1 1 0; min-width: 0; } .fc-post-card__text { flex: 1 1 0; min-width: 0; }
} }
/* Image tiles are buttons (open the post-scoped modal) — reset button chrome. */
.fc-post-card__hero,
.fc-post-card__rail-cell,
.fc-post-card__rail-more {
display: block; padding: 0; border: 0; background: none;
cursor: pointer;
}
.fc-post-card__hero { .fc-post-card__hero {
width: 100%; width: 100%;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
@@ -297,10 +274,13 @@ function formatBytes (n) {
.fc-post-card__hero img { .fc-post-card__hero img {
width: 100%; height: 100%; width: 100%; height: 100%;
object-fit: cover; display: block; object-fit: cover; display: block;
transition: transform 0.2s ease, filter 0.2s ease;
} }
.fc-post-card__hero:hover img,
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
.fc-post-card__rail { .fc-post-card__rail {
display: flex; gap: 6px; margin-top: 6px; display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
} }
.fc-post-card__rail-cell { .fc-post-card__rail-cell {
width: 80px; height: 80px; width: 80px; height: 80px;
@@ -318,6 +298,10 @@ function formatBytes (n) {
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem; font-size: 0.85rem;
} }
.fc-post-card__rail-more:hover {
border-color: rgb(var(--v-theme-accent));
color: rgb(var(--v-theme-accent));
}
.fc-post-card__title { .fc-post-card__title {
font-family: 'Fraunces', Georgia, serif; font-family: 'Fraunces', Georgia, serif;
@@ -346,67 +330,36 @@ function formatBytes (n) {
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.5;
color: rgb(var(--v-theme-on-surface)); color: rgb(var(--v-theme-on-surface));
margin: 0 0 12px 0; margin: 0;
white-space: pre-wrap;
}
/* Clamp ONLY while collapsed; expanding drops the clamp to show it all. */
.fc-post-card__desc--clamped {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@container (min-width: 800px) {
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
}
.fc-post-card__desc--missing { .fc-post-card__desc--missing {
font-style: italic; font-style: italic;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
} }
@container (min-width: 800px) {
.fc-post-card__desc { -webkit-line-clamp: 5; } .fc-post-card__more {
margin-top: 6px;
padding: 0;
background: none; border: 0; cursor: pointer;
color: rgb(var(--v-theme-accent));
font-size: 0.85rem; font-weight: 600;
} }
.fc-post-card__more:hover { text-decoration: underline; }
.fc-post-card__atts { .fc-post-card__atts {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
/* ---- EXPANDED BODY ---- */
.fc-post-card__expanded {
display: flex;
flex-direction: column;
gap: 20px;
}
.fc-post-card__title-full {
font-family: 'Fraunces', Georgia, serif;
font-size: 22px;
font-weight: 700;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
@container (min-width: 800px) {
.fc-post-card__title-full { font-size: 26px; }
}
.fc-post-card__sec { margin: 0; }
.fc-post-card__h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__desc-full {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__desc-full :deep(p) { margin: 0 0 12px 0; }
.fc-post-card__desc-full :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-card__atts-full {
display: flex; flex-wrap: wrap; gap: 8px; display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 12px;
} }
.fc-post-card__att { .fc-post-card__att {
display: inline-flex; display: inline-flex;
@@ -423,5 +376,6 @@ function formatBytes (n) {
color: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent)); border-color: rgb(var(--v-theme-accent));
} }
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); } .fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -1,63 +0,0 @@
<template>
<div class="fc-post-grid">
<button
v-for="(t, idx) in thumbnails"
:key="t.image_id"
type="button"
class="fc-post-grid__cell"
:aria-label="`Open image ${idx + 1} of ${thumbnails.length}`"
@click="openImage(t.image_id, idx)"
>
<img
:src="t.thumbnail_url"
:alt="`thumbnail ${idx + 1}`"
loading="lazy"
/>
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useModalStore } from '../../stores/modal.js'
const props = defineProps({
thumbnails: { type: Array, required: true }, // [{ image_id, thumbnail_url, ... }]
})
const modal = useModalStore()
const imageIds = computed(() => props.thumbnails.map(t => t.image_id))
function openImage (id, idx) {
modal.open(id, { postImageIds: imageIds.value, initialIndex: idx })
}
</script>
<style scoped>
.fc-post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
}
.fc-post-grid__cell {
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
border: 0;
padding: 0;
background: rgb(var(--v-theme-background));
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.fc-post-grid__cell:hover {
transform: scale(1.02);
box-shadow: 0 0 0 2px rgb(var(--v-theme-accent));
}
.fc-post-grid__cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
@@ -13,7 +13,7 @@
clearable clearable
no-filter no-filter
return-object return-object
style="min-width: 240px" class="fc-posts-filters__artist"
@update:search="onArtistSearch" @update:search="onArtistSearch"
@update:model-value="onArtistPicked" @update:model-value="onArtistPicked"
/> />
@@ -25,7 +25,7 @@
density="compact" density="compact"
hide-details hide-details
clearable clearable
style="min-width: 180px" class="fc-posts-filters__platform"
@update:model-value="emitFilters" @update:model-value="emitFilters"
/> />
@@ -135,6 +135,14 @@ watch(() => props.platform, (val) => {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
flex-wrap: wrap;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.fc-posts-filters__artist { min-width: 240px; flex: 1 1 240px; max-width: 360px; }
.fc-posts-filters__platform { min-width: 180px; flex: 1 1 180px; max-width: 240px; }
/* Phones: each field takes a full-width row instead of overflowing. */
@media (max-width: 600px) {
.fc-posts-filters__artist,
.fc-posts-filters__platform { min-width: 100%; max-width: none; flex-basis: 100%; }
}
</style> </style>
@@ -0,0 +1,84 @@
<template>
<v-card>
<v-card-title>Database maintenance</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">
VACUUM (ANALYZE) reclaims dead-tuple bloat which slows the random
showcase, since it samples physical blocks and refreshes the query
planner's statistics. Runs automatically each week; trigger a pass
here after a large import or cleanup.
</p>
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
<v-icon start>mdi-database-cog</v-icon> Run VACUUM ANALYZE now
</v-btn>
<span v-if="queued" class="ml-3 text-caption text-success">Queued ✓</span>
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
<v-table
v-if="store.tables.length" density="compact" class="mt-4 fc-dbm__table"
>
<thead>
<tr>
<th>Table</th>
<th class="text-right">Live rows</th>
<th class="text-right">Dead</th>
<th class="text-right">Dead %</th>
<th>Last vacuum</th>
</tr>
</thead>
<tbody>
<tr v-for="t in store.tables" :key="t.table">
<td>{{ t.table }}</td>
<td class="text-right">{{ t.live.toLocaleString() }}</td>
<td class="text-right">{{ t.dead.toLocaleString() }}</td>
<td
class="text-right"
:class="{ 'text-warning': t.dead_pct >= 20 }"
>{{ t.dead_pct }}%</td>
<td class="text-caption">{{ fmt(t.last_vacuum || t.last_autovacuum) }}</td>
</tr>
</tbody>
</v-table>
<p v-else class="text-caption mt-3" style="opacity: 0.6;">
No table statistics yet.
</p>
</v-card-text>
</v-card>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { toast } from '../../utils/toast.js'
import { useDbMaintenanceStore } from '../../stores/dbMaintenance.js'
import QueueStatusBar from './QueueStatusBar.vue'
const store = useDbMaintenanceStore()
const busy = ref(false)
const queued = ref(false)
onMounted(() => store.loadStats())
async function run () {
busy.value = true
queued.value = false
try {
await store.runVacuum()
queued.value = true
// pg_stat updates after the vacuum lands — refresh shortly after.
setTimeout(() => store.loadStats(), 5000)
} catch (e) {
toast({ text: e.message, type: 'error' })
} finally {
busy.value = false
}
}
function fmt (iso) {
return iso ? new Date(iso).toLocaleString() : ''
}
</script>
<style scoped>
.fc-dbm__table { background: transparent; }
</style>
@@ -2,6 +2,42 @@
<v-card> <v-card>
<v-card-title>Import filters</v-card-title> <v-card-title>Import filters</v-card-title>
<v-card-text v-if="store.settings"> <v-card-text v-if="store.settings">
<!-- Near-duplicate dedup sensitivity, hoisted to the top: it's the
most-asked knob too loose and edits/variants of the same image
get dropped as duplicates on import. Slider with sane labelled
stops for the gist + a number field for precision; both bind the
same phash_threshold. -->
<div class="fc-phash">
<div class="fc-phash__title">Near-duplicate sensitivity</div>
<div class="fc-help mb-1">
How aggressively imports merge look-alike images (perceptual-hash
distance). <strong>Lower it if edits/variants of the same image are
being dropped as duplicates;</strong> raise it to collapse more
look-alikes. Applies to new imports.
</div>
<v-row align="center" no-gutters>
<v-col cols="12" sm="9">
<v-slider
v-model="local.phash_threshold"
:min="0" :max="16" :step="1"
:ticks="PHASH_TICKS" show-ticks="always" tick-size="4"
thumb-label color="accent" hide-details
class="fc-phash__slider"
@end="save"
/>
</v-col>
<v-col cols="8" sm="3" class="ps-sm-4 mt-2 mt-sm-0">
<v-text-field
v-model.number="local.phash_threshold"
label="Distance" type="number" min="0"
density="compact" hide-details @blur="save"
/>
</v-col>
</v-row>
</div>
<v-divider class="my-5" />
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-text-field <v-text-field
@@ -41,14 +77,6 @@
:disabled="!local.skip_single_color" @end="save" :disabled="!local.skip_single_color" @end="save"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.phash_threshold"
label="Perceptual-hash threshold" type="number" min="0"
density="compact" hide-details @blur="save"
/>
<div class="fc-help">Higher = looser near-duplicate matching.</div>
</v-col>
</v-row> </v-row>
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable> <v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
@@ -66,6 +94,9 @@ import { reactive, watch } from 'vue'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
const store = useImportStore() const store = useImportStore()
// Labelled stops so the less-initiated get the gist without knowing what a
// Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default.
const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' }
// Downloader + schedule-defaults fields moved to // Downloader + schedule-defaults fields moved to
// /subscriptions?tab=settings (operator decision 2026-05-27). This form // /subscriptions?tab=settings (operator decision 2026-05-27). This form
// now only owns image-import filters. // now only owns image-import filters.
@@ -89,4 +120,11 @@ async function save() {
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
margin-top: 2px; margin-top: 2px;
} }
.fc-phash__title {
font-size: 0.95rem;
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
}
/* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */
.fc-phash__slider { margin-bottom: 18px; }
</style> </style>
@@ -14,6 +14,7 @@
<MLThresholdSliders class="mt-4" /> <MLThresholdSliders class="mt-4" />
<AllowlistTable class="mt-4" /> <AllowlistTable class="mt-4" />
<AliasTable class="mt-4" /> <AliasTable class="mt-4" />
<DbMaintenanceCard class="mt-6" />
<BackupCard class="mt-6" /> <BackupCard class="mt-6" />
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) it <!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) it
operates on the existing library which fits the Cleanup-tab operates on the existing library which fits the Cleanup-tab
@@ -31,6 +32,7 @@ import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue' import MLThresholdSliders from './MLThresholdSliders.vue'
import AllowlistTable from './AllowlistTable.vue' import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue' import AliasTable from './AliasTable.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import BackupCard from './BackupCard.vue' import BackupCard from './BackupCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js' import { useSystemActivityStore } from '../../stores/systemActivity.js'
@@ -89,6 +89,53 @@
@click="onKindCommit" @click="onKindCommit"
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn> >Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
</div> </div>
<v-divider class="my-5" />
<p class="text-body-2 mb-2">
<strong class="text-error">Reset content tagging.</strong>
Deletes every <code>general</code> and <code>character</code> tag and
removes them from every image, so you can re-tag from scratch with the
auto-suggest. <strong>Fandoms and series (with their page order) are
kept</strong>, and each image's saved predictions are untouched open
an image and its suggestions reappear.
</p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Irreversible there's no undo except restoring a DB backup.
Back one up first (Settings → Maintenance → Backup).
</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingResetPreview"
class="mb-3"
@click="onResetPreview"
>Preview content-tag reset</v-btn>
<div v-if="resetPreview">
<p class="text-body-2 mb-2">
<strong>{{ resetPreview.count }}</strong> content tag(s)
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
({{ k }}: {{ n }})&nbsp;
</span>
across <strong>{{ resetPreview.applications }}</strong> image
application(s).
</p>
<div v-if="resetPreview.sample_names?.length" class="fc-name-grid mb-3">
<span v-for="n in resetPreview.sample_names" :key="n" class="fc-name">
{{ n }}
</span>
</div>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-alert"
:disabled="!resetPreview.count"
:loading="resetCommitting"
@click="onResetCommit"
>Delete {{ resetPreview.count }} content tag(s) +
{{ resetPreview.applications }} application(s)</v-btn>
</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</template> </template>
@@ -105,6 +152,9 @@ const committing = ref(false)
const kindPreview = ref(null) const kindPreview = ref(null)
const loadingKindPreview = ref(false) const loadingKindPreview = ref(false)
const kindCommitting = ref(false) const kindCommitting = ref(false)
const resetPreview = ref(null)
const loadingResetPreview = ref(false)
const resetCommitting = ref(false)
async function onPreview() { async function onPreview() {
loadingPreview.value = true loadingPreview.value = true
@@ -143,6 +193,25 @@ async function onKindCommit() {
kindCommitting.value = false kindCommitting.value = false
} }
} }
async function onResetPreview() {
loadingResetPreview.value = true
try {
resetPreview.value = await store.resetContentTagging({ dryRun: true })
} finally {
loadingResetPreview.value = false
}
}
async function onResetCommit() {
resetCommitting.value = true
try {
await store.resetContentTagging({ dryRun: false })
resetPreview.value = { count: 0, by_kind: {}, applications: 0, sample_names: [] }
} finally {
resetCommitting.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@@ -0,0 +1,107 @@
<template>
<div class="fc-source-card">
<div class="fc-source-card__top">
<SourceHealthDot :source="source" :warning-threshold="warningThreshold" />
<v-chip size="x-small" variant="tonal" label>{{ source.platform }}</v-chip>
<v-spacer />
<v-switch
:model-value="source.enabled"
density="compact" hide-details color="accent"
@click.stop
@update:model-value="onToggleEnabled"
/>
</div>
<a
:href="source.url" target="_blank" rel="noopener"
class="fc-source-card__url" @click.stop
>{{ source.url }}</a>
<div class="fc-source-card__meta">
<span>Last {{ formatRelative(source.last_checked_at) }}</span>
<span>Next {{ formatRelative(source.next_check_at, { future: true }) }}</span>
<v-chip
v-if="(source.consecutive_failures || 0) > 0"
size="x-small" color="error" variant="tonal" label
>{{ source.consecutive_failures }} err</v-chip>
<v-chip
v-else-if="(source.backfill_runs_remaining || 0) > 0"
size="x-small" color="info" variant="tonal" label
>backfill ({{ source.backfill_runs_remaining }}×)</v-chip>
</div>
<div class="fc-source-card__actions">
<v-btn
size="x-small" variant="text" :loading="checking"
@click.stop="$emit('check', source)"
>
<v-icon>mdi-play</v-icon>
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
</v-btn>
<v-btn
size="x-small" variant="text"
:disabled="(source.backfill_runs_remaining || 0) > 0"
@click.stop="$emit('backfill', source)"
>
<v-icon>mdi-magnify-scan</v-icon>
<v-tooltip activator="parent" location="top">Deep scan</v-tooltip>
</v-btn>
<v-btn size="x-small" variant="text" @click.stop="$emit('edit', source)">
<v-icon>mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
</v-btn>
<v-btn
size="x-small" variant="text" color="error"
@click.stop="$emit('remove', source)"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="top">Remove</v-tooltip>
</v-btn>
</div>
</div>
</template>
<script setup>
import SourceHealthDot from './SourceHealthDot.vue'
import { formatRelative } from '../../utils/date.js'
// Mobile-stacked equivalent of SourceRow (the desktop <tr>) — same data and
// emits, but laid out vertically so the wide source columns never force the
// lateral scroll the operator flagged on phones.
const props = defineProps({
source: { type: Object, required: true },
checking: { type: Boolean, default: false },
warningThreshold: { type: Number, default: 5 },
})
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill'])
function onToggleEnabled(value) {
emit('toggle', { source: props.source, enabled: value })
}
</script>
<style scoped>
.fc-source-card {
padding: 8px 10px;
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px;
background: rgb(var(--v-theme-surface));
display: flex; flex-direction: column; gap: 6px;
}
.fc-source-card__top { display: flex; align-items: center; gap: 8px; }
.fc-source-card__url {
color: rgb(var(--v-theme-on-surface-variant));
text-decoration: none;
font-size: 0.8rem;
word-break: break-all;
}
.fc-source-card__url:hover { color: rgb(var(--v-theme-accent)); }
.fc-source-card__meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px;
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-source-card__actions {
display: flex; gap: 2px; justify-content: flex-end;
}
</style>
@@ -26,14 +26,14 @@
:items="STATUS_OPTIONS" :items="STATUS_OPTIONS"
:disabled="needsAttention" :disabled="needsAttention"
density="compact" variant="outlined" hide-details density="compact" variant="outlined" hide-details
style="max-width: 180px" class="fc-subs__status"
/> />
<v-text-field <v-text-field
v-model="search" v-model="search"
density="compact" variant="outlined" hide-details clearable density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
placeholder="Search subscriptions" placeholder="Search subscriptions"
style="max-width: 320px" class="fc-subs__search"
/> />
</div> </div>
@@ -75,7 +75,7 @@
<p v-else>No subscriptions match the current filter.</p> <p v-else>No subscriptions match the current filter.</p>
</div> </div>
<v-card v-else class="fc-subs__card" variant="outlined"> <v-card v-else-if="!isMobile" class="fc-subs__card" variant="outlined">
<v-data-table <v-data-table
:headers="headers" :headers="headers"
:items="filteredGroups" :items="filteredGroups"
@@ -189,6 +189,76 @@
</v-data-table> </v-data-table>
</v-card> </v-card>
<!-- Mobile: compact cards (several fit per screen), expanding to STACKED
source cards so the wide source columns never force lateral scroll. -->
<div v-else class="fc-subs__mlist">
<div
v-for="item in filteredGroups" :key="item.key"
class="fc-subs__mcard"
>
<div class="fc-subs__mhead" @click="toggleExpand(item)">
<v-checkbox-btn
:model-value="isSelected(item)" density="compact" hide-details
@click.stop @update:model-value="toggleSelect(item)"
/>
<span class="fc-subs__name">{{ item.artist.name }}</span>
<v-spacer />
<SourceHealthDot
v-if="item.worstSource"
:source="item.worstSource" :warning-threshold="failureThreshold"
/>
<v-icon size="small">
{{ isExpanded(item) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
</v-icon>
</div>
<div class="fc-subs__mmeta">
<PlatformChip
v-for="p in item.platforms" :key="p" :platform="p" size="x-small"
/>
<span class="fc-subs__mmeta-text">
{{ item.sources.length }} src · {{ formatRelative(item.lastActivity) }}
</span>
</div>
<div v-if="isExpanded(item)" class="fc-subs__mbody">
<div class="fc-subs__mactions">
<v-btn
size="small" variant="text" :loading="anyChecking(item.sources)"
@click="checkAll(item)"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Check all</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" @click="openAddSource(item.artist)">
<v-icon>mdi-plus</v-icon>
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" :to="`/posts?artist_id=${item.artist.id}`">
<v-icon>mdi-rss</v-icon>
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" :to="`/artist/${item.artist.slug}`">
<v-icon>mdi-account</v-icon>
<v-tooltip activator="parent" location="top">Artist page</v-tooltip>
</v-btn>
</div>
<SourceCard
v-for="s in item.sources" :key="s.id" :source="s"
:checking="store.checkingIds.has(s.id)"
:warning-threshold="failureThreshold"
@edit="openEditSource"
@remove="removeSource"
@toggle="toggleSourceEnabled"
@check="onCheck"
@backfill="onBackfill"
/>
<div v-if="item.sources.length === 0" class="fc-subs__sources-empty">
No sources yet. Tap + to add one.
</div>
</div>
</div>
</div>
<SourceFormDialog <SourceFormDialog
v-model="showSourceDialog" v-model="showSourceDialog"
:source="editingSource" :source="editingSource"
@@ -202,11 +272,13 @@
<script setup> <script setup>
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useSourcesStore } from '../../stores/sources.js' import { useSourcesStore } from '../../stores/sources.js'
import { usePlatformsStore } from '../../stores/platforms.js' import { usePlatformsStore } from '../../stores/platforms.js'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
import SourceRow from './SourceRow.vue' import SourceRow from './SourceRow.vue'
import SourceCard from './SourceCard.vue'
import SourceHealthDot from './SourceHealthDot.vue' import SourceHealthDot from './SourceHealthDot.vue'
import SourceFormDialog from './SourceFormDialog.vue' import SourceFormDialog from './SourceFormDialog.vue'
import ArtistCreateDialog from './ArtistCreateDialog.vue' import ArtistCreateDialog from './ArtistCreateDialog.vue'
@@ -230,6 +302,10 @@ const STATUS_OPTIONS = [
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const display = useDisplay()
// Match the 600px breakpoint the rest of the hub uses; below it the table is
// replaced by the custom compact-card list.
const isMobile = computed(() => display.width.value < 600)
const store = useSourcesStore() const store = useSourcesStore()
const platformsStore = usePlatformsStore() const platformsStore = usePlatformsStore()
const importStore = useImportStore() const importStore = useImportStore()
@@ -239,6 +315,19 @@ const statusFilter = ref('all')
const needsAttention = ref(false) const needsAttention = ref(false)
const expanded = ref([]) const expanded = ref([])
const selected = ref([]) const selected = ref([])
// Mobile card list drives the same `selected`/`expanded` key arrays the
// desktop v-data-table binds, so selection + bulk actions work identically.
function _toggleKey(arr, key) {
const i = arr.value.indexOf(key)
if (i === -1) arr.value = [...arr.value, key]
else arr.value = arr.value.filter((k) => k !== key)
}
function isSelected(item) { return selected.value.includes(item.key) }
function toggleSelect(item) { _toggleKey(selected, item.key) }
function isExpanded(item) { return expanded.value.includes(item.key) }
function toggleExpand(item) { _toggleKey(expanded, item.key) }
const showSourceDialog = ref(false) const showSourceDialog = ref(false)
const editingSource = ref(null) const editingSource = ref(null)
const editingArtist = ref(null) const editingArtist = ref(null)
@@ -568,6 +657,18 @@ async function bulkDelete() {
padding: 8px 0 1rem; padding: 8px 0 1rem;
} }
.fc-subs__bar .v-chip { cursor: pointer; } .fc-subs__bar .v-chip { cursor: pointer; }
.fc-subs__status { max-width: 180px; }
.fc-subs__search { max-width: 320px; flex: 1 1 200px; }
/* Phones: status + search each take a full-width row; drop the spacer that
would otherwise eat a row pushing them around. */
@media (max-width: 600px) {
.fc-subs__status, .fc-subs__search { max-width: none; flex-basis: 100%; }
.fc-subs__bar :deep(.v-spacer) { display: none; }
/* The table renders as stacked cards (mobile-breakpoint); reclaim the
desktop indent on the expanded sources detail (it keeps its own
horizontal scroll for the wide source columns). */
.fc-subs__sources-cell { padding-left: 0.5rem !important; }
}
.fc-subs__loading, .fc-subs__empty { .fc-subs__loading, .fc-subs__empty {
display: flex; justify-content: center; padding: 2rem; display: flex; justify-content: center; padding: 2rem;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
@@ -575,6 +676,30 @@ async function bulkDelete() {
.fc-subs__card { .fc-subs__card {
background: rgb(var(--v-theme-surface)); background: rgb(var(--v-theme-surface));
} }
/* Mobile compact-card list (replaces the data-table <600px). */
.fc-subs__mlist { display: flex; flex-direction: column; gap: 8px; }
.fc-subs__mcard {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 8px;
background: rgb(var(--v-theme-surface));
padding: 8px 10px;
}
.fc-subs__mhead { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.fc-subs__mmeta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
margin-top: 4px;
}
.fc-subs__mmeta-text {
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-subs__mbody {
margin-top: 8px; padding-top: 8px;
border-top: 1px solid rgb(var(--v-theme-surface-light));
display: flex; flex-direction: column; gap: 6px;
}
.fc-subs__mactions { display: flex; gap: 2px; }
.fc-subs__name { font-weight: 600; } .fc-subs__name { font-weight: 600; }
.fc-subs__chips { .fc-subs__chips {
display: flex; flex-wrap: wrap; gap: 4px; display: flex; flex-wrap: wrap; gap: 4px;
+16
View File
@@ -127,6 +127,21 @@ export const useAdminStore = defineStore('admin', () => {
} }
} }
// Destructive: deletes ALL general + character tags so the operator can
// re-tag from scratch via auto-suggest. fandom + series preserved.
async function resetContentTagging({ dryRun = true } = {}) {
lastError.value = null
try {
return await api.post(
'/api/admin/tags/reset-content',
{ body: { dry_run: dryRun } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
// --- Task progress polling (taps FC-3i activity dashboard) -------- // --- Task progress polling (taps FC-3i activity dashboard) --------
/** /**
@@ -162,6 +177,7 @@ export const useAdminStore = defineStore('admin', () => {
tagUsageCount, tagUsageCount,
pruneUnusedTags, pruneUnusedTags,
purgeLegacyTags, purgeLegacyTags,
resetContentTagging,
pollTaskUntilDone, pollTaskUntilDone,
} }
}) })
+9 -1
View File
@@ -55,6 +55,14 @@ export const useCleanupStore = defineStore('cleanup', () => {
return body.runs return body.runs
} }
// The most recent audit run for a given rule, or null. Cards call this on
// mount to reconnect to a scan that's still running (or to show the last
// completed result) after the user navigates away and back.
async function latestAuditForRule(rule) {
const body = await api.get('/api/cleanup/audit', { params: { rule, limit: 1 } })
return (body.runs && body.runs[0]) || null
}
async function applyAudit(id, confirm) { async function applyAudit(id, confirm) {
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } }) return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
} }
@@ -67,6 +75,6 @@ export const useCleanupStore = defineStore('cleanup', () => {
defaults, recentRuns, defaults, recentRuns,
loadDefaults, loadDefaults,
previewMinDim, deleteMinDim, previewMinDim, deleteMinDim,
startAudit, getAudit, loadHistory, applyAudit, cancelAudit, startAudit, getAudit, loadHistory, latestAuditForRule, applyAudit, cancelAudit,
} }
}) })
+25
View File
@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
export const useDbMaintenanceStore = defineStore('dbMaintenance', () => {
const api = useApi()
const tables = ref([])
const loading = ref(false)
async function loadStats() {
loading.value = true
try {
const body = await api.get('/api/admin/maintenance/db-stats')
tables.value = body.tables || []
} finally {
loading.value = false
}
}
async function runVacuum() {
return await api.post('/api/admin/maintenance/vacuum')
}
return { tables, loading, loadStats, runVacuum }
})
+114 -3
View File
@@ -24,7 +24,19 @@ export const useGalleryStore = defineStore('gallery', () => {
const filter = ref({ const filter = ref({
tag_ids: [], artist_id: null, media_type: null, tag_ids: [], artist_id: null, media_type: null,
sort: 'newest', post_id: null, sort: 'newest', post_id: null,
// Phase-2 faceted refine params.
platform: null, untagged: false, no_artist: false,
date_from: null, date_to: null,
// Phase-3 visual similarity: when set, the gallery is in "similar mode" —
// ranked by cosine distance to this image, bounded top-N, no cursor.
similar_to: null,
}) })
// Live facet counts for the refine panel; fetched on-demand (panel open +
// filter change), never on plain scroll. Null until first load.
const facets = ref(null)
const facetsLoading = ref(false)
const facetsInflight = useInflightToken()
// Display names for the active filter chips — resolved by id on deep-link // Display names for the active filter chips — resolved by id on deep-link
// and pre-noted by the filter bar when a user picks from autocomplete. // and pre-noted by the filter bar when a user picks from autocomplete.
const tagLabels = ref({}) // tagId -> name const tagLabels = ref({}) // tagId -> name
@@ -75,6 +87,39 @@ export const useGalleryStore = defineStore('gallery', () => {
} }
} }
// Visual "more like this": ranked top-N by cosine distance, scope filters
// composed (AND). No cursor / no timeline — bounded result set.
async function loadSimilar() {
inflight.cancel()
images.value = []
dateGroups.value = []
nextCursor.value = null
timelineBuckets.value = []
loading.value = true
error.value = null
const t = inflight.claim()
try {
const f = filter.value
const params = { similar_to: f.similar_to, limit: 100 }
if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
if (f.artist_id) params.artist_id = f.artist_id
if (f.media_type) params.media = f.media_type
if (f.platform) params.platform = f.platform
if (f.untagged) params.untagged = '1'
if (f.no_artist) params.no_artist = '1'
if (f.date_from) params.date_from = f.date_from
if (f.date_to) params.date_to = f.date_to
const body = await api.get('/api/gallery/similar', { params })
if (!t.isCurrent()) return
images.value = body.images
// ranked + bounded → no next page (nextCursor stays null → hasMore false)
} catch (e) {
error.value = e.message
} finally {
if (t.isCurrent()) loading.value = false
}
}
async function jumpTo(year, month) { async function jumpTo(year, month) {
// Rapid timeline-jump clicks need the same race guard as // Rapid timeline-jump clicks need the same race guard as
// loadMore — first jump's late body could clobber the second // loadMore — first jump's late body could clobber the second
@@ -100,9 +145,31 @@ export const useGalleryStore = defineStore('gallery', () => {
if (filter.value.artist_id) p.artist_id = filter.value.artist_id if (filter.value.artist_id) p.artist_id = filter.value.artist_id
if (filter.value.media_type) p.media = filter.value.media_type if (filter.value.media_type) p.media = filter.value.media_type
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
if (filter.value.platform) p.platform = filter.value.platform
if (filter.value.untagged) p.untagged = '1'
if (filter.value.no_artist) p.no_artist = '1'
if (filter.value.date_from) p.date_from = filter.value.date_from
if (filter.value.date_to) p.date_to = filter.value.date_to
return p return p
} }
// Live facet counts, scoped to the current filter (panel-gated — callers
// are the refine panel only). Single-flighted so rapid toggles don't let a
// stale response clobber a newer one.
async function loadFacets() {
facetsLoading.value = true
const t = facetsInflight.claim()
try {
const body = await api.get('/api/gallery/facets', { params: activeFilterParam() })
if (!t.isCurrent()) return
facets.value = body
} catch (e) {
if (t.isCurrent()) error.value = e.message
} finally {
if (t.isCurrent()) facetsLoading.value = false
}
}
// URL is the source of truth for filters. GalleryView calls this on mount // URL is the source of truth for filters. GalleryView calls this on mount
// and on every route-query change; the filter bar mutates the URL // and on every route-query change; the filter bar mutates the URL
// (router.push) rather than the store directly, so deep-links, the back // (router.push) rather than the store directly, so deep-links, the back
@@ -114,12 +181,28 @@ export const useGalleryStore = defineStore('gallery', () => {
media_type: ['image', 'video'].includes(q.media) ? q.media : null, media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest', sort: q.sort === 'oldest' ? 'oldest' : 'newest',
post_id: _toId(q.post_id), post_id: _toId(q.post_id),
platform: q.platform || null,
untagged: _truthy(q.untagged),
no_artist: _truthy(q.no_artist),
date_from: _parseDate(q.date_from),
date_to: _parseDate(q.date_to),
similar_to: _toId(q.similar_to),
}
if (filter.value.similar_to) {
// Similar mode: ranked, no timeline scroll.
await loadSimilar()
} else {
await loadInitial()
await loadTimeline()
} }
await loadInitial()
await loadTimeline()
_resolveLabels() _resolveLabels()
} }
function _truthy(v) { return v === '1' || v === 'true' || v === true }
function _parseDate(v) {
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null
}
function _toId(v) { function _toId(v) {
const n = Number(v) const n = Number(v)
return Number.isInteger(n) && n > 0 ? n : null return Number.isInteger(n) && n > 0 ? n : null
@@ -158,11 +241,39 @@ export const useGalleryStore = defineStore('gallery', () => {
return { return {
images, dateGroups, hasMore, isEmpty, loading, error, images, dateGroups, hasMore, isEmpty, loading, error,
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading, filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
loadInitial, loadMore, loadTimeline, jumpTo, facets, facetsLoading,
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar,
applyFilterFromQuery, noteTagLabel, noteArtistLabel, applyFilterFromQuery, noteTagLabel, noteArtistLabel,
} }
}) })
// Shared by GalleryFilterBar and GalleryFacetPanel so both write the URL in
// one format. post_id is intentionally absent — the bar/panel are hidden in
// the exclusive post-detail view.
export function cloneFilter(f) {
return {
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
sort: f.sort, platform: f.platform, untagged: f.untagged,
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
similar_to: f.similar_to,
}
}
export function filterToQuery(f) {
const q = {}
if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',')
if (f.artist_id) q.artist_id = String(f.artist_id)
if (f.media_type) q.media = f.media_type
if (f.sort && f.sort !== 'newest') q.sort = f.sort
if (f.platform) q.platform = f.platform
if (f.untagged) q.untagged = '1'
if (f.no_artist) q.no_artist = '1'
if (f.date_from) q.date_from = f.date_from
if (f.date_to) q.date_to = f.date_to
if (f.similar_to) q.similar_to = String(f.similar_to)
return q
}
function mergeGroups(existing, incoming) { function mergeGroups(existing, incoming) {
// Merge sequential groups with the same (year, month) instead of duplicating. // Merge sequential groups with the same (year, month) instead of duplicating.
const merged = [...existing] const merged = [...existing]
+3 -3
View File
@@ -18,9 +18,9 @@ export const useModalStore = defineStore('modal', () => {
const inflight = useInflightToken() const inflight = useInflightToken()
// Post-scoped cycle. When set, prev/next cycles within this array // Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When // (used by PostCard image clicks — the modal is scoped to that post's
// null, prev/next falls back to current.value.neighbors (the // images). When null, prev/next falls back to current.value.neighbors
// gallery-store-driven /api/gallery/image/<id> neighbors). // (the gallery-store-driven /api/gallery/image/<id> neighbors).
const postImageIds = ref(null) const postImageIds = ref(null)
const postImageIndex = ref(0) const postImageIndex = ref(0)
+124 -89
View File
@@ -1,37 +1,42 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { preloadImage } from '../utils/preloadImage.js'
// Operator-flagged 2026-05-30 (round 3): the all-parallel fetch was fast // Buffered producer/consumer so the cascade cadence is decoupled from fetch
// but risked later chunks arriving first — undesirable even when each // latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch
// chunk is a random sample. Switched to a PIPELINE: only one fetch in // right after its fetch and bet the next round-trip would finish inside the
// flight at any moment, but the next fetch kicks off as soon as the // ~240ms trickle window; when a fetch ran long (TABLESAMPLE hits random,
// previous one resolves (NOT after its trickle finishes). The next RTT // sometimes-cold blocks; RTT jitter) the animation starved and the view
// overlaps with the current batch's trickle, hiding the per-batch // "burped" out a clump of images. Now:
// round-trip behind the visible animation cadence. Responses arrive in // - PRODUCER (_fill): races ahead fetching batches into `queue` up to a
// fire-order, so no out-of-order rendering surprises. // target depth, refilling whenever the queue dips below BUFFER_MIN. It
// // also kicks off the image PRELOAD for each queued item so decoding
// Smaller PAGE (3 vs 5) → first chunk's items appear sooner: a chunk of // pipelines ahead of the reveal.
// 3 trickles in 240 ms, well within one RTT, so by the time chunk 2 is // - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully
// in-hand the trickle is just finishing. Total wall-clock is roughly // decoded, then reveals it — at most one per CADENCE_MS. Because the
// RTT + N × max(trickle_time, RTT); APPEND_DELAY_MS keeps the visible // producer preloads ahead, the decode-wait is usually already satisfied,
// cadence smooth throughout. // so the reveal stays evenly paced without idling the network.
// The decode-gate is what makes the showcase's signature cascade land each
// tile fully-loaded (the flip-in animates a real image, never a gray
// placeholder); the single-item reveal keeps it strictly one-at-a-time
// (operator-flagged 2026-06-04). The very first image still waits on the first
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
// everything after it is buffer-smoothed.
const PAGE = 3 const PAGE = 3
const INITIAL_BATCHES = 20 const CADENCE_MS = 160 // floor between fully-loaded reveals (doubled
const APPEND_DELAY_MS = 80 // ≈ the MasonryGrid stagger animation (70 ms) // 2026-06-04 — slower, more deliberate cadence)
// Operator-flagged 2026-06-01: scrolling the showcase eventually hit a const PRIME = 6 // items buffered before the drain starts
// premature "End." because /api/showcase returns a *random sample* and const BUFFER_TARGET = 30 // producer tops the queue up to this
// after enough scrolling the `seen` Set accumulated enough to fully const BUFFER_MIN = 12 // ...and refills once the queue dips below this
// collide with a 3-item batch. The showcase is supposed to be endless; const INITIAL_COUNT = 60 // cascade length on load / shuffle
// only a genuinely empty API response (library has zero images) should const SCROLL_COUNT = 15 // cascade length per infinite-scroll demand
// mark it exhausted. Retry up to FETCH_RETRY_CAP times on all-dupe // Showcase is endless by design (random sample); an unlucky all-duplicate
// batches; only flip `exhausted` when the API returns 0 items OR every // batch must be retried — only a genuinely empty API response is exhaustion.
// retry came back dupe-only (graceful fallback for tiny libraries
// where retries will keep returning the same handful of items).
const FETCH_RETRY_CAP = 8 const FETCH_RETRY_CAP = 8
function _sleep(ms) { return new Promise(r => setTimeout(r, ms)) } function _sleep(ms) { return new Promise((r) => setTimeout(r, ms)) }
export const useShowcaseStore = defineStore('showcase', () => { export const useShowcaseStore = defineStore('showcase', () => {
@@ -42,95 +47,125 @@ export const useShowcaseStore = defineStore('showcase', () => {
const exhausted = ref(false) const exhausted = ref(false)
const seen = new Set() const seen = new Set()
// Sequence token: every call to loadInitial bumps this. _trickleAppend // Internal buffer (not reactive — the consumer is what feeds the UI via
// bails between items if its captured seq is no longer current — guards // images.value).
// against a fast shuffle / mount-then-shuffle from interleaving two let queue = []
// trickles into the same images.value. // id -> Promise that settles when the thumbnail is paint-ready. Started by
// the producer so decoding runs ahead of the reveal; awaited by the consumer
// so no tile is shown before its image is loaded.
let _preloads = new Map()
// Sequence token: shuffle / re-mount bumps it so in-flight producers and
// the drain bail instead of interleaving two runs into one images list.
let _seq = 0 let _seq = 0
let _filling = false
let _draining = false
async function _trickleAppend(items, mySeq) { function _preload(item) {
for (const item of items) { if (!_preloads.has(item.id)) _preloads.set(item.id, preloadImage(item.thumbnail_url))
if (mySeq !== _seq) return return _preloads.get(item.id)
if (seen.has(item.id)) continue
seen.add(item.id)
images.value.push(item)
await _sleep(APPEND_DELAY_MS)
}
} }
// Single batch — used by infinite-scroll appends. Trickles its items // One batch, retried while the random sample comes back all-duplicates.
// in for the same one-at-a-time cadence as the initial load. Retries // Returns the fresh items, or null when the API is genuinely empty.
// up to FETCH_RETRY_CAP times when the API's random sample comes back async function _fetchFresh(mySeq) {
// all-duplicates (the showcase is endless by design; only a genuinely for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) {
// empty API response should mark it exhausted, not an unlucky sample). if (mySeq !== _seq) return []
async function fetchPage() { const body = await api
if (loading.value) return .get('/api/showcase', { params: { limit: PAGE } })
loading.value = true .catch((e) => {
error.value = null error.value = error.value || (e.message || String(e))
return null
})
if (mySeq !== _seq) return []
const items = (body && body.images) || []
if (items.length === 0) return null
const fresh = items.filter((i) => !seen.has(i.id))
fresh.forEach((f) => seen.add(f.id))
if (fresh.length) return fresh
}
return null // retry cap hit → tiny library, treat as exhausted
}
// Producer: top the buffer up to `target`. Single-flight.
async function _fill(mySeq, target) {
if (_filling) return
_filling = true
try { try {
for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) { while (mySeq === _seq && !exhausted.value && queue.length < target) {
const body = await api.get('/api/showcase', { params: { limit: PAGE } }) const batch = await _fetchFresh(mySeq)
const items = body.images || [] if (mySeq !== _seq) return
// API genuinely empty → library is empty / endpoint exhausted. if (batch === null) { exhausted.value = true; return }
if (items.length === 0) { exhausted.value = true; return } queue.push(...batch)
const fresh = items.filter(i => !seen.has(i.id)) batch.forEach(_preload) // pipeline decoding ahead of the reveal
if (fresh.length > 0) {
await _trickleAppend(fresh, _seq)
return
}
// All-dupes batch — keep trying. Showcase is endless by intent.
} }
// Retry cap hit with zero fresh items: library is probably much
// smaller than the running `seen` set, fall back to exhausted so
// the UI stops trying. Operator can shuffle to reset `seen`.
exhausted.value = true
} catch (e) {
error.value = e.message || String(e)
} finally { } finally {
loading.value = false _filling = false
} }
} }
function _fetchOne() { // Consumer: show `count` items at a fixed cadence, topping up the buffer as
return api.get('/api/showcase', { params: { limit: PAGE } }).catch(e => { // it drains. Single-flight so the initial cascade and scroll appends can't
error.value = error.value || (e.message || String(e)) // interleave.
return null async function _drain(mySeq, count) {
}) if (_draining) return
_draining = true
loading.value = true
try {
for (let shown = 0; shown < count && mySeq === _seq; shown++) {
if (!exhausted.value && queue.length < BUFFER_MIN) {
_fill(mySeq, BUFFER_TARGET) // topup, no await
}
let guard = 0
while (queue.length === 0 && !exhausted.value && mySeq === _seq) {
await _sleep(20)
if (++guard > 500) break // 10s starvation safety net
}
if (mySeq !== _seq) return
if (queue.length === 0) return // exhausted + empty
const item = queue.shift()
await _preload(item) // reveal only once the thumbnail is fully decoded
if (mySeq !== _seq) return
images.value.push(item)
await _sleep(CADENCE_MS)
}
} finally {
if (mySeq === _seq) {
_draining = false
loading.value = false
}
}
} }
// Reset state and pipeline INITIAL_BATCHES fetches: only one in flight
// at a time, but kick off the next one as soon as the previous resolves
// (NOT after its trickle finishes), so the next RTT runs concurrently
// with the current batch's trickle. Responses arrive in fire-order, so
// items always render in the order they were fetched — no out-of-order
// surprises from parallel races.
async function loadInitial() { async function loadInitial() {
_seq += 1 _seq += 1
const mySeq = _seq const mySeq = _seq
images.value = [] images.value = []
queue = []
_preloads = new Map()
seen.clear() seen.clear()
exhausted.value = false exhausted.value = false
error.value = null error.value = null
_filling = false
_draining = false
loading.value = true loading.value = true
try { try {
let nextFetch = _fetchOne() // Prime a small buffer before the cascade starts so it doesn't starve
for (let i = 0; i < INITIAL_BATCHES; i++) { // at the front; the drain's own topup grows it to BUFFER_TARGET.
if (mySeq !== _seq) return await _fill(mySeq, PRIME)
const body = await nextFetch if (mySeq !== _seq) return
// Fire the NEXT fetch immediately so its RTT overlaps the trickle. if (queue.length === 0 && exhausted.value) return // empty library
if (i + 1 < INITIAL_BATCHES) nextFetch = _fetchOne() await _drain(mySeq, INITIAL_COUNT)
if (!body || !body.images || body.images.length === 0) {
exhausted.value = true
break
}
await _trickleAppend(body.images, mySeq)
}
if (mySeq === _seq && images.value.length === 0) exhausted.value = true
} finally { } finally {
if (mySeq === _seq) loading.value = false if (mySeq === _seq) loading.value = false
} }
} }
async function fetchPage() {
// Infinite-scroll demand — append another cascade of SCROLL_COUNT.
if (_draining || exhausted.value) return
await _drain(_seq, SCROLL_COUNT)
}
async function shuffle() { async function shuffle() {
await loadInitial() await loadInitial()
} }
+21
View File
@@ -0,0 +1,21 @@
// Resolve once the image at `url` is fully loaded AND decoded (paint-ready),
// or after `timeoutMs` as a safety net so a broken/slow image never stalls a
// cascade. Never rejects — the caller only cares that it's safe to reveal.
//
// Used by the showcase cascade to gate each tile's entry animation on the
// thumbnail actually being ready, so the flip-in plays on a real image rather
// than on a gray placeholder (operator-flagged 2026-06-04).
export function preloadImage (url, timeoutMs = 4000) {
return new Promise((resolve) => {
let done = false
const finish = () => { if (!done) { done = true; resolve() } }
const img = new Image()
img.onload = finish
img.onerror = finish
img.src = url
// decode() resolves at paint-ready (a beat after onload); prefer it when
// available, but onload/onerror/timeout all still settle the promise.
if (typeof img.decode === 'function') img.decode().then(finish).catch(() => {})
setTimeout(finish, timeoutMs)
})
}
+21
View File
@@ -0,0 +1,21 @@
// Helpers for deciding whether a keyboard shortcut should fire or yield to a
// focused text field.
export function isTextEntry (el) {
if (!el) return false
const tag = el.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
}
export function hasText (el) {
if (!el) return false
if (el.isContentEditable) return (el.textContent || '').length > 0
return (el.value || '').length > 0
}
// Prev/next arrow navigation should fire UNLESS focus is in a text entry that
// already has content — in that case the arrows belong to the caret so it can
// move through the text. An empty (or non-text) target still navigates.
export function arrowNavAllowed (target) {
return !(isTextEntry(target) && hasText(target))
}
+3 -1
View File
@@ -76,7 +76,9 @@ onMounted(async () => {
} }
.fc-artists__grid { .fc-artists__grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); /* min(440px, 100%) so a card never exceeds the viewport — on phones the
min-track collapses to 100% (single column) instead of overflowing. */
grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 1fr));
gap: 12px; gap: 12px;
} }
.fc-artists__sentinel { .fc-artists__sentinel {
+9 -1
View File
@@ -16,7 +16,10 @@
<EmptyState v-if="store.isEmpty" /> <EmptyState v-if="store.isEmpty" />
<GalleryGrid v-else @open="openImage" /> <GalleryGrid v-else @open="openImage" />
</div> </div>
<TimelineSidebar v-if="store.images.length > 0" class="fc-gallery-layout__sidebar" /> <TimelineSidebar
v-if="store.images.length > 0 && store.filter.similar_to == null"
class="fc-gallery-layout__sidebar"
/>
</div> </div>
<BulkEditorPanel /> <BulkEditorPanel />
@@ -68,4 +71,9 @@ function openImage(id) {
.fc-gallery-layout { flex-direction: column-reverse; } .fc-gallery-layout { flex-direction: column-reverse; }
.fc-gallery-layout__sidebar { width: 100%; max-height: 200px; } .fc-gallery-layout__sidebar { width: 100%; max-height: 200px; }
} }
/* Phones: the year/month timeline strip eats vertical space and reads poorly
as a horizontal band — drop it; the gallery scroll is the primary nav here. */
@media (max-width: 600px) {
.fc-gallery-layout__sidebar { display: none; }
}
</style> </style>
+6 -5
View File
@@ -44,14 +44,15 @@
</v-window-item> </v-window-item>
<v-window-item value="import"> <v-window-item value="import">
<!-- Order: trigger → recent tasks → filters. Tasks sit directly <!-- Order: filters → trigger → recent tasks. Filters hoisted above the
below the trigger so operator sees hit/miss feedback without trigger (operator-flagged 2026-06-04); the task list stays
scrolling past the filter card (operator-flagged 2026-05-25). --> directly below the trigger so hit/miss feedback is adjacent to the
button that produced it (operator-flagged 2026-05-25). -->
<ImportFiltersForm />
<v-divider class="my-6" />
<ImportTriggerPanel /> <ImportTriggerPanel />
<v-divider class="my-6" /> <v-divider class="my-6" />
<ImportTaskList /> <ImportTaskList />
<v-divider class="my-6" />
<ImportFiltersForm />
</v-window-item> </v-window-item>
<v-window-item value="cleanup"> <v-window-item value="cleanup">
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, afterEach } from 'vitest'
import GalleryFacetPanel from '../../src/components/gallery/GalleryFacetPanel.vue'
import { useGalleryStore } from '../../src/stores/gallery.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
const FACETS = {
total: 5,
platforms: [
{ value: 'patreon', count: 3 },
{ value: null, count: 2 }, // unsourced → "No platform" bucket
],
untagged: 4,
no_artist: 1,
date_min: '2020-01-01T00:00:00+00:00',
date_max: '2026-06-01T00:00:00+00:00',
}
function stubFetchOk(body) {
globalThis.fetch = vi.fn(async () => ({
ok: true, status: 200, statusText: 'OK',
text: async () => JSON.stringify(body),
}))
}
describe('GalleryFacetPanel', () => {
afterEach(() => vi.restoreAllMocks())
it('renders platform options with counts and the curation flags', () => {
const pinia = freshPinia()
stubFetchOk(FACETS) // covers the onMounted loadFacets() call
const s = useGalleryStore()
s.facets = FACETS // seed so counts render synchronously
const w = mountComponent(GalleryFacetPanel, { pinia })
const t = w.text()
expect(t).toContain('patreon')
expect(t).toContain('3')
expect(t).toContain('No platform')
expect(t).toContain('Untagged')
expect(t).toContain('4')
expect(t).toContain('No artist')
})
it('shows an en-dash placeholder for a flag count before facets load', () => {
const pinia = freshPinia()
stubFetchOk(FACETS)
useGalleryStore().facets = null // not loaded yet
const w = mountComponent(GalleryFacetPanel, { pinia })
expect(w.text()).toContain('Untagged')
expect(w.text()).toContain('')
})
})
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import GalleryGrid from '../../src/components/gallery/GalleryGrid.vue'
import { useGalleryStore } from '../../src/stores/gallery.js'
import { freshPinia } from '../support/mountComponent.js'
const GIStub = { name: 'GalleryItem', props: ['image'], template: '<div class="gi" />' }
function mountGrid(pinia) {
return mount(GalleryGrid, {
global: {
plugins: [pinia],
stubs: { GalleryItem: GIStub, RouterLink: { template: '<a><slot /></a>' } },
},
})
}
describe('GalleryGrid', () => {
it('renders a flat ranked list when there are no date groups (similar mode)', () => {
const pinia = freshPinia()
const store = useGalleryStore()
// similar-mode shape: images present, date_groups empty, no cursor.
store.images = [
{ id: 1, thumbnail_url: '/a' },
{ id: 2, thumbnail_url: '/b' },
{ id: 3, thumbnail_url: '/c' },
]
store.dateGroups = []
const w = mountGrid(pinia)
expect(w.findAll('.gi').length).toBe(3)
})
it('renders grouped by date when date groups are present', () => {
const pinia = freshPinia()
const store = useGalleryStore()
store.images = [
{ id: 1, thumbnail_url: '/a' },
{ id: 2, thumbnail_url: '/b' },
]
store.dateGroups = [{ year: 2026, month: 6, image_ids: [1, 2] }]
const w = mountGrid(pinia)
expect(w.find('.fc-gallery-grid__date-header').exists()).toBe(true)
expect(w.findAll('.gi').length).toBe(2)
})
})
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import RelatedStrip from '../../src/components/modal/RelatedStrip.vue'
import { useModalStore } from '../../src/stores/modal.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
function stubFetch(handler) {
globalThis.fetch = vi.fn(async (url) => {
const { status, body } = handler(url)
return {
ok: status >= 200 && status < 300,
status, statusText: String(status),
text: async () => JSON.stringify(body),
}
})
}
describe('RelatedStrip (modal "more like this")', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
it('stays hidden and never fetches when the image has no embedding', async () => {
const pinia = freshPinia()
useModalStore().current = { id: 1, has_embedding: false }
const fetchSpy = vi.fn()
globalThis.fetch = fetchSpy
const w = mountComponent(RelatedStrip, { pinia })
await vi.advanceTimersByTimeAsync(500)
expect(fetchSpy).not.toHaveBeenCalled()
expect(w.find('.fc-related').exists()).toBe(false)
})
it('deferred-fetches and renders similar thumbs for an embedded image', async () => {
const pinia = freshPinia()
useModalStore().current = { id: 1, has_embedding: true }
stubFetch(() => ({
status: 200,
body: { images: [{ id: 2, thumbnail_url: '/t2' }, { id: 3, thumbnail_url: '/t3' }] },
}))
const w = mountComponent(RelatedStrip, { pinia })
// Nothing before the defer elapses (modal image gets priority).
expect(globalThis.fetch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(250)
await flushPromises()
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
expect(w.findAll('.fc-related__item').length).toBe(2)
})
it('collapses silently when the similar fetch returns nothing', async () => {
const pinia = freshPinia()
useModalStore().current = { id: 1, has_embedding: true }
stubFetch(() => ({ status: 200, body: { images: [] } }))
const w = mountComponent(RelatedStrip, { pinia })
await vi.advanceTimersByTimeAsync(250)
await flushPromises()
expect(w.find('.fc-related').exists()).toBe(false)
})
it('clicking a thumb opens that image in the modal', async () => {
const pinia = freshPinia()
const modal = useModalStore()
modal.current = { id: 1, has_embedding: true }
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
stubFetch(() => ({ status: 200, body: { images: [{ id: 7, thumbnail_url: '/t7' }] } }))
const w = mountComponent(RelatedStrip, { pinia })
await vi.advanceTimersByTimeAsync(250)
await flushPromises()
await w.find('.fc-related__item').trigger('click')
expect(openSpy).toHaveBeenCalledWith(7)
})
})
+48 -13
View File
@@ -1,26 +1,61 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import PostCard from '../../src/components/posts/PostCard.vue' import PostCard from '../../src/components/posts/PostCard.vue'
import { useModalStore } from '../../src/stores/modal.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js' import { freshPinia, mountComponent } from '../support/mountComponent.js'
const now = new Date().toISOString()
const BASE = {
id: 1, external_post_id: 'P1',
post_title: '<strong>Hello World</strong>',
post_url: 'https://x/1', post_date: now, downloaded_at: now,
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
source: { id: 1, platform: 'subscribestar' },
thumbnails: [], thumbnails_more: 0, attachments: [],
}
describe('PostCard', () => { describe('PostCard', () => {
it('renders the HTML-stripped title and the artist', () => { it('renders the HTML-stripped title and the artist', () => {
const pinia = freshPinia() const post = { ...BASE, description_plain: 'a description' }
const now = new Date().toISOString() const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
const post = {
id: 1, external_post_id: 'P1',
post_title: '<strong>Hello World</strong>',
post_url: 'https://x/1', post_date: now, downloaded_at: now,
description_plain: 'a description',
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
source: { id: 1, platform: 'subscribestar' },
thumbnails: [], thumbnails_more: 0, attachments: [],
}
const w = mountComponent(PostCard, { props: { post }, pinia })
const t = w.text() const t = w.text()
expect(t).toContain('Hello World') // plain title expect(t).toContain('Hello World') // plain title
expect(t).not.toContain('<strong>') // tags stripped expect(t).not.toContain('<strong>') // tags stripped
expect(t).toContain('Sabu') // artist (RouterLink slot) expect(t).toContain('Sabu') // artist (RouterLink slot)
}) })
it('shows a "Show more" toggle only when the description is truncated', () => {
const truncated = mountComponent(PostCard, {
props: { post: { ...BASE, description_plain: 'lots of text…', description_truncated: true } },
pinia: freshPinia(),
})
expect(truncated.text()).toContain('Show more')
const full = mountComponent(PostCard, {
props: { post: { ...BASE, description_plain: 'short', description_truncated: false } },
pinia: freshPinia(),
})
expect(full.text()).not.toContain('Show more')
})
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
const pinia = freshPinia()
const modal = useModalStore()
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
const post = {
...BASE,
description_plain: 'd',
thumbnails: [
{ image_id: 10, thumbnail_url: '/a' },
{ image_id: 11, thumbnail_url: '/b' },
],
thumbnails_more: 0,
}
const w = mountComponent(PostCard, { props: { post }, pinia })
await w.find('.fc-post-card__hero').trigger('click')
await flushPromises()
expect(openSpy).toHaveBeenCalledWith(10, { postImageIds: [10, 11] })
})
}) })
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useDbMaintenanceStore } from '../src/stores/dbMaintenance.js'
function stubFetch(handler) {
globalThis.fetch = vi.fn(async (url, init) => {
const { status, body } = handler(url, init)
return {
ok: status >= 200 && status < 300,
status,
statusText: String(status),
text: async () => (body == null ? '' : JSON.stringify(body)),
}
})
}
describe('dbMaintenance store', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('loadStats populates the bloat table', async () => {
const s = useDbMaintenanceStore()
stubFetch(() => ({
status: 200,
body: { tables: [{ table: 'image_record', live: 5, dead: 1, dead_pct: 16.7, last_vacuum: null }] },
}))
await s.loadStats()
expect(s.tables).toHaveLength(1)
expect(s.tables[0].table).toBe('image_record')
})
it('runVacuum POSTs the trigger endpoint', async () => {
const s = useDbMaintenanceStore()
const calls = []
stubFetch((url, init) => {
calls.push({ url, init })
return { status: 202, body: { status: 'queued' } }
})
await s.runVacuum()
const c = calls.at(-1)
expect(c.url).toContain('/api/admin/maintenance/vacuum')
expect(c.init.method).toBe('POST')
})
})
+108 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useGalleryStore } from '../src/stores/gallery.js' import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'
function stubFetch(handler) { function stubFetch(handler) {
globalThis.fetch = vi.fn(async (url, init) => { globalThis.fetch = vi.fn(async (url, init) => {
@@ -73,6 +73,77 @@ describe('gallery store: composable filter', () => {
expect(s.artistLabel).toBe('Kubo') expect(s.artistLabel).toBe('Kubo')
}) })
it('parses faceted refine params and loadMore forwards them', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
await s.applyFilterFromQuery({
platform: 'pixiv', untagged: '1', no_artist: '1',
date_from: '2024-01-01', date_to: '2024-12-31',
})
expect(s.filter.platform).toBe('pixiv')
expect(s.filter.untagged).toBe(true)
expect(s.filter.no_artist).toBe(true)
expect(s.filter.date_from).toBe('2024-01-01')
expect(s.filter.date_to).toBe('2024-12-31')
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
expect(scroll).toContain('platform=pixiv')
expect(scroll).toContain('untagged=1')
expect(scroll).toContain('no_artist=1')
expect(scroll).toContain('date_from=2024-01-01')
expect(scroll).toContain('date_to=2024-12-31')
})
it('drops a malformed date in the query', async () => {
const s = useGalleryStore()
stubFetch(() => ({ status: 200, body: EMPTY }))
await s.applyFilterFromQuery({ date_from: 'garbage' })
expect(s.filter.date_from).toBe(null)
})
it('loadFacets fetches counts scoped to the active filter', async () => {
const s = useGalleryStore()
const urls = []
const FACETS = {
total: 4, platforms: [{ value: 'patreon', count: 2 }],
untagged: 1, no_artist: 0,
date_min: '2020-01-01T00:00:00+00:00', date_max: '2026-06-01T00:00:00+00:00',
}
stubFetch((url) => {
urls.push(url)
if (url.includes('/api/gallery/facets')) return { status: 200, body: FACETS }
return { status: 200, body: EMPTY }
})
await s.applyFilterFromQuery({ platform: 'patreon', untagged: '1' })
await s.loadFacets()
const facetsUrl = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/facets')))
expect(facetsUrl).toContain('platform=patreon')
expect(facetsUrl).toContain('untagged=1')
expect(s.facets.total).toBe(4)
})
it('similar_to routes applyFilterFromQuery to the /similar endpoint, not scroll', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => {
urls.push(url)
if (url.includes('/api/gallery/similar')) {
return { status: 200, body: { images: [{ id: 9 }], next_cursor: null, date_groups: [] } }
}
return { status: 200, body: EMPTY }
})
await s.applyFilterFromQuery({ similar_to: '5', tag_id: '3' })
expect(s.filter.similar_to).toBe(5)
const sim = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/similar')))
expect(sim).toContain('similar_to=5')
expect(sim).toContain('limit=100')
expect(sim).toContain('tag_id=3') // scope composes
expect(s.images.map((i) => i.id)).toEqual([9])
expect(s.hasMore).toBe(false) // ranked + bounded → no pages
expect(urls.some((u) => u.includes('/api/gallery/scroll'))).toBe(false)
expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false)
})
it('loadInitial issues exactly one scroll request at the initial limit', async () => { it('loadInitial issues exactly one scroll request at the initial limit', async () => {
const s = useGalleryStore() const s = useGalleryStore()
const urls = [] const urls = []
@@ -88,3 +159,39 @@ describe('gallery store: composable filter', () => {
expect(scrollCalls[0]).toContain('limit=50') expect(scrollCalls[0]).toContain('limit=50')
}) })
}) })
describe('filterToQuery / cloneFilter', () => {
it('serializes faceted params incl. platform sentinel and flags', () => {
const q = filterToQuery({
tag_ids: [3], artist_id: null, media_type: null, sort: 'newest',
platform: '__unsourced__', untagged: true, no_artist: false,
date_from: '2024-01-01', date_to: null,
})
expect(q.tag_id).toBe('3')
expect(q.platform).toBe('__unsourced__')
expect(q.untagged).toBe('1')
expect(q.no_artist).toBeUndefined()
expect(q.date_from).toBe('2024-01-01')
expect(q.date_to).toBeUndefined()
expect(q.sort).toBeUndefined() // newest is the default → omitted
})
it('serializes similar_to', () => {
expect(filterToQuery({ tag_ids: [], similar_to: 42 }).similar_to).toBe('42')
expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined()
})
it('cloneFilter copies refine fields and detaches tag_ids', () => {
const orig = {
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
platform: 'patreon', untagged: true, no_artist: true,
date_from: '2024-01-01', date_to: '2024-02-01',
}
const c = cloneFilter(orig)
c.tag_ids.push(9)
expect(orig.tag_ids).toEqual([1]) // detached
expect(c.platform).toBe('patreon')
expect(c.untagged).toBe(true)
expect(c.date_to).toBe('2024-02-01')
})
})
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock the decode-gate so the cascade is paced purely by the store's cadence
// in tests (no real <img> load events in happy-dom). Individual tests can
// override per-call to exercise the gating itself.
vi.mock('../src/utils/preloadImage.js', () => ({
preloadImage: vi.fn(() => Promise.resolve()),
}))
import { preloadImage } from '../src/utils/preloadImage.js'
import { useShowcaseStore } from '../src/stores/showcase.js'
let nextId
function stubShowcase({ empty = false } = {}) {
globalThis.fetch = vi.fn(async () => {
const images = empty
? []
: Array.from({ length: 3 }, () => ({
id: nextId++, sha256: 's', mime: 'image/jpeg',
width: 1, height: 1, thumbnail_url: '/t',
}))
return {
ok: true,
status: 200,
statusText: '200',
text: async () => JSON.stringify({ images }),
}
})
}
describe('showcase store: buffered cascade', () => {
beforeEach(() => {
setActivePinia(createPinia())
nextId = 1
preloadImage.mockReset()
preloadImage.mockImplementation(() => Promise.resolve())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('drains buffered items in fire-order at a fixed cadence, no dupes', async () => {
stubShowcase()
const s = useShowcaseStore()
const p = s.loadInitial()
// Past INITIAL_COUNT (60) × CADENCE (160ms = 9.6s) plus fetch microtasks.
await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60)
const ids = s.images.map((i) => i.id)
expect(new Set(ids).size).toBe(60) // dedup held
expect(ids).toEqual([...ids].sort((a, b) => a - b)) // shown in fire order
})
it('reveals at most one item per cadence tick (decoupled from fetch)', async () => {
stubShowcase()
const s = useShowcaseStore()
const p = s.loadInitial()
await vi.advanceTimersByTimeAsync(1000)
const early = s.images.length
// Even though fetches resolve instantly, the consumer is rate-limited by
// the cadence — a 1s window must not dump the whole buffer at once.
expect(early).toBeGreaterThan(0)
expect(early).toBeLessThan(60)
await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60)
})
it('does not reveal a tile until its image has decoded', async () => {
stubShowcase()
let release
// The first tile's preload hangs until we release it; all others resolve.
preloadImage.mockImplementationOnce(() => new Promise((r) => { release = r }))
const s = useShowcaseStore()
const p = s.loadInitial()
await vi.advanceTimersByTimeAsync(1000)
expect(s.images.length).toBe(0) // gated on the un-decoded first image
release()
await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60) // cascade proceeds once it decodes
})
it('flags an empty library without starting the cascade', async () => {
stubShowcase({ empty: true })
const s = useShowcaseStore()
const p = s.loadInitial()
await vi.advanceTimersByTimeAsync(1000)
await p
expect(s.images.length).toBe(0)
expect(s.isEmpty).toBe(true)
})
})
+31
View File
@@ -0,0 +1,31 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { arrowNavAllowed, hasText, isTextEntry } from '../src/utils/textEntry.js'
describe('textEntry helpers', () => {
it('navigates when the target is not a text field', () => {
expect(arrowNavAllowed(document.createElement('div'))).toBe(true)
expect(arrowNavAllowed(null)).toBe(true)
})
it('navigates when a focused tag input is empty', () => {
const input = document.createElement('input')
input.value = ''
expect(arrowNavAllowed(input)).toBe(true)
})
it('does NOT navigate when the input has text (caret moves instead)', () => {
const input = document.createElement('input')
input.value = 'naruto'
expect(arrowNavAllowed(input)).toBe(false)
})
it('isTextEntry / hasText recognise inputs and textareas', () => {
const ta = document.createElement('textarea')
ta.value = 'x'
expect(isTextEntry(ta)).toBe(true)
expect(hasText(ta)).toBe(true)
expect(isTextEntry(document.createElement('div'))).toBe(false)
})
})
+42 -27
View File
@@ -104,32 +104,51 @@ async def client(app):
_SEED_SNAPSHOT: dict[str, list[dict]] | None = None _SEED_SNAPSHOT: dict[str, list[dict]] | None = None
@pytest.fixture(scope="session")
def _truncate_engine():
"""Session-scoped sync engine reused by the per-test DB-reset teardown.
Creating a fresh engine + Postgres connection for EVERY test's teardown was
the integration suite's dominant cost — `--durations` showed the 15 slowest
operations in both long shards were all ~1.5-2s teardowns, i.e. the
connect+SCRAM handshake, not test logic. A single pooled connection reused
across teardowns cuts that to the TRUNCATE itself. `pool_pre_ping` guards
against Postgres reaping the idle connection mid-suite (a stale-connection
failure would be a nasty flaky bounce; the ping is sub-millisecond).
`create_engine` is lazy — it opens no connection until first `.begin()` —
so the no-DB unit job instantiates this object but never connects.
"""
eng = create_engine(_sync_database_url(), future=True, pool_pre_ping=True)
yield eng
eng.dispose()
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_db_after_integration(request): def _reset_db_after_integration(request, _truncate_engine):
"""Integration tests exercise app/celery code that commits on its own """Integration tests exercise app/celery code that commits on its own
connection, which the rollback fixtures above can't undo — so data connection, which the rollback fixtures above can't undo — so data
would leak between tests. Capture the seeded baseline at the first would leak between tests. Capture the seeded baseline at the first
integration test, then after each integration-marked test truncate integration test, then after each integration-marked test truncate
every model table and restore that baseline. Connects to the DB ONLY every model table and restore that baseline. Touches the DB ONLY for
for integration-marked tests, so the no-DB fast unit job is untouched. integration-marked tests, so the no-DB fast unit job is untouched.
Uses the session-scoped `_truncate_engine` (pooled, reused) rather than
building a fresh engine per test — see that fixture's docstring.
""" """
global _SEED_SNAPSHOT global _SEED_SNAPSHOT
is_integration = request.node.get_closest_marker("integration") is not None is_integration = request.node.get_closest_marker("integration") is not None
if is_integration and _SEED_SNAPSHOT is None: if is_integration and _SEED_SNAPSHOT is None:
eng = create_engine(_sync_database_url(), future=True)
snap: dict[str, list[dict]] = {} snap: dict[str, list[dict]] = {}
try: with _truncate_engine.connect() as conn:
with eng.connect() as conn: for t in Base.metadata.sorted_tables:
for t in Base.metadata.sorted_tables: rows = [
rows = [ dict(m)
dict(m) for m in conn.execute(t.select()).mappings().all()
for m in conn.execute(t.select()).mappings().all() ]
] if rows:
if rows: snap[t.name] = rows
snap[t.name] = rows
finally:
eng.dispose()
_SEED_SNAPSHOT = snap _SEED_SNAPSHOT = snap
yield yield
@@ -139,18 +158,14 @@ def _reset_db_after_integration(request):
tables = ", ".join(t.name for t in Base.metadata.sorted_tables) tables = ", ".join(t.name for t in Base.metadata.sorted_tables)
if not tables: if not tables:
return return
eng = create_engine(_sync_database_url(), future=True) with _truncate_engine.begin() as conn:
try: conn.exec_driver_sql(
with eng.begin() as conn: f"TRUNCATE {tables} RESTART IDENTITY CASCADE"
conn.exec_driver_sql( )
f"TRUNCATE {tables} RESTART IDENTITY CASCADE" for t in Base.metadata.sorted_tables:
) rows = (_SEED_SNAPSHOT or {}).get(t.name)
for t in Base.metadata.sorted_tables: if rows:
rows = (_SEED_SNAPSHOT or {}).get(t.name) conn.execute(t.insert(), rows)
if rows:
conn.execute(t.insert(), rows)
finally:
eng.dispose()
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
+49
View File
@@ -409,3 +409,52 @@ async def test_purge_legacy_commit_deletes_only_legacy(client, db):
) )
)).scalar_one() )).scalar_one()
assert gone == 0 assert gone == 0
# --- DB maintenance: bloat readout + manual VACUUM trigger ----------
@pytest.mark.asyncio
async def test_db_stats_returns_table_bloat_shape(client):
resp = await client.get("/api/admin/maintenance/db-stats")
assert resp.status_code == 200
body = await resp.get_json()
assert "tables" in body
names = {t["table"] for t in body["tables"]}
assert "image_record" in names
for t in body["tables"]:
assert {"table", "live", "dead", "dead_pct", "last_vacuum"} <= set(t)
@pytest.mark.asyncio
async def test_trigger_vacuum_queues_the_task(client, monkeypatch):
from backend.app.tasks import maintenance
calls = []
monkeypatch.setattr(maintenance.vacuum_analyze, "delay", lambda: calls.append(1))
resp = await client.post("/api/admin/maintenance/vacuum")
assert resp.status_code == 202
assert calls == [1]
# --- Tier-A: POST /tags/reset-content -------------------------------
@pytest.mark.asyncio
async def test_reset_content_tagging_dry_run_returns_counts(client, db):
db.add_all([
Tag(name="solo", kind=TagKind.general),
Tag(name="naruto", kind=TagKind.character),
Tag(name="Naruto", kind=TagKind.fandom),
Tag(name="my-series", kind=TagKind.series),
])
await db.commit()
resp = await client.post(
"/api/admin/tags/reset-content", json={"dry_run": True}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["count"] == 2
assert body["by_kind"] == {"general": 1, "character": 1}
# dry-run leaves the rows in place — fandom + series untouched too.
assert "deleted" not in body
+21
View File
@@ -155,6 +155,27 @@ async def test_audit_history_returns_recent_runs(client, db):
assert len(body["runs"]) >= 3 assert len(body["runs"]) >= 3
@pytest.mark.asyncio
async def test_audit_history_filters_by_rule(client, db):
db.add(LibraryAuditRun(
rule="transparency", params={"threshold": 0.9},
status="applied", matched_ids=[], finished_at=datetime.now(UTC),
))
db.add(LibraryAuditRun(
rule="single_color", params={"threshold": 0.95, "tolerance": 30},
status="ready", matched_count=2, matched_ids=[1, 2],
finished_at=datetime.now(UTC),
))
await db.commit()
# ?rule=&limit=1 → just THIS rule's latest run (the card-reconnect query).
resp = await client.get("/api/cleanup/audit?rule=single_color&limit=1")
assert resp.status_code == 200
runs = (await resp.get_json())["runs"]
assert len(runs) == 1
assert runs[0]["rule"] == "single_color"
assert runs[0]["matched_count"] == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_audit_apply_with_token_deletes(client, db, tmp_path): async def test_audit_apply_with_token_deletes(client, db, tmp_path):
rec = await _seed_image(db, tmp_path, w=100, h=100, name="apply.png") rec = await _seed_image(db, tmp_path, w=100, h=100, name="apply.png")
+37
View File
@@ -77,3 +77,40 @@ async def test_scroll_media_param(client, db):
resp = await client.get("/api/gallery/scroll?limit=10&media=video") resp = await client.get("/api/gallery/scroll?limit=10&media=video")
assert resp.status_code == 200 assert resp.status_code == 200
assert (await resp.get_json())["images"] == [] assert (await resp.get_json())["images"] == []
@pytest.mark.asyncio
async def test_facets_endpoint(client, db):
await _seed(db, 3) # filesystem images: no artist, no tags, no platform
resp = await client.get("/api/gallery/facets")
assert resp.status_code == 200
body = await resp.get_json()
assert body["total"] == 3
assert body["untagged"] == 3
assert body["no_artist"] == 3
assert "platforms" in body
assert body["date_min"] is not None
assert body["date_max"] is not None
@pytest.mark.asyncio
async def test_facets_rejects_bad_date(client):
resp = await client.get("/api/gallery/facets?date_from=notadate")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_scroll_untagged_param(client, db):
await _seed(db, 2)
resp = await client.get("/api/gallery/scroll?untagged=1&limit=10")
assert resp.status_code == 200
assert len((await resp.get_json())["images"]) == 2
@pytest.mark.asyncio
async def test_scroll_date_from_param(client, db):
await _seed(db, 2) # all created ~now
# A future date_from excludes everything.
resp = await client.get("/api/gallery/scroll?date_from=2099-01-01&limit=10")
assert resp.status_code == 200
assert (await resp.get_json())["images"] == []
+60
View File
@@ -9,6 +9,7 @@ import pytest
from sqlalchemy import func, select from sqlalchemy import func, select
from backend.app.models import Artist, ImageRecord, Tag, TagKind from backend.app.models import Artist, ImageRecord, Tag, TagKind
from backend.app.models.series_page import SeriesPage
from backend.app.models.tag import image_tag from backend.app.models.tag import image_tag
from backend.app.services import cleanup_service from backend.app.services import cleanup_service
@@ -333,3 +334,62 @@ def test_prune_unused_tags_commit_deletes_them(db_sync, tmp_path):
surviving_names = db_sync.execute(select(Tag.name)).scalars().all() surviving_names = db_sync.execute(select(Tag.name)).scalars().all()
assert "kept" in surviving_names assert "kept" in surviving_names
assert "bye" not in surviving_names assert "bye" not in surviving_names
# --- reset_content_tagging ------------------------------------------
def test_reset_content_tagging_dry_run_counts_without_deleting(db_sync, tmp_path):
a = _make_artist(db_sync, slug="rc")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "r.jpg"), sha256="1" * 64,
)
g = _make_tag(db_sync, name="solo", kind=TagKind.general)
c = _make_tag(db_sync, name="naruto", kind=TagKind.character)
_make_tag(db_sync, name="Naruto", kind=TagKind.fandom)
_make_tag(db_sync, name="my-series", kind=TagKind.series)
db_sync.execute(image_tag.insert().values([
{"image_record_id": img.id, "tag_id": g.id},
{"image_record_id": img.id, "tag_id": c.id},
]))
db_sync.commit()
result = cleanup_service.reset_content_tagging(db_sync, dry_run=True)
assert result["count"] == 2
assert result["by_kind"] == {"general": 1, "character": 1}
assert result["applications"] == 2
# Nothing deleted — all 4 tags still present.
assert db_sync.execute(select(func.count(Tag.id))).scalar_one() == 4
def test_reset_content_tagging_deletes_content_keeps_fandom_series(db_sync, tmp_path):
a = _make_artist(db_sync, slug="rc2")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "r2.jpg"), sha256="2" * 64,
)
g = _make_tag(db_sync, name="solo", kind=TagKind.general)
c = _make_tag(db_sync, name="naruto", kind=TagKind.character)
_make_tag(db_sync, name="Naruto", kind=TagKind.fandom)
s = _make_tag(db_sync, name="my-series", kind=TagKind.series)
db_sync.execute(image_tag.insert().values([
{"image_record_id": img.id, "tag_id": g.id},
{"image_record_id": img.id, "tag_id": c.id},
{"image_record_id": img.id, "tag_id": s.id}, # series membership
]))
db_sync.add(SeriesPage(series_tag_id=s.id, image_id=img.id, page_number=1))
db_sync.commit()
result = cleanup_service.reset_content_tagging(db_sync, dry_run=False)
assert result["deleted"] == 2
# general + character gone; fandom + series kept.
kinds = db_sync.execute(select(Tag.kind)).scalars().all()
kind_vals = {k.value if hasattr(k, "value") else str(k) for k in kinds}
assert kind_vals == {"fandom", "series"}
# series_page ordering survived.
assert db_sync.execute(
select(func.count()).select_from(SeriesPage)
).scalar_one() == 1
# Only the series image_tag association survived (content ones cascaded).
remaining = db_sync.execute(select(image_tag.c.tag_id)).scalars().all()
assert remaining == [s.id]
+252
View File
@@ -0,0 +1,252 @@
"""Phase-2 faceted refine: new composable filter params + the facets()
aggregate (platform / curation-flags / date-range counts, scoped live to the
current filter with per-group minus-self semantics)."""
from datetime import UTC, datetime
import pytest
from backend.app.models import (
Artist,
ImageProvenance,
ImageRecord,
Post,
Source,
Tag,
TagKind,
)
from backend.app.models.tag import image_tag
from backend.app.services.gallery_service import (
UNSOURCED_PLATFORM,
GalleryService,
)
pytestmark = pytest.mark.integration
_BASE = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
async def _artist(db, name):
a = Artist(name=name, slug=name.lower())
db.add(a)
await db.flush()
return a
async def _sourced_image(db, n, platform, artist, eff):
"""A downloaded image with a Source(platform) + Post + provenance row."""
s = Source(
artist_id=artist.id, platform=platform,
url=f"https://{platform}.test/{artist.slug}/{n}",
)
db.add(s)
await db.flush()
p = Post(source_id=s.id, artist_id=artist.id, external_post_id=f"{platform}-{n}")
db.add(p)
await db.flush()
rec = ImageRecord(
path=f"/images/s/{platform}-{n}.jpg", sha256=f"a{n:063d}",
size_bytes=1, mime="image/jpeg", width=1, height=1,
origin="downloaded", integrity_status="ok",
artist_id=artist.id, primary_post_id=p.id,
)
rec.created_at = eff
rec.effective_date = eff
db.add(rec)
await db.flush()
db.add(ImageProvenance(image_record_id=rec.id, post_id=p.id, source_id=s.id))
await db.flush()
return rec
async def _fs_image(db, n, eff, artist=None):
"""A filesystem-imported image: no provenance/source (unsourced),
artist optional."""
rec = ImageRecord(
path=f"/images/fs/{n}.jpg", sha256=f"b{n:063d}",
size_bytes=1, mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
artist_id=artist.id if artist else None,
)
rec.created_at = eff
rec.effective_date = eff
db.add(rec)
await db.flush()
return rec
async def _tag(db, image, name):
t = Tag(name=name, kind=TagKind.general)
db.add(t)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=image.id, tag_id=t.id, source="manual"))
await db.flush()
return t
# --- facets() aggregate ----------------------------------------------------
@pytest.mark.asyncio
async def test_facets_platform_counts(db):
ar = await _artist(db, "Ar")
await _sourced_image(db, 1, "patreon", ar, _BASE)
await _sourced_image(db, 2, "patreon", ar, _BASE)
await _sourced_image(db, 3, "pixiv", ar, _BASE)
await _fs_image(db, 4, _BASE) # unsourced → null bucket
svc = GalleryService(db)
f = await svc.facets()
counts = {p["value"]: p["count"] for p in f.platforms}
assert counts["patreon"] == 2
assert counts["pixiv"] == 1
assert counts[None] == 1
assert f.total == 4
@pytest.mark.asyncio
async def test_facets_counts_image_under_each_platform(db):
"""A single image cross-posted to two platforms counts under both
(DISTINCT image per platform), but is one image in total."""
ar = await _artist(db, "Ar")
rec = await _sourced_image(db, 1, "patreon", ar, _BASE)
s2 = Source(artist_id=ar.id, platform="pixiv", url="https://pixiv.test/ar/x")
db.add(s2)
await db.flush()
p2 = Post(source_id=s2.id, artist_id=ar.id, external_post_id="pixiv-x")
db.add(p2)
await db.flush()
db.add(ImageProvenance(image_record_id=rec.id, post_id=p2.id, source_id=s2.id))
await db.flush()
svc = GalleryService(db)
f = await svc.facets()
counts = {p["value"]: p["count"] for p in f.platforms}
assert counts["patreon"] == 1
assert counts["pixiv"] == 1
assert f.total == 1
@pytest.mark.asyncio
async def test_facets_curation_flags(db):
ar = await _artist(db, "Ar")
tagged = await _fs_image(db, 1, _BASE, artist=ar)
await _tag(db, tagged, "x")
await _fs_image(db, 2, _BASE, artist=ar) # untagged, has artist
await _fs_image(db, 3, _BASE, artist=None) # untagged, no artist
svc = GalleryService(db)
f = await svc.facets()
assert f.untagged == 2
assert f.no_artist == 1
@pytest.mark.asyncio
async def test_facets_date_bounds(db):
ar = await _artist(db, "Ar")
await _fs_image(db, 1, datetime(2020, 1, 1, tzinfo=UTC), artist=ar)
await _fs_image(db, 2, datetime(2026, 6, 1, tzinfo=UTC), artist=ar)
svc = GalleryService(db)
f = await svc.facets()
assert f.date_min == datetime(2020, 1, 1, tzinfo=UTC)
assert f.date_max == datetime(2026, 6, 1, tzinfo=UTC)
@pytest.mark.asyncio
async def test_facets_platform_minus_self(db):
"""The platform group ignores its OWN active selection so siblings stay
visible/switchable; total still honors the platform filter."""
ar = await _artist(db, "Ar")
await _sourced_image(db, 1, "patreon", ar, _BASE)
await _sourced_image(db, 2, "pixiv", ar, _BASE)
svc = GalleryService(db)
f = await svc.facets(platform="patreon")
counts = {p["value"]: p["count"] for p in f.platforms}
assert counts.get("pixiv") == 1
assert counts.get("patreon") == 1
assert f.total == 1
@pytest.mark.asyncio
async def test_facets_other_filter_scopes_platforms(db):
"""A non-platform filter (media) narrows the platform counts."""
ar = await _artist(db, "Ar")
await _sourced_image(db, 1, "patreon", ar, _BASE)
vid = await _sourced_image(db, 2, "patreon", ar, _BASE)
vid.mime = "video/mp4"
await db.flush()
svc = GalleryService(db)
f = await svc.facets(media_type="image")
counts = {p["value"]: p["count"] for p in f.platforms}
assert counts["patreon"] == 1
# --- new scroll filter params ----------------------------------------------
@pytest.mark.asyncio
async def test_scroll_platform_filter(db):
ar = await _artist(db, "Ar")
pat = await _sourced_image(db, 1, "patreon", ar, _BASE)
await _sourced_image(db, 2, "pixiv", ar, _BASE)
svc = GalleryService(db)
page = await svc.scroll(cursor=None, limit=10, platform="patreon")
assert [i.id for i in page.images] == [pat.id]
@pytest.mark.asyncio
async def test_scroll_platform_unsourced(db):
ar = await _artist(db, "Ar")
await _sourced_image(db, 1, "patreon", ar, _BASE)
fs = await _fs_image(db, 2, _BASE)
svc = GalleryService(db)
page = await svc.scroll(cursor=None, limit=10, platform=UNSOURCED_PLATFORM)
assert [i.id for i in page.images] == [fs.id]
@pytest.mark.asyncio
async def test_scroll_untagged_filter(db):
ar = await _artist(db, "Ar")
tagged = await _fs_image(db, 1, _BASE, artist=ar)
await _tag(db, tagged, "x")
untag = await _fs_image(db, 2, _BASE, artist=ar)
svc = GalleryService(db)
page = await svc.scroll(cursor=None, limit=10, untagged=True)
assert [i.id for i in page.images] == [untag.id]
@pytest.mark.asyncio
async def test_scroll_no_artist_filter(db):
ar = await _artist(db, "Ar")
await _fs_image(db, 1, _BASE, artist=ar)
none = await _fs_image(db, 2, _BASE, artist=None)
svc = GalleryService(db)
page = await svc.scroll(cursor=None, limit=10, no_artist=True)
assert [i.id for i in page.images] == [none.id]
@pytest.mark.asyncio
async def test_scroll_date_range_filter(db):
ar = await _artist(db, "Ar")
old = await _fs_image(db, 1, datetime(2020, 1, 1, tzinfo=UTC), artist=ar)
new = await _fs_image(db, 2, datetime(2026, 6, 1, tzinfo=UTC), artist=ar)
svc = GalleryService(db)
page = await svc.scroll(
cursor=None, limit=10, date_from=datetime(2026, 1, 1, tzinfo=UTC))
assert [i.id for i in page.images] == [new.id]
page2 = await svc.scroll(
cursor=None, limit=10, date_to=datetime(2021, 1, 1, tzinfo=UTC))
assert [i.id for i in page2.images] == [old.id]
@pytest.mark.asyncio
async def test_scroll_combines_new_filters_and(db):
"""platform + untagged + no_artist compose with AND."""
ar = await _artist(db, "Ar")
# match: unsourced, untagged, no artist
match = await _fs_image(db, 1, _BASE, artist=None)
# decoys violating one clause each
await _fs_image(db, 2, _BASE, artist=ar) # has artist
tagged = await _fs_image(db, 3, _BASE, artist=None)
await _tag(db, tagged, "x") # tagged
svc = GalleryService(db)
page = await svc.scroll(
cursor=None, limit=10,
platform=UNSOURCED_PLATFORM, untagged=True, no_artist=True)
assert [i.id for i in page.images] == [match.id]
+134
View File
@@ -0,0 +1,134 @@
"""Phase-3 visual "more like this" — pgvector cosine ranking over the
precomputed SigLIP image embeddings. No query-time ML inference."""
from datetime import UTC, datetime, timedelta
import pytest
from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.gallery_service import GalleryService
pytestmark = pytest.mark.integration
def _vec(*head):
"""A 1152-dim embedding with the given leading values, rest zero. Cosine
distance to _vec(1,0) grows as the 2nd component grows, so callers can
order fixtures deterministically by direction."""
v = [0.0] * 1152
for i, x in enumerate(head):
v[i] = float(x)
return v
async def _img(db, n, emb):
rec = ImageRecord(
path=f"/images/sim/{n}.jpg", sha256=f"e{n:063d}",
size_bytes=1, mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
siglip_embedding=emb,
)
base = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
rec.created_at = base - timedelta(minutes=n)
rec.effective_date = rec.created_at
db.add(rec)
await db.flush()
return rec
@pytest.mark.asyncio
async def test_similar_ranks_nearest_first(db):
src = await _img(db, 1, _vec(1, 0))
near = await _img(db, 2, _vec(1, 0.05)) # almost same direction
mid = await _img(db, 3, _vec(1, 1)) # 45°
far = await _img(db, 4, _vec(0, 1)) # orthogonal
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10)
assert [i.id for i in res] == [near.id, mid.id, far.id] # self excluded
@pytest.mark.asyncio
async def test_similar_excludes_null_embeddings(db):
src = await _img(db, 1, _vec(1, 0))
have = await _img(db, 2, _vec(1, 0.1))
await _img(db, 3, None) # un-embedded (e.g. a video) → excluded
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10)
assert [i.id for i in res] == [have.id]
@pytest.mark.asyncio
async def test_similar_source_without_embedding_returns_empty(db):
src = await _img(db, 1, None)
await _img(db, 2, _vec(1, 0))
svc = GalleryService(db)
assert await svc.similar(src.id, limit=10) == []
@pytest.mark.asyncio
async def test_similar_missing_source_returns_none(db):
svc = GalleryService(db)
assert await svc.similar(99999, limit=10) is None
@pytest.mark.asyncio
async def test_similar_composes_with_tag_filter(db):
src = await _img(db, 1, _vec(1, 0))
await _img(db, 2, _vec(1, 0.02)) # nearest, but untagged
tagged = await _img(db, 3, _vec(1, 0.6)) # farther, but carries the tag
tag = Tag(name="t", kind=TagKind.general)
db.add(tag)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=tagged.id, tag_id=tag.id, source="manual"))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10, tag_ids=[tag.id])
assert [i.id for i in res] == [tagged.id] # scope AND-narrows the ranked set
@pytest.mark.asyncio
async def test_similar_respects_limit(db):
src = await _img(db, 1, _vec(1, 0))
for n in range(2, 7):
await _img(db, n, _vec(1, 0.1 * n))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=2)
assert len(res) == 2
# --- API ---
@pytest.mark.asyncio
async def test_api_similar_endpoint(client, db):
src = await _img(db, 1, _vec(1, 0))
near = await _img(db, 2, _vec(1, 0.05))
await db.commit()
resp = await client.get(f"/api/gallery/similar?similar_to={src.id}&limit=10")
assert resp.status_code == 200
body = await resp.get_json()
assert [i["id"] for i in body["images"]] == [near.id]
assert body["next_cursor"] is None
@pytest.mark.asyncio
async def test_api_similar_404_when_source_missing(client):
resp = await client.get("/api/gallery/similar?similar_to=99999")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_api_similar_requires_param(client):
resp = await client.get("/api/gallery/similar")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_image_detail_reports_has_embedding(client, db):
embedded = await _img(db, 1, _vec(1, 0))
plain = await _img(db, 2, None)
await db.commit()
e = await (await client.get(f"/api/gallery/image/{embedded.id}")).get_json()
p = await (await client.get(f"/api/gallery/image/{plain.id}")).get_json()
assert e["has_embedding"] is True
assert p["has_embedding"] is False
+9
View File
@@ -638,3 +638,12 @@ def test_recover_stalled_download_dedupes_per_source(db_sync):
select(Source.consecutive_failures).where(Source.id == sid) select(Source.consecutive_failures).where(Source.id == sid)
).scalar_one() ).scalar_one()
assert failures == 1 assert failures == 1
def test_vacuum_analyze_runs_over_high_churn_tables():
"""VACUUM (ANALYZE) runs (on its own AUTOCOMMIT connection) and reports the
tables it touched."""
from backend.app.tasks.maintenance import VACUUM_TABLES, vacuum_analyze
result = vacuum_analyze.apply().get()
assert result["vacuumed"] == list(VACUUM_TABLES)