Compare commits

...
78 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
bvandeusen 711abea567 Merge pull request 'Gallery speed + fandom editing + filters + pinned filter bar' (#61) 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 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s
2026-06-04 00:07:19 -04:00
bvandeusenandClaude Opus 4.8 6d630d13d6 feat(gallery): pinned filter bar (Phase 1)
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 29s
CI / intimp (push) Successful in 3m29s
CI / intapi (push) Successful in 7m24s
CI / intcore (push) Successful in 8m4s
Gallery now has in-view filtering, styled like the app's sticky v-tabs
chrome (pinned at top:64px under TopNav).

- GalleryFilterBar: combined tag+artist autocomplete (searches
  /api/tags + /api/artists), closable filter chips (multi-tag AND),
  media toggle (All/Images/Videos), Newest/Oldest sort, Clear. Writes all
  state to the URL via router.push.
- gallery store: filter is now { tag_ids, artist_id, media_type, sort,
  post_id }; applyFilterFromQuery makes the URL the single source of truth
  (deep-linkable, back-button works); chip labels resolved by id or
  pre-noted on pick. Replaces the standalone tag chip + setTag/PostFilter.
- GalleryView: renders the bar (hidden in post-detail), syncs route.query
  → store on mount + every query change.

Also untracks the transient .claude/scheduled_tasks.lock committed in
3f30327 and gitignores it.

Tests: store parses query → composable scroll params, post_id exclusivity,
newest-sort omitted, label pre-seed, single initial fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:57:11 -04:00
bvandeusenandClaude Opus 4.8 3f30327fa5 feat(gallery): composable scroll filter (multi-tag AND, media, sort)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 17s
CI / frontend-build (push) Successful in 24s
CI / intimp (push) Successful in 3m50s
CI / intcore (push) Successful in 8m33s
CI / intapi (push) Successful in 7m42s
Phase 1 backend for the gallery filter bar. Extends scroll/timeline/jump
from a single mutually-exclusive filter to a composable one:

- tag_ids: image must carry ALL of them (one correlated EXISTS per tag —
  AND, no row multiplication), replacing the single-tag JOIN.
- artist_id composes with tags; media_type ('image'|'video') narrows by
  mime; post_id stays the exclusive post-detail path.
- sort ('newest'|'oldest') flips the effective_date/id cursor comparison
  and ordering; the cursor value is unchanged (direction comes from the
  request). jump_cursor honors sort too.
- Shared _apply_scope helper applied across scroll/timeline/jump so the
  timeline sidebar reflects the filtered set. API _parse_filters parses
  tag_id (comma list), artist_id, media, sort.

Tests: multi-tag AND, media filter, sort reversal (service + API);
post_id-excludes-others; single tag_id back-compat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:50:19 -04:00
bvandeusenandClaude Opus 4.8 4f9464d215 feat(gallery,tags): clear active filters
CI / backend-lint-and-test (push) Successful in 13s
CI / intcore (push) Successful in 8m36s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 38s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m5s
Two gaps where a filter couldn't be removed:

- Gallery: a tag_id filter (from clicking a tag) had no indicator or clear
  control — only post_id did (PostInfoHeader). Add an "Tag: <name> ✕" chip
  that clears the filter by dropping tag_id from the URL. New lightweight
  GET /api/tags/<id> resolves the name; the store fetches it on filter set.
- Tags view: the kind chip-group used mandatory="false" — a STRING ("false"
  is truthy in JS), which made the group mandatory so the active kind chip
  couldn't be deselected. Fixed to :mandatory="false" so the filter clears.

Tests: GET /tags/<id> shape + 404; gallery store resolves filterTagName.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:26:04 -04:00
bvandeusenandClaude Opus 4.8 e678d1dfdf feat(tags): fandom-edit UI in tags directory + image modal
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m47s
CI / intapi (push) Successful in 7m52s
CI / intcore (push) Successful in 8m58s
Adds the missing UI to change a character tag's fandom, in both places:

- FandomSetDialog (shared): pick an existing fandom, create a new one, or
  clear it; on a name collision in the target fandom it surfaces a merge
  confirmation and resolves via setFandom(merge:true). Reuses the tags
  store's fandom cache.
- TagCard kebab gains "Set fandom…" for character tags (→ TagsView opens
  the dialog, reloads on success).
- TagPanel chip kebab gains "Set fandom…" for character tags (→ reloads the
  modal's tag list on success).
- tags store: setFandom(tagId, fandomId, {merge}) action + test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:21:25 -04:00
bvandeusenandClaude Opus 4.8 d9ab6e15c6 feat(tags): edit a character tag's fandom (backend)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 27s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m46s
CI / intcore (push) Successful in 8m29s
No way existed to change which fandom a character tag belongs to after
creation — PATCH /tags/<id> only renamed.

- TagService.set_fandom(tag_id, fandom_id, merge=False): set / change /
  clear (fandom_id=None) a character's fandom, with the same validation as
  find_or_create. On a name collision in the target fandom it raises
  TagMergeConflict (→ 409, same shape as rename); merge=True resolves it by
  merging this tag INTO the existing character.
- Extract _do_merge(source, target) from merge() so set_fandom can perform
  the deliberate CROSS-fandom merge the public merge() validation forbids.
- PATCH /tags/<id> now accepts optional fandom_id (+ merge flag) alongside
  name, and returns fandom_id.

Tests: set/change/clear, non-character + bad-ref rejection, collision
raises, merge resolves; API set/clear + collision→merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:13:24 -04:00
bvandeusenandClaude Opus 4.8 e05e0b9f37 perf(gallery): materialize indexed effective_date sort key
CI / lint (push) Successful in 3s
CI / intimp (push) Successful in 3m51s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m19s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 24s
The gallery cursored on COALESCE(post.post_date, image_record.created_at)
across the Post outer join — an expression spanning two tables that no
index can serve, so every /scroll sorted a large slice of the library
(and the old frontend fired ten serially). Materialize it:

- image_record.effective_date column + ix_image_record_effective_date
  (effective_date DESC, id DESC); alembic 0035 backfills
  COALESCE(primary post's post_date, created_at) for existing rows.
- gallery_service._effective_date_col() now returns the column, so scroll
  / timeline / jump / neighbors all order off the index instead of
  re-deriving the COALESCE. _neighbors reads record.effective_date
  directly (drops an extra Post lookup).
- importer._apply_sidecar maintains it: when a primary post with a date is
  linked, effective_date = post.post_date; plain inserts keep the
  created_at-equivalent server default.

Tests: sidecar import asserts effective_date == post.post_date; gallery
ordering/timeline/jump test seeds set effective_date alongside created_at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:58:46 -04:00
bvandeusenandClaude Opus 4.8 56cc253009 feat(gallery): reveal tiles on image load + single initial fetch
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 29s
CI / intcore (push) Successful in 8m22s
CI / intimp (push) Successful in 3m43s
CI / intapi (push) Successful in 7m43s
The 5×10 metadata batching only staggered the cheap layer (JSON);
thumbnails load as independent <img> requests and clustered, so tiles
"popped in together" after a wait. Two changes:

- GalleryItem reveals each tile when ITS OWN thumbnail fires @load (with
  an onMounted complete-check for cached thumbs), playing a showcase-style
  flip-up entrance. Tiles now cascade in natural load order instead of all
  at once. Honors prefers-reduced-motion.
- gallery store does ONE initial fetch (limit=50) instead of 10 serial
  /scroll round-trips. Fewer RTTs, faster first paint; the reveal-on-load
  is what makes appearance progressive now. Infinite scroll pulls 25/trigger.

Tests: GalleryItem gains is-loaded only after @load; loadInitial issues
exactly one scroll request at the initial limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:50:03 -04:00
bvandeusen 844bb86802 Merge pull request 'fix(download): release DB connections across the gallery-dl subprocess' (#60) 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 10s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m24s
2026-06-03 22:11:36 -04:00
bvandeusenandClaude Opus 4.8 576e16d14d fix(download): release DB connections across the gallery-dl subprocess
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 27s
CI / intimp (push) Successful in 3m30s
CI / intapi (push) Successful in 7m19s
CI / intcore (push) Successful in 8m6s
Backfill events were STILL stranding empty after the timeout-ladder fix.
Worker logs showed the salvage path working ("Download timeout for
anduo/patreon after 1170.0s (18 files written)") but then:
  Retry in 3s: DBAPIError(ConnectionDoesNotExistError: connection was
  closed in the middle of operation)
  ...succeeded in 0.149s   <- in-flight guard no-op

Root cause: DownloadService held the async + sync DB connections checked
out across the entire (≤19.5-min backfill) gallery-dl subprocess. The
server reaps the idle connection, so phase 3's first query hits a dead
socket. That DBAPIError trips download_source's autoretry_for, the retry
re-enters _phase1_setup, sees the event still 'running', returns
in_flight and no-ops — leaving the event to be stranded empty by the
recovery sweep. pool_pre_ping was already on both engines but can't help
a *held* connection (it only validates on pool checkout).

Fix:
- DownloadService.download_source closes the async + sync sessions after
  phase 1, before the subprocess, so phase 3 re-acquires a live
  connection (matches the class's "Phase 2 — no DB connection" docstring).
- The per-task async engine switches to NullPool so phase 3 always opens
  a fresh connection rather than a pooled one the server may have reaped.

Tests: assert connections are released before gdl.download runs and the
event still finalizes; assert the task engine uses NullPool. Also fixes a
stale 1800s->1170s comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:49:52 -04:00
bvandeusen a8f6a464aa Merge pull request 'fix(download): salvage soft-time-limit kills + fix timeout ladder' (#59) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 21s
CI / frontend-build (push) Successful in 24s
Build images / build-web (push) Successful in 2m37s
Build images / build-ml (push) Successful in 3m21s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m15s
2026-06-03 19:35:45 -04:00
bvandeusenandClaude Opus 4.8 9cb24c9e1b style(test): fix ruff I001 import order in download task test
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:04:29 -04:00
bvandeusenandClaude Opus 4.8 6590dcdb39 fix(download): salvage soft-time-limit kills + fix timeout ladder
CI / frontend-build (push) Successful in 21s
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 21s
CI / intimp (push) Successful in 3m32s
CI / intapi (push) Successful in 7m22s
CI / intcore (push) Successful in 8m4s
Backfill downloads stranded with empty logs + a generic "stranded by
recovery sweep" error. Root cause: the backfill gallery-dl subprocess
timeout (1170s) exceeded download_source's Celery soft_time_limit (900s),
so SoftTimeLimitExceeded preempted subprocess.TimeoutExpired. The
TimeoutExpired path (which captures partial stdout/stderr and finalizes
the event) never ran, the event was left 'running', and phase 3 never
decremented backfill_runs_remaining — so the source re-ran and
re-stranded every tick (Anduo #39912).

Two layers:
1. Raise download_source limits (soft 900→1350, hard 1200→1500) so both
   subprocess budgets (870 tick / 1170 backfill) sit below the soft
   limit with phase-3 persist headroom. Promote to module constants and
   guard the invariant with a test.
2. Catch SoftTimeLimitExceeded in download_source and finalize the
   in-flight event with a real reason, mirror phase-3 source-health, and
   decrement backfill so a chronically-slow source self-heals to tick
   mode. The existing celery_signals handler only covered TaskRun, not
   DownloadEvent — that was the gap.

Updates stale 900/1200 references in gallery_dl.py + maintenance.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:56:13 -04:00
bvandeusen ab9922ad2e Merge pull request 'feat(artist): "new since last visit" badge + banner' (#58) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
Build images / build-ml (push) Successful in 2m53s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 2m9s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m9s
2026-06-03 16:20:54 -04:00
bvandeusen 3162cff96b fix(artist): ruff UP017 + test_directory_card_shape pin
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 27s
CI / intimp (push) Successful in 3m29s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 8m4s
Two CI bounces on b65e956:
1. ruff UP017 — Python 3.14's preferred form is `datetime.UTC`, not
   `timezone.utc`. Switch the test's two TZ literals.
2. test_directory_card_shape pinned the card key set to the pre-feature
   shape; `unseen_count` was added to the API payload but the pin
   wasn't updated. Same shape as the recurring 'plan-grep-pinned-tests'
   trap — should have grepped tests/ for card.keys() before pushing.
2026-06-03 15:45:59 -04:00
bvandeusen b65e956ad2 feat(artist): "new since last visit" badge + banner
CI / lint (push) Failing after 2s
CI / backend-lint-and-test (push) Successful in 14s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m39s
CI / intapi (push) Failing after 7m41s
CI / intcore (push) Successful in 8m42s
Per-artist "+N" accent pill on the artists directory and a "N new since
last visit" banner inside ArtistView. Counts new IMAGES (not posts) so
multi-image posts increment correctly.

- alembic 0034: artist_visit (artist_id PK, last_viewed_at NOT NULL).
  Seeds every existing artist with last_viewed_at=NOW() so the badge
  starts at 0 across the board — no noisy "5000 unseen images" on
  first deploy.
- ArtistService.find_or_create autoseeds a visit row alongside new
  artists, so freshly imported content doesn't read as unseen.
- ArtistService.overview reads pre-visit last_viewed_at, counts images
  created since, then atomically UPSERTs last_viewed_at=NOW() via
  postgres ON CONFLICT DO UPDATE (no SELECT-then-INSERT race per
  reference_scalar_one_or_none_duplicates). Returns the pre-update
  count as `unseen_count_at_visit` so the banner has data.
- ArtistDirectoryService.list_artists adds an `unseen_count` aggregate
  to each card via LEFT JOIN artist_visit + conditional COUNT. NULL
  last_viewed_at (artist created before this code shipped) defensively
  counts as "never visited" → all images unseen.
- Frontend: ArtistCard renders an accent pill in the preview-strip
  corner when unseen_count > 0 (capped at 99+); ArtistView shows a
  closable v-alert banner on initial load when
  unseen_count_at_visit > 0, re-arms on slug change.

Single-row-per-artist (no user_id) — rule #47 multi-user ACL is
aspirational; widens to (user_id, artist_id) PK when User lands, per
rule #22.

Scribe plan #597.
2026-06-03 15:27:11 -04:00
bvandeusen 0533807669 Merge pull request 'feat(ext): verify cookies in-browser before uploading (1.0.7)' (#57) from dev into main
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 3m28s
Build images / build-web (push) Successful in 3m42s
CI / intapi (push) Successful in 9m32s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 14s
Build images / sign-extension (push) Successful in 3m17s
CI / intimp (push) Successful in 4m26s
CI / lint (push) Successful in 3s
CI / intcore (push) Successful in 10m18s
2026-06-03 14:17:42 -04:00
bvandeusen d3245f0c22 feat(ext): verify cookies in-browser before uploading (1.0.7)
CI / intcore (push) Successful in 8m45s
extension / lint (pull_request) Successful in 14s
CI / backend-lint-and-test (push) Successful in 37s
CI / frontend-build (push) Successful in 40s
extension / lint (push) Successful in 36s
CI / lint (push) Successful in 4s
CI / intimp (push) Successful in 4m4s
CI / intapi (push) Successful in 8m2s
Pre-upload verify request: after capturing the live browser cookies,
hit a known authenticated endpoint with credentials:'include' from the
extension's background context. If the platform reports we're not
logged in, abort the upload so we don't overwrite FC-side credentials
with stale data.

- platforms.js: add `verify` config per cookie-auth platform
  - hentaifoundry: HEAD /?enterAgree=1 (mirrors gallery-dl's HF
    _init_site_filters; same 401 path the operator hit 2026-06-03)
  - patreon: GET /api/current_user (clean 401 when logged out)
  - subscribestar, deviantart: no stable auth endpoint, skip verify
- cookies.js: verifyCookiesForPlatform() returns {ok, status, reason}.
  ok=true/false/null tri-state — null = verify not configured, caller
  treats as "proceed".
- background.js EXPORT_COOKIES + EXPORT_ALL_COOKIES: verify gates the
  upload; failures bubble up with the platform's name + reason.
- popup.js: success message now appends "(verified ✓)" when applicable.
- manifest + package.json: 1.0.6 → 1.0.7.
2026-06-03 14:04:45 -04:00
bvandeusen 279dff3fb6 Merge pull request 'feat(ml): normalize Camie suggestion names to human-readable' (#56) from dev into main
Build images / build-ml (push) Successful in 2m55s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m24s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 2m17s
2026-06-03 13:18:44 -04:00
bvandeusen e450145304 fix(ml): preserve digit-only tag names in normalize (year tags)
CI / frontend-build (push) Successful in 21s
CI / intapi (push) Successful in 7m42s
CI / intcore (push) Successful in 8m12s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 14s
CI / intimp (push) Successful in 3m45s
Rule 8 'no letters -> drop' was over-eager: bare digit tags like '2005'
returned None even though they're legitimate (booru year-tag shape).
Widen the keep-condition to any alphanumeric. Emoticons (':/', '^_^',
'+_+') still drop since they contain neither letters nor digits.
2026-06-03 13:09:35 -04:00
bvandeusen a6e8d4b52e feat(ml): normalize Camie suggestion names to human-readable
CI / lint (push) Successful in 2s
CI / intimp (push) Successful in 3m57s
CI / intapi (push) Successful in 7m40s
CI / intcore (push) Successful in 8m22s
CI / backend-lint-and-test (push) Failing after 24s
CI / frontend-build (push) Successful in 28s
Camie's booru-style vocab strings (`uchiha_sasuke_(naruto)`,
`#unicus_(idolmaster)`, `1000-nen_ikiteru_(vocaloid)`, `:/`) were
surfacing raw in SuggestionsPanel — and worse, the SAME raw string was
written to tag.name on Accept, polluting the DB with `underscored_lowercase`
names that don't match the operator's "Title Case" tag convention.

Add backend/app/services/ml/tag_name.py with a single normalize()
applying nine rules (strip leading junk #/./+/;/~/_/ws, drop trailing
_(disambiguator) blocks iteratively, strip wrapping quotes, underscores
to spaces, space after colon, title-case each word's first char,
preserve hyphens/apostrophes/digits, drop entries with no letters).

Wire into SuggestionService.for_image:
- raw Camie key kept for alias_map lookup (alias rows are hand-curated
  against raw keys; don't disturb)
- display_name = normalize(raw); None means drop the candidate
- existing-tag lookup widened to case-insensitive match against BOTH
  raw and normalized forms so legacy underscore-named Tag rows accepted
  before this change still surface as "existing" not "+ new"
2026-06-03 13:00:08 -04:00
bvandeusen 37e66cddc4 Merge pull request 'chore(modal): drop ?image=N soft-compat — pure overlay' (#55) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m46s
Build images / build-web (push) Successful in 2m36s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m12s
2026-06-02 19:35:04 -04:00
bvandeusen f1860866de chore(modal): drop the ?image=N soft-compat from G5.4
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 14s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m38s
CI / intcore (push) Successful in 9m31s
Operator confirmed they have no existing ?image=N bookmarks to
preserve, so the soft-compat read on initial mount + router.replace
strip is dead weight. The modal is now purely a Pinia overlay — no
URL involvement on open OR initial mount.

Drops 33 lines plus the now-unused vue-router imports in both
ArtistGalleryTab (entire onMounted gone) and GalleryView (just the
?image=N block; the post_id/tag_id filter handling stays).
2026-06-02 19:24:09 -04:00
bvandeusen 9cf6b2d363 Merge pull request 'audit-g5 final + ML threshold default + kebab menu fix' (#54) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 27s
Build images / build-web (push) Successful in 3m0s
Build images / build-ml (push) Successful in 3m45s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 8m8s
CI / intcore (push) Successful in 8m34s
2026-06-02 19:09:49 -04:00
bvandeusen b181d779fe fix(test): default suggestion_threshold_general now 0.70 (alembic 0033)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 17s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m49s
CI / intapi (push) Successful in 8m13s
CI / intcore (push) Successful in 7m6s
2026-06-02 18:49:24 -04:00
bvandeusen 0fbb19dc24 fix(modal): TagPanel kebab — apply the wrapping-span fix that the prior commit missed
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 37s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m59s
CI / intapi (push) Failing after 8m56s
CI / intcore (push) Successful in 10m0s
The previous commit (8326e54) updated the CSS but the structural
edit to the v-menu wrapper didn't take. Re-applying so the chip
kebab actually opens.
2026-06-02 18:48:53 -04:00
bvandeusen 8326e5447a fix(modal): kebab menus weren't opening (chip + suggestion rows)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Failing after 8m41s
CI / intcore (push) Successful in 10m0s
Operator-flagged 2026-06-02. Both kebab activators were broken:

- TagPanel chip kebab had `@click.stop` directly on the v-icon
  activator. In Vue 3, an explicit @click on the same element as
  `v-bind="props"` overrides the spread onClick — so Vuetify's
  activator handler never fired. Menu never opened.

- SuggestionItem kebab didn't have @click.stop, but for consistency
  and to make both kebabs follow the same shape, wrap it too.

The fix: each kebab is now wrapped in a `<span @click.stop>`. The
v-icon / v-btn receives Vuetify's onClick cleanly and opens the menu;
the bubbling click then reaches the span where stopPropagation
absorbs it before it can affect a parent (the v-chip's close button
in TagPanel's case).
2026-06-02 18:48:32 -04:00
bvandeusen 1fd594baaf chore(ml): suggestion_threshold default 0.50 → 0.70
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 28s
CI / frontend-build (push) Successful in 29s
CI / intimp (push) Successful in 3m44s
CI / intapi (push) Failing after 8m16s
CI / intcore (push) Successful in 8m43s
Operator-flagged 2026-06-02 — the 0.50 default (set on 2026-06-01)
surfaces too many low-confidence picks in the modal's Suggestions
rail. 0.70 keeps the rail signal-rich while still showing more than
the original 0.95 (which hid almost everything).

Alembic 0033 updates the singleton row conditionally — only rows
still at the old 0.50 default flip to 0.70. Operators who tuned to
some other value via Settings → ML keep their pick.

Settings UI already exposes both sliders (MLThresholdSliders.vue),
so further tuning continues to work without a deploy.
2026-06-02 18:38:12 -04:00
bvandeusen ecac6c4bda fix(audit-g5): centroid version DB-as-truth + modal as overlay
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 24s
CI / frontend-build (push) Successful in 27s
CI / intimp (push) Successful in 3m49s
CI / intapi (push) Successful in 7m57s
CI / intcore (push) Successful in 8m46s
Closes the last two findings from the 2026-06-02 audit (G5.1 + G5.4).

G5.1 — Centroid version no longer drifts:

CentroidService now reads MLSettings.embedder_model_version (the DB
row tag_and_embed already writes from) for both the centroid model-
version stamp and the drift-detection comparison. Previously the
centroid sites imported MODEL_VERSION from env, so the version stamped
on centroids could disagree with the version stamped on the embeddings
they were built from. By construction those now match, so list_drifted
won't silently miss the env-vs-DB drift case.

embedder.py keeps MODEL_VERSION as an env-driven constant for the
actual model loader — that's a different concern (which weights are
loaded) from the version-stamp that gets persisted alongside data.

G5.4 — Modal is a Pinia-only overlay:

The previous URL↔modal sync in GalleryView and ArtistGalleryTab
leaked the modal across route changes (RouterLink to /artist/<slug>
left the modal mounted on top of the new route) and re-opened it
on history back/forward with stale ?image=N entries.

Now: openImage() just calls modal.open(id) — no URL push.
GalleryView's dead closeImage helper is deleted. A route.name
watcher in App.vue closes the modal whenever the route changes,
which auto-fixes RouterLink-in-modal and back/forward.

Backward-compat: ?image=N is still honored on initial mount as a
one-shot deep-link opener, then router.replace strips the query so
the URL doesn't re-trigger and no extra history entry is added.
Existing bookmarks / shared URLs keep working; new opens stay
Pinia-only.
2026-06-02 18:28:57 -04:00
bvandeusen 6ef0fed41f Merge pull request 'audit-g5: architectural debt — 4 bundles (A/B/C/D)' (#53) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 18s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m51s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m52s
2026-06-02 18:07:25 -04:00
bvandeusen 9f7261b9c0 fix(audit-g5c): set CURATOR_BOOTSTRAP_NEW_KEY=1 in conftest
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 17s
CI / frontend-build (push) Successful in 19s
CI / intimp (push) Successful in 3m50s
CI / intapi (push) Successful in 8m33s
CI / intcore (push) Successful in 9m7s
CredentialCrypto's safety check fires on create_app() instantiation
because the test environment has no pre-seeded Fernet key file. Set
the bootstrap env var before any test imports so the auto-create path
is allowed during tests.
2026-06-02 17:55:25 -04:00
bvandeusen f05aaa707b fix(audit-g5d): surface ErrorType taxonomy on FailingSourcesCard
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Failing after 26s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Failing after 8m10s
CI / intcore (push) Failing after 9m42s
Alembic 0032 adds Source.error_type (varchar(32), indexed).
_update_source_health stamps it alongside last_error on status='error'
and clears it on 'ok'. SourceRecord/to_dict exposes it.

FailingSourcesCard renders a colored chip next to the consecutive-
failures count, with a tooltip explaining the suggested operator
action. Color reflects intent:
  - warning (yellow) — operator action needed (auth_error)
  - info (blue)      — backend-paced (rate_limited / timeout /
                       network_error / partial / tier_limited)
  - error (red)      — likely terminal without intervention
                       (not_found / access_denied / validation_failed /
                        unsupported_url / http_error / unknown_error)

Audit 2026-06-02: the backend computed 13 ErrorType categories but
only the free-text last_error reached the operator. Bulk-triage by
class ("all auth_error → rotate cookies", "12 rate_limited → just
wait") required opening Logs per row.
2026-06-02 17:54:44 -04:00
bvandeusen 4df98171ab fix(audit-g5c): refuse silent Fernet key regeneration on partial restore
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Failing after 25s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Failing after 8m13s
CI / intcore (push) Failing after 8m48s
Audit 2026-06-02: `_load_or_create_key` silently minted a new Fernet
key whenever the key file was missing — no log, no warning. The
failure mode the audit flagged: a partial disaster restore where the
DB was restored but `/images/secrets/` was lost would produce a
working-looking system in which every authenticated download fails
AUTH_ERROR until the operator re-uploads every credential by hand.

Two opt-ins now needed for auto-creation:
  1. Explicit `bootstrap_ok=True` kwarg (tests, scripts), OR
  2. `CURATOR_BOOTSTRAP_NEW_KEY=1` env var (operator first-time setup)

Otherwise the constructor raises `MissingCredentialKey` so the app
fails fast at startup and the operator can restore the key file
from backup before encrypted_blob rows go undecryptable.

Also: docstring path was wrong (said "images/data root" but actual
location is `/images/secrets/credential_key.b64`) — corrected.

Tests updated to pass `bootstrap_ok=True` explicitly, and two new
tests cover the safety behavior (missing-key-raises, env-var-bootstraps).
2026-06-02 17:04:27 -04:00
bvandeusen 8d75ade1d5 fix(audit-g5b): race-poisoning in three find-or-create sites
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m34s
CI / intapi (push) Successful in 7m59s
CI / intcore (push) Successful in 9m29s
Audit 2026-06-02 flagged three SELECT-then-INSERT sites that lost the
race under concurrent writers / recovery-sweep replays, poisoning the
outer transaction with an unrecoverable IntegrityError and crashing
the calling task. Same shape as the banked 2026-05-26 ImageProvenance
incident (see reference_scalar_one_or_none_duplicates memory).

Mirrors the importer._get_or_create pattern in all three: savepoint
via begin_nested + IntegrityError rollback to that savepoint + re-
SELECT to grab the row the other worker committed first.

- importer._capture_attachment: PostAttachment.sha256 UNIQUE. attachments.store
  is sha-addressed so both workers race to write the same on-disk path
  (shutil.copy2 + rename is idempotent), so no extra cleanup needed.

- TagService.find_or_create: partial uniqueness index on
  (name, kind, COALESCE(fandom_id, -1)). The previous docstring
  claimed "INSERT ... ON CONFLICT" but the implementation was
  SELECT-then-INSERT with no recovery.

- ArtistService.find_or_create: already had IntegrityError handling
  but did session.rollback() (unwinds the WHOLE transaction); now
  uses begin_nested + sp.rollback() so the surrounding request's
  progress isn't lost.
2026-06-02 17:02:12 -04:00
bvandeusen 75c63e1511 fix(audit-g5a): ruff isort — platforms after patreon_resolver
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m20s
2026-06-02 16:55:56 -04:00
bvandeusen 98673d4dca fix(audit-g5a): small architectural cleanups bundle
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 17s
CI / frontend-build (push) Successful in 31s
CI / intimp (push) Successful in 3m45s
CI / intapi (push) Successful in 7m46s
CI / intcore (push) Successful in 8m23s
Five small G5 findings from the 2026-06-02 audit. Each is local and
follows an established FC pattern.

- download_service: replace hardcoded ('discord','pixiv') tuple with
  auth_type_for(platform) == 'token'. A 7th token-platform now picks
  up the right credential path without touching this site.

- /api/tags/<source_id>/merge enqueues recompute_centroid.delay after
  merge so the target's centroid reflects its new image set
  immediately. Daily list_drifted catches it within 24h, but eager
  recompute closes the suggestion-quality dip in the meantime.

- backfill_thumbnails added to beat_schedule (daily). The task
  docstring claimed periodic Beat but the entry was never registered,
  so the library got no self-healing thumbnail repair; only the
  manual admin-UI button fired it.

- modal.createAndAdd pushes a kind='fandom' tag into
  tagsStore.fandomCache so FandomPicker sees the new fandom on next
  open. Was: cache-gated load (length===0) skipped refetch, new
  fandom invisible until full page reload.

- cleanup cluster:
  - Drop .webp from cleanup_service.unlink — thumbnailer only writes
    .jpg/.png; the third tuple member was dead code.
  - Drop effective_date from /api/gallery/scroll response — no FE
    consumer reads it. Service still computes the attribute for
    timeline ordering; this just trims the JSON.
  - Rename store.recentMinute → store.recentRuns across the
    systemActivity store + three consumers (SystemActivitySummary,
    QueuesTable, SystemActivityTab). The data is the last 200 runs
    (not actually "last minute"), so the name lied.

NOT in this bundle: the duplicate tag-merge endpoint
(/api/tags vs /api/admin/tags) is harder — has 1 FE caller and 3 tests
on the admin variant; consolidation is its own change.
2026-06-02 16:46:46 -04:00
bvandeusen 89b48f8f35 Merge pull request 'audit-g4: status-enum miss batch' (#52) 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 11s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 35s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m10s
2026-06-02 16:15:00 -04:00
bvandeusen 4bff1d8558 fix(audit-g4): status-enum miss batch
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 14s
CI / frontend-build (push) Successful in 19s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m37s
CI / intcore (push) Successful in 8m18s
Five extension-miss findings from the 2026-06-02 audit, where a status
value was added on one side but a downstream consumer didn't pick it up.

- download_service._phase3_persist: explicit branches for
  ImportResult.status in ('failed','refreshed'). For 'failed' (archive
  probe crash from _import_archive), unlink the source file so the
  filesystem scanner doesn't re-import and re-crash on the same
  archive forever. 'refreshed' is currently unreachable from the
  download path (no deep=True) but matches the importer's documented
  contract; treat as 'attached'.

- gallery-dl backfill auto-complete now gates on dl_result.success +
  no error_type, not just return_code==0 + files_downloaded==0.
  VALIDATION_FAILED exits the subprocess with returncode=0 and
  files_downloaded=0 when every file was quarantined, matching the
  prior predicate exactly and zeroing the operator's armed backfill
  budget on the FIRST quarantine run instead of decrementing.

- attach_in_place archive dispatch now threads artist + source_row
  through _import_archive (and _import_media for archive members)
  and _supersede. The path-walk fallback (_resolve_artist) is still
  used by filesystem-import; the download path now binds
  ImageProvenance to the explicit subscription Source instead of
  rediscovering by (artist_id, platform).

- Three FE handlers now recognize status:'deferred' from
  /api/sources/<id>/check: SubscriptionsTab.onCheck (was toasting
  "event #undefined"), SubscriptionsTab.checkAll (was counting
  deferred as queued), DownloadEventRow.onRetry (was saying
  "re-queued" when nothing was). Pattern matches DownloadsTab.onRetryAll
  which already had it.

- celery_signals._queue_for now maps backup/admin/library_audit
  prefixes to 'maintenance' (matching task_routes). TaskRun.queue
  was returning 'default' for those rows, so per-queue dashboard
  filters and per-queue threshold overrides (added in G3) silently
  missed them.
2026-06-02 16:04:59 -04:00
bvandeusen d60e0b9494 Merge pull request 'audit-g3: lifecycle batch — recovery sweeps, retention, timeouts' (#51) 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 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 36s
CI / intimp (push) Successful in 3m39s
CI / intapi (push) Successful in 7m39s
CI / intcore (push) Successful in 8m10s
2026-06-02 14:49:28 -04:00
bvandeusen e30f50e6fe fix(audit-g3): lifecycle batch — recovery sweeps, retention, timeouts
CI / lint (push) Successful in 3s
CI / intapi (push) Successful in 7m30s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 33s
CI / intimp (push) Successful in 3m30s
CI / intcore (push) Successful in 8m8s
Plugs the FC long-running-entity discipline gaps the 2026-06-02 audit
flagged: every entity that can get stuck now has recovery + retention +
timeout, and the long-runners no longer collide with the FC-3i sweep.

Recovery sweeps (every 5 min):
- recover_stalled_backup_runs — flips BackupRun stuck in
  running/restoring past 7h (covers the 6.5h images-backup hard
  limit) to error. prune_backups docstring corrected — the FC-3i
  TaskRun sweep never touched BackupRun rows.
- recover_stalled_library_audit_runs — flips LibraryAuditRun stuck
  past 135 min (10-min buffer above scan_library_for_rule's 2h5m
  hard limit) to error. Previously a SIGKILL'd row blocked all
  future audits until manual DB surgery.
- recover_stalled_import_batches — finalizes ImportBatch rows
  stuck running >2h whose child tasks are all terminal (orphan case
  where the orchestrator crashed before the closing UPDATE). Uses
  the same EXISTS predicate /api/system/stats already had.

Retention (daily):
- prune_library_audit_runs — 30-day window. Audit rows carry
  matched_ids JSONB blobs that can hold tens of thousands of ids.
- prune_import_batches — 30-day window. Cascades to ImportTask via
  the model relationship.

time_limits on five long-runners that previously had none (the
audit's headline finding — every one of these collided with the
recover_stalled_task_runs 5-min default and could be marked
'error' mid-flight):
- scan_directory: 60m soft / 70m hard
- verify_integrity: 60m / 70m
- backfill_phash: 30m / 35m
- apply_allowlist_tags: 30m / 35m
- recompute_centroids: 30m / 35m

QUEUE_STUCK_THRESHOLD_MINUTES now covers maintenance (75) and scan
(75) — above the longest task on each — with per-task overrides
for the outliers (backup_images_task 420, restore_images_task 420,
scan_library_for_rule 130).

start_audit_run guard is now age-aware: a 'running' row older than
the audit hard limit doesn't block a new run (the sweep will catch
it within 5 min). Previously a SIGKILL'd row blocked forever.

/api/import/status now uses the same EXISTS predicate
/api/system/stats does, so the two endpoints no longer disagree on
the active-batch question.

DownloadEvent.started_at resets on pending→running so a freshly-
promoted event from a busy queue isn't measured against its
original enqueue time (was racing recover_stalled_download_events
on heavy-queue days).
2026-06-02 14:30:46 -04:00
bvandeusen 9c27a2d3c7 Merge pull request 'audit-g2: async race / state-leak fixes across eight stores' (#50) 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 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s
2026-06-02 14:17:12 -04:00
bvandeusen e66987f092 fix(audit-g2): async race / state-leak across eight stores
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 21s
CI / frontend-build (push) Successful in 34s
CI / intimp (push) Successful in 3m31s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m2s
Extracts gallery.js's hand-rolled inflightId pattern into a new
useInflightToken composable; adopts in every store that previously
had no guard against late-response overwrites or wrong-image URL
interpolation.

Two operator-impacting bugs the audit (workflow wf_bbe3fdb1-e62)
flagged:

- modal.removeTag rolled back the chip rail unconditionally even
  when only the secondary dismiss POST had failed — UI lied until
  refresh. And all tag-mutation URLs interpolated currentImageId
  AFTER an await, so a fast prev/next could route DELETE/POST to
  the wrong image. Both fixed: split try/catch (dismiss failure
  surfaces a warning, doesn't roll back the delete); imageId
  captured at call-time and used in URLs throughout.

- suggestions.accept dereferenced currentImageId after the awaited
  POST /api/tags, so the subsequent /suggestions/accept could
  apply A's chosen tag to image B AND push it to B's allowlist.
  Fixed by capturing imageId at click-time + inflight guard on
  load().

Same shape across artist / downloads / artistDirectory /
tagDirectory / posts stores: rapid filter/nav changes used to
interleave responses (last-writer-wins). Now the late response is
discarded and the most-recent request wins. Filter-change-during-
search no longer drops the second fetch because the loading flag
was still true from the first.

gallery.js's inflightId removed in favor of the shared composable
so the pattern stays consistent.
2026-06-02 14:07:58 -04:00
134 changed files with 6457 additions and 1084 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'
+3
View File
@@ -61,6 +61,9 @@ Thumbs.db
# Claude Code per-user local overrides (shared .claude/settings.json is OK to commit) # Claude Code per-user local overrides (shared .claude/settings.json is OK to commit)
.claude/settings.local.json .claude/settings.local.json
# Transient scheduler lock/state (committed by accident in 3f30327)
.claude/scheduled_tasks.lock
.claude/scheduled_tasks*.json
# Alembic / DB scratch # Alembic / DB scratch
alembic/versions/__pycache__/ alembic/versions/__pycache__/
@@ -0,0 +1,41 @@
"""source.error_type: surface ErrorType taxonomy in FailingSourcesCard
Revision ID: 0032
Revises: 0031
Create Date: 2026-06-02
Audit 2026-06-02: the backend computes 13 ErrorType categories (auth_error,
rate_limited, not_found, access_denied, validation_failed, etc.) and
stamps each one on DownloadEvent.metadata, but the Source row only carried
the free-text last_error. Operators couldn't bulk-triage failing sources
("all auth_error → rotate cookies, all rate_limited → just wait") without
opening Logs per row.
This column receives the last error_type from _update_source_health
and gets cleared on a successful run. Nullable + indexed so the failing-
sources rollup can filter/group cheaply.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0032"
down_revision: Union[str, None] = "0031"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"source",
sa.Column("error_type", sa.String(length=32), nullable=True),
)
op.create_index(
"ix_source_error_type", "source", ["error_type"],
)
def downgrade() -> None:
op.drop_index("ix_source_error_type", table_name="source")
op.drop_column("source", "error_type")
@@ -0,0 +1,48 @@
"""suggestion_threshold default 0.50 → 0.70
Revision ID: 0033
Revises: 0032
Create Date: 2026-06-02
Operator-flagged 2026-06-02 — the 0.50 default (set on 2026-06-01) is
too noisy in practice; raise to 0.70 for both suggestion categories.
Only conditionally updates singletons whose current value is still the
2026-06-01 default (0.50). Operators who deliberately tuned their row
to some other value (0.55, 0.65, 0.80, etc. via the Settings UI) keep
their pick — the migration only catches the unchanged-default case.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0033"
down_revision: Union[str, None] = "0032"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_character = 0.70 "
"WHERE id = 1 AND suggestion_threshold_character = 0.50"
)
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_general = 0.70 "
"WHERE id = 1 AND suggestion_threshold_general = 0.50"
)
def downgrade() -> None:
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_character = 0.50 "
"WHERE id = 1 AND suggestion_threshold_character = 0.70"
)
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_general = 0.50 "
"WHERE id = 1 AND suggestion_threshold_general = 0.70"
)
+53
View File
@@ -0,0 +1,53 @@
"""artist_visit: per-artist last-viewed timestamp for the "+N new" badge
Revision ID: 0034
Revises: 0033
Create Date: 2026-06-03
Powers the artists-directory "+N new since last visit" badge + ArtistView
banner. Single row per artist (no user_id yet — rule #47 multi-user ACL
is aspirational; widens to (user_id, artist_id) PK when User lands).
Seed every existing artist with `last_viewed_at = NOW()` so the badge
starts at 0 across the board — no noisy "you have 5000 unseen images"
on first deploy. New artists auto-get a row via
`ArtistService.find_or_create`.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0034"
down_revision: Union[str, None] = "0033"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"artist_visit",
sa.Column(
"artist_id",
sa.Integer,
sa.ForeignKey("artist.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"last_viewed_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# Seed: every existing artist starts "fully caught up". Without this,
# every operator with N artists would see N badges (worth of every
# image ever imported) on first deploy.
op.execute(
"INSERT INTO artist_visit (artist_id, last_viewed_at) "
"SELECT id, NOW() FROM artist"
)
def downgrade() -> None:
op.drop_table("artist_visit")
@@ -0,0 +1,70 @@
"""image_record.effective_date: materialized gallery sort key + index
Revision ID: 0035
Revises: 0034
Create Date: 2026-06-04
The gallery ordered/cursored on COALESCE(post.post_date,
image_record.created_at) across the Post outer join. That expression spans
two tables, so no index can serve it — every /scroll sorted a large slice
of the library, and the frontend fired ten of them serially per initial
load. Materialize the value into image_record.effective_date and index
(effective_date DESC, id DESC) so the cursor scroll is an index range scan.
Backfill = COALESCE(primary post's post_date, created_at) so existing rows
keep their exact ordering. New rows get the created_at-equivalent server
default; services/importer.py overrides it with the post's date when a
primary post with a date is linked.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0035"
down_revision: Union[str, None] = "0034"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add nullable first so the backfill can populate before NOT NULL.
op.add_column(
"image_record",
sa.Column("effective_date", sa.DateTime(timezone=True), nullable=True),
)
# Pure set-based UPDATEs (no per-row params) — immune to the 65535
# bind-parameter ceiling regardless of library size.
op.execute(
"""
UPDATE image_record AS ir
SET effective_date = COALESCE(p.post_date, ir.created_at)
FROM post AS p
WHERE ir.primary_post_id = p.id
"""
)
op.execute(
"""
UPDATE image_record
SET effective_date = created_at
WHERE effective_date IS NULL
"""
)
op.alter_column(
"image_record",
"effective_date",
nullable=False,
server_default=sa.text("now()"),
)
# DESC/DESC matches the gallery's ORDER BY effective_date DESC, id DESC
# so the scroll is a forward index scan; raw SQL because alembic's
# column list doesn't express per-column DESC cleanly.
op.execute(
"CREATE INDEX ix_image_record_effective_date "
"ON image_record (effective_date DESC, id DESC)"
)
def downgrade() -> None:
op.drop_index("ix_image_record_effective_date", table_name="image_record")
op.drop_column("image_record", "effective_date")
@@ -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]})
+131 -44
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,47 +10,88 @@ 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():
"""Parse the composable gallery filters from query args, returning
``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
`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_ids = (
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
) or None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
media = request.args.get("media")
media_type = media if media in ("image", "video") else None
sort = request.args.get("sort")
sort = sort if sort in ("newest", "oldest") else "newest"
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"])
async def scroll(): 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"))
filters, sort = _parse_filters()
except ValueError: except ValueError:
return jsonify({"error": "limit must be an integer"}), 400 return jsonify({"error": "invalid filter or limit parameter"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
page = await svc.scroll( page = await svc.scroll(
cursor=cursor, limit=limit, tag_id=tag_id, cursor=cursor, limit=limit, sort=sort, **filters,
post_id=post_id, artist_id=artist_id,
) )
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,
"effective_date": i.effective_date.isoformat(),
"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
@@ -57,20 +100,46 @@ async def scroll():
) )
@gallery_bp.route("/timeline", methods=["GET"]) @gallery_bp.route("/similar", methods=["GET"])
async def timeline(): async def similar():
tag_id_raw = request.args.get("tag_id") """Visual "more like this": images ranked by cosine distance to the
tag_id = int(tag_id_raw) if tag_id_raw else None `similar_to` image's embedding. Composes with the scope filters (AND) but
post_id_raw = request.args.get("post_id") ignores post_id and sort. Bounded top-N, no cursor."""
post_id = int(post_id_raw) if post_id_raw else None try:
artist_id_raw = request.args.get("artist_id") similar_to = int(request.args["similar_to"])
artist_id = int(artist_id_raw) if artist_id_raw else None 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: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
buckets = await svc.timeline( images = await svc.similar(image_id=similar_to, limit=limit, **scope)
tag_id=tag_id, post_id=post_id, artist_id=artist_id 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"])
async def timeline():
try:
filters, _sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400
async with get_session() as session:
svc = GalleryService(session)
try:
buckets = await svc.timeline(**filters)
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
return jsonify( return jsonify(
@@ -78,25 +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"])
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
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
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_id=tag_id, year=year, month=month, sort=sort, **filters,
post_id=post_id, artist_id=artist_id,
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
+17 -1
View File
@@ -35,10 +35,26 @@ async def trigger_scan():
@import_admin_bp.route("/status", methods=["GET"]) @import_admin_bp.route("/status", methods=["GET"])
async def status(): async def status():
async with get_session() as session: async with get_session() as session:
# Active batch = running batch that still has outstanding work.
# Plain "most recent running" picks freshly-created scans that
# enqueued zero new files and hides the older batch that's
# actually being processed. Mirrors the EXISTS predicate
# /api/system/stats already uses (api/settings.py:145-160).
# Audit 2026-06-02 — /api/import/status and /api/system/stats
# used to disagree on the active-batch predicate; the UI banner
# said "Scanning…" indefinitely while the stats card said idle.
active = ( active = (
await session.execute( await session.execute(
select(ImportBatch) select(ImportBatch)
.where(ImportBatch.status == "running") .where(
ImportBatch.status == "running",
select(ImportTask.id)
.where(
ImportTask.batch_id == ImportBatch.id,
ImportTask.status.in_(["pending", "queued", "processing"]),
)
.exists(),
)
.order_by(ImportBatch.started_at.desc()) .order_by(ImportBatch.started_at.desc())
.limit(1) .limit(1)
) )
+48 -6
View File
@@ -194,15 +194,46 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
return "", 204 return "", 204
@tags_bp.route("/tags/<int:tag_id>", methods=["GET"])
async def get_tag(tag_id: int):
"""Resolve a single tag (used by the gallery to label its active
tag-filter chip)."""
async with get_session() as session:
tag = await session.get(Tag, tag_id)
if tag is None:
return jsonify({"error": "tag not found"}), 404
return jsonify(
{
"id": tag.id,
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
}
)
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"]) @tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
async def rename_tag(tag_id: int): async def update_tag(tag_id: int):
body = await request.get_json() """Rename and/or re-fandom a tag. Body may carry `name` and/or
if not body or "name" not in body: `fandom_id` (a fandom tag id, or null to clear — character tags only).
return jsonify({"error": "name required"}), 400 `merge: true` resolves a collision by merging into the existing tag.
"""
body = await request.get_json() or {}
has_name = "name" in body
has_fandom = "fandom_id" in body
if not has_name and not has_fandom:
return jsonify({"error": "name or fandom_id required"}), 400
do_merge = bool(body.get("merge"))
async with get_session() as session: async with get_session() as session:
svc = TagService(session) svc = TagService(session)
try: try:
tag = await svc.rename(tag_id, body["name"]) tag = None
if has_name:
tag = await svc.rename(tag_id, body["name"])
if has_fandom:
tag = await svc.set_fandom(
tag_id, body["fandom_id"], merge=do_merge
)
except TagMergeConflict as exc: except TagMergeConflict as exc:
return jsonify( return jsonify(
{ {
@@ -219,7 +250,12 @@ async def rename_tag(tag_id: int):
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
await session.commit() await session.commit()
return jsonify( return jsonify(
{"id": tag.id, "name": tag.name, "kind": tag.kind.value} {
"id": tag.id,
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
}
) )
@@ -245,6 +281,12 @@ async def merge_tag(source_id: int):
from ..tasks.ml import apply_allowlist_tags from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=result.target_id) apply_allowlist_tags.delay(tag_id=result.target_id)
# Tag merge invalidates the target's centroid (the merged-in source
# tag's images now contribute to it). Daily list_drifted catches it
# within 24h, but eager recompute closes the suggestion-quality dip
# in the meantime. Audit 2026-06-02.
from ..tasks.ml import recompute_centroid
recompute_centroid.delay(result.target_id)
return jsonify( return jsonify(
{ {
"target": { "target": {
+39
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
@@ -105,6 +109,41 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.backup.prune_backups", "task": "backend.app.tasks.backup.prune_backups",
"schedule": 86400.0, # daily "schedule": 86400.0, # daily
}, },
# Audit 2026-06-02 — three new per-entity recovery sweeps.
# Each runs every 5 min like the other recover_stalled_*
# sweeps; each is a no-op when nothing is stuck.
"recover-stalled-backup-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_backup_runs",
"schedule": 300.0,
},
"recover-stalled-library-audit-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_library_audit_runs",
"schedule": 300.0,
},
"recover-stalled-import-batches": {
"task": "backend.app.tasks.maintenance.recover_stalled_import_batches",
"schedule": 300.0,
},
# Audit 2026-06-02 — daily retention for two entities
# whose terminal rows otherwise accumulate forever.
"prune-library-audit-runs": {
"task": "backend.app.tasks.maintenance.prune_library_audit_runs",
"schedule": 86400.0,
},
"prune-import-batches": {
"task": "backend.app.tasks.maintenance.prune_import_batches",
"schedule": 86400.0,
},
# Audit 2026-06-02 — backfill_thumbnails's docstring claimed
# "periodic Beat" but the entry was never registered, so the
# library got no self-healing thumbnail repair; only the
# manual admin-UI button fired it. Daily cadence is gentle
# (the task is idempotent and only enqueues regen for rows
# whose stored thumbnails are missing or corrupt).
"backfill-thumbnails-daily": {
"task": "backend.app.tasks.thumbnail.backfill_thumbnails",
"schedule": 86400.0,
},
}, },
timezone="UTC", timezone="UTC",
) )
+14 -2
View File
@@ -54,7 +54,14 @@ _INT32_MIN = -2_147_483_648
def _queue_for(task) -> str: def _queue_for(task) -> str:
"""Reverse the task→queue routing from celery_app.task_routes. """Reverse the task→queue routing from celery_app.task_routes.
Keep in sync if task_routes is reordered.""" Keep in sync if task_routes is reordered.
Audit 2026-06-02: backup/admin/library_audit prefixes were
missing here even though task_routes sent all three to
'maintenance'. The TaskRun.queue column then lied for those
rows (claimed 'default') so per-queue dashboard filters and
per-queue threshold overrides silently missed them.
"""
name = getattr(task, "name", "") or "" name = getattr(task, "name", "") or ""
if name.startswith("backend.app.tasks.import_file."): if name.startswith("backend.app.tasks.import_file."):
return "import" return "import"
@@ -66,7 +73,12 @@ def _queue_for(task) -> str:
return "download" return "download"
if name.startswith("backend.app.tasks.scan."): if name.startswith("backend.app.tasks.scan."):
return "scan" return "scan"
if name.startswith("backend.app.tasks.maintenance."): if name.startswith((
"backend.app.tasks.maintenance.",
"backend.app.tasks.backup.",
"backend.app.tasks.admin.",
"backend.app.tasks.library_audit.",
)):
return "maintenance" return "maintenance"
return "default" return "default"
+2
View File
@@ -2,6 +2,7 @@
from .app_setting import AppSetting from .app_setting import AppSetting
from .artist import Artist from .artist import Artist
from .artist_visit import ArtistVisit
from .backup_run import BackupRun from .backup_run import BackupRun
from .base import Base from .base import Base
from .credential import Credential from .credential import Credential
@@ -28,6 +29,7 @@ __all__ = [
"Base", "Base",
"AppSetting", "AppSetting",
"Artist", "Artist",
"ArtistVisit",
"BackupRun", "BackupRun",
"Source", "Source",
"Credential", "Credential",
+36
View File
@@ -0,0 +1,36 @@
"""ArtistVisit — per-artist 'last viewed' timestamp.
Powers the "+N new since last visit" badge on the artists directory and
the matching banner on `ArtistView`. One row per artist, single global
operator. When the multi-user model lands, the PK widens to
`(user_id, artist_id)` — currently aspirational only (no User model,
no services/access.py); operator approved skipping `user_id` for now
under rule #22 (breaking changes welcome).
Seed at migration time: every existing artist gets `last_viewed_at = NOW()`
so the badge starts at 0 across the board (no noisy "5000 unseen" on
first deploy). New artists also auto-get a row via
`ArtistService.find_or_create`.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ArtistVisit(Base):
__tablename__ = "artist_visit"
artist_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("artist.id", ondelete="CASCADE"),
primary_key=True,
)
last_viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
+11
View File
@@ -74,6 +74,17 @@ class ImageRecord(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
# Denormalized gallery sort key = COALESCE(primary post's post_date,
# created_at) (alembic 0035). The gallery used to compute this as a
# COALESCE across the Post outer join on every /scroll, which can't use
# an index and re-sorted a large slice of the library per page (×10 with
# the old serial batching). Materializing it lets the cursor scroll read
# ix_image_record_effective_date directly. Maintained by the importer
# (services/importer.py _apply_sidecar) when a primary post with a date
# is linked; plain inserts keep the created_at-equivalent server default.
effective_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=False, nullable=False,
+6 -5
View File
@@ -16,13 +16,14 @@ class MLSettings(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
suggestion_threshold_character: Mapped[float] = mapped_column( suggestion_threshold_character: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50 Float, nullable=False, default=0.70
) )
# Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that # Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50
# 0.95 hid most general suggestions. Operator-tunable via Settings → # surfaced too many low-confidence picks; 0.70 keeps the rail
# ML if too noisy. # signal-rich while still surfacing more than the original 0.95
# which hid almost everything. Operator-tunable via Settings → ML.
suggestion_threshold_general: Mapped[float] = mapped_column( suggestion_threshold_general: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50 Float, nullable=False, default=0.70
) )
centroid_similarity_threshold: Mapped[float] = mapped_column( centroid_similarity_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.55 Float, nullable=False, default=0.55
+5
View File
@@ -26,6 +26,11 @@ class Source(Base):
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True) last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# alembic 0032: last ErrorType category (auth_error, rate_limited,
# not_found, ...). Lets FailingSourcesCard surface the taxonomy as
# a colored chip so operators can bulk-triage by error class. Set
# by _update_source_health alongside last_error; cleared on 'ok'.
error_type: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True) check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0) consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -13,10 +13,10 @@ from __future__ import annotations
import base64 import base64
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import and_, exists, func, or_, select from sqlalchemy import and_, case, exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, ImageRecord, Source from ..models import Artist, ArtistVisit, ImageRecord, Source
from .gallery_service import thumbnail_url from .gallery_service import thumbnail_url
_SEP = "|" _SEP = "|"
@@ -58,9 +58,27 @@ class ArtistDirectoryService:
raise ValueError("limit must be between 1 and 200") raise ValueError("limit must be between 1 and 200")
count_col = func.count(ImageRecord.id).label("image_count") count_col = func.count(ImageRecord.id).label("image_count")
# Unseen = images imported since the artist's last_viewed_at.
# NULL last_viewed_at (artist created before alembic 0034 seed
# or before find_or_create autoseed) defensively counts as
# "never visited" → all images unseen. Single grouped query, no
# N+1.
unseen_col = func.count(
case(
(
or_(
ArtistVisit.last_viewed_at.is_(None),
ImageRecord.created_at > ArtistVisit.last_viewed_at,
),
ImageRecord.id,
),
else_=None,
)
).label("unseen_count")
stmt = ( stmt = (
select(Artist, count_col) select(Artist, count_col, unseen_col)
.outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id) .outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id)
.outerjoin(ArtistVisit, ArtistVisit.artist_id == Artist.id)
.group_by(Artist.id) .group_by(Artist.id)
) )
if q: if q:
@@ -94,7 +112,7 @@ class ArtistDirectoryService:
next_cursor = _encode(last_artist.name, last_artist.id) next_cursor = _encode(last_artist.name, last_artist.id)
rows = rows[:limit] rows = rows[:limit]
artist_ids = [a.id for a, _ in rows] artist_ids = [a.id for a, _, _ in rows]
previews = await self._previews(artist_ids) previews = await self._previews(artist_ids)
cards = [ cards = [
@@ -104,9 +122,10 @@ class ArtistDirectoryService:
"slug": artist.slug, "slug": artist.slug,
"is_subscription": bool(artist.is_subscription), "is_subscription": bool(artist.is_subscription),
"image_count": int(image_count), "image_count": int(image_count),
"unseen_count": int(unseen_count),
"preview_thumbnails": previews.get(artist.id, []), "preview_thumbnails": previews.get(artist.id, []),
} }
for artist, image_count in rows for artist, image_count, unseen_count in rows
] ]
return DirectoryPage(cards=cards, next_cursor=next_cursor) return DirectoryPage(cards=cards, next_cursor=next_cursor)
+64 -10
View File
@@ -9,11 +9,13 @@ Dates come from Post.post_date via ImageProvenance.post_id.
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import and_, case, func, or_, select from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ( from ..models import (
Artist, Artist,
ArtistVisit,
ImageProvenance, ImageProvenance,
ImageRecord, ImageRecord,
Post, Post,
@@ -122,6 +124,12 @@ class ArtistService:
) )
).scalar_one() ).scalar_one()
# Mark this artist as "visited now"; the returned count is what
# the operator should see in the banner ("N new since last
# visit"). Done LAST so the read aggregates above all see the
# pre-visit state (cosmetic — none depend on visit data).
unseen_at_visit = await self._mark_visited_returning_unseen(aid)
return { return {
"id": artist.id, "id": artist.id,
"name": artist.name, "name": artist.name,
@@ -129,6 +137,7 @@ class ArtistService:
"is_subscription": bool(artist.is_subscription), "is_subscription": bool(artist.is_subscription),
"image_count": int(image_count), "image_count": int(image_count),
"post_count": int(post_count), "post_count": int(post_count),
"unseen_count_at_visit": unseen_at_visit,
"date_range": { "date_range": {
"min": dmin.isoformat() if dmin else None, "min": dmin.isoformat() if dmin else None,
"max": dmax.isoformat() if dmax else None, "max": dmax.isoformat() if dmax else None,
@@ -157,6 +166,39 @@ class ArtistService:
], ],
} }
async def _mark_visited_returning_unseen(self, artist_id: int) -> int:
"""Read pre-visit `last_viewed_at`, count images added since,
then upsert `last_viewed_at = NOW()`. Returns the count BEFORE
the upsert so the banner has data to render.
Postgres UPSERT (`ON CONFLICT DO UPDATE`) keeps the write
atomic — no SELECT-then-INSERT race per
`reference_scalar_one_or_none_duplicates`.
"""
prev = (
await self.session.execute(
select(ArtistVisit.last_viewed_at).where(
ArtistVisit.artist_id == artist_id
)
)
).scalar_one_or_none()
count_stmt = select(func.count(ImageRecord.id)).where(
ImageRecord.artist_id == artist_id
)
if prev is not None:
count_stmt = count_stmt.where(ImageRecord.created_at > prev)
unseen = (await self.session.execute(count_stmt)).scalar_one()
upsert = pg_insert(ArtistVisit.__table__).values(artist_id=artist_id)
upsert = upsert.on_conflict_do_update(
index_elements=["artist_id"],
set_={"last_viewed_at": func.now()},
)
await self.session.execute(upsert)
await self.session.commit()
return int(unseen)
async def images( async def images(
self, slug: str, cursor: str | None, limit: int = 60 self, slug: str, cursor: str | None, limit: int = 60
) -> ArtistImagesPage | None: ) -> ArtistImagesPage | None:
@@ -208,27 +250,39 @@ class ArtistService:
) )
async def find_or_create(self, name: str) -> tuple[Artist, bool]: async def find_or_create(self, name: str) -> tuple[Artist, bool]:
"""Return (artist, created). Slug-keyed; idempotent under races.""" """Return (artist, created). Slug-keyed; idempotent under races.
Audit 2026-06-02: switched from session.rollback() to a
begin_nested savepoint + IntegrityError recovery so a lost
race doesn't unwind the calling request's surrounding work.
Mirrors importer._get_or_create.
"""
cleaned = (name or "").strip() cleaned = (name or "").strip()
if not cleaned: if not cleaned:
raise ValueError("artist name must not be empty") raise ValueError("artist name must not be empty")
slug = slugify(cleaned) slug = slugify(cleaned)
existing = (await self.session.execute( select_existing = select(Artist).where(Artist.slug == slug)
select(Artist).where(Artist.slug == slug) existing = (await self.session.execute(select_existing)).scalar_one_or_none()
)).scalar_one_or_none()
if existing is not None: if existing is not None:
return existing, False return existing, False
artist = Artist(name=cleaned, slug=slug) sp = await self.session.begin_nested()
self.session.add(artist)
try: try:
artist = Artist(name=cleaned, slug=slug)
self.session.add(artist)
await self.session.flush() await self.session.flush()
# New artist starts "caught up" — seed ArtistVisit so the
# directory's `+N new` badge stays at 0 until real new
# content arrives. Without this, the unseen-count query
# treats NULL last_viewed_at as "never visited" and would
# count every image imported in the same session.
self.session.add(ArtistVisit(artist_id=artist.id))
await self.session.flush()
await sp.commit()
except IntegrityError: except IntegrityError:
await self.session.rollback() await sp.rollback()
existing = (await self.session.execute( existing = (await self.session.execute(select_existing)).scalar_one()
select(Artist).where(Artist.slug == slug)
)).scalar_one()
return existing, False return existing, False
await self.session.commit() await self.session.commit()
return artist, True return artist, True
+78 -5
View File
@@ -11,7 +11,7 @@ the one-and-done GS/IR migration tooling.)
""" """
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -187,10 +187,14 @@ def unlink_image_files(
out["thumbnail"] = True out["thumbnail"] = True
except OSError: except OSError:
out["thumbnail"] = False out["thumbnail"] = False
# Convention thumbs dir — try all extensions; missing OK. # Convention thumbs dir — try both extensions thumbnailer writes
# (.jpg for opaque, .png for alpha). `.webp` used to be in this
# tuple but the thumbnailer never writes it (operator-flagged in
# the 2026-06-02 audit) — keep the tuple aligned with what
# actually lands on disk.
if image.sha256: if image.sha256:
bucket = image.sha256[:3] bucket = image.sha256[:3]
for ext in ("jpg", "png", "webp"): for ext in ("jpg", "png"):
try: try:
(images_root / "thumbs" / bucket / f"{image.sha256}.{ext}").unlink( (images_root / "thumbs" / bucket / f"{image.sha256}.{ext}").unlink(
missing_ok=True, missing_ok=True,
@@ -451,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.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -517,6 +577,9 @@ class ConfirmTokenMismatch(Exception):
_VALID_RULES = ("transparency", "single_color") _VALID_RULES = ("transparency", "single_color")
_AUDIT_GUARD_THRESHOLD_MINUTES = 135 # matches LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES
def start_audit_run( def start_audit_run(
session: Session, *, rule: str, params: dict[str, Any], session: Session, *, rule: str, params: dict[str, Any],
) -> int: ) -> int:
@@ -524,11 +587,21 @@ def start_audit_run(
scan_library_for_rule Celery task. Returns the new audit_id. scan_library_for_rule Celery task. Returns the new audit_id.
Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run
has status='running'. Operator must cancel or wait.""" has status='running' AND started recently. Audit 2026-06-02 made
the guard age-aware: a SIGKILL'd run leaves a row in 'running'
that the recovery sweep flips on its next pass (~5 min), but a
fresh start_audit_run between the SIGKILL and the sweep would
previously block forever. Past the threshold, treat the running
row as stale and let the sweep clean it up — the new run still
gets to start.
"""
if rule not in _VALID_RULES: if rule not in _VALID_RULES:
raise ValueError(f"unknown rule {rule!r}; expected one of {_VALID_RULES}") raise ValueError(f"unknown rule {rule!r}; expected one of {_VALID_RULES}")
cutoff = datetime.now(UTC) - timedelta(minutes=_AUDIT_GUARD_THRESHOLD_MINUTES)
existing = session.execute( existing = session.execute(
select(LibraryAuditRun.id).where(LibraryAuditRun.status == "running") select(LibraryAuditRun.id)
.where(LibraryAuditRun.status == "running")
.where(LibraryAuditRun.started_at >= cutoff)
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None: if existing is not None:
raise AuditAlreadyRunning(existing) raise AuditAlreadyRunning(existing)
+53 -11
View File
@@ -1,40 +1,82 @@
"""Fernet-based encryption for credential blobs. """Fernet-based encryption for credential blobs.
The key is a single 32-byte value (urlsafe-base64-encoded; what The key is a single 32-byte value (urlsafe-base64-encoded; what
Fernet.generate_key produces) stored at a fixed path inside the Fernet.generate_key produces) stored at /images/secrets/credential_key.b64
images/data root. Created on first boot if absent; mode 0600. No KDF (mode 0600, parent dir 0700). The 2026-06-02 audit caught a silent
needed — the file contents are already maximum-entropy random bytes. key-regeneration path: on a partial disaster restore where the DB was
restored but the secrets dir was lost, the old `_load_or_create_key`
would mint a fresh key with no log, producing a working-looking system
where every authenticated download failed AUTH_ERROR until the operator
re-uploaded every credential by hand. Now the constructor refuses to
auto-generate unless either:
Operator backup procedure must include this file alongside the rest * the caller explicitly passes `bootstrap_ok=True` (tests, scripts), or
of /images/ — losing it makes existing encrypted_blob rows * the env var `CURATOR_BOOTSTRAP_NEW_KEY=1` is set (operator opt-in
undecryptable (recovery = delete the rows and re-upload). during first-time setup).
Otherwise it raises `MissingCredentialKey` so the app fails fast at
startup and the operator can restore the key file from backup.
Operator backup procedure must include /images/secrets/ alongside the
rest of /images/ — losing the key file makes existing encrypted_blob
rows undecryptable (recovery = delete the rows and re-upload).
""" """
import logging
import os import os
from pathlib import Path from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
log = logging.getLogger(__name__)
_BOOTSTRAP_ENV_VAR = "CURATOR_BOOTSTRAP_NEW_KEY"
class InvalidCredentialBlob(Exception): class InvalidCredentialBlob(Exception):
"""Raised when decryption fails (wrong key, tampered blob, …).""" """Raised when decryption fails (wrong key, tampered blob, …)."""
class MissingCredentialKey(Exception):
"""The Fernet key file is missing AND the caller hasn't opted in to
generating a new one. Audit 2026-06-02: prevents silent key
regeneration on partial DB-restored / secrets-lost deployments.
Set CURATOR_BOOTSTRAP_NEW_KEY=1 for first-time setup, or restore the
key file from backup."""
class CredentialCrypto: class CredentialCrypto:
"""Fernet encrypt/decrypt with an on-disk key file. """Fernet encrypt/decrypt with an on-disk key file.
Instantiate with a path; the file is created on first access and Instantiate with a path; the file is loaded if present, or created
reused thereafter. Tests pass a tmp_path; production calls with if absent AND the caller has opted in (bootstrap_ok=True or
CURATOR_BOOTSTRAP_NEW_KEY=1 env var). Production sites:
`IMAGES_ROOT / "secrets" / "credential_key.b64"`. `IMAGES_ROOT / "secrets" / "credential_key.b64"`.
""" """
def __init__(self, key_path: Path): def __init__(self, key_path: Path, *, bootstrap_ok: bool | None = None):
self._key_path = Path(key_path) self._key_path = Path(key_path)
self._fernet = Fernet(self._load_or_create_key()) if bootstrap_ok is None:
bootstrap_ok = os.environ.get(_BOOTSTRAP_ENV_VAR) == "1"
self._fernet = Fernet(self._load_or_create_key(bootstrap_ok))
def _load_or_create_key(self) -> bytes: def _load_or_create_key(self, bootstrap_ok: bool) -> bytes:
if self._key_path.exists(): if self._key_path.exists():
return self._key_path.read_bytes() return self._key_path.read_bytes()
if not bootstrap_ok:
raise MissingCredentialKey(
f"Fernet key file not found at {self._key_path}. "
f"For first-time setup, set {_BOOTSTRAP_ENV_VAR}=1. "
f"If this is a restored instance, restore the key file "
f"from backup — generating a new one would make every "
f"existing Credential row undecryptable."
)
log.warning(
"Generating NEW Fernet credential key at %s. Any existing "
"encrypted_blob rows in the DB will be undecryptable — "
"re-upload each credential after this completes.",
self._key_path,
)
parent = self._key_path.parent parent = self._key_path.parent
parent.mkdir(parents=True, exist_ok=True) parent.mkdir(parents=True, exist_ok=True)
os.chmod(parent, 0o700) os.chmod(parent, 0o700)
+68 -3
View File
@@ -35,6 +35,7 @@ from .gallery_dl import (
) )
from .importer import Importer from .importer import Importer
from .patreon_resolver import resolve_campaign_id from .patreon_resolver import resolve_campaign_id
from .platforms import auth_type_for
from .scheduler_service import set_platform_cooldown from .scheduler_service import set_platform_cooldown
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -90,10 +91,25 @@ class DownloadService:
return setup["event_id"] return setup["event_id"]
ctx = setup ctx = setup
# Release the phase-1 DB connections before the (up to ~19.5-min in
# backfill) gallery-dl subprocess. Held checked-out across that idle
# window, the asyncpg/psycopg connections get reaped by the server,
# and phase 3's first query then hits a dead socket
# (asyncpg ConnectionDoesNotExistError) → download_source autoretry →
# _phase1_setup's in-flight guard no-ops the retry → the event
# strands empty for the recovery sweep (Anduo #40014, 2026-06-04).
# pool_pre_ping can't help a *held* connection — it only validates on
# pool checkout. Closing returns them to the pool so phase 3 re-
# acquires a live one (the async task engine uses NullPool, the sync
# engine pre_ping + pool_recycle=300). This is what makes the
# "Phase 2 — no DB connection" contract in the class docstring true.
await self.async_session.close()
self.sync_session.close()
source_config = SourceConfig.from_dict(ctx["config_overrides"] or {}) source_config = SourceConfig.from_dict(ctx["config_overrides"] or {})
# alembic 0031 / plan #544: derive skip_value + timeout from the # alembic 0031 / plan #544: derive skip_value + timeout from the
# source's backfill_runs_remaining counter. When > 0, walk the full # source's backfill_runs_remaining counter. When > 0, walk the full
# post history (skip: True + 1800s); when 0, exit gallery-dl after # post history (skip: True + 1170s); when 0, exit gallery-dl after
# 20 contiguous archived items (skip: "exit:20" + the default # 20 contiguous archived items (skip: "exit:20" + the default
# 870s). Operator sets backfill via POST /api/sources/{id}/backfill. # 870s). Operator sets backfill via POST /api/sources/{id}/backfill.
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0 backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
@@ -177,6 +193,13 @@ class DownloadService:
return {"status": "in_flight", "event_id": existing.id} return {"status": "in_flight", "event_id": existing.id}
if existing and existing.status == "pending": if existing and existing.status == "pending":
existing.status = "running" existing.status = "running"
# Reset started_at on the pending→running transition so the
# recovery sweep (DOWNLOAD_STALL_THRESHOLD_MINUTES, 30 min)
# measures from real start, not from enqueue. On heavy-queue
# days a freshly-promoted event whose original started_at
# predated the cutoff would otherwise get swept mid-flight,
# racing phase3's commit. Audit 2026-06-02.
existing.started_at = datetime.now(UTC)
await self.async_session.commit() await self.async_session.commit()
event_id = existing.id event_id = existing.id
else: else:
@@ -187,7 +210,12 @@ class DownloadService:
event_id = ev.id event_id = ev.id
artist = source.artist artist = source.artist
if source.platform in ("discord", "pixiv"): # Drive cookies-vs-token selection from the platform registry's
# auth_type so a new 7th token-platform automatically picks the
# right credential path. The hardcoded tuple here used to drift
# out of sync with credential_service's auth_type_for(). Audit
# 2026-06-02.
if auth_type_for(source.platform) == "token":
cookies_path = None cookies_path = None
auth_token = await self.cred_service.get_token(source.platform) auth_token = await self.cred_service.get_token(source.platform)
else: else:
@@ -308,6 +336,23 @@ class DownloadService:
# failure. Don't flag the run as error; the file stays # failure. Don't flag the run as error; the file stays
# on disk for operator inspection. # on disk for operator inspection.
import_summary["skipped"] += 1 import_summary["skipped"] += 1
elif result.status == "failed":
# Hard failure (today only: archive probe crash/timeout).
# The original archive sits in /images/ as an orphan; the
# filesystem scanner would re-import and re-crash on the
# same file, so delete the source file and surface the
# error in import_summary. Audit 2026-06-02.
import_summary["errors"] += 1
try:
path.unlink(missing_ok=True) # noqa: ASYNC240
except OSError:
pass
elif result.status == "refreshed":
# Currently unreachable from attach_in_place (the download
# path never runs in deep=True mode), but the importer's
# ImportResult contract enumerates it. Treat the same as
# 'attached' — work happened, no error. Audit 2026-06-02.
import_summary["attached"] += 1
else: else:
import_summary["errors"] += 1 import_summary["errors"] += 1
@@ -354,12 +399,26 @@ class DownloadService:
# backfill run drained the queue (gallery-dl exited 0 + zero files # backfill run drained the queue (gallery-dl exited 0 + zero files
# downloaded means there was nothing to fetch); otherwise decrement # downloaded means there was nothing to fetch); otherwise decrement
# the counter. Next tick falls back to tick mode once it hits 0. # the counter. Next tick falls back to tick mode once it hits 0.
#
# Audit 2026-06-02 gating: VALIDATION_FAILED also exits the
# subprocess with return_code=0 and files_downloaded=0 (every
# file was quarantined), which used to match the auto-complete
# predicate exactly — zeroing the operator's armed budget on
# the FIRST quarantine run instead of decrementing. Require
# dl_result.success + no error_type so only genuinely-empty
# successful runs drain the counter.
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0 backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
if backfill_remaining > 0: if backfill_remaining > 0:
src = (await self.async_session.execute( src = (await self.async_session.execute(
select(Source).where(Source.id == ctx["source_id"]) select(Source).where(Source.id == ctx["source_id"])
)).scalar_one() )).scalar_one()
if dl_result.return_code == 0 and dl_result.files_downloaded == 0: queue_drained = (
dl_result.success
and dl_result.error_type is None
and dl_result.return_code == 0
and dl_result.files_downloaded == 0
)
if queue_drained:
src.backfill_runs_remaining = 0 src.backfill_runs_remaining = 0
else: else:
src.backfill_runs_remaining = max(0, backfill_remaining - 1) src.backfill_runs_remaining = max(0, backfill_remaining - 1)
@@ -390,9 +449,15 @@ class DownloadService:
if status == "ok": if status == "ok":
source.consecutive_failures = 0 source.consecutive_failures = 0
source.last_error = None source.last_error = None
# alembic 0032 — clear the failure-class chip on success.
source.error_type = None
elif status == "error": elif status == "error":
source.consecutive_failures = (source.consecutive_failures or 0) + 1 source.consecutive_failures = (source.consecutive_failures or 0) + 1
source.last_error = error_message source.last_error = error_message
# alembic 0032 — stamp the failure-class so FailingSourcesCard
# can render a colored chip and operators can bulk-triage
# by error class without opening Logs per row.
source.error_type = error_type
if error_type == "rate_limited": if error_type == "rate_limited":
await set_platform_cooldown(self.async_session, source.platform) await set_platform_cooldown(self.async_session, source.platform)
elif status == "skipped": elif status == "skipped":
+23 -19
View File
@@ -59,29 +59,33 @@ TICK_SKIP_VALUE = "exit:20"
# Source.backfill_runs_remaining > 0 selects this mode; the longer # Source.backfill_runs_remaining > 0 selects this mode; the longer
# timeout below absorbs creators with thousands of posts. # timeout below absorbs creators with thousands of posts.
# #
# 30 seconds shy of Celery's hard `time_limit=1200` on download_source # Sits below download_source's Celery soft_time_limit
# (tasks/download.py:33). subprocess.run MUST raise TimeoutExpired # (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py) with ~180s of
# before Celery SIGKILLs the worker — same rationale as the tick # headroom for phase-3 persist. subprocess.run MUST raise TimeoutExpired
# default at line 74. The audit (2026-06-02) caught this at 1800, # before Celery raises SoftTimeLimitExceeded — that exception path
# guaranteeing SIGKILL on any backfill that ran to its subprocess # captures partial stdout/stderr and finalizes the event; the soft-limit
# budget: stdout/stderr lost, backfill_runs_remaining never # path (until the 2026-06-03 fix) did not. Audit history: 1800 guaranteed
# decrements, recovery sweep stamps generic "stranded" 30 min later. # SIGKILL against the old hard limit (Knuxy #38275); 1170 was then sized
# Recreates the exact Knuxy #38275 failure mode the tick 870s default # "30s shy of the hard limit (1200)" but still EXCEEDED the soft limit
# was added to prevent. backfill_runs_remaining=3 still gives ~58 # (900), so SoftTimeLimitExceeded preempted TimeoutExpired and every
# minutes of cumulative walk across three runs for prolific creators. # backfill stranded empty (Anduo #39912). Raising the Celery soft/hard
# limits to 1350/1500 (tasks/download.py) is what made 1170 safe.
# backfill_runs_remaining=3 still gives ~58 minutes of cumulative walk
# across three runs for prolific creators.
BACKFILL_SKIP_VALUE = True BACKFILL_SKIP_VALUE = True
BACKFILL_TIMEOUT_SECONDS = 1170 BACKFILL_TIMEOUT_SECONDS = 1170
# 30 seconds shy of download_source's Celery soft_time_limit (900s, see # Sits well below download_source's Celery soft_time_limit
# tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before # (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py). subprocess.run MUST
# Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race, # raise TimeoutExpired before Celery raises SoftTimeLimitExceeded —
# SIGKILLs the worker, in-memory stdout/stderr is lost, and the # otherwise Celery wins the race, SIGKILLs the worker, in-memory
# DownloadEvent ends up empty-logged with "stranded by recovery sweep" # stdout/stderr is lost, and the DownloadEvent ends up empty-logged with
# 18 minutes later (operator-flagged 2026-05-31, Knuxy event #38275). # "stranded by recovery sweep" (operator-flagged 2026-05-31, Knuxy event
# The 30s buffer absorbs scheduler jitter / GC pauses without making # #38275; recurred in backfill mode as Anduo #39912). Per-source bumps
# legitimately-long-running syncs timeout-friendlier. Per-source bumps # still live in source.config_overrides for legitimately long syncs —
# still live in source.config_overrides for legitimately long syncs. # keep any override below the soft limit, or the soft-limit salvage path
# in tasks/download.py (_finalize_soft_limited) is the only safety net.
_DEFAULT_GDL_TIMEOUT_SECONDS = 870 _DEFAULT_GDL_TIMEOUT_SECONDS = 870
+332 -81
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}"
@@ -43,16 +50,17 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
def _effective_date_col(): def _effective_date_col():
"""SQL expression: COALESCE(post.post_date, image_record.created_at). """The materialized gallery sort key: image_record.effective_date
(alembic 0035) = COALESCE(primary post's post_date, created_at),
maintained at write time by the importer.
Used as the canonical sort/group/filter key across the gallery so Canonical sort/group/filter key across the gallery so images attached
images backfilled with primary_post_id (e.g. via tag_apply phase 4) to a post surface at their original publish date, not their FC import
surface at their original publish date, not their FC import date. date — and, now that it's a single indexed column rather than a
Images without a Post (or with Post.post_date NULL) fall back to COALESCE across the Post outer join, the cursor scroll is an index
image_record.created_at and still order coherently against range scan instead of a full re-sort per page.
post-attached ones.
""" """
return func.coalesce(Post.post_date, ImageRecord.created_at) return ImageRecord.effective_date
def _outer_join_primary_post(stmt: Select) -> Select: def _outer_join_primary_post(stmt: Select) -> Select:
@@ -91,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.
@@ -117,13 +135,85 @@ def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}" return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
def _require_single_filter(tag_id, post_id, artist_id) -> None: def _require_single_filter(tag_ids, post_id, artist_id) -> None:
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1: """post_id is the post-detail view — it can't combine with the
composable filters. tag_ids + artist_id (+ media_type) compose freely
(AND)."""
if post_id is not None and (tag_ids or artist_id is not None):
raise ValueError( raise ValueError(
"tag_id, post_id, artist_id are mutually exclusive" "post_id cannot be combined with tag or artist filters"
) )
def _apply_scope(
stmt, *, tag_ids, post_id, artist_id, media_type,
platform=None, untagged=False, no_artist=False,
date_from=None, date_to=None,
):
"""Apply the composable gallery filters to a statement.
All clauses are correlated EXISTS / scalar predicates on ImageRecord, so
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
by _require_single_filter).
- 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 []:
stmt = stmt.where(
exists().where(
image_tag.c.image_record_id == ImageRecord.id,
image_tag.c.tag_id == tid,
)
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
if media_type == "image":
stmt = stmt.where(ImageRecord.mime.like("image/%"))
elif media_type == "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
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
@@ -155,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."""
@@ -179,35 +290,50 @@ class GalleryService:
self, self,
cursor: str | None, cursor: str | None,
limit: int = 50, limit: int = 50,
tag_id: int | None = None, tag_ids: list[int] | None = None,
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,
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")
_require_single_filter(tag_id, post_id, artist_id) _require_single_filter(tag_ids, post_id, artist_id)
eff = _effective_date_col() eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff")) stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt) stmt = _outer_join_primary_post(stmt)
if tag_id is not None: stmt = _apply_scope(
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( stmt, tag_ids=tag_ids, post_id=post_id,
image_tag.c.tag_id == tag_id artist_id=artist_id, media_type=media_type,
) platform=platform, untagged=untagged, no_artist=no_artist,
prov = _provenance_clause(post_id, artist_id) date_from=date_from, date_to=date_to,
if prov is not None: )
stmt = stmt.where(prov)
descending = sort != "oldest"
if cursor: if cursor:
cur_ts, cur_id = decode_cursor(cursor) cur_ts, cur_id = decode_cursor(cursor)
stmt = stmt.where( # The cursor is just (last eff, last id); the request's sort
or_( # decides which side of it the next page lies on.
eff < cur_ts, if descending:
and_(eff == cur_ts, ImageRecord.id < cur_id), stmt = stmt.where(
or_(eff < cur_ts, and_(eff == cur_ts, ImageRecord.id < cur_id))
)
else:
stmt = stmt.where(
or_(eff > cur_ts, and_(eff == cur_ts, ImageRecord.id > cur_id))
) )
)
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1) if descending:
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
else:
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
stmt = stmt.limit(limit + 1)
rows = (await self.session.execute(stmt)).all() rows = (await self.session.execute(stmt)).all()
next_cursor = None next_cursor = None
@@ -219,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,
@@ -243,9 +354,15 @@ class GalleryService:
async def timeline( async def timeline(
self, self,
tag_id: int | None = None, tag_ids: list[int] | None = None,
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,
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")
@@ -254,25 +371,28 @@ class GalleryService:
year_col, month_col, func.count(ImageRecord.id).label("cnt") year_col, month_col, func.count(ImageRecord.id).label("cnt")
) )
stmt = _outer_join_primary_post(stmt) stmt = _outer_join_primary_post(stmt)
_require_single_filter(tag_id, post_id, artist_id) _require_single_filter(tag_ids, post_id, artist_id)
if tag_id is not None: stmt = _apply_scope(
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( stmt, tag_ids=tag_ids, post_id=post_id,
image_tag.c.tag_id == tag_id artist_id=artist_id, media_type=media_type,
) platform=platform, untagged=untagged, no_artist=no_artist,
prov = _provenance_clause(post_id, artist_id) date_from=date_from, date_to=date_to,
if prov is not None: )
stmt = stmt.where(prov)
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()
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows] return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
async def jump_cursor( async def jump_cursor(
self, year: int, month: int, tag_id: 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",
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(), positions at the """Returns a cursor that, when passed to scroll() with the same sort,
first image of the given year-month (by effective_date, not positions at the first image of the given year-month. None if the
created_at). None if the bucket is empty. bucket is empty.
""" """
from sqlalchemy import extract from sqlalchemy import extract
@@ -282,22 +402,157 @@ class GalleryService:
extract("month", eff) == month, extract("month", eff) == month,
) )
stmt = _outer_join_primary_post(stmt) stmt = _outer_join_primary_post(stmt)
_require_single_filter(tag_id, post_id, artist_id) _require_single_filter(tag_ids, post_id, artist_id)
if tag_id is not None: stmt = _apply_scope(
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( stmt, tag_ids=tag_ids, post_id=post_id,
image_tag.c.tag_id == tag_id artist_id=artist_id, media_type=media_type,
) platform=platform, untagged=untagged, no_artist=no_artist,
prov = _provenance_clause(post_id, artist_id) date_from=date_from, date_to=date_to,
if prov is not None: )
stmt = stmt.where(prov) descending = sort != "oldest"
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1) if descending:
first = (await self.session.execute(stmt)).first() stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
else:
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
first = (await self.session.execute(stmt.limit(1))).first()
if first is None: if first is None:
return None return None
record, eff_date = first record, eff_date = first
# Cursor is exclusive; we encode a cursor with id+1 so the row itself # Cursor is exclusive; nudge the id one past the boundary row (in the
# is the first result in the next scroll(). # scan direction) so the row itself is the first result of scroll().
return encode_cursor(eff_date, record.id + 1) boundary = record.id + 1 if descending else record.id - 1
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)
@@ -336,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),
@@ -357,17 +615,10 @@ class GalleryService:
} }
async def _neighbors(self, record: ImageRecord) -> dict: async def _neighbors(self, record: ImageRecord) -> dict:
# Compute the boundary image's effective_date in Python (one query # The boundary image's sort key is materialized on the row now
# below + the SELECT we already have on `record`) and use it for # (alembic 0035) — read it directly instead of re-deriving COALESCE
# the neighbor comparison. Cheaper than re-deriving in SQL via # via an extra Post lookup.
# correlated subquery. boundary_eff = record.effective_date
boundary_eff = record.created_at
if record.primary_post_id is not None:
post_date = (await self.session.execute(
select(Post.post_date).where(Post.id == record.primary_post_id)
)).scalar_one_or_none()
if post_date is not None:
boundary_eff = post_date
eff = _effective_date_col() eff = _effective_date_col()
prev_stmt = _outer_join_primary_post( prev_stmt = _outer_join_primary_post(
+75 -17
View File
@@ -374,10 +374,19 @@ class Importer:
artist = self._resolve_artist(source) artist = self._resolve_artist(source)
post = self._post_for_sidecar(source, artist) post = self._post_for_sidecar(source, artist)
sha = _sha256_of(source) sha = _sha256_of(source)
existing = self.session.execute( select_existing = select(PostAttachment).where(PostAttachment.sha256 == sha)
select(PostAttachment).where(PostAttachment.sha256 == sha) existing = self.session.execute(select_existing).scalar_one_or_none()
).scalar_one_or_none() if existing is not None:
if existing is None: self.session.commit()
return ImportResult(status="attached")
# Savepoint + IntegrityError recovery — PostAttachment.sha256 is
# UNIQUE, so two workers can both pass the SELECT and only the
# second INSERT fails. Without savepoint, the outer transaction
# poisons and the calling task crashes. attachments.store is
# sha-addressed so both workers race to write the same target
# path; shutil.copy2 + rename is idempotent. Audit 2026-06-02.
sp = self.session.begin_nested()
try:
stored = self.attachments.store(source, sha) stored = self.attachments.store(source, sha)
self.session.add(PostAttachment( self.session.add(PostAttachment(
post_id=post.id if post else None, post_id=post.id if post else None,
@@ -390,10 +399,19 @@ class Importer:
size_bytes=source.stat().st_size, size_bytes=source.stat().st_size,
)) ))
self.session.flush() self.session.flush()
sp.commit()
except IntegrityError:
sp.rollback()
# Lost the race — the other worker's row is canonical.
self.session.execute(select_existing).scalar_one()
self.session.commit() self.session.commit()
return ImportResult(status="attached") return ImportResult(status="attached")
def _import_archive(self, source: Path) -> ImportResult: def _import_archive(
self, source: Path, *,
artist: Artist | None = None,
source_row: Source | None = None,
) -> ImportResult:
# Layer-3 isolation: bomb-size guard + integrity test in a # Layer-3 isolation: bomb-size guard + integrity test in a
# spawned child BEFORE extracting in this process. A # spawned child BEFORE extracting in this process. A
# decompression bomb or a native-lib crash on a malformed # decompression bomb or a native-lib crash on a malformed
@@ -401,6 +419,14 @@ class Importer:
# instead of OOMing/segfaulting the import worker. extract_archive # instead of OOMing/segfaulting the import worker. extract_archive
# is already fail-soft for plain exceptions, so this only adds # is already fail-soft for plain exceptions, so this only adds
# the hard-crash protection. # the hard-crash protection.
#
# Audit 2026-06-02: optional artist/source_row kwargs let the
# download path thread its explicit subscription context
# through instead of having _resolve_artist re-derive from
# path-walk (which works by coincidence today because gallery-dl
# lays files out under /images/<artist_slug>/...). Filesystem
# import still calls bare _import_archive(source) and falls
# back to the path-walk derivation as before.
probe = safe_probe.probe_archive(source) probe = safe_probe.probe_archive(source)
if not probe.ok: if not probe.ok:
if probe.crashed: if probe.crashed:
@@ -412,24 +438,28 @@ class Importer:
# still preserve the archive file itself as an attachment so # still preserve the archive file itself as an attachment so
# nothing silently vanishes, matching extract_archive's # nothing silently vanishes, matching extract_archive's
# fail-soft contract. # fail-soft contract.
artist = self._resolve_artist(source) artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist) post = self._post_for_sidecar(source, artist_use)
self._capture_attachment(source, post=post, artist=artist, resolved=True) self._capture_attachment(
source, post=post, artist=artist_use, resolved=True,
)
return ImportResult(status="attached") return ImportResult(status="attached")
artist = self._resolve_artist(source) artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist) post = self._post_for_sidecar(source, artist_use)
member_ids: list[int] = [] member_ids: list[int] = []
with extract_archive(source) as members: with extract_archive(source) as members:
for _name, member_path in members: for _name, member_path in members:
if not is_supported(member_path): if not is_supported(member_path):
continue # non-media preserved via the stored archive continue # non-media preserved via the stored archive
res = self._import_media(member_path, source) res = self._import_media(
member_path, source, explicit_source=source_row,
)
if res.status in ("imported", "superseded") and res.image_id: if res.status in ("imported", "superseded") and res.image_id:
member_ids.append(res.image_id) member_ids.append(res.image_id)
# Preserve the archive itself (links to the same Post/Artist). # Preserve the archive itself (links to the same Post/Artist).
self._capture_attachment( self._capture_attachment(
source, post=post, artist=artist, resolved=True source, post=post, artist=artist_use, resolved=True
) )
if member_ids: if member_ids:
return ImportResult( return ImportResult(
@@ -439,7 +469,8 @@ class Importer:
return ImportResult(status="attached") return ImportResult(status="attached")
def _import_media( def _import_media(
self, source: Path, attribution_path: Path self, source: Path, attribution_path: Path,
*, explicit_source: Source | None = None,
) -> ImportResult: ) -> ImportResult:
"""The media import pipeline (filters, dedup, copy, provenance). """The media import pipeline (filters, dedup, copy, provenance).
@@ -586,7 +617,15 @@ class Importer:
artist = self._attach_artist(record, artist_name) artist = self._attach_artist(record, artist_name)
# Sidecar provenance (best-effort; never fails the import). # Sidecar provenance (best-effort; never fails the import).
self._apply_sidecar(record, attribution_path, artist) # explicit_source lets the FC-3c download path bind the new
# ImageProvenance row to its subscription Source instead of
# having _apply_sidecar re-derive via _lookup_source_for_sidecar.
# Audit 2026-06-02 — archive members extracted from a
# subscription-downloaded zip previously lost subscription
# linkage if the on-disk layout didn't match assumptions.
self._apply_sidecar(
record, attribution_path, artist, explicit_source=explicit_source,
)
# Thumbnail is queued separately by the calling task; the importer # Thumbnail is queued separately by the calling task; the importer
# does not generate thumbnails inline so the import queue stays moving. # does not generate thumbnails inline so the import queue stays moving.
@@ -673,7 +712,9 @@ class Importer:
error="sidecar json is metadata, not content", error="sidecar json is metadata, not content",
) )
if is_archive(path): if is_archive(path):
return self._import_archive(path) return self._import_archive(
path, artist=artist, source_row=source,
)
if not is_supported(path): if not is_supported(path):
post = self._post_for_sidecar(path, artist) if artist else None post = self._post_for_sidecar(path, artist) if artist else None
return self._capture_attachment( return self._capture_attachment(
@@ -749,7 +790,8 @@ class Importer:
if rel == "smaller_exists": if rel == "smaller_exists":
target = self.session.get(ImageRecord, match_id) target = self.session.get(ImageRecord, match_id)
self._supersede( self._supersede(
target, path, sha, phash, width, height, new_path=path target, path, sha, phash, width, height,
new_path=path, artist=artist, source_row=source,
) )
return ImportResult(status="superseded", image_id=match_id) return ImportResult(status="superseded", image_id=match_id)
@@ -918,6 +960,14 @@ class Importer:
sp.rollback() sp.rollback()
if record.primary_post_id is None: if record.primary_post_id is None:
record.primary_post_id = post.id record.primary_post_id = post.id
# Keep the denormalized gallery sort key (alembic 0035) aligned with
# the primary post's publish date so /scroll orders off
# ix_image_record_effective_date instead of COALESCE-ing across the
# post join. Only override when THIS post is the primary AND carries
# a date; otherwise the column keeps its created_at-equivalent server
# default (matches the old COALESCE(post_date, created_at) fallback).
if record.primary_post_id == post.id and post.post_date is not None:
record.effective_date = post.post_date
self.session.flush() self.session.flush()
def _copy_to_library( def _copy_to_library(
@@ -944,6 +994,8 @@ class Importer:
self, existing: ImageRecord, source: Path, sha: str, self, existing: ImageRecord, source: Path, sha: str,
phash: str, width: int | None, height: int | None, phash: str, width: int | None, height: int | None,
*, new_path: Path | None = None, *, new_path: Path | None = None,
artist: Artist | None = None,
source_row: Source | None = None,
) -> None: ) -> None:
"""Replace `existing`'s file with the larger `source`, keeping the """Replace `existing`'s file with the larger `source`, keeping the
row id (so tags/series/curation stay attached). ML is cleared so row id (so tags/series/curation stay attached). ML is cleared so
@@ -995,8 +1047,14 @@ class Importer:
# _apply_sidecar resolves artist from the sidecar itself if the # _apply_sidecar resolves artist from the sidecar itself if the
# existing row has none, and is internally guarded against # existing row has none, and is internally guarded against
# missing-or-malformed sidecars (silent return). # missing-or-malformed sidecars (silent return).
# Audit 2026-06-02: thread artist/source_row from the
# download-path caller (attach_in_place smaller_exists branch)
# so the supersede preserves explicit subscription linkage
# instead of re-deriving via path-walk.
try: try:
self._apply_sidecar(existing, source, None) self._apply_sidecar(
existing, source, artist, explicit_source=source_row,
)
except Exception as exc: except Exception as exc:
# Don't unwind the supersede DB swap if sidecar parsing # Don't unwind the supersede DB swap if sidecar parsing
# blows up unexpectedly — the file replacement is the # blows up unexpectedly — the file replacement is the
+20 -4
View File
@@ -19,7 +19,6 @@ from ...models import (
TagReferenceEmbedding, TagReferenceEmbedding,
) )
from ...models.tag import image_tag from ...models.tag import image_tag
from .embedder import MODEL_VERSION as SIGLIP_VERSION
ELIGIBLE_KINDS = { ELIGIBLE_KINDS = {
TagKind.character, TagKind.character,
@@ -46,6 +45,21 @@ class CentroidService:
) )
).scalar_one() ).scalar_one()
async def _model_version(self) -> str:
"""Audit 2026-06-02: SigLIP model-version stamp comes from the
DB row, not the env constant. tag_and_embed (tasks/ml.py:110)
already reads from MLSettings.embedder_model_version, so by
sourcing centroid stamps + drift checks from the same row, we
eliminate the silent-drift case the audit flagged. env
SIGLIP_MODEL_VERSION still drives which model embedder.py
loads at runtime; the version stamp is purely the operator-
controlled identifier."""
return (
await self.session.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
)
).scalar_one()
async def recompute_for_tag(self, tag_id: int) -> bool: async def recompute_for_tag(self, tag_id: int) -> bool:
"""Recompute one tag's centroid. Returns True if a centroid was """Recompute one tag's centroid. Returns True if a centroid was
written, False if skipped (ineligible kind or too few members).""" written, False if skipped (ineligible kind or too few members)."""
@@ -69,19 +83,20 @@ class CentroidService:
return False return False
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32) centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
model_version = await self._model_version()
stmt = insert(TagReferenceEmbedding).values( stmt = insert(TagReferenceEmbedding).values(
tag_id=tag_id, tag_id=tag_id,
embedding=centroid.tolist(), embedding=centroid.tolist(),
reference_count=len(embeddings), reference_count=len(embeddings),
model_version=SIGLIP_VERSION, model_version=model_version,
) )
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=["tag_id"], index_elements=["tag_id"],
set_={ set_={
"embedding": centroid.tolist(), "embedding": centroid.tolist(),
"reference_count": len(embeddings), "reference_count": len(embeddings),
"model_version": SIGLIP_VERSION, "model_version": model_version,
"updated_at": func.now(), "updated_at": func.now(),
}, },
) )
@@ -92,6 +107,7 @@ class CentroidService:
"""Tag ids whose centroid is stale: member count != reference_count, """Tag ids whose centroid is stale: member count != reference_count,
OR no centroid row, OR centroid built on a different SigLIP version. OR no centroid row, OR centroid built on a different SigLIP version.
Only considers eligible-kind tags with embeddings present.""" Only considers eligible-kind tags with embeddings present."""
current_model_version = await self._model_version()
member_counts = ( member_counts = (
select( select(
image_tag.c.tag_id.label("tag_id"), image_tag.c.tag_id.label("tag_id"),
@@ -116,7 +132,7 @@ class CentroidService:
TagReferenceEmbedding.reference_count TagReferenceEmbedding.reference_count
!= member_counts.c.members != member_counts.c.members
) )
| (TagReferenceEmbedding.model_version != SIGLIP_VERSION) | (TagReferenceEmbedding.model_version != current_model_version)
) )
) )
return list((await self.session.execute(stmt)).scalars().all()) return list((await self.session.execute(stmt)).scalars().all())
+27 -9
View File
@@ -4,7 +4,7 @@ threshold-filtered, category-grouped, ranked suggestions for one image.
from dataclasses import dataclass, field from dataclasses import dataclass, field
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ( from ...models import (
@@ -16,6 +16,7 @@ from ...models import (
from ...models.tag import image_tag from ...models.tag import image_tag
from .aliases import AliasService from .aliases import AliasService
from .centroids import CentroidService from .centroids import CentroidService
from .tag_name import normalize as normalize_tag_name
from .tagger import SURFACED_CATEGORIES from .tagger import SURFACED_CATEGORIES
@@ -84,7 +85,12 @@ class SuggestionService:
) )
# --- Camie predictions --- # --- Camie predictions ---
candidates: list[tuple[str, str, float]] = [] # candidates carry (raw_name, display_name, category, confidence).
# raw_name = the booru-formatted vocab key, kept for alias_map
# lookup since alias rows are hand-curated against raw keys.
# display_name = normalize_tag_name(raw_name) — what the operator
# sees AND what gets written to tag.name on Accept.
candidates: list[tuple[str, str, str, float]] = []
for name, p in predictions.items(): for name, p in predictions.items():
category = p.get("category", "general") category = p.get("category", "general")
if category not in SURFACED_CATEGORIES: if category not in SURFACED_CATEGORIES:
@@ -92,10 +98,14 @@ class SuggestionService:
conf = float(p.get("confidence", 0.0)) conf = float(p.get("confidence", 0.0))
if conf < self._threshold_for(settings, category): if conf < self._threshold_for(settings, category):
continue continue
candidates.append((name, category, conf)) display = normalize_tag_name(name)
if display is None:
# emoticon / pure-punctuation vocab entry — drop entirely
continue
candidates.append((name, display, category, conf))
alias_map = await self.aliases.resolve_many( alias_map = await self.aliases.resolve_many(
[(n, c) for n, c, _ in candidates] [(raw, c) for raw, _disp, c, _conf in candidates]
) )
merged: dict[object, Suggestion] = {} merged: dict[object, Suggestion] = {}
@@ -116,8 +126,8 @@ class SuggestionService:
creates_new_tag=existing.creates_new_tag, creates_new_tag=existing.creates_new_tag,
) )
for name, category, conf in candidates: for raw, display, category, conf in candidates:
canonical = alias_map.get((name, category)) canonical = alias_map.get((raw, category))
if canonical is not None: if canonical is not None:
if canonical.id in applied or canonical.id in rejected: if canonical.id in applied or canonical.id in rejected:
continue continue
@@ -133,9 +143,17 @@ class SuggestionService:
), ),
) )
else: else:
# Case-insensitive match on BOTH the raw camie key AND
# the normalized form — covers legacy underscore-named
# Tag rows accepted before normalization shipped, AND
# any tag the operator created with the human form.
existing_tag = ( existing_tag = (
await self.session.execute( await self.session.execute(
select(Tag).where(Tag.name == name) select(Tag).where(
func.lower(Tag.name).in_(
[raw.lower(), display.lower()]
)
)
) )
).scalars().first() ).scalars().first()
if existing_tag is not None: if existing_tag is not None:
@@ -157,10 +175,10 @@ class SuggestionService:
) )
else: else:
_merge( _merge(
f"raw:{name}:{category}", f"raw:{display}:{category}",
Suggestion( Suggestion(
canonical_tag_id=None, canonical_tag_id=None,
display_name=name, display_name=display,
category=category, category=category,
score=conf, score=conf,
source="tagger", source="tagger",
+62
View File
@@ -0,0 +1,62 @@
"""Camie vocabulary -> human-readable tag-name normalization.
Camie v2's ~57k tag vocabulary is booru-derived and arrives as raw
strings like `uchiha_sasuke_(naruto)`, `#unicus_(idolmaster)`,
`1000-nen_ikiteru_(vocaloid)`, or `:/`. We want the operator to see
"Uchiha Sasuke", "Unicus", "1000-Nen Ikiteru", or to never see the
emoticon at all — and we want the same clean string to be what lands
in `tag.name` when the suggestion is accepted, so Accept matches the
existing-tag convention (`tag_service.find_or_create`).
Rules (operator-approved 2026-06-03):
1. Strip leading junk chars (#, ., +, ;, ~, _, whitespace)
2. Drop trailing `_(disambiguator)` block(s), iteratively
3. Strip wrapping single/double quotes (after disambig removal so
`"foo_em_up"_(series)` -> `"foo_em_up"` -> `foo_em_up`)
4. Replace remaining `_` with space; collapse runs of whitespace
5. Add a space after any `:` (namespace:tag -> namespace: tag)
6. Preserve hyphens (booru hyphens often carry meaning)
7. Title-case each space-separated word (first character only —
apostrophes, digits, hyphens stay)
8. If no letters AND no digits remain, return None (drops emoticons
like `:/` or `^_^`; preserves bare digit tags like `2005`)
9. No surname/givenname swap — no reliable signal in the vocab
"""
import re
_LEADING_JUNK = re.compile(r"^[#.+;~_\s]+")
_TRAILING_DISAMBIG = re.compile(r"_\([^)]*\)\s*$")
_MULTISPACE = re.compile(r"\s+")
_COLON_NOSPACE = re.compile(r":(?=\S)")
_HAS_ALPHANUMERIC = re.compile(r"[A-Za-z0-9]")
def _strip_wrapping_quotes(s: str) -> str:
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
return s[1:-1]
return s
def _title_word(w: str) -> str:
return w[:1].upper() + w[1:] if w else w
def normalize(raw: str) -> str | None:
"""Return the human-readable form of a raw Camie tag, or None if the
string is junk (emoticon, empty after stripping)."""
if not raw:
return None
s = _LEADING_JUNK.sub("", raw)
while True:
new = _TRAILING_DISAMBIG.sub("", s)
if new == s:
break
s = new
s = _strip_wrapping_quotes(s)
s = s.replace("_", " ")
s = _COLON_NOSPACE.sub(": ", s)
s = _MULTISPACE.sub(" ", s).strip()
if not s or not _HAS_ALPHANUMERIC.search(s):
return None
return " ".join(_title_word(w) for w in s.split(" "))
+3
View File
@@ -59,6 +59,7 @@ class SourceRecord:
config_overrides: dict | None config_overrides: dict | None
last_checked_at: str | None last_checked_at: str | None
last_error: str | None last_error: str | None
error_type: str | None
check_interval_override: int | None check_interval_override: int | None
consecutive_failures: int consecutive_failures: int
next_check_at: str | None next_check_at: str | None
@@ -76,6 +77,7 @@ class SourceRecord:
"config_overrides": self.config_overrides, "config_overrides": self.config_overrides,
"last_checked_at": self.last_checked_at, "last_checked_at": self.last_checked_at,
"last_error": self.last_error, "last_error": self.last_error,
"error_type": self.error_type,
"check_interval_override": self.check_interval_override, "check_interval_override": self.check_interval_override,
"consecutive_failures": self.consecutive_failures, "consecutive_failures": self.consecutive_failures,
"next_check_at": self.next_check_at, "next_check_at": self.next_check_at,
@@ -144,6 +146,7 @@ class SourceService:
config_overrides=source.config_overrides, config_overrides=source.config_overrides,
last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None, last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None,
last_error=source.last_error, last_error=source.last_error,
error_type=source.error_type,
check_interval_override=source.check_interval_override, check_interval_override=source.check_interval_override,
consecutive_failures=source.consecutive_failures or 0, consecutive_failures=source.consecutive_failures or 0,
next_check_at=nxt.isoformat() if nxt else None, next_check_at=nxt.isoformat() if nxt else None,
+87 -7
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from sqlalchemy import and_, case, exists, func, select, text, update from sqlalchemy import and_, case, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Tag, TagKind, image_tag from ..models import Tag, TagKind, image_tag
@@ -86,9 +87,12 @@ class TagService:
f"fandom_id {fandom_id} does not reference a fandom tag" f"fandom_id {fandom_id} does not reference a fandom tag"
) )
# Upsert via INSERT ... ON CONFLICT DO NOTHING. We can't use the # Audit 2026-06-02: race-safe upsert via savepoint +
# uniqueness index name directly (it's a partial coalesce-based # IntegrityError recovery. The partial uniqueness index on
# expression), so we re-select after insert. # (name, kind, COALESCE(fandom_id, -1)) catches concurrent
# inserts; without the savepoint the outer transaction would
# poison and the calling request crashes. Mirrors
# importer._get_or_create.
stmt = ( stmt = (
select(Tag) select(Tag)
.where(Tag.name == name) .where(Tag.name == name)
@@ -101,10 +105,16 @@ class TagService:
if existing: if existing:
return existing return existing
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id) sp = await self.session.begin_nested()
self.session.add(new_tag) try:
await self.session.flush() new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
return new_tag self.session.add(new_tag)
await self.session.flush()
await sp.commit()
return new_tag
except IntegrityError:
await sp.rollback()
return (await self.session.execute(stmt)).scalar_one()
async def autocomplete( async def autocomplete(
self, self,
@@ -270,6 +280,69 @@ class TagService:
await self.session.flush() await self.session.flush()
return tag return tag
async def set_fandom(
self, tag_id: int, fandom_id: int | None, *, merge: bool = False
) -> Tag:
"""Set / change / clear a character tag's fandom.
Raises TagValidationError unless the tag is a character and fandom_id
(when given) references a fandom tag. If the change would collide with
an existing character of the same name in the TARGET fandom, raises
TagMergeConflict (the API turns that into a 409 merge hint) — unless
merge=True, in which case this tag is merged INTO that existing
character (a deliberate cross-fandom merge) and the surviving target
is returned. Passing fandom_id=None clears the fandom.
"""
tag = await self.session.get(Tag, tag_id)
if tag is None:
raise TagValidationError(f"Tag {tag_id} not found")
if tag.kind != TagKind.character:
raise TagValidationError("Only character tags can have a fandom")
if fandom_id is not None:
fandom = await self.session.get(Tag, fandom_id)
if fandom is None or fandom.kind != TagKind.fandom:
raise TagValidationError(
f"fandom_id {fandom_id} does not reference a fandom tag"
)
if fandom_id == tag.fandom_id:
return tag
# Collision: another character with the same name already lives in the
# target fandom. Mirrors rename's (name, kind, fandom_id) uniqueness.
clash_stmt = (
select(Tag)
.where(Tag.name == tag.name)
.where(Tag.kind == TagKind.character)
.where(
Tag.fandom_id.is_(None)
if fandom_id is None
else Tag.fandom_id == fandom_id
)
.where(Tag.id != tag_id)
)
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
if clash is not None:
if not merge:
source_image_count = await self.session.scalar(
select(func.count())
.select_from(image_tag)
.where(image_tag.c.tag_id == tag_id)
)
will_alias = await self._keep_as_alias(tag_id)
raise TagMergeConflict(
f"A character named {tag.name!r} already exists in that fandom",
target_id=clash.id,
target_name=clash.name,
source_image_count=int(source_image_count or 0),
will_alias=will_alias,
)
await self._do_merge(tag, clash)
return clash
tag.fandom_id = fandom_id
await self.session.flush()
return tag
async def merge(self, source_id: int, target_id: int) -> MergeResult: async def merge(self, source_id: int, target_id: int) -> MergeResult:
"""Transactionally repoint every FK from source→target, optionally """Transactionally repoint every FK from source→target, optionally
keep source's name as a tagger alias, delete source. Atomic: any keep source's name as a tagger alias, delete source. Atomic: any
@@ -288,7 +361,14 @@ class TagService:
raise TagValidationError( raise TagValidationError(
"Tags must be the same kind and fandom to merge" "Tags must be the same kind and fandom to merge"
) )
return await self._do_merge(source, target)
async def _do_merge(self, source: Tag, target: Tag) -> MergeResult:
"""Repoint every FK source→target, optionally keep source's name as a
tagger alias, delete source. NO kind/fandom validation — callers that
need it (public merge()) validate first; set_fandom's collision
resolution calls this directly for a deliberate CROSS-fandom merge."""
source_id, target_id = source.id, target.id
keep_as_alias = await self._keep_as_alias(source_id) keep_as_alias = await self._keep_as_alias(source_id)
source_name = source.name source_name = source.name
source_kind = source.kind source_kind = source.kind
+10 -1
View File
@@ -10,6 +10,7 @@ disposes it (``await engine.dispose()``) when its loop ends.
""" """
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from ..config import get_config from ..config import get_config
@@ -17,5 +18,13 @@ from ..config import get_config
def async_session_factory(): def async_session_factory():
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine.""" """Return ``(sessionmaker, engine)`` bound to a fresh async engine."""
cfg = get_config() cfg = get_config()
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True) # NullPool: this engine lives for ONE task (created + disposed per
# asyncio.run loop), so intra-task connection pooling buys nothing and
# actively bit us — download_source releases its phase-1 connection
# before a multi-minute gallery-dl subprocess, and a *pooled* idle
# connection would be reaped by the server and handed back dead to
# phase 3 (asyncpg ConnectionDoesNotExistError, Anduo #40014). NullPool
# opens a fresh real connection on each checkout, so phase 3 always
# reconnects clean; pre_ping is then redundant.
engine = create_async_engine(cfg.database_url, future=True, poolclass=NullPool)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
+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
+5 -2
View File
@@ -240,8 +240,11 @@ def prune_backups() -> dict:
Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}. Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}.
Tagged rows (tag IS NOT NULL) are never pruned. Tagged rows (tag IS NOT NULL) are never pruned.
Status='running' / 'restoring' rows are never pruned (recovery Status='running' / 'restoring' rows are never pruned — the
sweep from FC-3i handles those via task_run). recover_stalled_backup_runs sweep flips truly-stuck ones to
'error' first. (Earlier docstring claimed the FC-3i TaskRun sweep
handled those, but TaskRun cleanup never touched BackupRun rows.
Audit 2026-06-02 added the dedicated sweep.)
""" """
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0} counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
+94 -4
View File
@@ -1,12 +1,17 @@
"""download_source Celery task — runs DownloadService for one source.""" """download_source Celery task — runs DownloadService for one source."""
import asyncio import asyncio
import logging
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError from sqlalchemy.exc import DBAPIError, OperationalError
from sqlalchemy.orm import Session as SyncSession
from ..celery_app import celery from ..celery_app import celery
from ..models import ImportSettings from ..models import DownloadEvent, ImportSettings, Source
from ..services.credential_crypto import CredentialCrypto from ..services.credential_crypto import CredentialCrypto
from ..services.credential_service import CredentialService from ..services.credential_service import CredentialService
from ..services.download_service import DownloadService from ..services.download_service import DownloadService
@@ -16,9 +21,79 @@ from ..services.thumbnailer import Thumbnailer
from ._async_session import async_session_factory from ._async_session import async_session_factory
from .import_file import _sync_session_factory from .import_file import _sync_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images") IMAGES_ROOT = Path("/images")
_KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64" _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
# Celery time budget for one download_source run. The ceiling that
# governs *clean* teardown is the SOFT limit: it raises a catchable
# SoftTimeLimitExceeded in-process, whereas the HARD limit SIGKILLs the
# worker (no chance to finalize). Both gallery-dl subprocess budgets
# (gallery_dl.py: _DEFAULT_GDL_TIMEOUT_SECONDS=870 tick,
# BACKFILL_TIMEOUT_SECONDS=1170 backfill) MUST sit below the soft limit
# so subprocess.run raises its own TimeoutExpired first — that path
# captures partial stdout/stderr and finalizes the DownloadEvent. soft is
# max-subprocess (1170) + ~180s phase-3 persist headroom; hard is soft +
# 150s SIGKILL backstop. Audit 2026-06-03 (Anduo #39912): the old
# soft=900 sat BELOW the 1170 backfill budget, so SoftTimeLimitExceeded
# preempted TimeoutExpired and the event stranded empty. The recovery
# sweep's DOWNLOAD_STALL_THRESHOLD_MINUTES (30 min) still trails the new
# 25-min hard kill by 5 min, so it stays a true backstop. Invariant
# guarded by test_timeout_ladder_keeps_subprocess_budgets_under_soft_limit.
DOWNLOAD_SOFT_TIME_LIMIT = 1350
DOWNLOAD_HARD_TIME_LIMIT = 1500
def _finalize_soft_limited(session: SyncSession, source_id: int) -> None:
"""Defense in depth for the soft-time-limit kill path.
A SoftTimeLimitExceeded unwinds download_source before phase 3 can
finalize the DownloadEvent, leaving it 'running' until the recovery
sweep stamps a context-free "stranded" error 30 min later — AND
leaving backfill_runs_remaining undecremented so the source re-runs
and re-strands every tick (Anduo #39912, 2026-06-03). Flip the
in-flight event to error with a real reason, mirror phase 3's
source-health write, and decrement any backfill budget so a
chronically-slow source self-heals back to tick mode.
The caller owns the commit. All mutations are gated on actually
finding a running event, so a benign late soft-limit (phase 3 already
committed) is a no-op.
"""
now = datetime.now(UTC)
ev = session.execute(
select(DownloadEvent)
.where(DownloadEvent.source_id == source_id)
.where(DownloadEvent.status == "running")
.order_by(DownloadEvent.id.desc())
.limit(1)
).scalar_one_or_none()
if ev is None:
return
ev.status = "error"
ev.finished_at = now
ev.error = (
f"killed by Celery soft time limit ({DOWNLOAD_SOFT_TIME_LIMIT}s) "
"before the gallery-dl subprocess returned — the run exceeded its "
"budget and its stdout/stderr were lost with the worker thread. "
"If this recurs, the source is too large for one run; the backfill "
"budget was decremented so the next tick walks less."
)
ev.metadata_ = {
**(ev.metadata_ or {}),
"error_type": "timeout",
"soft_time_limited": True,
}
src = session.get(Source, source_id)
if src is not None:
src.consecutive_failures = (src.consecutive_failures or 0) + 1
src.last_error = "soft time limit exceeded"
src.error_type = "timeout"
src.last_checked_at = now
if (src.backfill_runs_remaining or 0) > 0:
src.backfill_runs_remaining = max(0, src.backfill_runs_remaining - 1)
@celery.task( @celery.task(
name="backend.app.tasks.download.download_source", name="backend.app.tasks.download.download_source",
@@ -29,8 +104,8 @@ _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
retry_backoff_max=120, retry_backoff_max=120,
retry_jitter=True, retry_jitter=True,
max_retries=3, max_retries=3,
soft_time_limit=900, soft_time_limit=DOWNLOAD_SOFT_TIME_LIMIT,
time_limit=1200, time_limit=DOWNLOAD_HARD_TIME_LIMIT,
) )
def download_source(self, source_id: int) -> int: def download_source(self, source_id: int) -> int:
"""Returns the DownloadEvent.id.""" """Returns the DownloadEvent.id."""
@@ -73,4 +148,19 @@ def download_source(self, source_id: int) -> int:
finally: finally:
await async_engine.dispose() await async_engine.dispose()
return asyncio.run(_run()) try:
return asyncio.run(_run())
except SoftTimeLimitExceeded:
# phase 3 never ran — salvage the in-flight event so the operator
# sees a real reason instead of the recovery sweep's generic
# "stranded" 30 min later (Anduo #39912). Best-effort: a failure
# here must not mask the timeout. Re-raise so Celery + the
# task_run signal handler still record the kill.
try:
SyncFactory = _sync_session_factory()
with SyncFactory() as session:
_finalize_soft_limited(session, source_id)
session.commit()
except Exception: # noqa: BLE001 — cleanup must not swallow the kill
log.exception("soft-limit finalize failed for source %s", source_id)
raise
+245 -6
View File
@@ -11,18 +11,38 @@ from sqlalchemy import Integer, and_, cast, delete, func, or_, select, update
from ..celery_app import celery from ..celery_app import celery
from ..models import ( from ..models import (
BackupRun,
DownloadEvent, DownloadEvent,
ImageRecord, ImageRecord,
ImportBatch,
ImportSettings, ImportSettings,
ImportTask, ImportTask,
LibraryAuditRun,
Source, Source,
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
@@ -43,9 +63,12 @@ MAX_RECOVERY_ATTEMPTS = 3
ORPHAN_PENDING_THRESHOLD_MINUTES = 30 ORPHAN_PENDING_THRESHOLD_MINUTES = 30
# DownloadEvent (pending|running) recovery threshold. download_source has # DownloadEvent (pending|running) recovery threshold. download_source has
# time_limit=1200s (20 min); 30 min is 10 min past that, so a legitimately- # time_limit=1500s (25 min, DOWNLOAD_HARD_TIME_LIMIT); 30 min is 5 min past
# running task is never killed by the sweep. Operator-confirmed 2026-05-29 # that, so a legitimately-running task is hard-killed before the sweep ever
# after 43 sources stranded at "last check never" by the in-flight guard. # touches it — the sweep only catches events whose worker died without
# finalizing. Operator-confirmed 2026-05-29 after 43 sources stranded at
# "last check never" by the in-flight guard; budget bumped 2026-06-03 with
# the soft/hard limit raise (Anduo #39912).
DOWNLOAD_STALL_THRESHOLD_MINUTES = 30 DOWNLOAD_STALL_THRESHOLD_MINUTES = 30
OLD_TASK_DAYS = 7 OLD_TASK_DAYS = 7
@@ -55,6 +78,29 @@ FFPROBE_TIMEOUT_SECONDS = 10
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
# Audit 2026-06-02: per-entity recovery sweep thresholds. Each must be
# > the entity's longest legitimate runtime (its task's time_limit + a
# small buffer) so the sweep never flags in-flight work.
#
# Backups: images backup has time_limit=23400s (6.5h). 7h covers it
# with a 30-min buffer; db backup at 12 min hard limit fits trivially.
BACKUP_STALL_THRESHOLD_MINUTES = 7 * 60
# Library audit: scan_library_for_rule has time_limit=7500s (2h5m).
# 2h15m gives a 10-min buffer.
LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES = 135
# Import batches finalize only after every child ImportTask hits a
# terminal state. The recovery sweep targets the case where every
# task is done but the batch never got its closing UPDATE
# (orchestrator crashed at the wrong instant). 2h is well past any
# realistic single-batch import.
IMPORT_BATCH_STALL_THRESHOLD_MINUTES = 120
# Retention windows (terminal rows older than these get deleted by
# the daily prune sweeps). 30 days = operator-flagged "useful for
# triage for a few weeks, then noise."
LIBRARY_AUDIT_KEEP_DAYS = 30
IMPORT_BATCH_KEEP_DAYS = 30
# Overrides for recover_stalled_task_runs (the TaskRun 'running' sweep). # Overrides for recover_stalled_task_runs (the TaskRun 'running' sweep).
# Tasks/queues that legitimately run longer than the default 5-min # Tasks/queues that legitimately run longer than the default 5-min
# threshold need their own larger value, else the sweep marks in-flight # threshold need their own larger value, else the sweep marks in-flight
@@ -69,9 +115,24 @@ TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
# files); time_limit=2100. # files); time_limit=2100.
QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = { QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
"ml": 25, "ml": 25,
# Audit 2026-06-02 — maintenance/scan queues run tasks that
# legitimately exceed the 5-min default (verify_integrity at 70m
# hard, scan_directory at 70m hard, apply_allowlist_tags /
# recompute_centroids / backfill_phash at 35m hard). 75 min lives
# above the longest of those and the per-task overrides below
# cover the outliers (backups, library audit).
"maintenance": 75,
"scan": 75,
} }
TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = { TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
"backend.app.tasks.import_file.import_archive_file": 40, "backend.app.tasks.import_file.import_archive_file": 40,
# Backup images runs hours, not minutes (6.5h hard limit). The
# task-name override beats the queue's 75-min default so a
# legitimately-running backup isn't flagged.
"backend.app.tasks.backup.backup_images_task": 420,
"backend.app.tasks.backup.restore_images_task": 420,
# Library audit scans the full library — 2h hard limit.
"backend.app.tasks.library_audit.scan_library_for_rule": 130,
} }
@@ -360,7 +421,12 @@ def prune_task_runs() -> dict:
return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted} return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted}
@celery.task(name="backend.app.tasks.maintenance.backfill_phash") @celery.task(
name="backend.app.tasks.maintenance.backfill_phash",
# Audit 2026-06-02 — keyset-paginated phash recompute over the whole
# library; legitimately runs >5 min on large libraries.
soft_time_limit=1800, time_limit=2100,
)
def backfill_phash() -> int: def backfill_phash() -> int:
"""Recompute phash for stored images that have none (imported before """Recompute phash for stored images that have none (imported before
FC-2d-i+ii). Keyset-paginated by id (restart-safe), NULL-only fill, FC-2d-i+ii). Keyset-paginated by id (restart-safe), NULL-only fill,
@@ -438,7 +504,13 @@ def _verify_one(path: Path, expected_sha: str, mime: str, sha_fn) -> str:
return "failed_verification" return "failed_verification"
@celery.task(name="backend.app.tasks.maintenance.verify_integrity") @celery.task(
name="backend.app.tasks.maintenance.verify_integrity",
# Audit 2026-06-02 — full library sha256 + decode probe; on 100k-image
# libraries this runs an hour or more. Match the maintenance queue's
# recovery threshold (75 min) with 30s buffer below.
soft_time_limit=3600, time_limit=4200,
)
def verify_integrity() -> int: def verify_integrity() -> int:
"""Verify every ImageRecord file: sha256 recompute + decode/probe """Verify every ImageRecord file: sha256 recompute + decode/probe
(PIL for images; ffprobe for videos). Writes integrity_status (PIL for images; ffprobe for videos). Writes integrity_status
@@ -483,7 +555,7 @@ def recover_stalled_download_events() -> int:
tasks.scan._tick_due_sources_async) inserts DownloadEvent(status='pending') tasks.scan._tick_due_sources_async) inserts DownloadEvent(status='pending')
and fires download_source.delay(). If that task dies before finalizing the and fires download_source.delay(). If that task dies before finalizing the
event — worker OOM/SIGKILL, lost task, or a gallery-dl that didn't unwind event — worker OOM/SIGKILL, lost task, or a gallery-dl that didn't unwind
on the 1200s hard time_limit — the event stays in-flight forever. The next on the 1500s hard time_limit — the event stays in-flight forever. The next
tick then skips that source because of the in-flight guard (scan.py:168) tick then skips that source because of the in-flight guard (scan.py:168)
and Source.last_checked_at never updates; the operator sees "last check and Source.last_checked_at never updates; the operator sees "last check
never" in the Subscriptions health column, permanently. never" in the Subscriptions health column, permanently.
@@ -534,6 +606,156 @@ def recover_stalled_download_events() -> int:
return events_recovered return events_recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_backup_runs")
def recover_stalled_backup_runs() -> int:
"""Flip BackupRun rows stuck in running/restoring past the hard limit
to error. Audit 2026-06-02.
prune_backups (FC-3h) used to claim the FC-3i task_run sweep handled
these — but that sweep only flips TaskRun rows, not the BackupRun
artifact rows. A SIGKILL'd backup left BackupRun stuck forever
(dashboard showed phantom in-flight backups, keep_last_n offset
arithmetic skewed because zombies sat outside the ok/error window).
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=BACKUP_STALL_THRESHOLD_MINUTES)
msg = (
f"stranded by recovery sweep (no terminal status after "
f"{BACKUP_STALL_THRESHOLD_MINUTES // 60}h)"
)
with SessionLocal() as session:
result = session.execute(
update(BackupRun)
.where(BackupRun.status.in_(["running", "restoring"]))
.where(BackupRun.started_at < cutoff)
.values(status="error", finished_at=now, error=msg)
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info("recover_stalled_backup_runs: recovered %d rows", recovered)
return recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_library_audit_runs")
def recover_stalled_library_audit_runs() -> int:
"""Flip LibraryAuditRun rows stuck in running past the hard limit
to error. Audit 2026-06-02.
LibraryAuditRun.status='running' was protected by an exclusive
guard in start_audit_run — a SIGKILL'd run would block all future
audits until manual DB surgery. (The guard is now age-aware, but
this sweep is what makes that work in practice.)
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES)
msg = (
f"stranded by recovery sweep (no terminal status after "
f"{LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES} min)"
)
with SessionLocal() as session:
result = session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.status == "running")
.where(LibraryAuditRun.started_at < cutoff)
.values(status="error", finished_at=now, error=msg)
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info(
"recover_stalled_library_audit_runs: recovered %d rows", recovered,
)
return recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_import_batches")
def recover_stalled_import_batches() -> int:
"""Finalize ImportBatch rows stuck in running past the hard limit
when NO outstanding ImportTask remains. Audit 2026-06-02.
A batch row finalizes only after every child task hits a terminal
state. The orphan case: scanner crashed between the last task's
completion and the batch's closing UPDATE. The
`/api/import/status` route then surfaces the batch as 'active'
indefinitely while `/api/system/stats` (which uses the same
EXISTS predicate we apply below) correctly returns null.
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=IMPORT_BATCH_STALL_THRESHOLD_MINUTES)
with SessionLocal() as session:
# Batches still 'running' past the cutoff whose tasks are all
# terminal — there's no outstanding work, so flip the batch
# too. Mirrors the EXISTS predicate the active-batch surfaces use.
result = session.execute(
update(ImportBatch)
.where(ImportBatch.status == "running")
.where(ImportBatch.started_at < cutoff)
.where(
~select(ImportTask.id)
.where(
ImportTask.batch_id == ImportBatch.id,
ImportTask.status.in_(["pending", "queued", "processing"]),
)
.exists()
)
.values(status="complete", finished_at=now)
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info(
"recover_stalled_import_batches: finalized %d zombie batches",
recovered,
)
return recovered
@celery.task(name="backend.app.tasks.maintenance.prune_library_audit_runs")
def prune_library_audit_runs() -> int:
"""Daily retention: delete terminal LibraryAuditRun rows older than
LIBRARY_AUDIT_KEEP_DAYS. Never touches 'running'. Audit 2026-06-02.
Audit rows carry matched_ids JSONB blobs that can hold tens of
thousands of ids; without retention these accumulate.
"""
SessionLocal = _sync_session_factory()
cutoff = datetime.now(UTC) - timedelta(days=LIBRARY_AUDIT_KEEP_DAYS)
with SessionLocal() as session:
result = session.execute(
delete(LibraryAuditRun)
.where(LibraryAuditRun.status.in_(["ready", "applied", "cancelled", "error"]))
.where(LibraryAuditRun.finished_at < cutoff)
)
session.commit()
return result.rowcount or 0
@celery.task(name="backend.app.tasks.maintenance.prune_import_batches")
def prune_import_batches() -> int:
"""Daily retention: delete terminal ImportBatch rows older than
IMPORT_BATCH_KEEP_DAYS. Cascade-deletes child ImportTask rows via
the model relationship. Never touches 'running'. Audit 2026-06-02.
"""
SessionLocal = _sync_session_factory()
cutoff = datetime.now(UTC) - timedelta(days=IMPORT_BATCH_KEEP_DAYS)
with SessionLocal() as session:
# ORM-level delete here (not Core delete) so the
# ImportBatch->tasks cascade fires; Core delete would skip it.
old_batches = session.execute(
select(ImportBatch)
.where(ImportBatch.status.in_(["complete", "cancelled"]))
.where(ImportBatch.finished_at < cutoff)
).scalars().all()
for batch in old_batches:
session.delete(batch)
session.commit()
return len(old_batches)
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_download_events") @celery.task(name="backend.app.tasks.maintenance.cleanup_old_download_events")
def cleanup_old_download_events() -> int: def cleanup_old_download_events() -> int:
"""FC-3d: delete terminal DownloadEvent rows older than the configured """FC-3d: delete terminal DownloadEvent rows older than the configured
@@ -556,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}
+15 -2
View File
@@ -212,7 +212,14 @@ def backfill(self) -> int:
return enqueued return enqueued
@celery.task(name="backend.app.tasks.ml.apply_allowlist_tags", bind=True) @celery.task(
name="backend.app.tasks.ml.apply_allowlist_tags",
bind=True,
# Audit 2026-06-02 — the full-sweep mode (neither tag_id nor image_id)
# is O(images × allowlist) and legitimately runs >5 min on large
# libraries. Cap matches the maintenance queue's recovery threshold.
soft_time_limit=1800, time_limit=2100,
)
def apply_allowlist_tags(self, tag_id: int | None = None, def apply_allowlist_tags(self, tag_id: int | None = None,
image_id: int | None = None) -> int: image_id: int | None = None) -> int:
"""Retroactively apply allowlisted tags. """Retroactively apply allowlisted tags.
@@ -341,7 +348,13 @@ def recompute_centroid(self, tag_id: int) -> bool:
return asyncio.run(_run()) return asyncio.run(_run())
@celery.task(name="backend.app.tasks.ml.recompute_centroids", bind=True) @celery.task(
name="backend.app.tasks.ml.recompute_centroids",
bind=True,
# Audit 2026-06-02 — drifted-centroid rebuild over potentially
# hundreds of tags.
soft_time_limit=1800, time_limit=2100,
)
def recompute_centroids(self) -> int: def recompute_centroids(self) -> int:
"""Daily: find drifted centroids, enqueue recompute_centroid for each.""" """Daily: find drifted centroids, enqueue recompute_centroid for each."""
import asyncio import asyncio
+9 -1
View File
@@ -35,7 +35,15 @@ def _iter_import_files(import_root: Path):
yield entry yield entry
@celery.task(name="backend.app.tasks.scan.scan_directory", bind=True) @celery.task(
name="backend.app.tasks.scan.scan_directory",
bind=True,
# Audit 2026-06-02 — large libraries make the scan legitimately long.
# Hard cap at 70 min so the corresponding QUEUE_STUCK_THRESHOLD_MINUTES
# ("scan") of 75 min always wins; soft limit gives the task a clean
# exit window before SIGKILL.
soft_time_limit=3600, time_limit=4200,
)
def scan_directory(self, triggered_by: str = "manual", def scan_directory(self, triggered_by: str = "manual",
mode: str = "quick") -> int: mode: str = "quick") -> int:
"""Walks the import root and creates ImportTasks. `mode` is 'quick' """Walks the import root and creates ImportTasks. `mode` is 'quick'
+18 -2
View File
@@ -194,9 +194,20 @@ browser.runtime.onMessage.addListener(async (msg) => {
if (platform.authType === 'cookies') { if (platform.authType === 'cookies') {
const cookies = await extractCookiesForPlatform(key); const cookies = await extractCookiesForPlatform(key);
if (cookies.length === 0) return { error: 'No cookies found — log in first.' }; if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
// Verify the captured cookies are actually live BEFORE
// uploading. Skips upload on confirmed-stale sessions so we
// don't overwrite FC-side credentials with garbage. Platforms
// without a verify config (verify.ok === null) fall through
// to upload as before.
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
return {
error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`,
};
}
const data = toNetscapeFormat(cookies); const data = toNetscapeFormat(cookies);
await api.uploadCredentials(key, 'cookies', data); await api.uploadCredentials(key, 'cookies', data);
return { success: true, cookieCount: cookies.length }; return { success: true, cookieCount: cookies.length, verified: v.ok === true };
} }
if (key === 'discord') { if (key === 'discord') {
if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
@@ -229,8 +240,13 @@ browser.runtime.onMessage.addListener(async (msg) => {
results[key] = { skipped: true, reason: 'no cookies' }; results[key] = { skipped: true, reason: 'no cookies' };
continue; continue;
} }
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
results[key] = { error: `verify failed: ${v.reason}` };
continue;
}
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies)); await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
results[key] = { success: true, cookieCount: cookies.length }; results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true };
} catch (e) { } catch (e) {
results[key] = { error: e.message }; results[key] = { error: e.message };
} }
+35
View File
@@ -76,3 +76,38 @@ async function getCookieCount(platformKey) {
return 0; return 0;
} }
} }
/**
* Verify cookies are live by hitting an authenticated endpoint with the
* browser's current cookie jar. Returns:
* { ok: true, status } — verified
* { ok: false, status, reason } — endpoint said we're not logged in
* { ok: null, reason } — no verify config for this platform; caller
* should treat as "verify not available,
* proceed with upload"
*
* Implementation note: extensions with `host_permissions` for the target
* domain get the user's cookies auto-attached to fetch() — same set
* gallery-dl will later use on the backend.
*/
async function verifyCookiesForPlatform(platformKey) {
const platform = PLATFORMS[platformKey];
if (!platform) return { ok: false, reason: `Unknown platform: ${platformKey}` };
if (!platform.verify) return { ok: null, reason: 'verify-not-configured' };
const { url, method, okStatuses } = platform.verify;
let resp;
try {
resp = await fetch(url, { method, credentials: 'include', cache: 'no-store' });
} catch (e) {
return { ok: false, reason: `Verify request failed: ${e.message}` };
}
if (okStatuses.includes(resp.status)) {
return { ok: true, status: resp.status };
}
return {
ok: false,
status: resp.status,
reason: `${url} returned HTTP ${resp.status} — session looks stale or logged out`,
};
}
+21
View File
@@ -13,6 +13,13 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#FF424D', color: '#FF424D',
urlPattern: /^https?:\/\/(www\.)?patreon\.com/, urlPattern: /^https?:\/\/(www\.)?patreon\.com/,
// Patreon's `/api/current_user` returns 200 + the logged-in user
// when authenticated, 401 otherwise. Cheapest definitive check.
verify: {
url: 'https://www.patreon.com/api/current_user',
method: 'GET',
okStatuses: [200],
},
}, },
subscribestar: { subscribestar: {
name: 'SubscribeStar', name: 'SubscribeStar',
@@ -26,6 +33,9 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#FFD700', color: '#FFD700',
urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/, urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/,
// No known stable auth-required endpoint that returns a definitive
// status code; skipping verify so we don't false-positive-fail
// good cookies. Operator can add later if a clean endpoint surfaces.
}, },
hentaifoundry: { hentaifoundry: {
name: 'Hentai Foundry', name: 'Hentai Foundry',
@@ -33,6 +43,14 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#9C27B0', color: '#9C27B0',
urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/, urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/,
// Mirror gallery-dl's _init_site_filters: HEAD on `?enterAgree=1`.
// Logged in → 200, logged out → 401. Catches the exact failure mode
// the backend extractor would hit later.
verify: {
url: 'https://www.hentai-foundry.com/?enterAgree=1',
method: 'HEAD',
okStatuses: [200],
},
}, },
discord: { discord: {
name: 'Discord', name: 'Discord',
@@ -56,6 +74,9 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#05CC47', color: '#05CC47',
urlPattern: /^https?:\/\/(www\.)?deviantart\.com/, urlPattern: /^https?:\/\/(www\.)?deviantart\.com/,
// DA's logged-in-only endpoints sit behind their internal _napi
// namespace which shifts; skipping verify until a stable check
// surfaces. Same posture as SubscribeStar.
}, },
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "FabledCurator", "name": "FabledCurator",
"version": "1.0.6", "version": "1.0.7",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": { "browser_specific_settings": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fabledcurator-extension", "name": "fabledcurator-extension",
"version": "1.0.6", "version": "1.0.7",
"private": true, "private": true,
"description": "Firefox extension for FabledCurator", "description": "Firefox extension for FabledCurator",
"scripts": { "scripts": {
+2 -1
View File
@@ -132,8 +132,9 @@ async function exportPlatformCookies(key, card) {
if (r.error) showError(r.error); if (r.error) showError(r.error);
else { else {
const n = r.cookieCount ?? null; const n = r.cookieCount ?? null;
const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
const msg = n !== null const msg = n !== null
? `${PLATFORMS[key].name}: ${n} cookies exported` ? `${PLATFORMS[key].name}: ${n} cookies exported${verifiedSuffix}`
: `${PLATFORMS[key].name}: token exported`; : `${PLATFORMS[key].name}: token exported`;
showSuccess(msg); showSuccess(msg);
await loadPlatformStatus(); await loadPlatformStatus();
+14 -1
View File
@@ -9,7 +9,9 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AppShell from './components/AppShell.vue' import AppShell from './components/AppShell.vue'
import AppSnackbar from './components/AppSnackbar.vue' import AppSnackbar from './components/AppSnackbar.vue'
import ImageViewer from './components/modal/ImageViewer.vue' import ImageViewer from './components/modal/ImageViewer.vue'
@@ -17,9 +19,20 @@ import { useModalStore } from './stores/modal.js'
const modal = useModalStore() const modal = useModalStore()
const snackbar = ref(null) const snackbar = ref(null)
const route = useRoute()
onMounted(() => { onMounted(() => {
// Expose snackbar via a simple global so stores can call it without props. // Expose snackbar via a simple global so stores can call it without props.
window.__fcToast = (opts) => snackbar.value?.open(opts) window.__fcToast = (opts) => snackbar.value?.open(opts)
}) })
// Audit 2026-06-02: the modal is an overlay, not a page. When the
// route changes (RouterLink inside the modal, history back/forward,
// programmatic push from any view), close the modal so it doesn't
// hover over a different route. Watching route.name (not the path)
// keeps within-route nav like /artist/foo → /artist/bar from
// dismissing the modal mid-browse.
watch(() => route.name, () => {
if (modal.isOpen) modal.close()
})
</script> </script>
+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>
@@ -11,9 +11,6 @@
</template> </template>
<script setup> <script setup>
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useArtistStore } from '../../stores/artist.js' import { useArtistStore } from '../../stores/artist.js'
import { useModalStore } from '../../stores/modal.js' import { useModalStore } from '../../stores/modal.js'
import MasonryGrid from '../discovery/MasonryGrid.vue' import MasonryGrid from '../discovery/MasonryGrid.vue'
@@ -24,22 +21,9 @@ const props = defineProps({
const store = useArtistStore() const store = useArtistStore()
const modal = useModalStore() const modal = useModalStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
const initial = parseInt(route.query.image, 10)
if (!isNaN(initial)) modal.open(initial)
})
watch(() => route.query.image, (q) => {
const id = parseInt(q, 10)
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
})
function openImage (id) { function openImage (id) {
router.push({ query: { ...route.query, image: id } }) modal.open(id)
} }
</script> </script>
@@ -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())
@@ -8,6 +8,15 @@
<div v-if="card.preview_thumbnails.length === 0" class="fc-artistcard__noimg"> <div v-if="card.preview_thumbnails.length === 0" class="fc-artistcard__noimg">
No preview No preview
</div> </div>
<!-- Accent pill in the corner when this artist has content imported
since the operator last opened their detail view. Caps at 99+
to keep the layout compact; the actual count appears in the
banner inside ArtistView. -->
<span
v-if="(card.unseen_count || 0) > 0"
class="fc-artistcard__unseen"
:aria-label="`${card.unseen_count} new since last visit`"
>+{{ card.unseen_count > 99 ? '99+' : card.unseen_count }}</span>
</div> </div>
<v-card-text class="fc-artistcard__body"> <v-card-text class="fc-artistcard__body">
<div class="fc-artistcard__name">{{ card.name }}</div> <div class="fc-artistcard__name">{{ card.name }}</div>
@@ -37,6 +46,7 @@ function onCardClick() {
<style scoped> <style scoped>
.fc-artistcard { cursor: pointer; } .fc-artistcard { cursor: pointer; }
.fc-artistcard__previews { .fc-artistcard__previews {
position: relative;
display: grid; grid-template-columns: repeat(3, 1fr); display: grid; grid-template-columns: repeat(3, 1fr);
gap: 2px; aspect-ratio: 3 / 1; gap: 2px; aspect-ratio: 3 / 1;
/* Explicit floor + ceiling so tall source images can't escape the /* Explicit floor + ceiling so tall source images can't escape the
@@ -45,6 +55,19 @@ function onCardClick() {
overflow: hidden; overflow: hidden;
background: rgb(var(--v-theme-surface-light)); background: rgb(var(--v-theme-surface-light));
} }
.fc-artistcard__unseen {
position: absolute;
top: 6px; right: 6px;
display: inline-flex; align-items: center;
padding: 2px 8px;
font-size: 11px; font-weight: 700; letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
color: rgb(var(--v-theme-on-accent, 0, 0, 0));
background: rgb(var(--v-theme-accent));
border-radius: 999px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.fc-artistcard__previews img { .fc-artistcard__previews img {
display: block; display: block;
width: 100%; height: 100%; width: 100%; height: 100%;
@@ -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 {
@@ -55,6 +55,12 @@
/> />
</template> </template>
<v-list density="compact"> <v-list density="compact">
<v-list-item
v-if="card.kind === 'character'"
title="Set fandom…"
prepend-icon="mdi-book-open-page-variant"
@click="$emit('set-fandom', card)"
/>
<v-list-item <v-list-item
title="Merge with…" title="Merge with…"
prepend-icon="mdi-call-merge" prepend-icon="mdi-call-merge"
@@ -78,7 +84,9 @@
import { ref } from 'vue' import { ref } from 'vue'
const props = defineProps({ card: { type: Object, required: true } }) const props = defineProps({ card: { type: Object, required: true } })
const emit = defineEmits(['open', 'rename', 'manage', 'read', 'merge-with', 'delete']) const emit = defineEmits([
'open', 'rename', 'manage', 'read', 'merge-with', 'delete', 'set-fandom',
])
const editing = ref(false) const editing = ref(false)
const draft = ref('') const draft = ref('')
@@ -124,10 +124,16 @@ async function onRetry() {
if (!props.event.source_id) return if (!props.event.source_id) return
retrying.value = true retrying.value = true
try { try {
await sourcesStore.checkNow(props.event.source_id) const body = await sourcesStore.checkNow(props.event.source_id)
toast({ // Audit 2026-06-02: the previous handler unconditionally toasted
text: `Source check re-queued`, type: 'success', // "re-queued" even when the platform was in cooldown (202 +
}) // status='deferred'). Operator thought work was in flight when
// nothing was actually enqueued.
if (body?.status === 'deferred') {
toast({ text: 'Retry deferred — platform in cooldown', type: 'info' })
} else {
toast({ text: 'Source check re-queued', type: 'success' })
}
} catch (e) { } catch (e) {
const isInFlight = !!e?.body?.download_event_id const isInFlight = !!e?.body?.download_event_id
toast({ toast({
@@ -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>
@@ -0,0 +1,269 @@
<template>
<div class="fc-filterbar-wrap">
<div class="fc-filterbar">
<v-autocomplete
v-model="selected"
:items="searchItems"
:loading="searchLoading"
item-title="name" item-value="value"
no-filter clearable hide-details density="compact" variant="outlined"
placeholder="Filter by tag or artist…"
prepend-inner-icon="mdi-filter-variant"
class="fc-filterbar__search"
@update:search="onSearch"
@update:model-value="onPick"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :title="item.raw.name">
<template #prepend>
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
</template>
<template #subtitle>
{{ item.raw.kind === 'artist' ? 'artist'
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
</template>
</v-list-item>
</template>
</v-autocomplete>
<div class="fc-filterbar__chips">
<v-chip
v-for="id in store.filter.tag_ids" :key="`t${id}`"
size="small" closable :color="chipColor(id)" variant="tonal"
@click:close="removeTag(id)"
>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
<v-chip
v-if="store.filter.artist_id"
size="small" closable color="accent" variant="tonal"
prepend-icon="mdi-account"
@click:close="clearArtist"
>{{ 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>
<v-spacer />
<v-btn-toggle
:model-value="store.filter.media_type ?? 'all'"
density="compact" mandatory variant="outlined" divided
@update:model-value="(v) => setMedia(v === 'all' ? null : v)"
>
<v-btn value="all" size="small">All</v-btn>
<v-btn value="image" size="small">Images</v-btn>
<v-btn value="video" size="small">Videos</v-btn>
</v-btn-toggle>
<!-- Sort is meaningless in similar-mode (results are distance-ranked). -->
<v-select
v-if="!store.filter.similar_to"
:model-value="store.filter.sort"
:items="SORTS"
density="compact" hide-details variant="outlined"
class="fc-filterbar__sort"
@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-if="hasActiveFilters" variant="text" size="small"
prepend-icon="mdi-close" @click="clearAll"
>Clear</v-btn>
</div>
<GalleryFacetPanel v-if="refineOpen" />
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
import { useTagStore } from '../../stores/tags.js'
import GalleryFacetPanel from './GalleryFacetPanel.vue'
const store = useGalleryStore()
const tagStore = useTagStore()
const api = useApi()
const router = useRouter()
const SORTS = [
{ title: 'Newest first', value: 'newest' },
{ title: 'Oldest first', value: 'oldest' },
]
const selected = ref(null)
const searchItems = ref([])
const searchLoading = ref(false)
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(() =>
store.filter.tag_ids.length > 0 ||
store.filter.artist_id != null ||
store.filter.media_type != null ||
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) {
if (raw.kind === 'artist') return 'mdi-account'
return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant',
series: 'mdi-bookshelf' }[raw.kind] || 'mdi-tag'
}
function chipColor(id) {
// Tag chips use the same per-kind palette as the rest of the app; we only
// know the kind from the autocomplete pick, so fall back to a neutral tone.
return tagStore.colorFor(pickedKind.value[id] || 'general')
}
const pickedKind = ref({})
function onSearch(q) {
if (debounce) clearTimeout(debounce)
if (!q || !q.trim()) { searchItems.value = []; return }
debounce = setTimeout(async () => {
searchLoading.value = true
try {
const [tags, artists] = await Promise.all([
api.get('/api/tags/autocomplete', { params: { q, limit: 10 } }),
api.get('/api/artists/autocomplete', { params: { q, limit: 10 } }),
])
searchItems.value = [
...(artists || []).map((a) => ({
kind: 'artist', id: a.id, name: a.name, value: `artist:${a.id}`,
})),
...(tags || []).map((t) => ({
kind: t.kind, id: t.id, name: t.name, value: `tag:${t.id}`,
fandom_name: t.fandom_name,
})),
]
} catch {
searchItems.value = []
} finally {
searchLoading.value = false
}
}, 250)
}
function onPick(value) {
const item = searchItems.value.find((i) => i.value === value)
selected.value = null
searchItems.value = []
if (!item) return
if (item.kind === 'artist') {
store.noteArtistLabel(item.name)
pushFilter((n) => { n.artist_id = item.id })
} else {
store.noteTagLabel(item.id, item.name)
pickedKind.value = { ...pickedKind.value, [item.id]: item.kind }
pushFilter((n) => { if (!n.tag_ids.includes(item.id)) n.tag_ids.push(item.id) })
}
}
function removeTag(id) {
pushFilter((n) => { n.tag_ids = n.tag_ids.filter((t) => t !== id) })
}
function clearArtist() {
store.noteArtistLabel(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 setSort(s) { pushFilter((n) => { n.sort = s }) }
function clearAll() { router.push({ name: 'gallery', query: {} }) }
// Single write path: clone the current filter, mutate, serialize to the URL.
// 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) {
const n = cloneFilter(store.filter)
mutate(n)
router.push({ name: 'gallery', query: filterToQuery(n) })
}
</script>
<style scoped>
/* The whole chrome (bar row + expandable refine panel) is one sticky,
frosted block pinned directly under the 64px TopNav and continuous with it. */
.fc-filterbar-wrap {
position: sticky;
top: 64px;
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;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 12px;
}
/* The inputs/toggles float on the haze: still translucent, but a touch more
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__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.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>
@@ -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>
@@ -9,11 +9,16 @@
> >
<div class="fc-gallery-item__media"> <div class="fc-gallery-item__media">
<img <img
v-if="!isVideo" :src="image.thumbnail_url" :alt="`Image ${image.id}`" v-if="!isVideo" ref="imgEl" :src="image.thumbnail_url"
loading="lazy" @error="onThumbError" :alt="`Image ${image.id}`" loading="lazy"
:class="{ 'is-loaded': loaded }"
@load="loaded = true" @error="onThumbError"
> >
<div v-else class="fc-gallery-item__video-thumb"> <div v-else class="fc-gallery-item__video-thumb">
<img :src="image.thumbnail_url" :alt="`Video ${image.id}`" loading="lazy"> <img
ref="imgEl" :src="image.thumbnail_url" :alt="`Video ${image.id}`"
loading="lazy" :class="{ 'is-loaded': loaded }" @load="loaded = true"
>
<v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" /> <v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" />
</div> </div>
<div v-if="thumbError" class="fc-gallery-item__placeholder"> <div v-if="thumbError" class="fc-gallery-item__placeholder">
@@ -38,7 +43,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useGallerySelectionStore } from '../../stores/gallerySelection.js' import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
const props = defineProps({ image: { type: Object, required: true } }) const props = defineProps({ image: { type: Object, required: true } })
@@ -46,6 +51,18 @@ const emit = defineEmits(['open'])
const sel = useGallerySelectionStore() const sel = useGallerySelectionStore()
const thumbError = ref(false) const thumbError = ref(false)
// Reveal each tile when ITS OWN thumbnail finishes loading (`@load`), not
// when its metadata batch lands — so tiles fade/flip in individually in
// load order instead of popping in together (operator-flagged 2026-06-04).
const loaded = ref(false)
const imgEl = ref(null)
onMounted(() => {
// A cached thumbnail can already be complete before @load binds; reflect
// that so the tile reveals instead of sitting invisible at opacity 0.
const el = imgEl.value
if (el && el.complete && el.naturalWidth > 0) loaded.value = true
})
const isVideo = computed( const isVideo = computed(
() => props.image.mime && props.image.mime.startsWith('video/') () => props.image.mime && props.image.mime.startsWith('video/')
) )
@@ -82,6 +99,30 @@ function onThumbError() { thumbError.value = true }
} }
.fc-gallery-item__media img { .fc-gallery-item__media img {
width: 100%; height: 100%; object-fit: cover; display: block; width: 100%; height: 100%; object-fit: cover; display: block;
opacity: 0;
}
/* Entrance: each thumbnail flips up out of a slight backward tilt and
settles into place, played WHEN ITS OWN image finishes loading (the
`is-loaded` class) rather than on a batch/timer — so tiles cascade in
natural load order instead of popping in together. Mirrors the showcase
MasonryGrid entrance styling (operator-flagged 2026-06-04). */
.fc-gallery-item__media img.is-loaded {
animation: fc-gallery-item-in 0.5s cubic-bezier(0.34, 1.45, 0.64, 1) both;
}
@keyframes fc-gallery-item-in {
0% {
opacity: 0;
transform: perspective(1000px) rotateX(-22deg) translateY(20px) scale(0.96);
}
55% { opacity: 1; }
100% {
opacity: 1;
transform: perspective(1000px) rotateX(0) translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.fc-gallery-item__media img { opacity: 1; }
.fc-gallery-item__media img.is-loaded { animation: none; }
} }
.fc-gallery-item__video-thumb { position: relative; height: 100%; } .fc-gallery-item__video-thumb { position: relative; height: 100%; }
.fc-gallery-item__video-badge { .fc-gallery-item__video-badge {
@@ -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>
@@ -0,0 +1,128 @@
<template>
<v-card>
<v-card-title class="text-body-1">Fandom for {{ tag.name }}</v-card-title>
<v-card-text>
<template v-if="!collision">
<v-autocomplete
v-model="selectedId"
:items="store.fandomCache"
:item-title="(f) => f.name" :item-value="(f) => f.id"
label="Fandom" clearable density="compact"
:hint="selectedId == null
? 'No fandom — the character will be unassigned.' : ''"
persistent-hint
/>
<v-divider class="my-3" />
<p class="text-caption mb-2">Or create a new fandom:</p>
<div class="d-flex" style="gap: 8px;">
<v-text-field
v-model="newName" placeholder="New fandom name"
density="compact" hide-details
@keydown.enter.prevent="onCreate"
/>
<v-btn
:disabled="!newName.trim() || busy" rounded="pill"
@click="onCreate"
>Create</v-btn>
</div>
<v-alert
v-if="error" type="error" variant="tonal" density="compact"
class="mt-3"
>{{ error }}</v-alert>
</template>
<template v-else>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
A character named “{{ tag.name }}” already exists in that fandom.
</v-alert>
<p class="text-body-2">
Merge this tag into “{{ collision.target.name }}”?
{{ collision.source_image_count }} image
association{{ collision.source_image_count === 1 ? '' : 's' }}
will move over and this tag will be deleted{{
collision.will_alias ? ' (its name kept as a tagger alias)' : '' }}.
</p>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<template v-if="!collision">
<v-btn variant="text" :disabled="busy" @click="$emit('cancel')">
Cancel
</v-btn>
<v-btn
color="primary" rounded="pill" :loading="busy"
:disabled="selectedId === (tag.fandom_id ?? null)"
@click="onSave"
>Save</v-btn>
</template>
<template v-else>
<v-btn variant="text" :disabled="busy" @click="collision = null">
Back
</v-btn>
<v-btn
color="warning" variant="flat" rounded="pill" :loading="busy"
@click="onConfirmMerge"
>Merge</v-btn>
</template>
</v-card-actions>
</v-card>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js'
const props = defineProps({ tag: { type: Object, required: true } })
const emit = defineEmits(['updated', 'cancel'])
const store = useTagStore()
const selectedId = ref(props.tag.fandom_id ?? null)
const newName = ref('')
const busy = ref(false)
const error = ref(null)
const collision = ref(null)
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
async function onCreate() {
const name = newName.value.trim()
if (!name) return
busy.value = true
error.value = null
try {
const f = await store.createFandom(name)
selectedId.value = f.id
newName.value = ''
} catch (e) {
error.value = e.message || String(e)
} finally {
busy.value = false
}
}
async function save(merge) {
busy.value = true
error.value = null
try {
const body = await store.setFandom(props.tag.id, selectedId.value, { merge })
emit('updated', body)
} catch (e) {
// 409 on first attempt → surface the merge confirmation; cross-fandom
// collisions can't go through the regular /merge endpoint, so the
// resolution is a second setFandom with merge: true.
if (!merge && e.status === 409 && e.body && e.body.target) {
collision.value = e.body
} else {
error.value = e.message || String(e)
collision.value = null
}
} finally {
busy.value = false
}
}
function onSave() { save(false) }
function onConfirmMerge() { save(true) }
</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,34 +19,45 @@
> >
Accept Accept
</v-btn> </v-btn>
<v-menu> <!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
<template #activator="{ props }"> prior `#activator` + `v-bind="props"` path never toggled the menu
<v-btn inside this teleported modal, while v-model-driven overlays (the
class="fc-suggestion__menu" dialogs in this modal) work fine. So drive the menu explicitly:
icon="mdi-dots-vertical" size="small" the button toggles `menuOpen` with @click.stop (also shields any
variant="outlined" density="compact" parent), and `activator="parent"` anchors the menu for positioning
:aria-label="`More actions for ${suggestion.display_name}`" only — `:open-on-click="false"` keeps Vuetify's activator-click out
v-bind="props" of it, so there's a single, reliable opener. -->
/> <span class="fc-suggestion__menu-wrap">
</template> <v-btn
<v-list density="compact"> class="fc-suggestion__menu"
<v-list-item @click="$emit('alias', suggestion)"> icon="mdi-dots-vertical" size="small"
<v-list-item-title>Treat as alias for</v-list-item-title> variant="outlined" density="compact"
</v-list-item> :aria-label="`More actions for ${suggestion.display_name}`"
<v-list-item @click="$emit('dismiss', suggestion)"> @click.stop="menuOpen = !menuOpen"
<v-list-item-title>Dismiss for this image</v-list-item-title> />
</v-list-item> <v-menu
</v-list> v-model="menuOpen" activator="parent" :open-on-click="false"
</v-menu> >
<v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for…</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('dismiss', suggestion)">
<v-list-item-title>Dismiss for this image</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</div> </div>
</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>
@@ -90,6 +101,11 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
.fc-suggestion__accept :deep(.v-btn__content) { .fc-suggestion__accept :deep(.v-btn__content) {
font-size: 12px; letter-spacing: 0.02em; font-size: 12px; letter-spacing: 0.02em;
} }
.fc-suggestion__menu-wrap {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.fc-suggestion__menu { .fc-suggestion__menu {
flex: 0 0 auto; flex: 0 0 auto;
} }
@@ -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()
+55 -13
View File
@@ -10,19 +10,36 @@
> >
<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>
<v-menu> <!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
<template #activator="{ props: mp }"> never opened inside this teleported modal. Drive it explicitly
<v-icon instead (same mechanism as the dialogs below, which work): the
v-bind="mp" size="x-small" class="ml-1" icon toggles `openTagId` with @click.stop (shielding the chip's
icon="mdi-dots-vertical" @click.stop close button), and `activator="parent"` + `:open-on-click=false`
/> anchors the menu for positioning only. One tag's menu open at a
</template> time, so a single id is enough. -->
<v-list density="compact"> <span class="kebab-wrap">
<v-list-item @click="openRename(tag)"> <v-icon
<v-list-item-title>Rename</v-list-item-title> size="x-small" class="ml-1 kebab-icon"
</v-list-item> icon="mdi-dots-vertical"
</v-list> @click.stop="openTagId = openTagId === tag.id ? null : tag.id"
</v-menu> />
<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-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title>
</v-list-item>
<v-list-item
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
>
<v-list-item-title>Set fandom…</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</v-chip> </v-chip>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span> <span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
</div> </div>
@@ -49,6 +66,13 @@
@renamed="onRenamed" @cancel="renameDialog = false" @renamed="onRenamed" @cancel="renameDialog = false"
/> />
</v-dialog> </v-dialog>
<v-dialog v-model="fandomDialog" max-width="460">
<FandomSetDialog
v-if="fandomTarget" :tag="fandomTarget"
@updated="onFandomUpdated" @cancel="fandomDialog = false"
/>
</v-dialog>
</aside> </aside>
</template> </template>
@@ -59,10 +83,14 @@ import { useTagStore } from '../../stores/tags.js'
import TagAutocomplete from './TagAutocomplete.vue' import TagAutocomplete from './TagAutocomplete.vue'
import SuggestionsPanel from './SuggestionsPanel.vue' import SuggestionsPanel from './SuggestionsPanel.vue'
import TagRenameDialog from './TagRenameDialog.vue' import TagRenameDialog from './TagRenameDialog.vue'
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',
@@ -99,6 +127,18 @@ async function onRenamed() {
// Reflect the new name in the modal's current tag list without a full reload. // Reflect the new name in the modal's current tag list without a full reload.
await modal.reloadTags() await modal.reloadTags()
} }
const fandomDialog = ref(false)
const fandomTarget = ref(null)
function openSetFandom(tag) {
fandomTarget.value = tag
fandomDialog.value = true
}
async function onFandomUpdated() {
fandomDialog.value = false
// A fandom change can merge the tag away; reload to reflect the new state.
await modal.reloadTags()
}
</script> </script>
<style scoped> <style scoped>
@@ -113,4 +153,6 @@ async function onRenamed() {
margin-bottom: 12px; margin-bottom: 12px;
} }
.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-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'
@@ -35,7 +35,7 @@ import { computed } from 'vue'
const props = defineProps({ const props = defineProps({
queues: { type: Object, default: null }, // store.queues queues: { type: Object, default: null }, // store.queues
workers: { type: Object, default: null }, // store.workers workers: { type: Object, default: null }, // store.workers
recentMinute: { type: Array, default: () => [] }, // store.recentMinute recentRuns: { type: Array, default: () => [] }, // store.recentRuns
compact: { type: Boolean, default: false }, compact: { type: Boolean, default: false },
}) })
@@ -80,7 +80,7 @@ function activeCount(name) {
const recentByQueue = computed(() => { const recentByQueue = computed(() => {
const out = {} const out = {}
for (const r of props.recentMinute) { for (const r of props.recentRuns) {
if (!out[r.queue]) out[r.queue] = { ok: 0, err: 0 } if (!out[r.queue]) out[r.queue] = { ok: 0, err: 0 }
if (r.status === 'ok') out[r.queue].ok++ if (r.status === 'ok') out[r.queue].ok++
else if (r.status === 'error' || r.status === 'timeout') out[r.queue].err++ else if (r.status === 'error' || r.status === 'timeout') out[r.queue].err++
@@ -24,7 +24,7 @@
<QueuesTable <QueuesTable
:queues="store.queues" :queues="store.queues"
:workers="store.workers" :workers="store.workers"
:recent-minute="store.recentMinute" :recent-runs="store.recentRuns"
compact compact
/> />
</v-card-text> </v-card-text>
@@ -47,7 +47,7 @@ function pollOnce() {
if (document.hidden) return if (document.hidden) return
store.loadQueues() store.loadQueues()
store.loadWorkers() store.loadWorkers()
store.loadRecentMinute() store.loadRecentRuns()
} }
onMounted(() => { onMounted(() => {
@@ -14,7 +14,7 @@
<QueuesTable <QueuesTable
:queues="store.queues" :queues="store.queues"
:workers="store.workers" :workers="store.workers"
:recent-minute="store.recentMinute" :recent-runs="store.recentRuns"
/> />
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -202,7 +202,7 @@ function pollQueues() {
if (document.hidden) return if (document.hidden) return
store.loadQueues() store.loadQueues()
store.loadWorkers() store.loadWorkers()
store.loadRecentMinute() store.loadRecentRuns()
} }
function pollFailures() { function pollFailures() {
if (document.hidden) return if (document.hidden) return
@@ -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>
@@ -25,6 +25,15 @@
<v-chip size="x-small" color="error" variant="flat" label class="fc-fail__count"> <v-chip size="x-small" color="error" variant="flat" label class="fc-fail__count">
{{ s.consecutive_failures }}× failed {{ s.consecutive_failures }}× failed
</v-chip> </v-chip>
<v-chip
v-if="s.error_type"
size="x-small" variant="outlined" label
:color="errorTypeColor(s.error_type)"
class="fc-fail__class"
:title="errorTypeHint(s.error_type)"
>
{{ s.error_type }}
</v-chip>
<span class="fc-fail__err" :title="s.last_error || ''"> <span class="fc-fail__err" :title="s.last_error || ''">
{{ s.last_error || 'no error message recorded' }} {{ s.last_error || 'no error message recorded' }}
</span> </span>
@@ -66,6 +75,42 @@ const open = ref(true)
// Per-row loading flag so the spinner lives on the row whose Logs // Per-row loading flag so the spinner lives on the row whose Logs
// button was clicked, not on every row. // button was clicked, not on every row.
const logLoadingIds = ref(new Set()) const logLoadingIds = ref(new Set())
// Audit 2026-06-02: surface the ErrorType taxonomy as a colored chip
// next to the consecutive-failures count so operators can bulk-triage
// by error class. Color reflects "what to do next":
// warning (yellow) — auth/cookie issue: operator should rotate
// info (blue) — backend-paced (cooldown / rate limit / timeout)
// error (red) — likely terminal without operator intervention
const ERROR_TYPE_COLOR = {
auth_error: 'warning',
rate_limited: 'info',
timeout: 'info',
network_error: 'info',
not_found: 'error',
access_denied: 'error',
validation_failed: 'error',
unsupported_url: 'error',
http_error: 'error',
unknown_error: 'error',
partial: 'info',
tier_limited: 'info',
no_new_content: 'info',
}
const ERROR_TYPE_HINT = {
auth_error: 'Cookies likely expired — re-upload in Credentials.',
rate_limited: 'Platform-wide cooldown active. Will retry after it expires.',
timeout: 'Subprocess exceeded its time budget. Often retries cleanly.',
network_error: 'Transient network issue. Will retry on next tick.',
not_found: 'URL 404 — creator may have renamed or deleted.',
access_denied: 'Subscription tier may not grant this content.',
validation_failed: 'Downloaded files were quarantined by the validator.',
http_error: 'Generic HTTP error — see Logs.',
unsupported_url: 'gallery-dl does not support this URL pattern.',
unknown_error: 'Could not classify — see Logs.',
}
function errorTypeColor(t) { return ERROR_TYPE_COLOR[t] || 'error' }
function errorTypeHint(t) { return ERROR_TYPE_HINT[t] || '' }
async function onViewLogs(s) { async function onViewLogs(s) {
if (logLoadingIds.value.has(s.id)) return if (logLoadingIds.value.has(s.id)) return
logLoadingIds.value = new Set(logLoadingIds.value).add(s.id) logLoadingIds.value = new Set(logLoadingIds.value).add(s.id)
@@ -115,6 +160,7 @@ async function onViewLogs(s) {
} }
.fc-fail__artist { font-weight: 600; white-space: nowrap; } .fc-fail__artist { font-weight: 600; white-space: nowrap; }
.fc-fail__count { flex: 0 0 auto; } .fc-fail__count { flex: 0 0 auto; }
.fc-fail__class { flex: 0 0 auto; }
.fc-fail__err { .fc-fail__err {
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.8rem; font-size: 0.8rem;
@@ -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)
@@ -412,6 +501,17 @@ function onArtistCreated(artist) {
async function onCheck(source) { async function onCheck(source) {
try { try {
const body = await store.checkNow(source.id) const body = await store.checkNow(source.id)
// Audit 2026-06-02: /api/sources/<id>/check returns 202 with
// `{status:'deferred', cooldown_until}` when the platform is in
// cooldown — the previous handler treated this as success and
// toasted "event #undefined", masking that nothing was enqueued.
if (body?.status === 'deferred') {
toast({
text: 'Check deferred — platform in cooldown',
type: 'info',
})
return
}
toast({ toast({
text: `Check enqueued (event #${body.download_event_id})`, text: `Check enqueued (event #${body.download_event_id})`,
type: 'success', type: 'success',
@@ -465,17 +565,23 @@ async function onBackfill(source) {
async function checkAll(group) { async function checkAll(group) {
let ok = 0 let ok = 0
let conflict = 0 let conflict = 0
let deferred = 0
for (const s of group.sources) { for (const s of group.sources) {
if (!s.enabled) continue if (!s.enabled) continue
try { try {
await store.checkNow(s.id) const body = await store.checkNow(s.id)
ok += 1 // Audit 2026-06-02: deferred (202 + cooldown_until) used to be
// counted as queued, inflating the success tally and hiding
// that the cooldown actually held the work back.
if (body?.status === 'deferred') deferred += 1
else ok += 1
} catch (e) { } catch (e) {
if (e?.body?.download_event_id) conflict += 1 if (e?.body?.download_event_id) conflict += 1
} }
} }
const parts = [] const parts = []
if (ok) parts.push(`${ok} queued`) if (ok) parts.push(`${ok} queued`)
if (deferred) parts.push(`${deferred} deferred (cooldown)`)
if (conflict) parts.push(`${conflict} already running`) if (conflict) parts.push(`${conflict} already running`)
toast({ toast({
text: parts.join(', ') || 'Nothing to check (no enabled sources)', text: parts.join(', ') || 'Nothing to check (no enabled sources)',
@@ -551,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));
@@ -558,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;
@@ -0,0 +1,52 @@
// Inflight-token guard for stores whose async loads can be re-triggered
// by rapid filter/navigation changes. Without this, late responses
// from a prior load overwrite the store with stale data
// (last-writer-wins, not request-order-wins). gallery.js had a
// hand-rolled `inflightId` of the same shape; this composable
// extracts it so every store can use the same pattern.
//
// Audit 2026-06-02 (workflow wf_bbe3fdb1-e62) found this missing in
// modal/suggestions/artist/downloads/directory/posts. The two most
// operator-impacting consequences: (1) modal tag mutations could
// land DELETE/POST on the wrong image when the user navigated mid-
// flight; (2) suggestions accept could push a tag to the wrong
// image AND add it to the allowlist.
//
// Usage:
// const inflight = useInflightToken()
//
// async function load() {
// const t = inflight.claim()
// const body = await api.get(...)
// if (!t.isCurrent()) return // stale — abort write
// items.value = body.items // safe to commit
// }
//
// function setFilter(f) {
// inflight.cancel() // any in-flight token is now stale
// filter.value = f
// load() // claims a fresh token
// }
//
// For multi-await flows (POST then GET, optimistic mutation then
// reconcile), check isCurrent() after EACH await — any intervening
// claim() or cancel() invalidates the prior token.
export function useInflightToken() {
let _seq = 0
let _current = 0
function claim() {
_current = ++_seq
const id = _current
return {
id,
isCurrent: () => _current === id,
}
}
function cancel() {
_current = ++_seq
}
return { claim, cancel }
}
+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,
} }
}) })
+16 -3
View File
@@ -1,6 +1,7 @@
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 { useInflightToken } from '../composables/useInflightToken.js'
import { usePostsStore } from './posts.js' import { usePostsStore } from './posts.js'
const PAGE = 60 const PAGE = 60
@@ -15,12 +16,17 @@ export const useArtistStore = defineStore('artist', () => {
const error = ref(null) const error = ref(null)
const notFound = ref(false) const notFound = ref(false)
let started = false let started = false
// Rapid artist-to-artist navigation used to render the previous
// artist's overview/images briefly when the second load resolved
// after the third. Audit 2026-06-02.
const inflight = useInflightToken()
async function load (slug) { async function load (slug) {
// Cross-artist reset: clear this store AND the posts store so the new // Cross-artist reset: clear this store AND the posts store so the new
// artist doesn't briefly render with the previous artist's content // artist doesn't briefly render with the previous artist's content
// when the user is on the Posts tab. (Gallery tab uses this artist // when the user is on the Posts tab. (Gallery tab uses this artist
// store's own images list — cleared above.) // store's own images list — cleared above.)
inflight.cancel()
overview.value = null overview.value = null
images.value = [] images.value = []
nextCursor.value = null nextCursor.value = null
@@ -29,14 +35,18 @@ export const useArtistStore = defineStore('artist', () => {
error.value = null error.value = null
loading.value = true loading.value = true
usePostsStore().$reset?.() usePostsStore().$reset?.()
const t = inflight.claim()
try { try {
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`) const body = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
if (!t.isCurrent()) return
overview.value = body
await loadMoreImages(slug) await loadMoreImages(slug)
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
if (e.status === 404) notFound.value = true if (e.status === 404) notFound.value = true
else error.value = e.message else error.value = e.message
} finally { } finally {
loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
@@ -44,19 +54,22 @@ export const useArtistStore = defineStore('artist', () => {
if (imagesLoading.value) return if (imagesLoading.value) return
if (started && nextCursor.value === null) return if (started && nextCursor.value === null) return
imagesLoading.value = true imagesLoading.value = true
const t = inflight.claim()
try { try {
const params = { limit: PAGE } const params = { limit: PAGE }
if (nextCursor.value) params.cursor = nextCursor.value if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get( const body = await api.get(
`/api/artist/${encodeURIComponent(slug)}/images`, { params } `/api/artist/${encodeURIComponent(slug)}/images`, { params }
) )
if (!t.isCurrent()) return
images.value.push(...body.images) images.value.push(...body.images)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
started = true started = true
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
error.value = e.message error.value = e.message
} finally { } finally {
imagesLoading.value = false if (t.isCurrent()) imagesLoading.value = false
} }
} }
+10 -1
View File
@@ -2,6 +2,7 @@ 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 { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
const PAGE = 60 const PAGE = 60
@@ -13,16 +14,23 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
const q = ref('') const q = ref('')
const platform = ref(null) const platform = ref(null)
let started = false let started = false
// Typed "alice" then "alice bob" used to drop the second fetch
// entirely (loading flag still true from the first), so the UI
// showed alice results while the input said "alice bob". Inflight
// token + reset() cancelling in-flight requests fixes both: the
// first response is discarded, the second is fetched. Audit 2026-06-02.
const inflight = useInflightToken()
async function loadMore() { async function loadMore() {
if (loading.value) return
if (started && nextCursor.value === null) return if (started && nextCursor.value === null) return
const t = inflight.claim()
await run(async () => { await run(async () => {
const params = { limit: PAGE } const params = { limit: PAGE }
if (q.value) params.q = q.value if (q.value) params.q = q.value
if (platform.value) params.platform = platform.value if (platform.value) params.platform = platform.value
if (nextCursor.value) params.cursor = nextCursor.value if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/artists/directory', { params }) const body = await api.get('/api/artists/directory', { params })
if (!t.isCurrent()) return
cards.value.push(...body.cards) cards.value.push(...body.cards)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
started = true started = true
@@ -30,6 +38,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
} }
async function reset() { async function reset() {
inflight.cancel()
cards.value = [] cards.value = []
nextCursor.value = null nextCursor.value = null
started = false started = false
+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 }
})
+12
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const useDownloadsStore = defineStore('downloads', () => { export const useDownloadsStore = defineStore('downloads', () => {
const api = useApi() const api = useApi()
@@ -22,6 +23,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
// the "active now" panel always reflects what's happening regardless of // the "active now" panel always reflects what's happening regardless of
// how the operator has filtered the historical list below. // how the operator has filtered the historical list below.
const activeEvents = ref([]) const activeEvents = ref([])
// Filter changes (applyFilter) and rapid pagination can interleave
// responses; without an inflight guard the late response from a
// prior filter overwrites the current view. Audit 2026-06-02.
const inflight = useInflightToken()
function _params(extra = {}) { function _params(extra = {}) {
const out = { limit: 50, ...extra } const out = { limit: 50, ...extra }
@@ -32,8 +37,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
} }
async function loadFirst() { async function loadFirst() {
const t = inflight.claim()
await run(async () => { await run(async () => {
const body = await api.get('/api/downloads', { params: _params() }) const body = await api.get('/api/downloads', { params: _params() })
if (!t.isCurrent()) return
events.value = body events.value = body
cursor.value = body.length ? body[body.length - 1].id : null cursor.value = body.length ? body[body.length - 1].id : null
hasMore.value = body.length === 50 hasMore.value = body.length === 50
@@ -42,8 +49,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
async function loadMore() { async function loadMore() {
if (!hasMore.value || cursor.value == null) return if (!hasMore.value || cursor.value == null) return
const t = inflight.claim()
await run(async () => { await run(async () => {
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) }) const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
if (!t.isCurrent()) return
events.value.push(...body) events.value.push(...body)
cursor.value = body.length ? body[body.length - 1].id : cursor.value cursor.value = body.length ? body[body.length - 1].id : cursor.value
hasMore.value = body.length === 50 hasMore.value = body.length === 50
@@ -71,6 +80,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
} }
async function applyFilter(patch) { async function applyFilter(patch) {
// Drop any in-flight loadFirst/loadMore from the previous filter
// so its late response doesn't overwrite this filter's results.
inflight.cancel()
filter.value = { ...filter.value, ...patch } filter.value = { ...filter.value, ...patch }
await loadFirst() await loadFirst()
} }
+200 -34
View File
@@ -1,13 +1,17 @@
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 { useInflightToken } from '../composables/useInflightToken.js'
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one // Initial paint is a SINGLE request (INITIAL_LIMIT). The old 10×serial-
// 50-item request so items render as each batch lands. Total initial // batch loop (2026-05-30) only staggered METADATA, which isn't the visual
// count is unchanged (PAGE * INITIAL_BATCHES = 50). Infinite-scroll also // bottleneck — thumbnails load as independent `<img>` requests, and
// pulls PAGE per trigger to keep appends progressive. // GalleryItem now reveals each tile on its own image `@load`. One fetch is
const PAGE = 5 // far fewer round-trips and faster to first paint; the reveal-on-load is
const INITIAL_BATCHES = 10 // what makes appearance progressive. Infinite scroll pulls PAGE per trigger.
// Reworked 2026-06-04.
const PAGE = 25
const INITIAL_LIMIT = 50
export const useGalleryStore = defineStore('gallery', () => { export const useGalleryStore = defineStore('gallery', () => {
const api = useApi() const api = useApi()
@@ -17,43 +21,59 @@ export const useGalleryStore = defineStore('gallery', () => {
const nextCursor = ref(null) const nextCursor = ref(null)
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const filter = ref({ tag_id: null, post_id: null }) const filter = ref({
tag_ids: [], artist_id: null, media_type: 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
// and pre-noted by the filter bar when a user picks from autocomplete.
const tagLabels = ref({}) // tagId -> name
const artistLabel = ref(null)
const timelineBuckets = ref([]) const timelineBuckets = ref([])
const timelineLoading = ref(false) const timelineLoading = ref(false)
let inflightId = 0 // Was a hand-rolled inflightId counter; the audit-2026-06-02 fan-out
// moved this pattern into useInflightToken so every store can share it.
const inflight = useInflightToken()
async function loadInitial() { async function loadInitial() {
inflight.cancel()
images.value = [] images.value = []
dateGroups.value = [] dateGroups.value = []
nextCursor.value = null nextCursor.value = null
// Sequentially fetch INITIAL_BATCHES chunks so items render as each await loadMore(INITIAL_LIMIT)
// batch lands rather than blocking on one big response. Stop early
// when the backend reports no more pages.
for (let i = 0; i < INITIAL_BATCHES; i++) {
if (i > 0 && nextCursor.value === null) break
await loadMore()
}
} }
async function loadMore() { async function loadMore(limit = PAGE) {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
error.value = null error.value = null
const myId = ++inflightId const t = inflight.claim()
try { try {
const params = { limit: PAGE, ...activeFilterParam() } const params = { limit, ...activeFilterParam() }
if (nextCursor.value) params.cursor = nextCursor.value if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/gallery/scroll', { params }) const body = await api.get('/api/gallery/scroll', { params })
if (myId !== inflightId) return // stale response if (!t.isCurrent()) return
images.value.push(...body.images) images.value.push(...body.images)
dateGroups.value = mergeGroups(dateGroups.value, body.date_groups) dateGroups.value = mergeGroups(dateGroups.value, body.date_groups)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
} finally { } finally {
if (myId === inflightId) loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
@@ -67,9 +87,48 @@ 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
// loadMore — first jump's late body could clobber the second
// jump's already-applied state.
inflight.cancel()
const t = inflight.claim()
const params = { year, month, ...activeFilterParam() } const params = { year, month, ...activeFilterParam() }
const body = await api.get('/api/gallery/jump', { params }) const body = await api.get('/api/gallery/jump', { params })
if (!t.isCurrent()) return
if (body.cursor) { if (body.cursor) {
images.value = [] images.value = []
dateGroups.value = [] dateGroups.value = []
@@ -79,23 +138,101 @@ export const useGalleryStore = defineStore('gallery', () => {
} }
function activeFilterParam() { function activeFilterParam() {
if (filter.value.tag_id) return { tag_id: filter.value.tag_id } // post_id is the exclusive post-detail view.
if (filter.value.post_id) return { post_id: filter.value.post_id } if (filter.value.post_id) return { post_id: filter.value.post_id }
return {} const p = {}
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
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.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
} }
function setTagFilter(tagId) { // Live facet counts, scoped to the current filter (panel-gated — callers
filter.value.tag_id = tagId // are the refine panel only). Single-flighted so rapid toggles don't let a
filter.value.post_id = null // stale response clobber a newer one.
loadInitial() async function loadFacets() {
loadTimeline() 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
}
} }
function setPostFilter(postId) { // URL is the source of truth for filters. GalleryView calls this on mount
filter.value.post_id = postId // and on every route-query change; the filter bar mutates the URL
filter.value.tag_id = null // (router.push) rather than the store directly, so deep-links, the back
loadInitial() // button, and bar actions all funnel through one path.
loadTimeline() async function applyFilterFromQuery(q) {
filter.value = {
tag_ids: _parseIds(q.tag_id),
artist_id: _toId(q.artist_id),
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
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()
}
_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) {
const n = Number(v)
return Number.isInteger(n) && n > 0 ? n : null
}
function _parseIds(raw) {
if (!raw) return []
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
}
// Pre-seed a label so a freshly-picked chip shows its name without a
// round-trip; the bar calls these before pushing the new URL.
function noteTagLabel(id, name) { tagLabels.value = { ...tagLabels.value, [id]: name } }
function noteArtistLabel(name) { artistLabel.value = name || null }
async function _resolveLabels() {
for (const id of filter.value.tag_ids) {
if (tagLabels.value[id]) continue
try {
const t = await api.get(`/api/tags/${id}`)
tagLabels.value = { ...tagLabels.value, [id]: t.name }
} catch { /* chip falls back to #id */ }
}
if (filter.value.artist_id && !artistLabel.value) {
// The filtered set is this artist — derive the chip label from a tile.
const hit = images.value.find(
(i) => i.artist && i.artist.id === filter.value.artist_id
)
artistLabel.value = hit?.artist?.name || null
}
if (!filter.value.artist_id) artistLabel.value = null
} }
const hasMore = computed(() => nextCursor.value !== null) const hasMore = computed(() => nextCursor.value !== null)
@@ -103,11 +240,40 @@ export const useGalleryStore = defineStore('gallery', () => {
return { return {
images, dateGroups, hasMore, isEmpty, loading, error, images, dateGroups, hasMore, isEmpty, loading, error,
filter, timelineBuckets, timelineLoading, filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter facets, facetsLoading,
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar,
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]
+78 -15
View File
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const useModalStore = defineStore('modal', () => { export const useModalStore = defineStore('modal', () => {
const api = useApi() const api = useApi()
@@ -10,15 +11,23 @@ export const useModalStore = defineStore('modal', () => {
const currentImageId = ref(null) const currentImageId = ref(null)
const current = ref(null) const current = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
// Tag mutations interpolate the image id into the URL after an
// await; without an inflight token, a fast prev/next can route the
// DELETE/POST to the wrong image AND the response to the wrong
// chip rail. Audit 2026-06-02.
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)
async function open (id, opts = {}) { async function open (id, opts = {}) {
// Cancel any in-flight tag mutation or reloadTags from the
// previous image so its late response can't apply to this one.
inflight.cancel()
currentImageId.value = id currentImageId.value = id
current.value = null // cleared upfront so it stays null on error current.value = null // cleared upfront so it stays null on error
// Update post-scoped state if caller passed it; otherwise clear so // Update post-scoped state if caller passed it; otherwise clear so
@@ -31,12 +40,16 @@ export const useModalStore = defineStore('modal', () => {
postImageIds.value = null postImageIds.value = null
postImageIndex.value = 0 postImageIndex.value = 0
} }
const t = inflight.claim()
await run(async () => { await run(async () => {
current.value = await api.get(`/api/gallery/image/${id}`) const body = await api.get(`/api/gallery/image/${id}`)
if (!t.isCurrent()) return
current.value = body
}) })
} }
async function close () { async function close () {
inflight.cancel()
currentImageId.value = null currentImageId.value = null
current.value = null current.value = null
error.value = null error.value = null
@@ -75,37 +88,87 @@ export const useModalStore = defineStore('modal', () => {
} }
async function reloadTags () { async function reloadTags () {
if (!currentImageId.value) return // Capture image id at call-time. After an await, currentImageId
const tags = await api.get(`/api/images/${currentImageId.value}/tags`) // may have advanced via prev/next navigation; without capture, the
if (current.value) current.value.tags = tags // GET would target the new image and write its tags onto a chip
// rail the user didn't open. Audit 2026-06-02.
const imageId = currentImageId.value
if (!imageId) return
const t = inflight.claim()
const tags = await api.get(`/api/images/${imageId}/tags`)
if (!t.isCurrent()) return
// Only commit if the modal is still showing this image — guards
// against close() / nav clearing current between the await and now.
if (current.value && currentImageId.value === imageId) {
current.value.tags = tags
}
} }
async function removeTag (tagId) { async function removeTag (tagId) {
if (!currentImageId.value) return const imageId = currentImageId.value
if (!imageId) return
const prev = current.value.tags const prev = current.value.tags
// Optimistic UI: drop the chip immediately.
current.value.tags = current.value.tags.filter(t => t.id !== tagId) current.value.tags = current.value.tags.filter(t => t.id !== tagId)
// Split the two POSTs so a dismiss failure (secondary side-effect)
// doesn't roll back the successful DELETE — previously the catch
// unconditionally restored the chip rail even when only the
// dismiss had failed, so the UI lied until refresh. Audit 2026-06-02.
try { try {
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`) await api.delete(`/api/images/${imageId}/tags/${tagId}`)
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, { } catch (e) {
// Real failure: roll back, surface, rethrow.
if (current.value && currentImageId.value === imageId) {
current.value.tags = prev
}
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
throw e
}
// DELETE landed. The dismiss is fire-and-best-effort — log on
// failure but DON'T roll back the chip rail; the tag is gone
// server-side regardless.
try {
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: tagId }, body: { tag_id: tagId },
}) })
} catch (e) { } catch (e) {
current.value.tags = prev toast({
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' }) text: `Tag removed, but failed to dismiss suggestion: ${e.message}`,
throw e type: 'warning',
})
} }
} }
async function addExistingTag (tagId) { async function addExistingTag (tagId) {
if (!currentImageId.value) return const imageId = currentImageId.value
await api.post(`/api/images/${currentImageId.value}/tags`, { if (!imageId) return
await api.post(`/api/images/${imageId}/tags`, {
body: { tag_id: tagId, source: 'manual' }, body: { tag_id: tagId, source: 'manual' },
}) })
await reloadTags() await reloadTags()
} }
async function createAndAdd ({ name, kind, fandom_id = null }) { async function createAndAdd ({ name, kind, fandom_id = null }) {
// Capture imageId so the post-create reloadTags / addExistingTag
// flow stays bound to the image the user clicked on, even if
// they navigated during the /api/tags POST.
const imageId = currentImageId.value
if (!imageId) return
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } }) const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
// Audit 2026-06-02: a kind='fandom' created here used to be
// invisible to FandomPicker until a full page reload — its load
// gates on fandomCache.length, so a non-empty cache skips the
// refetch and the new fandom never appears. Push it into the
// cache directly so the next open sees it.
if (kind === 'fandom') {
const { useTagStore } = await import('./tags.js')
const tagStore = useTagStore()
tagStore.fandomCache.push({
id: tag.id, name: tag.name, kind: 'fandom',
fandom_id: null, fandom_name: null, image_count: 0,
})
}
if (currentImageId.value !== imageId) return // navigated away
await addExistingTag(tag.id) await addExistingTag(tag.id)
} }
+23 -3
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const usePostsStore = defineStore('posts', () => { export const usePostsStore = defineStore('posts', () => {
const api = useApi() const api = useApi()
@@ -19,6 +20,12 @@ export const usePostsStore = defineStore('posts', () => {
const doneOlder = ref(false) const doneOlder = ref(false)
const doneNewer = ref(false) const doneNewer = ref(false)
const anchorId = ref(null) const anchorId = ref(null)
// loadInitial, loadMore, loadAround, loadOlder, loadNewer all share
// one `loading` flag and previously had no inflight guard. A filter
// change (loadInitial) racing a still-in-flight loadMore would
// append the prior filter's items into the new filter's feed.
// Audit 2026-06-02.
const inflight = useInflightToken()
function _qs() { function _qs() {
const q = {} const q = {}
@@ -36,6 +43,7 @@ export const usePostsStore = defineStore('posts', () => {
} }
async function loadInitial(newFilters) { async function loadInitial(newFilters) {
inflight.cancel()
filters.value = { filters.value = {
artist_id: newFilters?.artist_id ?? null, artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null, platform: newFilters?.platform ?? null,
@@ -46,8 +54,10 @@ export const usePostsStore = defineStore('posts', () => {
async function loadMore() { async function loadMore() {
if (loading.value || done.value) return if (loading.value || done.value) return
const t = inflight.claim()
await run(async () => { await run(async () => {
const body = await api.get('/api/posts', { params: _qs() }) const body = await api.get('/api/posts', { params: _qs() })
if (!t.isCurrent()) return
items.value.push(...body.items) items.value.push(...body.items)
cursor.value = body.next_cursor cursor.value = body.next_cursor
if (body.next_cursor == null) done.value = true if (body.next_cursor == null) done.value = true
@@ -79,6 +89,7 @@ export const usePostsStore = defineStore('posts', () => {
// posts feed; without this the older/newer scroll loaded unfiltered // posts feed; without this the older/newer scroll loaded unfiltered
// global posts instead of staying in the artist's stream). // global posts instead of staying in the artist's stream).
async function loadAround(postId, newFilters) { async function loadAround(postId, newFilters) {
inflight.cancel()
filters.value = { filters.value = {
artist_id: newFilters?.artist_id ?? null, artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null, platform: newFilters?.platform ?? null,
@@ -86,10 +97,12 @@ export const usePostsStore = defineStore('posts', () => {
loading.value = true loading.value = true
error.value = null error.value = null
anchorId.value = null anchorId.value = null
const t = inflight.claim()
try { try {
const body = await api.get('/api/posts', { const body = await api.get('/api/posts', {
params: _aroundParams({ around: postId }), params: _aroundParams({ around: postId }),
}) })
if (!t.isCurrent()) return
items.value = body.items items.value = body.items
cursorOlder.value = body.cursor_older cursorOlder.value = body.cursor_older
cursorNewer.value = body.cursor_newer cursorNewer.value = body.cursor_newer
@@ -97,47 +110,54 @@ export const usePostsStore = defineStore('posts', () => {
doneNewer.value = body.cursor_newer == null doneNewer.value = body.cursor_newer == null
anchorId.value = body.anchor_id anchorId.value = body.anchor_id
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
error.value = e error.value = e
} finally { } finally {
loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
async function loadOlder() { async function loadOlder() {
if (loading.value || doneOlder.value || cursorOlder.value == null) return if (loading.value || doneOlder.value || cursorOlder.value == null) return
loading.value = true loading.value = true
const t = inflight.claim()
try { try {
const body = await api.get('/api/posts', { const body = await api.get('/api/posts', {
params: _aroundParams({ params: _aroundParams({
cursor: cursorOlder.value, direction: 'older', cursor: cursorOlder.value, direction: 'older',
}), }),
}) })
if (!t.isCurrent()) return
items.value.push(...body.items) items.value.push(...body.items)
cursorOlder.value = body.next_cursor cursorOlder.value = body.next_cursor
if (body.next_cursor == null) doneOlder.value = true if (body.next_cursor == null) doneOlder.value = true
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
error.value = e error.value = e
} finally { } finally {
loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
async function loadNewer() { async function loadNewer() {
if (loading.value || doneNewer.value || cursorNewer.value == null) return if (loading.value || doneNewer.value || cursorNewer.value == null) return
loading.value = true loading.value = true
const t = inflight.claim()
try { try {
const body = await api.get('/api/posts', { const body = await api.get('/api/posts', {
params: _aroundParams({ params: _aroundParams({
cursor: cursorNewer.value, direction: 'newer', cursor: cursorNewer.value, direction: 'newer',
}), }),
}) })
if (!t.isCurrent()) return
items.value.unshift(...body.items) items.value.unshift(...body.items)
cursorNewer.value = body.next_cursor cursorNewer.value = body.next_cursor
if (body.next_cursor == null) doneNewer.value = true if (body.next_cursor == null) doneNewer.value = true
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
error.value = e error.value = e
} finally { } finally {
loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
+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()
} }
+37 -6
View File
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
// Category display order: people first, general last. // Category display order: people first, general last.
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
@@ -18,12 +19,25 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
const byCategory = ref({}) // { category: [suggestion, ...] } const byCategory = ref({}) // { category: [suggestion, ...] }
const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
let currentImageId = null let currentImageId = null
// Audit 2026-06-02: this store had no inflight guard — a late
// /suggestions response from a prior image could overwrite
// byCategory while currentImageId pointed at a new one, and
// accept() dereferenced currentImageId AFTER an awaited POST so
// the subsequent /suggestions/accept could apply A's chosen tag
// to image B (and push it to the allowlist). Both fixed below
// by capturing imageId at call-time and gating writes on the token.
const inflight = useInflightToken()
async function load(imageId) { async function load(imageId) {
// Cancel any in-flight load from the previous image so its late
// response can't overwrite this image's byCategory.
inflight.cancel()
currentImageId = imageId currentImageId = imageId
byCategory.value = {} // cleared upfront so it stays empty on error byCategory.value = {} // cleared upfront so it stays empty on error
const t = inflight.claim()
await run(async () => { await run(async () => {
const body = await api.get(`/api/images/${imageId}/suggestions`) const body = await api.get(`/api/images/${imageId}/suggestions`)
if (!t.isCurrent()) return
byCategory.value = body.by_category || {} byCategory.value = body.by_category || {}
}) })
} }
@@ -35,6 +49,11 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
async function accept(suggestion) { async function accept(suggestion) {
// Capture imageId so a mid-flight prev/next can't reroute the
// accept POST to a different image AND push the tag to that
// image's allowlist.
const imageId = currentImageId
if (imageId == null) return
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's // Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
// accept endpoint needs a tag_id, so for raw tags we create the tag // accept endpoint needs a tag_id, so for raw tags we create the tag
// first via the existing /api/tags endpoint, then accept by id. // first via the existing /api/tags endpoint, then accept by id.
@@ -45,10 +64,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}) })
tagId = created.id tagId = created.id
} }
await api.post(`/api/images/${currentImageId}/suggestions/accept`, { await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId } body: { tag_id: tagId }
}) })
_drop(suggestion.category, s => s === suggestion) // Only drop from THIS image's category list — if the user navigated,
// the new image has its own suggestions and this drop would corrupt them.
if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion)
}
toast({ toast({
text: `Tagged: ${suggestion.display_name}`, text: `Tagged: ${suggestion.display_name}`,
type: 'success' type: 'success'
@@ -56,14 +79,18 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
async function aliasAccept(suggestion, canonicalTagId) { async function aliasAccept(suggestion, canonicalTagId) {
await api.post(`/api/images/${currentImageId}/suggestions/alias`, { const imageId = currentImageId
if (imageId == null) return
await api.post(`/api/images/${imageId}/suggestions/alias`, {
body: { body: {
alias_string: suggestion.display_name, alias_string: suggestion.display_name,
alias_category: suggestion.category, alias_category: suggestion.category,
canonical_tag_id: canonicalTagId canonical_tag_id: canonicalTagId
} }
}) })
_drop(suggestion.category, s => s === suggestion) if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion)
}
toast({ toast({
text: `Aliased & tagged: ${suggestion.display_name}`, text: `Aliased & tagged: ${suggestion.display_name}`,
type: 'success' type: 'success'
@@ -71,15 +98,19 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
async function dismiss(suggestion) { async function dismiss(suggestion) {
const imageId = currentImageId
if (imageId == null) return
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw // Dismiss needs a tag_id; raw tags have none, so dismissing a raw
// suggestion just hides it client-side (nothing to persist a rejection // suggestion just hides it client-side (nothing to persist a rejection
// against until the tag exists). // against until the tag exists).
if (suggestion.canonical_tag_id != null) { if (suggestion.canonical_tag_id != null) {
await api.post(`/api/images/${currentImageId}/suggestions/dismiss`, { await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id } body: { tag_id: suggestion.canonical_tag_id }
}) })
} }
_drop(suggestion.category, s => s === suggestion) if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion)
}
} }
return { return {
+5 -5
View File
@@ -9,7 +9,7 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
// Live polled state. // Live polled state.
const queues = ref(null) // { queues: {name: depth|null}, fetched_at } const queues = ref(null) // { queues: {name: depth|null}, fetched_at }
const workers = ref(null) // { workers: {hostname: {...}}, fetched_at } const workers = ref(null) // { workers: {hostname: {...}}, fetched_at }
const recentMinute = ref([]) // last-60s rows (for Overview summary) const recentRuns = ref([]) // last-60s rows (for Overview summary)
const failures = ref(null) // { recent, count_by_type, since } const failures = ref(null) // { recent, count_by_type, since }
// Paginated runs (Activity tab "All recent activity" pane). // Paginated runs (Activity tab "All recent activity" pane).
@@ -45,14 +45,14 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
} }
} }
async function loadRecentMinute() { async function loadRecentRuns() {
// Used by the Overview summary card: pull last 60s of runs to compute // Used by the Overview summary card: pull last 60s of runs to compute
// per-queue ok/err counts. One call covers all queues; UI groups. // per-queue ok/err counts. One call covers all queues; UI groups.
try { try {
const body = await api.get('/api/system/activity/runs', { const body = await api.get('/api/system/activity/runs', {
params: { limit: 200 }, params: { limit: 200 },
}) })
recentMinute.value = body.runs || [] recentRuns.value = body.runs || []
} catch (e) { } catch (e) {
lastError.value = e.message lastError.value = e.message
} }
@@ -106,10 +106,10 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
} }
return { return {
queues, workers, recentMinute, failures, summary, queues, workers, recentRuns, failures, summary,
runs, runsCursor, runsHasMore, runsFilter, runs, runsCursor, runsHasMore, runsFilter,
loading, lastError, loading, lastError,
loadQueues, loadWorkers, loadRecentMinute, loadQueues, loadWorkers, loadRecentRuns,
loadRuns, loadFailures, loadSummary, setFilter, loadRuns, loadFailures, loadSummary, setFilter,
} }
}) })
+8 -1
View File
@@ -2,6 +2,7 @@ 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 { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
const PAGE = 60 const PAGE = 60
@@ -13,16 +14,21 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
const kind = ref(null) const kind = ref(null)
const q = ref('') const q = ref('')
let started = false let started = false
// Same shape as artistDirectory — rapid setQuery/setKind dropped
// the second fetch because loading was still true from the first.
// Audit 2026-06-02.
const inflight = useInflightToken()
async function loadMore() { async function loadMore() {
if (loading.value) return
if (started && nextCursor.value === null) return if (started && nextCursor.value === null) return
const t = inflight.claim()
await run(async () => { await run(async () => {
const params = { limit: PAGE } const params = { limit: PAGE }
if (kind.value) params.kind = kind.value if (kind.value) params.kind = kind.value
if (q.value) params.q = q.value if (q.value) params.q = q.value
if (nextCursor.value) params.cursor = nextCursor.value if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/tags/directory', { params }) const body = await api.get('/api/tags/directory', { params })
if (!t.isCurrent()) return
cards.value.push(...body.cards) cards.value.push(...body.cards)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
started = true started = true
@@ -30,6 +36,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
} }
async function reset() { async function reset() {
inflight.cancel()
cards.value = [] cards.value = []
nextCursor.value = null nextCursor.value = null
started = false started = false
+14 -1
View File
@@ -49,8 +49,21 @@ export const useTagStore = defineStore('tags', () => {
return fandom return fandom
} }
// Set / change / clear a character tag's fandom. fandomId null clears it.
// Throws ApiError (status 409, body.target) on a name collision in the
// target fandom; pass { merge: true } to resolve it by merging this tag
// into the existing character. Returns the updated/surviving tag.
async function setFandom(tagId, fandomId, { merge = false } = {}) {
const body = { fandom_id: fandomId ?? null }
if (merge) body.merge = true
return await api.patch(`/api/tags/${tagId}`, { body })
}
function kindOptions() { return KIND_OPTIONS } function kindOptions() { return KIND_OPTIONS }
function colorFor(kind) { return KIND_COLOR[kind] || 'on-surface' } function colorFor(kind) { return KIND_COLOR[kind] || 'on-surface' }
return { fandomCache, autocomplete, loadFandoms, createFandom, kindOptions, colorFor } return {
fandomCache, autocomplete, loadFandoms, createFandom, setFandom,
kindOptions, colorFor
}
}) })
+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))
}
+25 -1
View File
@@ -20,6 +20,22 @@
:last-added="store.lastAdded" :last-added="store.lastAdded"
/> />
<v-container fluid class="pt-2 pb-4"> <v-container fluid class="pt-2 pb-4">
<!-- "N new since last visit" banner. Visible only on the initial
load that triggered the visit-mark; dismissable via close
button or by switching tabs. Re-entry only re-shows if more
content has arrived (overview returns 0 immediately after a
previous visit). -->
<v-alert
v-if="unseenBanner"
type="info" variant="tonal" density="compact"
class="mb-3" closable
@click:close="unseenBanner = false"
>
<span class="fc-artist__unseen-msg">
<strong>{{ store.overview.unseen_count_at_visit }}</strong>
new since last visit
</span>
</v-alert>
<v-window v-model="tab"> <v-window v-model="tab">
<v-window-item value="posts"> <v-window-item value="posts">
<ArtistPostsTab <ArtistPostsTab
@@ -39,7 +55,7 @@
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useArtistStore } from '../stores/artist.js' import { useArtistStore } from '../stores/artist.js'
@@ -60,6 +76,10 @@ const { tab, resolve } = useTabQuery(
() => ((store.postCount ?? 0) > 0 ? 'posts' : 'gallery'), () => ((store.postCount ?? 0) > 0 ? 'posts' : 'gallery'),
) )
// One-shot banner — reset on each new artist-slug load so it re-appears
// when navigating between artists that each have unseen content.
const unseenBanner = ref(false)
watch(slug, async (s) => { watch(slug, async (s) => {
if (!s) return if (!s) return
await store.load(s) await store.load(s)
@@ -67,6 +87,7 @@ watch(slug, async (s) => {
? `${store.overview.name} — FabledCurator` ? `${store.overview.name} — FabledCurator`
: 'FabledCurator' : 'FabledCurator'
tab.value = resolve() tab.value = resolve()
unseenBanner.value = (store.overview?.unseen_count_at_visit || 0) > 0
}, { immediate: true }) }, { immediate: true })
</script> </script>
@@ -74,4 +95,7 @@ watch(slug, async (s) => {
.fc-artist__loading { .fc-artist__loading {
display: flex; justify-content: center; padding: 64px 0; display: flex; justify-content: center; padding: 64px 0;
} }
.fc-artist__unseen-msg {
font-size: 14px;
}
</style> </style>
+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 {
+18 -35
View File
@@ -12,10 +12,14 @@
<div class="fc-gallery-layout"> <div class="fc-gallery-layout">
<div class="fc-gallery-layout__main"> <div class="fc-gallery-layout__main">
<PostInfoHeader /> <PostInfoHeader />
<GalleryFilterBar v-if="store.filter.post_id == null" />
<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 />
@@ -24,10 +28,11 @@
<script setup> <script setup>
import { onMounted, watch } from 'vue' import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { useGalleryStore } from '../stores/gallery.js' import { useGalleryStore } from '../stores/gallery.js'
import { useModalStore } from '../stores/modal.js' import { useModalStore } from '../stores/modal.js'
import GalleryGrid from '../components/gallery/GalleryGrid.vue' import GalleryGrid from '../components/gallery/GalleryGrid.vue'
import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue'
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue' import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
import EmptyState from '../components/gallery/EmptyState.vue' import EmptyState from '../components/gallery/EmptyState.vue'
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue' import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
@@ -37,46 +42,19 @@ import { useGallerySelectionStore } from '../stores/gallerySelection.js'
const store = useGalleryStore() const store = useGalleryStore()
const modal = useModalStore() const modal = useModalStore()
const sel = useGallerySelectionStore() const sel = useGallerySelectionStore()
const router = useRouter()
const route = useRoute() const route = useRoute()
onMounted(async () => { // The URL query is the single source of truth for filters. Apply it on
const postId = parseInt(route.query.post_id, 10) // mount and on any query change (filter bar pushes, back button, deep-link).
const tagId = parseInt(route.query.tag_id, 10) onMounted(() => store.applyFilterFromQuery(route.query))
if (!isNaN(postId)) store.setPostFilter(postId)
else if (!isNaN(tagId)) store.setTagFilter(tagId)
await store.loadInitial()
await store.loadTimeline()
// Open modal if URL has ?image=N
const initial = parseInt(route.query.image, 10)
if (!isNaN(initial)) modal.open(initial)
})
watch(() => route.query.tag_id, (q) => { watch(() => route.query, (q) => {
sel.clear() // result set changed — selected ids are no longer valid sel.clear() // result set changed — selected ids are no longer valid
const tagId = parseInt(q, 10) store.applyFilterFromQuery(q)
store.setTagFilter(isNaN(tagId) ? null : tagId)
})
watch(() => route.query.post_id, (q) => {
sel.clear() // result set changed — selected ids are no longer valid
const postId = parseInt(q, 10)
store.setPostFilter(isNaN(postId) ? null : postId)
})
watch(() => route.query.image, (q) => {
const id = parseInt(q, 10)
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
}) })
function openImage(id) { function openImage(id) {
router.push({ query: { ...route.query, image: id } }) modal.open(id)
}
function closeImage() {
const q = { ...route.query }
delete q.image
router.push({ query: q })
} }
</script> </script>
@@ -93,4 +71,9 @@ function closeImage() {
.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">

Some files were not shown because too many files have changed in this diff Show More