Recent failures gains a client-side search over the already-loaded 24h
rows (task/queue/target/error), shown as a filtered/total count alongside
the existing error-type chips. All recent activity gains a debounced
server-side task-name search (new `task` ILIKE param on /runs) so it
spans the full history, not just the loaded page. LIKE wildcards are
escaped so task names' literal underscores match literally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The icon+title v-card-title heading (d-flex align-center + gap + <v-icon size=small> +
<span>) was hand-rolled identically in 13 cards/dialogs (15 heading instances).
Consolidate to <CardHeading icon title> (components/common) with an iconColor
prop (error headings) and a default slot for trailing content (spacer+actions,
inline status chip). Adopted everywhere the pattern appears — all-or-nothing per
the hardened DRY process.
Over-DRY guard: plain text-only <v-card-title> one-liners are NOT this pattern
and stay; DownloadDetailModal leads with a status CHIP (not an icon), a different
concept, left alone. §8b: the only remaining d-flex align-center v-card-title is
that intentional variant. Catalog updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The preview sample-name grid (scrollable monospace chip grid) was hand-rolled
5 times with verbatim-duplicated markup + CSS — TagMaintenanceCard (×4) and
PostMaintenanceCard. Consolidate to <SampleNameGrid> (components/common): pass
:names for the plain case, default slot for the normalize from→to chips
(styled via :slotted .fc-name). Removed the duplicated .fc-name-grid/.fc-name
CSS from both cards.
Over-DRY guard: only the verbatim-duplicated grid is merged — each card's
preview/commit logic and result-count lines genuinely differ and stay put;
MinDimensionCard's typed-token confirm is a separate variant, untouched.
§8b: fc-name-grid now lives only in SampleNameGrid. Catalog updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The management view showed the series name but had no way to change it post-
creation (rename was only on the browse-card kebab). Add a pencil next to the
title that opens TagRenameDialog (reuses the canonical rename → PATCH
/api/tags/<id> with its collision→merge flow, since a series IS a
Tag(kind=series)); the new name reflects in place. Operator-asked 2026-06-09.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The muted-text token was redefined identically in 12 component <style scoped>
blocks. Consolidate to one global utility in styles/app.css; remove the 12
copies. Keeps the explicit on-surface-variant (vellum) token, NOT Vuetify's
opacity-based text-medium-emphasis (per the muted-text-token rule). Behavior-
preserving: every class=fc-muted usage now resolves to the single source.
§8b exhaustiveness caught (and I fixed) my own sed clobbering the new app.css
rule — now exactly one .fc-muted definition exists, zero component-local.
Catalog updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First pattern-consistency DRY pass (process #594). The overflow kebab was
hand-rolled 7 ways in two divergent activator strategies — Pattern A
(#activator + v-bind) which silently breaks inside the teleported image modal
(#711), and Pattern B (manual v-model + activator=parent + open-on-click=false +
z-index 2400) the modal kebabs needed as a workaround.
New <KebabMenu> (components/common) bakes in the modal-safe strategy
UNIVERSALLY, so every kebab works in modal and non-modal contexts — folding the
latent #711-class bug fix into all five Pattern-A sites. Menu items go in the
default slot; variations (size/variant/location/label/min-width) are props.
Adopted across all 7: TagChip, SuggestionItem, TagCard, SeriesView card,
SeriesManageView, BackupRunsTable, SourceActions. Exhaustiveness (§8b):
mdi-dots-vertical now lives only in KebabMenu. Labeled dropdowns / nav menus /
filter popovers are a different concept and left alone. Seeded the pattern
catalog so new code reuses the primitive. Test: KebabMenu renders slot items +
trigger label/glyph + presentational props.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Posts, Artists, and Tags are the three 'browse the library by an axis'
surfaces; Subscriptions stays purely management (operator-asked 2026-06-09).
New BrowseView renders them as tabs (?tab=posts|artists|tags); only the active
tab mounts. The old standalone paths become redirects into the matching tab,
preserving deep-link query (/posts?post_id=N → /browse?tab=posts&post_id=N) and
keeping the route names so existing { name: 'posts'|'artists'|'tags' } links and
path pushes still resolve. Nav now reads Showcase · Gallery · Browse · Series ·
Subscriptions, with Settings pinned right.
Test: /browse resolves; /tags and /artists redirect into their tabs; a posts
deep link survives the redirect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Settings is configuration, not content, but sat mid-row (between Series and
Posts). Pull it out of the centered content links and pin it to the right as a
gear+label, matching the convention that config lives at the right edge. Mobile
is unchanged — Settings stays in the hamburger menu (navRoutes still includes
it). Operator-asked 2026-06-09.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The typed dropdown sourced the threshold-filtered panel list (>= 0.70 general),
so low-confidence actions/features the model DID predict never appeared — forcing
hand-typed custom tags instead of accepting the model's canonical formatting.
Add a threshold override: SuggestionService.for_image(threshold_override=) and
GET /images/<id>/suggestions?min=<f> surface EVERY stored prediction (down to the
0.05 store floor), alias-resolved and normalized, still excluding applied/rejected
and unsurfaced categories. The suggestions store gains allByCategory + loadAll
(min=0); the dropdown searches that full set (cap 20), while the Suggestions panel
stays curated at the configured threshold. Accept/dismiss drop from both lists.
Operator-asked 2026-06-09. Test: a 0.30 general prediction is hidden by default
but surfaced with threshold_override=0.0; unsurfaced categories still excluded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A character chip with a fandom only rendered a bare arrow. Surface the fandom
NAME inline, truncated to 15 chars (full name in the tooltip). Resolve the name
via a Tag self-join in both tag paths the modal uses — list_for_image
(/api/images/<id>/tags) and gallery get_image_with_tags
(/api/gallery/image/<id>) — so chips show the fandom on first open and after any
reload. Falls back to the bare arrow when only fandom_id is known. Operator-asked
2026-06-09.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Series browse tab had no way to find a series in a long grid and no
per-series actions. Add a search field (instant client-side name/artist filter
over the already-loaded list) and a kebab on each card with Rename (reuses
TagRenameDialog → PATCH /api/tags/<id>, with its collision-merge flow) and
Delete (confirm dialog → DELETE /api/admin/tags/<id>; series_page/chapter/
suggestion cascade, images kept). Gap badge moved to the cover's top-left so the
kebab can sit top-right. Operator-asked 2026-06-09.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Accepting an auto-suggested tag (Suggestions panel or the autocomplete
dropdown) left focus on <body>, so the operator had to re-click the tag field
to add the next one. Expose TagAutocomplete.focus (the existing mobile-aware
focusInput) and call it after accept from both paths; SuggestionsPanel emits
'accepted' for the parent to refocus. Operator-asked 2026-06-08.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native Patreon backfill flooded the feed with bare 'Post <id>' shells
(1589 for Anduo). Root cause: PostAttachment.sha256 was GLOBALLY unique, so a
non-art file reused across posts only ever linked to the first one, and
_capture_attachment created the Post before that dedup check — leaving later
posts with no image and no attachment. Duplicate IMAGES had the mirror gap:
attach_in_place returned duplicate_hash/duplicate_phash before _apply_sidecar,
so the second post got no provenance row, and the feed only rendered via
primary_post_id (one post per image).
Operator requirement: a duplicate item must show on EVERY post it appears in.
Unify the fix as link-not-suppress:
- importer: on duplicate_hash / duplicate_phash(larger_exists), append an
image_provenance row for the new post (keep primary on the first). Both the
download path (attach_in_place) and the filesystem path (_import_media).
- post_feed_service: render thumbnails by image_provenance UNION primary_post_id,
so a cross-posted image shows on every post (and legacy primary-only images
still show).
- PostAttachment: per-post uniqueness — drop UNIQUE(sha256), add partial
UNIQUE(post_id, sha256) + partial UNIQUE(sha256) WHERE post_id IS NULL
(migration 0043); _capture_attachment dedups per-(post,sha) over the shared
sha-addressed blob, so no post is left bare.
- cleanup: new prune-bare-posts maintenance action (cleanup_service
_bare_post_conditions shared by preview/count/delete per preview/apply parity;
admin endpoint; PostMaintenanceCard). Deletes posts with zero image links
(primary or provenance) AND zero attachments. Run after the feed fix so a
hidden provenance link spares the post instead of deleting it.
Tests: dup image shows on both posts; dup attachment shows on both posts; feed
renders provenance-linked duplicates; prune-bare delete-path == preview.
Operator redeploys (migration 0043) then runs the prune to clear the shells.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-flagged (again) on the tag-merge picker: Enter on the dropdown re-opens
it instead of accepting the selection. I'd already patched this twice (fandom
picker + fandom set dialog) with copy-pasted capture-phase handlers, so DRY it.
New composable useAcceptOnEnter(accept): tracks the menu state and, on a
capture-phase Enter, lets Vuetify pick when the menu is open but calls accept()
(and blocks the re-open) when it's closed. Applied to every confirm-style picker:
- TagsView merge-into picker (the reported one)
- AliasPickerDialog
- PostSeriesMenu add-to-existing
- FandomPicker + FandomSetDialog (refactored off their bespoke handlers)
One behavior, one place to change it.
The image-modal tag kebab's rename dialog still showed a leftover stub
('Merging two tags into one lands in FC-2c') on a name collision, dead-ending
the operator. The merge machinery has existed for a while — the Tags view
already resolves rename collisions this way. Wire TagRenameDialog to it: on the
409 collision hint, show the same merge confirmation FandomSetDialog uses
(target name, image associations moved, alias kept) and POST /api/tags/<id>/merge
into the existing tag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 'Fandom for <character>' dialog (FandomSetDialog) used plain autofocus and
had no Enter handling, so Enter re-opened the dropdown instead of submitting —
the same bug FandomPicker already fixed. Mirror that flow: parent v-dialogs
focus the field via @after-enter→focusSearch (reliable past the focus-trap);
capture-phase Enter Saves the changed selection instead of re-opening the menu;
Tab jumps to the new-fandom field; creating a fandom returns focus to the
dropdown so a single Enter saves it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator feedback: thumbnails too small to judge order, no obvious way to mark
'this installment is Part 2', and the permanent two-pane picker was busy and
competed with the ordering work.
- Full-width parts, each a card with a big page grid (150px, contain so whole
pages are visible) and drag-to-reorder; positional page number as a badge.
- Editable Part # (hero field) backed by new series_chapter.stated_part —
separate from the auto-managed chapter_number, mirroring the page_number vs
stated_page split so reorder/delete renumbering can't wipe a hand-set part.
Missing-Part hints when consecutive parts' stated_part jump >1.
- Each part labels its source post (derived from pages' primary_post_id) and
shows the printed-page range with clear labels.
- Picker demoted to an on-demand right slide-over ('Add pages') with a target-
part selector; part actions (move/merge/delete) collapsed into an overflow ⋮.
alembic 0042 adds series_chapter.stated_part (nullable int).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#739 — DB backups hung on NFS in uninterruptible D-state, defeating the 12-min
subprocess timeout AND Celery's hard limit, so a stuck pg_dump held the
concurrency-1 maintenance_long lane for hours — starving normalize_tags,
re-extract, audits, and the new series rescan (which is why #740 "never
applied"). Three fixes:
- _run_bounded: Popen + bounded post-kill reap; if the child is unkillable
(D-state) we stop waiting and re-raise TimeoutExpired, freeing the slot. The
orphan is reaped by the OS once its syscall clears.
- backup_db dumps to a LOCAL temp file then moves the finished .sql to the
(NFS) _backups dir — pg_dump's long phase is now a DB-socket wait + local
writes (killable) instead of an NFS write that hangs. backup_images keeps
bounded-kill (too big to stage locally).
- recover_stalled_backup_runs: split the stall window — db 40 min (was sharing
images' 7h), so a hung DB backup is flipped to error promptly.
#740 — Standardize tag casing showed "0 groups to change" the instant it was
clicked: onNormCommit overwrote the preview with zeros. Keep the real preview
visible and disable the button while queued; backend apply was already correct.
Tests: fake subprocess.Popen alongside run; bounded-kill fail-fast; local-temp
target; per-kind stall sweep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes FC-6.3 with the UI.
- SeriesView gains tabs: Browse (the existing grid) + Suggestions.
- Suggestions tab: pending matches as rows (post → series, per-signal strength
chips, score), Add (→ chapter) / Skip (→ dismiss); a "Matching on" toggle and
a threshold field (both DB-backed via /settings/import), and a Rescan button
that enqueues the background matcher.
- seriesSuggestions store wires load / accept / dismiss / rescan / settings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes FC-6.2 with the UI.
- PostSeriesMenu: a "Series ▾" control on each post card — "New series from
this post" (promote → navigates to manage) and "Add to existing series…"
(dialog with a browsable picker loaded from GET /api/series, client-side
filtered — avoids the empty-autocomplete #712 trap).
- SeriesView (/series): a top-level Series browse grid — cover, name, artist,
chapter/page counts, gap badge; sort recent|name|size; cards → manage/read.
meta.title adds it to the nav automatically (peer of Posts).
- seriesBrowse store for the list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes FC-6.1: the series management UI now works in chapters.
- SeriesManageView: chapters as cards (inline-rename, stated-page range inputs,
move up/down, merge-into-previous, delete, pick-as-add-target), pages
drag-reorder WITHIN a chapter, a "gap: N-M missing" badge between chapters
with a stated-page hole, and Add chapter / Add placeholder. The picker adds
the selection into the targeted chapter.
- seriesManage store: chapter CRUD + reorderChapters/moveChapter/mergeChapter/
reorderPages actions; consumes chapters[]/gaps[]; addSelected targets a chapter.
- Reader: page_number is now within-chapter, so anchors switched to a global
`seq` (reading-order position) — fixes scroll/jump/active collisions across
chapters — plus chapter-title dividers at each chapter boundary.
- Updated seriesManage.spec to the chaptered store shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Enter handler listened in the bubbling phase, so Vuetify's own input handler
(which opens the menu on Enter) fired first and my accept logic saw the menu
already opening and bailed — Enter popped the dropdown instead of submitting.
Bind it in the capture phase so it runs first, and stop the event when a fandom
is already selected so Vuetify never reopens the menu (operator-flagged
2026-06-07).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ArtistView set document.title to "<artist> — FabledCurator" on load but nothing
reset it when navigating away, so the artist name stuck on the Showcase/Gallery
tab title (operator-flagged 2026-06-07). Add a router.afterEach that sets the
title from meta.title on every navigation; detail views with no meta.title reset
to the default and then set their own dynamic title.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The image's Camie suggestions now appear in the tag input's dropdown as you
type, filtered to the query and de-duped against the server autocomplete hits,
so the operator can pick a suggestion without hunting for it in the Suggestions
panel below (operator-asked 2026-06-07).
- Unified `rows` model (hits → matching suggestions → create row) so the
highlight index maps 1:1 to a row across all three sections; arrow/Enter/Tab
drive the whole list.
- Suggestion rows are marked (accent left-border + mdi-auto-fix score chip) and
show a "new" hint when the suggestion would create a tag.
- Picking a suggestion emits accept-suggestion → TagPanel runs the SAME accept
path as the Suggestions panel (creates raw tags, records acceptance, drops it
from the panel), then refreshes the chip rail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-specified flow for character-tag creation: focus starts in the
fandom search dropdown; Tab moves to the new-fandom field where Enter
creates; creating fills the dropdown and returns focus there; Enter in the
dropdown accepts the selection.
- Drive focus from the dialog's @after-enter (autofocus is unreliable inside
a v-dialog — the focus-trap steals it post-mount); FandomPicker exposes
focusSearch.
- Drop the @update:model-value auto-confirm that closed the dialog the instant
selectedId was set — that's what broke create-then-accept (creating set the
value and immediately confirmed). Enter now accepts (menu-closed + value),
while an open menu lets Vuetify pick the highlighted item first.
- Tab from search → new-fandom field; Enter there creates, then focus returns
to the dropdown for a single Enter-to-accept.
- Restore focus to the tag input after the dialog confirms/cancels so the
keyboard flow continues into the next tag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
THE actual root cause of the "dead" tag-chip kebab (operator inspected it
2026-06-07: the menu was ghosted, blurred, behind the sidebar). The teleported
v-menu landed below .fc-viewer (z-index 2000) and the modal's
backdrop-filter: blur(8px) smeared it — so it opened the whole time, just
underneath. Every prior "fix" (un-nesting the button, the explicit activator
pattern) was chasing a click/activation problem that never existed.
Set :z-index="2400" on the tag-chip and suggestion kebab menus so they paint
above the modal. (Dialogs already render on top — only the anchored menus tied
with the modal's z-index and lost.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old size-36 v-progress-circular sat tiny in the top-left because
.fc-viewer__media doesn't center its children (the canvas centers itself).
Replace it with a 108px dual counter-rotating accent-ring spinner as a centered,
non-interactive overlay over the modal (operator-flagged 2026-06-07).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
normalize_tags_task timed out at the 40-min hard limit on a large back-catalog
(the first run recases the whole booru vocabulary) — operator-flagged, and it
monopolized the concurrency-1 maintenance queue while doing so.
normalize_existing_tags now takes time_budget_seconds: the live run stops
cleanly at the budget and reports {partial, remaining}. The task runs 600s
chunks and re-enqueues itself until nothing remains (idempotent — commits per
group, so the next chunk skips already-canonical groups). Short chunks let the
recovery sweep and other maintenance tasks interleave instead of being blocked
for 40 minutes.
Frontend: the Standardize button is now fire-and-forget ("Queued — runs in the
background; re-run Preview to confirm") instead of poll-until-done, which would
have falsely reported "complete" after the first chunk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-requested modal/tagging keyboard improvements:
- A2/A3: fandom dialogs autofocus their autocomplete on open; in the character-
creation FandomPicker, picking a fandom (keyboard Enter or click) confirms in
one step. FandomSetDialog stays autofocus-only (its Save can trigger a merge).
- B5: Tab accepts the highlighted autocomplete row (standard convention).
- C9: T or / jumps focus to the tag input from anywhere in the modal.
- C8: ? toggles a keyboard cheatsheet (corner hint advertises it; Esc closes the
cheatsheet first, then the viewer).
Builds on the same-batch regression fixes (kebab #711, ESC-after-accept #700,
autocomplete scroll-into-view). B6 (keep focus after add) is covered — the input
retains focus after adding a tag, and Esc now works after accepting a suggestion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two regressions the operator re-flagged (the earlier "fixes" didn't work):
#711 tag-chip kebab: TagPanel's kebab used the #activator + v-bind="props"
v-menu pattern — the exact pattern SuggestionItem's own comment documents as
NEVER toggling inside the teleported ImageViewer modal. Extracted TagChip.vue
using the proven explicit pattern (activator="parent" + :open-on-click="false"
+ a manual v-model), mirroring the working suggestion kebab. Now opens.
#700 ESC-after-accept: the guard suppressed close whenever ANY non-tooltip
overlay was active anywhere in the DOM, so a stray overlay after accepting a
suggestion (focus drops to <body>) blocked Esc. Now key off the event origin —
only defer to an overlay when Esc is pressed from INSIDE its content
(ev.target.closest('.v-overlay__content')); a stray overlay no longer traps the
modal, and dialogs/menus still handle their own Esc.
A1: TagAutocomplete arrow-nav now scrollIntoView's the highlighted row — the
list is capped at 240px and arrowing past the fold left the active item
off-screen (operator-flagged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-requested follow-ups:
- #1 Failure reason on hover: the red error-count chip now shows source.last_error
in a tooltip (desktop row + mobile card), so the cause (e.g. the new
"vanity=cw" message) is visible without opening Downloads.
- #2 Collapse the single-source case: a subscription with exactly one source now
shows that source's URL + its own actions (Check / Backfill / ⋮ / Edit) inline
on the artist row — no expand needed for the common case. Multi-source keeps
the artist-level actions + expandable per-source table.
- #3 Sort by health: the Health column is sortable on a numeric rank
(never/ok/warn/fail) and the table defaults to worst-first, name as tiebreak.
- #4 Drop Preview: removed the low-value bounded-peek action from the menu and
all its wiring (backend endpoint + store fn left in place, unused).
- #5 Backfill selected: a "Backfill" button in the bulk bar arms a run-until-done
backfill on every enabled, not-already-running source in the selection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lock/reload: every inline source action (check / backfill / recover / toggle /
remove) ended by refetching the WHOLE subscription list (store.loadAll /
refresh), which blocked the UI and re-rendered the table — collapsing the
expanded row, which read as "locks then resets." The action APIs already return
the updated source, so the store now patches that one row in place
(_patchSource / _dropSource); the post-action loadAll/refresh calls are gone.
Toggling enabled, starting/stopping a backfill, recovering, and removing are now
instant and leave the expansion intact.
Button regroup (operator-flagged: tiny, mis-clickable, not grouped by function):
- New shared SourceActions.vue used by desktop SourceRow + mobile SourceCard.
- Frequent actions stay as size="small" buttons: Check, Backfill/Stop.
- Low-frequency / destructive actions move into a labelled overflow (⋮) menu —
Preview backfill, Recover dropped near-duplicates, Remove source — so they
can't be fat-fingered, and the labels spell out recover-vs-backfill.
- Edit moves next to the source URL (its identity), out of the action cluster
where it sat beside Remove.
- Single-source Remove now confirms (it had no guard before).
Tests: store patches/drops in place without dropping the cache.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hero collage's thumbnail rail hard-capped at 3 fixed-80px cells, left-
aligned, so it never reached the edge of the (50%-width) hero. Make the rail a
CSS grid of equal columns (1fr) at a fixed height that stretches to the hero's
full width: show up to 5 thumbnails, and when a post has more images than fit,
the last cell becomes the "+N" overflow tile (count unchanged). Column count is
driven by --fc-rail-cols so the strip always reaches the hero edge regardless
of image count.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #701: new tags are saved canonical, but the back-catalog keeps
whatever casing it was created with. This adds a maintenance action that
Title-Cases every existing tag (collapsing whitespace) and merges
case/whitespace-variant duplicates into one.
Backend:
- tag_service.normalize_existing_tags(session, *, dry_run): groups all tags by
(kind, coalesce(fandom_id,-1), canonical_name). Per group it picks a survivor
(prefer an already-canonical member → no rename/self-alias; else the
best-connected tag → fewest FK repoints; else lowest id), merges the variants
INTO it via the tested TagService._do_merge (image_tag/allowlist/embedding/
aliases/series_page repoints + protective ML aliases), then renames the
survivor to canonical. Losers are deleted before the rename so there's no
transient unique-index clash; commits per group and isolates failures per
group. Idempotent — an already-canonical lone tag is a no-op.
- normalize_tags_task (maintenance queue, asyncio.run + per-task NullPool async
engine, soft 1800/hard 2400) — recovery/timeout/duration covered by FC-3i.
- POST /api/admin/tags/normalize: dry_run=true returns a projection inline
(group/collision/rename counts + sample); dry_run=false enqueues the task.
Frontend: a "Standardize tag casing" section in TagMaintenanceCard (Cleanup
tab) — preview → apply (polls the activity dashboard to terminal status),
behind a back-up-first warning. admin store gains normalizeTags().
Tests: tests/test_tag_normalize.py — dry-run counts, live merge + image-tag
dedup/repoint, idempotency, same-name-different-fandom and -different-kind kept
separate, ML-known loser keeps a protective alias.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that we own the walk, surface live counts on the in-flight download in the
Downloads view. ingest_core.run takes an event_id and does a TIME-THROTTLED
write (~5s, decoupled from page boundaries so it ticks steadily regardless of
how big/slow a page is) of {downloaded, skipped, errors, quarantined, posts} to
the running download_event's metadata.live (jsonb_set; short session; status
guard so a finalized event isn't clobbered). download_backends threads
event_id from ctx; the /api/downloads list surfaces `live`; ActiveDownloadsPanel
renders it beside the elapsed timer. Native (Patreon) only — gallery-dl is an
opaque subprocess; the row only shows when `live` is present. Phase 3 overwrites
metadata with run_stats on finish, dropping `live`.
Test: _write_live_progress updates a running event's metadata.live and leaves a
finalized (status != running) event alone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#712 — fandom picker showed no existing fandoms: loadFandoms enumerated via
/tags/autocomplete with q=' ', which the backend strips to empty → returns [].
Switch to the cursor-paged /tags/directory?kind=fandom (loop all pages); drop the
load-once guard so the dialog reflects fandoms created elsewhere.
#711 — modal tag-chip kebab never opened: the kebab + menu were nested INSIDE the
v-chip, which swallowed the click / mis-anchored the teleported menu. Un-nest it
as a sibling v-btn using the standard v-menu activator slot (Vuetify wires the
click and stacks the overlay above the modal natively). Removes the openTagId
workaround.
#700 — ESC didn't close the modal after accepting a suggested tag: the guard
suppressed close while ANY .v-overlay--active existed, which includes tooltips —
a lingering tooltip blocked the close. Exclude .v-tooltip from the guard so only
real interactive overlays (menus/dialogs) keep ESC from closing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Existing PostAttachments that are actually archives (filed opaquely before the
magic-byte gate) need extracting retroactively. cleanup_service.
reextract_archive_attachments scans PostAttachments, magic-detects the archives,
and for each reconstructs the post's sidecar from the DB + re-runs attach_in_place
in a temp dir — so the members extract and re-link to the SAME post via
find_or_create_post (source_id + external_post_id). Idempotent (members dedupe by
sha256). Enqueues thumbnail+ML for new members.
Wired as a maintenance-queue Celery task (tasks/admin) + POST
/api/admin/maintenance/reextract-archives (202) + a "Re-extract archive
attachments" card in Settings → Maintenance.
Test: a zip stored under a mangled extension-less name extracts + links its
member to the post via ImageProvenance, and a second run is a no-op (idempotent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two operator-reported Artists-view bugs.
Layout: ArtistCard previews used aspect-ratio: 3/1 + max-height: 220px — when a
lone grid column got wide (>~660px), the max-height made the aspect-ratio box
shrink its own WIDTH to keep the ratio, leaving dead space beside the 3
thumbnails. Dropped max-height (kept the min-height floor) so the strip fills the
card width; lowered the grid min 440→360px so a lone column stays <732px (strip
≲244px) and desktop gets 2+ columns.
Flicker: isEmpty checked !loading, but on the first render loading is still false
(the initial loadMore fires in onMounted, after paint) so "No artists match"
flashed for a frame before the spinner/data. Added a reactive `loaded` flag (true
after the first load attempt, reset on reset()); isEmpty now also requires it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owning the walk lets an operator gauge "is this source worth a backfill?" before
arming one. ingest_core.Ingester.preview walks the first few feed pages and
counts media NOT already in the seen/dead ledgers, downloading nothing
(read-only). download_backends.preview_source resolves the campaign id + runs it
(native-only, mirrors verify_source_credential / run_download); POST
/api/sources/{id}/preview returns {total_new, posts_scanned, has_more, sample[]}
(409 on auth/drift/unresolvable, 400 for gallery-dl platforms). PatreonClient
gains post_meta(post) for the sample's title/date.
UI: a Patreon-only Preview button (mdi-eye-outline) on SourceRow + SourceCard
opens PreviewDialog — self-fetches with loading / error / empty / result states
and a "Start backfill" shortcut. Store action previewSource.
Tests: preview counts new media without downloading + samples only posts with
new items; page_limit caps the walk + flags has_more.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The running badge only showed the chunk counter; now it shows posts walked
— real walk progress. The ingester already reports posts_processed per
chunk (step 1); the backfill lifecycle accumulates it into
config_overrides._backfill_posts across chunks. SourceRecord exposes
backfill_posts; start_backfill/start_recovery clear it (fresh walk); stop
clears it too. SourceRow/SourceCard badge renders "Recovering · 45 posts"
(falls back to "(N)" chunks before any posts are counted).
Per-chunk accumulation (no mid-walk DB write) — simple and race-free; a
small over-count from each chunk re-walking its resumed page is fine for a
progress indicator.
Tests: lifecycle accumulates posts_processed across chunks; start clears a
prior _backfill_posts and the record exposes it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Before arming a deep walk on a native-ingester platform (Patreon — where
verify is one cheap API page), POST /sources/{id}/backfill {start|recover}
runs the shared verify_source_credential first and REFUSES (409 + reason)
only on a definitive rejection (verify→False, e.g. expired cookies). It
proceeds on valid (True) or inconclusive (None — a network blip must not
block). Gated to native platforms: gallery-dl verify is a slow --simulate
subprocess, too heavy for an arm action. The credential read happens in a
session that's CLOSED before the verify network call (no held conn).
Frontend: onBackfill/onRecover now read e.body.detail (ApiError carries the
reason in .body, not .detail) so the rejection text surfaces in the toast.
Tests: arm blocked on rejection (409, source not armed), proceeds on
inconclusive, stop never pre-flights, gallery-dl platform skips pre-flight.
An autouse fixture stubs verify to 'valid' for the existing backfill
endpoint tests so they stay network-free.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final step of the native Patreon ingester: a first-class Recovery action,
and removal of the now-dead gallery-dl Patreon path.
Recovery (rules #23/#24/#27 — full product, with UI):
- source_service.start_recovery arms the #693 backfill state machine PLUS
`_backfill_bypass_seen`, flipping download mode to recovery (bypass the
seen-ledger to re-fetch dropped-and-deleted near-dups and re-evaluate
under the current pHash threshold). Stop via the shared stop_backfill.
- SourceRecord exposes backfill_bypass_seen; POST /sources/{id}/backfill
gains action="recover".
- Frontend: Recovery button (Patreon-only, mdi-backup-restore) on SourceRow
+ SourceCard; the running badge labels "Recovering (N)" vs "Backfilling
(N)"; the Stop tooltip says "Stop recovery". sources.js recoverSource +
SubscriptionsTab onRecover.
Cutover (rule #22 — no legacy):
- gallery_dl: removed PLATFORM_DEFAULTS["patreon"], the patreon
files/cursor branch in _build_config_for_source, and the patreon/Mux
yt-dlp Referer/Origin block (was patreon-specific and wrongly tagged the
other platforms' yt-dlp fetches; native ingester owns it now).
- download_service: removed the dead campaign-id-retry helpers
(_looks_like_campaign_id_failure / _CAMPAIGN_ID_FAILURE_PATTERN) and
_effective_url. Vanity→campaign resolution + resume_cursor + the
cursor/PARTIAL lifecycle stay — they serve the native ingester.
Tests: removed the two obsolete patreon-gallery-dl config tests (yt-dlp
Referer, resume-cursor); repointed the generic skip-value config tests to
subscribestar; added start_recovery + recover-endpoint coverage
(backfill_bypass_seen). gallery-dl stays for the other 5 platforms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typed, loud failure mapping for the native Patreon ingester so a changed
API shape or expired auth never silently zero-downloads as "success".
- New ErrorType.API_DRIFT (free varchar error_type col → no migration):
distinct from auth so the operator knows the fix is updating the
ingester, not rotating cookies.
- patreon_client: PatreonAPIError carries status_code; new PatreonAuthError
for 401/403 + HTML-login/non-JSON bodies (reclassified from drift —
expired-session is auth, actionable as "rotate cookies").
- patreon_ingester._failure_result maps: PatreonAuthError→AUTH_ERROR,
PatreonDriftError→API_DRIFT ("Patreon API changed — ingester needs
update"), HTTP 429→RATE_LIMITED, 404→NOT_FOUND, other HTTP→HTTP_ERROR,
transport→NETWORK_ERROR. (429 thus drives the platform cooldown.)
- FailingSourcesCard: api_drift chip (red) + hint.
Contract test (new test_patreon_contract.py): the recorded /api/posts
fixture must parse end-to-end (no drift, 5 media across 4 posts) AND the
request params must still carry every field the parser depends on
(file_name, image_urls/download_url, images/attachments_media/media
includes, content/post_file/image post fields) — a trim of either trips a
red build. Plus client HTTP-status classification tests and ingester
error-type mapping tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan #693 (frontend). Backend landed in 96fffaf.
- sources store: setBackfill(runs) → startBackfill/stopBackfill ({action}).
- SubscriptionsTab: the deep-scan window.prompt for N runs becomes a
Start/Stop toggle keyed on source.backfill_state.
- SourceRow + SourceCard: the 'backfill (N×)' chip becomes a state badge —
Backfilling (chunk N) / Backfilled / Stalled — and the scan button
toggles between Backfill (mdi-magnify-scan) and Stop (mdi-stop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-flagged 2026-06-05 (mobile):
- TagAutocomplete no longer autofocuses on the ≤900px stacked layout —
focusing popped the soft keyboard, shrinking the viewport and shoving the
pinned image + nav/close controls out of view. matchMedia gate (safe on
plain HTTP); desktop autofocus unchanged.
- ImageViewer stacked layout: the body now scrolls and the media pane is
sticky (height:55vh, top:0), so the image + prev/next/close + integrity
badge stay pinned while the metadata panel scrolls beneath. Replaces the
old fixed image + 40vh internally-scrolled panel that could push the
controls off. Prev/next re-centered over the image band; opaque obsidian
bg so the scrolling panel doesn't bleed through.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>