Commit Graph
100 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 4.8 7309d1d6d4 fix(alembic): serialize concurrent migrators with an advisory lock
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m10s
Every web replica runs 'alembic upgrade head' in its entrypoint, so under
docker stack deploy two replicas can boot at once and race the same DDL —
0040 raced in prod (operator-flagged 2026-06-07): one backend wedged on the
series_page lock while a second tried to re-CREATE series_chapter, and the
loser died with AdminShutdown, crash-looping the web service.

Wrap run_migrations() in a transaction-scoped pg_advisory_xact_lock acquired
BEFORE the version table is read. The first replica to reach it migrates and
holds the lock for the whole upgrade; siblings block, then find the version
already at head and apply nothing. Works regardless of replica count and
needs no Swarm depends_on ordering (which stack deploy ignores anyway).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:59:45 -04:00
bvandeusenandClaude Opus 4.8 daaa7543a8 fix(backup,tags): unwedge backups on NFS (#739) + tag-standardize "0 groups" (#740)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m10s
#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>
2026-06-07 19:20:16 -04:00
bvandeusenandClaude Opus 4.8 19a91a1641 feat(series): Suggestions tab + matcher controls — frontend (FC-6.3)
CI / frontend-build (push) Successful in 19s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m7s
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>
2026-06-07 19:02:19 -04:00
bvandeusenandClaude Opus 4.8 c0fd80e694 feat(series): assisted-continuation matcher + suggestion queue — backend (FC-6.3)
CI / backend-lint-and-test (push) Successful in 26s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m8s
CI / lint (push) Successful in 3s
Confirm-only "this post may continue this series" matcher.

- series_suggestion table (post_id, series_tag_id, score, signals jsonb, status
  pending|added|dismissed, UNIQUE(post,series)); migration 0041 + two settings
  knobs (series_suggest_enabled, series_suggest_threshold).
- series_match_service: weighted additive score (title-stem / same-artist /
  page-continuity / shared-distinctive-tags), no single signal gating. The title
  "pattern" is derived on the fly from the post titles already in a series, so it
  sharpens as more are confirmed (no persisted state to drift). Candidates are
  bounded to the post's artist. match_post upserts pending suggestions (UNIQUE +
  on-conflict, respecting prior added/dismissed decisions).
- accept reuses add_post_as_chapter then marks 'added'; dismiss marks 'dismissed'.
- rescan_series_suggestions_task: settings-gated, time-boxed + self-resuming from
  a post-id cursor (maintenance_long lane), like normalize_tags_task.
- API: GET /series/suggestions, POST .../<id>/accept|dismiss, POST .../rescan.
- Settings: enabled + threshold exposed via /settings/import.
- Tests: pure scoring helpers + matcher/accept/dismiss/rescan lifecycle + UNIQUE
  dedup.

Frontend (Suggestions tab + settings card) lands next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:58:18 -04:00
bvandeusenandClaude Opus 4.8 9e262cc5f0 feat(series): Add-to-series control + Series browse view + nav (FC-6.2)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 25s
CI / integration (push) Successful in 3m4s
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>
2026-06-07 18:38:37 -04:00
bvandeusenandClaude Opus 4.8 db490e92df feat(series): post→series flows + browse list — backend (FC-6.2)
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
CI / lint (push) Successful in 3s
The post-aware on-ramp + the data behind the missing Series browse view.

- page_number_parser: conservative stated-page parser (pages 9-12 / page 5 /
  [3/8] / 3 of 8), keyword-gated to avoid false positives. Pure + unit-tested.
- SeriesService.promote_post_to_series: a self-contained post becomes its own
  series — series tag named after the post, one chapter, the post's images as
  pages (ordered by capture order; stated pages parsed from title/description).
- SeriesService.add_post_as_chapter: append a post as the next chapter of an
  existing series, titled after the post and slotted by parsed page number
  (a "pages 1-4" post lands ahead of the "pages 9-12" chapter).
- SeriesService.list_series: browse cards — cover thumb, artist, chapter/page
  counts, gap flag, last-updated; sort recent|name|size + filter by artist.
- API: GET /api/series, POST /api/series/from-post, POST /api/series/<id>/add-post.
- Resolver uses ImageRecord.primary_post_id (same linkage the posts feed renders).

Frontend (Add-to-series control + Series view + nav) lands next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:34:11 -04:00
bvandeusenandClaude Opus 4.8 8ad40da145 feat(series): chapter-aware manage view + reader — frontend (FC-6.1)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 3m5s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 26s
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>
2026-06-07 16:43:10 -04:00
bvandeusenandClaude Opus 4.8 1804a2c622 feat(series): chapter layer over series_page — backend (FC-6.1)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m5s
Adds an ordered chapter layer to series. Reading order becomes
(series_chapter.chapter_number, series_page.page_number); a chapter may be a
placeholder reserving a slot, and carries an optional parsed stated-page range
used to flag missing-page gaps. An image still lives in at most one series ⇒ one
chapter (image_id stays UNIQUE).

- models: series_chapter; series_page gains chapter_id (NOT NULL, cascade) +
  stated_page. Migration 0040 backfills every existing series into one
  auto-chapter holding its current flat pages — no data loss.
- SeriesService: chapter CRUD (create/update/reorder/delete/merge), page→chapter
  assignment, reorder_pages, chapter-aware set_cover; list_pages now returns
  chapters[] + gaps[] alongside a back-compat flat pages[]. Legacy series-wide
  reorder operates on the single default chapter and rejects multi-chapter series.
- API: chapter endpoints under /api/series/<tag>/chapters; POST pages accepts an
  optional chapter_id.
- TagService.merge now repoints series_chapter too, so a merged series' chapters
  (and their pages) survive the source tag's deletion instead of cascading away.
- Tests: new chapter suite; updated the 4 direct SeriesPage(...) constructions to
  supply chapter_id.

Frontend (chapter-aware manage view + reader) lands next; until then the
existing UI keeps working via the flat pages[] + single default chapter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:31:55 -04:00
bvandeusenandClaude Opus 4.8 43b02d79a4 fix(infra): size Postgres /dev/shm via tmpfs mount (shm_size ignored under Swarm)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m8s
The prod stack runs under Docker Swarm (docker stack deploy), which SILENTLY
IGNORES `shm_size` — container inspect showed ShmSize still 64MB after the
a183be7 fix, and vacuum_analyze kept hitting DiskFull resizing a ~64MB POSIX
DSM segment in /dev/shm (operator-flagged 2026-06-07). Replace the ignored
`shm_size: 512m` with a tmpfs mount on /dev/shm (size 512MB), which Swarm AND
plain Compose both honor. Requires a stack redeploy to take effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:22:09 -04:00
bvandeusenandClaude Opus 4.8 677317244e fix(modal): Enter accepts the fandom instead of reopening the dropdown
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m3s
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>
2026-06-07 12:38:33 -04:00
bvandeusenandClaude Opus 4.8 a92817677d fix(router): reset tab title on navigation (artist name stuck on other tabs)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m2s
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>
2026-06-07 12:34:28 -04:00
bvandeusenandClaude Opus 4.8 394c7dcd67 test(maintenance): patterned images for re-extract resume test
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m2s
Solid-color members phash-collapse to distance 0, so the second archive's member
deduped away ("held no supported members") and members_imported was 0. Use
structurally distinct patterned jpegs so both members import — the resume cursor
mechanics were already correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:08:02 -04:00
bvandeusenandClaude Opus 4.8 a73d9327d8 fix(maintenance): time-box + self-resume the archive re-extract task
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Failing after 3m3s
reextract_archive_attachments loaded ALL PostAttachments and ran in one pass up
to a 30-min soft limit, then died without re-enqueueing — a large archive
backlog would only ever partially process. And a naive re-run can't advance: an
already-extracted archive is still an archive on disk, so it'd re-extract the
same first batch forever.

Give it a real cursor + time-box + self-resume (mirrors normalize_tags_task,
operator-asked 2026-06-07: reasonable timeout, then re-queue so other work keeps
flowing):
- service scans attachments with id > after_id in ascending order, time-boxes
  the chunk, and reports partial=True + resume_after_id (last scanned id).
- task passes a 600s budget and re-enqueues itself from the cursor until the
  scan is exhausted. Routes on the maintenance_long lane.
- This is independent of the maintenance_long lane isolation (already shipped) —
  that stops long tasks starving the quick maintenance queue; this stops the
  re-extract itself dying on a big backlog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:03:13 -04:00
bvandeusenandClaude Opus 4.8 5201fab088 feat(modal): surface ML suggestions inline in the tag autocomplete
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m2s
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>
2026-06-07 11:56:50 -04:00
bvandeusenandClaude Opus 4.8 b79708524e fix(modal): keyboard focus flow for the Pick-a-fandom dialog
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m7s
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>
2026-06-07 11:49:28 -04:00
bvandeusenandClaude Opus 4.8 1226d3b23a fix(modal): kebab menus render BEHIND the modal — bump z-index above it
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m3s
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>
2026-06-07 10:52:48 -04:00
bvandeusenandClaude Opus 4.8 6c5dbfe4a0 feat(modal): large centered loading spinner
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 3m3s
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>
2026-06-07 10:42:05 -04:00
bvandeusenandClaude Opus 4.8 68cda6114d chore(compose): maintenance-long needs only /images; drop dead /downloads mounts
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
Operator-flagged: /downloads was never mapped in prod and everything worked —
confirmed nothing in the app references a filesystem /downloads (only the
unrelated /api/downloads route). Dropped the dead mount from web/worker/
scheduler, and scoped the new maintenance-long worker to just /images (backups
write to /images/_backups; audits + admin tasks all operate on /images).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:26:36 -04:00
bvandeusenandClaude Opus 4.8 c217009425 feat(maintenance): dedicated maintenance_long lane for long one-shot tasks
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m3s
Even chunked, a single concurrency-1 maintenance lane is fragile — a 30-min DB
backup or a multi-chunk library audit holds the slot and delays the quick
self-healing recovery sweeps / vacuum (operator-flagged 2026-06-07: long runs
must never block quick maintenance).

Route the long one-shots — backup.*, admin.* (normalize/re-extract/cascade-
delete), library_audit.* — to a new `maintenance_long` queue served by a
dedicated worker (concurrency 1), added to docker-compose (+ dev override). The
scheduler keeps the quick `maintenance` lane (sweeps, vacuum, cleanup) for
itself, so a backup can no longer starve a 5-min vacuum. UI queue list +
routing tests updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:00:03 -04:00
bvandeusenandClaude Opus 4.8 f4f49d407e fix(tags): move _NORMALIZE_CHUNK_SECONDS above the decorator (syntax error)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m3s
The constant + comment landed BETWEEN @celery.task(...) and the function def,
which is a syntax error that broke the whole tasks.admin import (cascaded to
lint E999 + every backend/integration test). Move it above the decorator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:10:03 -04:00
bvandeusenandClaude Opus 4.8 a183be7e6e fix(infra): bump Postgres shm_size (vacuum DiskFull) + raise DB-backup time limit
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Failing after 12s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Failing after 2m23s
Two more maintenance-queue failures from the operator's 24h list:
- vacuum_analyze died with "could not resize shared memory segment to 67MB: No
  space left on device" — Docker's default /dev/shm is 64MB, too small for
  VACUUM (ANALYZE)'s parallel-worker shared memory. Set the postgres service
  shm_size: 512m.
- backup_db_task timed out at its 12-min limit once the DB grew; a pg_dump can't
  be chunked, so raise it to 30/35 min. (A long backup still briefly holds the
  concurrency-1 lane — the structural fix is a dedicated lane for long one-shots.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:08:19 -04:00
bvandeusenandClaude Opus 4.8 f2e9ae07dc fix(audit): chunk + self-resume library scans (stop the 2h queue-hog timeouts)
scan_library_for_rule ran one 2-hour pass that timed out on large libraries and
held the concurrency-1 maintenance queue the whole time, starving vacuum/backup/
normalize (operator-flagged — it was the dominant entry in the 24h failures).

It now runs ~10-min chunks and re-enqueues itself until the library is
exhausted, matching the operator's preferred pattern (reasonable timeout → retry
queued → other things process between). New columns (alembic 0039):
resume_after_id persists the keyset cursor so a chunk continues where the last
left off; last_progress_at lets the recovery sweep tell a progressing multi-
chunk audit from a dead one (it now measures staleness from last_progress_at,
not started_at). Matches accumulate across chunks. soft/hard limits dropped
2h→15/16.7 min so the in-chunk budget fires first; a soft-limit backstop
re-enqueues to resume instead of erroring the whole run.

Tests: time-box → re-enqueue (status stays running); resume carries prior
matches and appends new ones. Existing full-scan tests unchanged (small sets
finish in one chunk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:08:19 -04:00
bvandeusenandClaude Opus 4.8 d9d502a60d fix(tags): time-box + self-resume the tag standardization (stop the 40-min timeout)
CI / lint (push) Failing after 2s
CI / backend-lint-and-test (push) Failing after 9s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Failing after 2m17s
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>
2026-06-06 23:57:06 -04:00
bvandeusenandClaude Opus 4.8 1819caaf5b feat(modal): keyboard-friendly tagging — fandom dialogs, Tab-accept, jump hotkey, cheatsheet
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 29s
CI / integration (push) Successful in 3m1s
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>
2026-06-06 23:46:46 -04:00
bvandeusenandClaude Opus 4.8 22dc516dc7 fix(modal): tag-chip kebab + ESC-after-accept + autocomplete scroll-into-view
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m2s
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>
2026-06-06 23:42:30 -04:00
bvandeusenandClaude Opus 4.8 4c42a15fa1 fix(patreon): a missing media file_name is a URL-basename fallback, not API drift
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 3m0s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 25s
The native client treated a gallery image without `file_name` as schema drift
and raised "Patreon API changed — ingester needs update", failing the whole walk
(operator-flagged 2026-06-07: BlenderKnight post 73665615, kind=images). But the
resource had a valid URL, and the code already derives a filename from the URL
basename right below the raise — the same fallback gallery-dl uses. Patreon
legitimately serves some images without file_name, so this isn't drift.

Drop the require_file_name gate from _media_item: file_name is now optional for
every kind (images/attachments/postfile), falling back to the URL basename.
Genuine drift still raises — no resolvable URL, or a media id referenced by a
relationship but absent from `included`. Test updated to assert the fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:10:46 -04:00
bvandeusenandClaude Opus 4.8 14c4dd1ea0 test(patreon): adjust deterministic clock for the new per-media should_stop read
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m3s
The mid-post time-box check (619e771) reads time.monotonic() once more per post,
so test_backfill_budget_cut_returns_partial_with_progress's discrete tick
sequence shifted — the >budget tick (200) landed on post1's first-item check
instead of post2's gate, cutting post1 to 0 files. Add the extra tick (20, still
under budget) so post1's item downloads, matching production where the gate and
the first should_stop are microseconds apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:26:27 -04:00
bvandeusenandClaude Opus 4.8 619e7712c2 fix(patreon): enforce the backfill time-box mid-post (stop overrunning to the soft limit)
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Failing after 3m3s
CI / lint (push) Successful in 2s
A backfill chunk's time-box (BACKFILL_CHUNK_SECONDS=600) was only checked
between POSTS, but download_post downloads ALL of one post's media
synchronously — so a single media-heavy post could run the chunk far past 600s,
all the way to the Celery soft time limit (1350s), where it was killed and
finalized as error (Pocketacer, event #41330: ran the full 22.5 min).

download_post now polls a should_stop() deadline BEFORE each media item and the
engine passes `now - start >= time_budget_seconds`, so a heavy post stops at the
budget and the remaining media (never marked seen) re-fetch next chunk. Bounds
chunk overrun to one media download instead of one whole post.

Also genericized the soft-limit salvage message — it claimed the "gallery-dl
subprocess" failed, which is wrong for a native Patreon walk; it now describes
the time-budget overrun + per-page checkpoint resume in platform-neutral terms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:20:05 -04:00
bvandeusenandClaude Opus 4.8 416d8d71cd feat(patreon): resolve the creator's campaign from a single-post URL
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 35s
CI / integration (push) Successful in 3m2s
A source URL like https://www.patreon.com/posts/mimic-in-dungeon-158372536 is a
single-post permalink, not a creator page — the resolver grabbed "posts" as the
vanity and failed (operator-flagged 2026-06-07). Add a resolution path: extract
the trailing post id and follow it to the owning campaign via the Patreon post
API (/api/posts/<id>?include=campaign), so pasting any post URL subscribes to
that creator's whole feed. `posts/` is excluded from the vanity regex so it
can't masquerade as a creator slug.

Resolution order is now: cached override → id: URL → /posts/<id> → vanity
(campaigns API + creator-page scrape). Tests cover the post→campaign resolve
and that /posts/ URLs aren't treated as vanities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:32:15 -04:00
bvandeusenandClaude Opus 4.8 a3c9499e93 feat(subs): kick off the first backfill walk immediately on source create
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m3s
A new enabled source is armed for run-until-done backfill (#693) but would sit
idle until the next scheduler tick (~60s). create_source now enqueues the first
walk right away (pending DownloadEvent + download_source.delay), skipping only
when the platform is in a rate-limit cooldown (the scheduler picks it up when
that clears). Disabled sources still don't dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:22:32 -04:00
bvandeusenandClaude Opus 4.8 87c7318125 feat(downloads): serialize same-platform downloads (Patreon concurrency cap)
Two concurrent Patreon walks could trip the server rate limit even with each
source pacing its own requests. The platform-cooldown handled the aftermath of
a 429; this adds the preventive half — a per-platform Redis lock so only one
Patreon walk runs at a time. Different platforms still run concurrently up to
the worker concurrency; only a second walk on the SAME serialized platform
waits.

download_source acquires fc:download_lock:<platform> (non-blocking) before the
run. On contention it re-enqueues itself with a short countdown (the pending
event stays — no new event, no log spam), bounded to ~15 min then runs uncapped
as a safety valve. The lock TTL sits just past the hard kill so a SIGKILL'd
worker auto-releases; a backfill chunk only holds it ~10 min, well under the
30-min DownloadEvent recovery sweep. A broker hiccup degrades to uncapped
(prior behaviour) rather than stalling downloads. SERIALIZED_PLATFORMS={patreon};
gallery-dl platforms are left uncapped (self-pacing subprocesses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:22:32 -04:00
bvandeusenandClaude Opus 4.8 e4e35163ab feat(subs): subscriptions UX batch — error reasons, single-source rows, health sort, bulk backfill
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 3m2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
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>
2026-06-06 21:00:47 -04:00
bvandeusenandClaude Opus 4.8 b7d07324ee fix(subs): stop the lock/reload on source actions + regroup the row buttons
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 24s
CI / integration (push) Successful in 3m2s
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>
2026-06-06 20:48:01 -04:00
bvandeusenandClaude Opus 4.8 c65da42593 fix(patreon): handle the /cw/ creator-URL prefix in vanity extraction
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m2s
A source URL like https://www.patreon.com/cw/Atole resolved vanity='cw' — the
vanity regex only skipped a /c/ prefix, so Patreon's current /cw/ ("creator
workspace") form fell through to the bare-vanity branch and captured the prefix
instead of the slug. Every /cw/ source then failed campaign-id resolution
(API + page-scrape both looked up "cw"). Operator-confirmed 2026-06-07 via the
new error text: source_url='.../cw/Atole'; vanity='cw'.

Add cw/ to the optional prefix group (ordered before c/ so the longer prefix
wins), and have the creator-page fallback try the /cw/ form too. Test covers
bare / c/ / cw/ extraction and that id: URLs stay non-vanity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:34:15 -04:00
bvandeusenandClaude Opus 4.8 19eb4e9388 feat(import): surface WHY an archive was captured without extracting images
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
The recurring "post shows a zip but no images" report had no diagnostic: when
_import_archive captured an archive as a bare PostAttachment — because the
bomb-guard probe rejected it, or extraction yielded zero members (corrupt /
unsupported / missing extractor backend), or it held only non-media files — it
returned status="attached" silently.

Now those paths set ImportResult.error with the specific reason and log a
warning, and download_service records each as {file, reason} under the event's
metadata.unextracted_archives (None when every archive extracted cleanly). So
the next run names exactly which archives failed and why, instead of leaving the
operator to guess. No behaviour change to the happy path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:01:22 -04:00
bvandeusenandClaude Opus 4.8 a559fabdd5 feat(patreon): scrape creator-page HTML as a campaign-id resolution fallback
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m1s
CI / backend-lint-and-test (push) Successful in 25s
Patreon's /api/campaigns?filter[vanity]= lookup returns empty data for creators
that plainly exist (operator-flagged 2026-06-06 — Atole etc. erroring at the
resolve step). gallery-dl never used that endpoint; it pulls the campaign id out
of the creator page's bootstrap JSON. Add the same as a fallback: when the API
misses, GET the creator page (bare + /c/ vanity paths) and scrape the first
campaign id from any known embedding ("id":"…","type":"campaign" /
"campaign":{"data":{"id" / /api/campaigns/<id> / "campaign_id"). API is still
tried first (cheap, structured); the page scrape only runs on a miss.

Tests: API-empty → page-scrape fallback resolves; _scrape_campaign_id pattern
coverage. Existing API-path tests unchanged (happy paths short-circuit before
the fallback; failure paths hit the guarded scrape and still return None).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:57:39 -04:00
bvandeusenandClaude Opus 4.8 711fd2bb75 style: drop aliased cross-module import (I001) — use a local _CAMPAIGNS_API constant
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m0s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:46:09 -04:00
bvandeusenandClaude Opus 4.8 3556a54260 feat(patreon): surface source URL + vanity in campaign-id resolution errors
CI / lint (push) Failing after 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m2s
Naming/lookup failures now report the source_url, the extracted vanity, and the
exact campaigns-API lookup URL attempted, so a "could not resolve campaign id"
error is diagnosable (wrong vanity? cookie/auth? creator renamed?) instead of
opaque. Applied to all three resolution surfaces: the native download event,
the dry-run preview, and the credential-verify probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:39:54 -04:00
bvandeusenandClaude Opus 4.8 df2310bc70 fix(tags): preserve acronym casing in tag normalization (DC, NSFW)
normalize_tag_name now only capitalizes the first letter of each word and
leaves the rest of the word untouched (was lowercasing the tail, which turned
DC→Dc / NSFW→Nsfw). This matches ml/tag_name._title_word, so a Camie-suggested
tag keeps the exact casing the suggestion UI showed when it round-trips through
POST /api/tags on Accept — addressing "auto-suggested tags must obey
capitalization" and "don't mangle acronyms" in one rule.

Trade-off (operator-chosen): all-caps input no longer folds to Title Case, so
case-variant merging in #714 still folds the dominant lowercase-vs-Title case
but leaves all-caps stylizations distinct (protecting acronyms wins). Tests
updated + a new test documenting acronym preservation / non-folding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:39:54 -04:00
bvandeusenandClaude Opus 4.8 9374f63953 fix(posts): post-card thumbnail strip spans the full hero width
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 3m1s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 25s
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>
2026-06-06 17:04:23 -04:00
bvandeusenandClaude Opus 4.8 6332ae13fd style(tags): C416 — use dict(members) instead of identity comprehension (#714)
CI / integration (push) Successful in 3m2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:32:24 -04:00
bvandeusenandClaude Opus 4.8 3c89223dcb feat(tags): retro-normalize existing tags to Title Case + merge case-collisions (plan #714)
CI / lint (push) Failing after 2s
CI / integration (push) Successful in 3m2s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 27s
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>
2026-06-06 16:28:34 -04:00
bvandeusenandClaude Opus 4.8 23f452021f fix(tags): Title-Case operator-entered tags at create endpoint only (plan #701)
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 24s
CI / integration (push) Successful in 3m2s
CI / lint (push) Successful in 2s
normalize_tag_name (per-word capitalize + whitespace collapse) is applied in
the POST /api/tags handler so operator-entered tags get clean display casing.
It is NOT applied in the shared find_or_create / rename paths — those are used
by the ML tagger and allowlist matching, which must preserve the booru
vocabulary's original casing (Title-Casing it broke apply_allowlist matching).

find_or_create / rename keep case-insensitive lookup + clash detection so a
differently-cased entry dedups onto the existing tag instead of forking.

Tests updated to expect Title-Cased create output (sunset→Sunset,
character:Saber→Character:saber, http://example.com→Http://example.com) and a
dedicated normalize_tag_name unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:57:29 -04:00
bvandeusenandClaude Opus 4.8 62cca64dce feat(downloads): live per-file progress on running events — #709
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Failing after 3m1s
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>
2026-06-06 15:48:31 -04:00
bvandeusenandClaude Opus 4.8 89dfa42e18 fix(showcase): over-sample + random-order to break near-dup clustering — #699
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Failing after 3m0s
CI / frontend-build (push) Successful in 23s
TABLESAMPLE SYSTEM_ROWS reads CONTIGUOUS rows from each sampled page, so
sequentially-imported near-duplicates (multi-image posts, variant sets) came
back adjacent and clustered in the showcase ("three near-identical in a row").
Sample limit*5 rows (spanning more pages) then ORDER BY random() before taking
limit — breaks the physical adjacency for much better spread, still cheap
(random() over a few hundred rows, not the whole table).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:44:04 -04:00
bvandeusenandClaude Opus 4.8 2b69540ecc feat(tags): Title-Case normalization on create/rename — #701 (core)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Failing after 3m0s
Tags now normalize to Title Case + collapsed whitespace at the central
TagService.find_or_create (and rename) — so manual entry, the create API, and
anything routed through find_or_create produce the canonical form. The lookup is
case-insensitive, so a differently-cased entry finds the existing tag instead of
forking a case-variant duplicate ('hatsune miku' / 'HATSUNE MIKU' → one tag).

normalize_tag_name uses per-word capitalize (not str.title(), which mangles
apostrophes) and folds ALL-CAPS input. Existing tags keep their current casing
until touched — a retro-normalize maintenance pass (Title-Case + merge
case-collisions) is the follow-up to convert the back-catalog.

Test: create title-cases + collapses whitespace; case/whitespace variants dedupe
to one tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:42:20 -04:00
bvandeusenandClaude Opus 4.8 4fe53cdf6b fix(modal/tags): fandom list, modal kebab, ESC-after-accept — #712 #711 #700
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
#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>
2026-06-06 15:15:02 -04:00
bvandeusenandClaude Opus 4.8 cb9b286c53 fix(maintenance): stage re-extract under the artist dir so members link — #713 part 2 fix
CI / frontend-build (push) Successful in 20s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m2s
The integration test caught it: _import_media re-derives the artist by path-walk
from the attribution path (ignoring the explicit artist) AND _copy_to_library
lands members relative to that path. Staging the archive in /tmp meant the
artist didn't resolve (provenance skipped) and members would land in the temp
dir. Stage under images_root/<slug>/<platform>/<post>/ instead so the artist
resolves and members land in the real library; remove only the staged archive +
sidecar afterward (members stay). Require a real artist+slug (skip + count
otherwise).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:51:22 -04:00
bvandeusenandClaude Opus 4.8 a497104661 feat(maintenance): re-extract archive attachments + link to post — #713 part 2
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Failing after 3m3s
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>
2026-06-06 14:43:26 -04:00
bvandeusenandClaude Opus 4.8 5bb25245a5 fix(archive): magic-byte archive detection so mis-named archives extract — #713 part 1
CI / integration (push) Successful in 2m59s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
Patreon attachment downloads land with sanitized URL-blob filenames
(01_https___www.patreon.com_media-u_v3_<id>) whose Path.suffix is junk, never
.zip — so the extension-only is_archive() filed them as opaque PostAttachments
and never extracted them ("No images attached to this post").

Add archive_extractor.detect_archive_format() — extension first, then magic-byte
sniff (zipfile.is_zipfile + RAR/7z signatures). is_archive(), extract_archive(),
and safe_probe._inspect_archive() (the bomb-guard) all route through it, so a
mis-named/extension-less archive is now detected, bomb-guarded, integrity-tested,
AND extracted regardless of filename. Stops new ones; part 2 re-extracts the
already-imported backlog.

Tests: mis-named zip detected + extracted; non-archive dotted name not
misdetected; _inspect_archive on a mis-named zip; signature updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:33:24 -04:00
bvandeusenandClaude Opus 4.8 911d535f56 fix(artists): card preview dead-space + empty-state flicker on first load
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 25s
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 3m1s
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>
2026-06-06 13:45:03 -04:00
bvandeusenandClaude Opus 4.8 e82c2ee57b feat(subscriptions): dry-run backfill preview — B4 preview (plan #708)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 24s
CI / integration (push) Successful in 3m1s
CI / frontend-build (push) Successful in 5m13s
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>
2026-06-06 12:18:08 -04:00
bvandeusenandClaude Opus 4.8 cd43439401 feat(ingester): graceful mid-walk cancel on Stop — B4 cancel (plan #708)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 25s
CI / integration (push) Successful in 3m0s
Owning the walk lets Stop interrupt a live backfill chunk instead of letting it
run to its ~14.5-min time-box. ingest_core.run now polls _backfill_state at each
page boundary (a short SELECT, never held across the walk) and bails with PARTIAL
when an operator Stop has popped it. Latched on the first observed "running"
state so a run invoked without one (unit test / stale call) never self-cancels.
Progress is already checkpointed per-page, so a restart resumes from the cursor;
Stop clears it for a clean reset. No UI change — the existing Stop button now
just takes effect immediately.

Tests: _still_running reads the state; a latched run bails PARTIAL at the next
boundary when the state disappears (only the pre-cancel post ran).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:10:24 -04:00
bvandeusenandClaude Opus 4.8 bde19944db test(patreon): fix _BoomSession stub for the B5 headers kwarg
CI / integration (push) Successful in 3m2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
test_one_failure_isolated's _BoomSession overrode get() without the headers
kwarg _fetch_to_file now passes (B5 Range resume), so the call TypeError'd and
both items errored. Add headers=None to match the base fake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:41:48 -04:00
bvandeusenandClaude Opus 4.8 402086c34c feat(patreon): resume partial media downloads via HTTP Range — B5 (plan #708)
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Failing after 26s
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 3m1s
Owning the media fetch means a mid-download transport cut no longer refetches
from zero. _fetch_to_file now resumes: on a transient retry, if bytes already
landed in the .part, it requests Range: bytes=<have>- and appends on a 206;
falls back to a clean truncate-and-restart if the server ignores Range (200) or
the range is past EOF (416). The .part staging means a non-range server never
corrupts the output — worst case is the old behavior (refetch from zero).

Tests: mid-stream cut resumes from the offset (asserts the Range header);
a Range-ignoring server refetches clean (no double-write). Test session fakes
updated to accept the new headers kwarg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:38:11 -04:00
bvandeusenandClaude Opus 4.8 fb7383eea7 feat(downloads): platform cooldown honors server Retry-After — B1 (plan #708)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m1s
Owning the native client means we see the 429 Retry-After header — previously
discarded. PatreonAPIError now carries `retry_after`; on a PERSISTENT page-fetch
429 the client attaches the server's raw Retry-After seconds. New
DownloadResult.retry_after_seconds; patreon_ingester._failure_result sets it on
RATE_LIMITED. download_service._update_source_health passes it to
set_platform_cooldown as `seconds=`, clamped to [60, 3600] (a tiny hint can't
leave the platform effectively un-cooled; a huge one can't strand it for hours);
no hint → the flat 900s default. So a rate-limited platform cools for as long as
the server actually asks, not a fixed guess.

Tests: terminal 429 surfaces retry_after (test_patreon_client); cooldown honors
+ clamps the hint, falls back to default when absent (test_download_service).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:34:37 -04:00
bvandeusenandClaude Opus 4.8 e47fa0cf4b refactor(downloads): unify phase-2 dispatch in download_backends — A5 (plan #707)
CI / frontend-build (push) Successful in 31s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 2m59s
Mirror verify_source_credential: download_backends.run_download is now the single
download entry, so that module is the ONE registry of how each platform both
downloads AND verifies — the seam that makes adding a platform a bounded job
(write its adapter construction next to its verify). The native-ingester
construction + campaign-id resolution moves out of download_service into
download_backends._run_native_ingester.

download_service.download_source drops its `if uses_native_ingester ... else
gdl.download` branch and calls one `self._run_download(...)` (a thin delegate to
run_download passing the service's gdl + sync sessionmaker). mode (tick/backfill/
recovery) is still chosen there from the backfill state machine and threaded
through. Removed the now-unused PatreonIngester / resolve_campaign_id_for_source
imports from download_service.

Tests: the phase-2 stub seam moves from svc._run_patreon_ingester to
svc._run_download (helper + the db-release test); the two native-construction
tests repoint to download_backends.run_download (patching
download_backends.resolve_campaign_id_for_source / PatreonIngester).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:22:48 -04:00
bvandeusenandClaude Opus 4.8 b211900390 refactor(downloads): DRY the ingester/gallery-dl seam — A1–A4 (plan #707)
CI / backend-lint-and-test (push) Successful in 25s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 3m0s
Consolidates duplication that owning the native ingester left against the still-
live gallery-dl path, and fixes a parity gap the duplication hid.

A1 — shared quarantine: extract file_validator.quarantine_file (move to
_quarantine/<slug>/<platform> + write the .quarantine.json provenance sidecar).
gallery_dl._validate_and_quarantine and patreon_downloader._validate_path both
call it. PARITY FIX: the native path now writes the provenance sidecar it
previously skipped — threads the media url through for source_url.

A2 — make_run_stats(**counts) factory in gallery_dl for the canonical run_stats
key set; gallery_dl._compute_run_stats and ingest_core both build through it so
the shape can't drift (gallery-dl path gains a benign dead_lettered_count=0).

A3 — one safe_ext in utils/paths.py; importer._safe_ext (thin wrapper, kept for
the Path call sites + memory pointer) and patreon_client both use it. Closes the
double-impl of the URL-encoded-basename ext gotcha.

A4 — promote gallery_dl._truncate_log/_extract_errors_warnings to module-level
truncate_log/extract_errors_warnings; download_service calls them directly
instead of reaching through self.gdl for native-result log shaping. The
staticmethods stay as thin delegators for existing callers/tests.

Behavior-preserving except the A1 sidecar parity fix. Test: native quarantine
writes a .quarantine.json (test_patreon_downloader).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:11:25 -04:00
bvandeusenandClaude Opus 4.8 697a86d31c fix(ingester): close #5 within-chunk live posts + #8 video transient retry
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 2m59s
Review of the #1–#9 ingester roadmap found two real-but-small gaps; this closes
both.

#5 (live posts progress) shipped at per-chunk granularity — _apply_backfill_
lifecycle accumulated DownloadResult.posts_processed AFTER each chunk, so the
badge didn't move during a chunk (up to ~14.5 min) and over-counted the
re-walked resume page. The plan called for within-chunk live updates. Move
ownership of _backfill_posts into the ingester: ingest_core writes a monotonic
absolute (posts_base + net-new) via _checkpoint_posts at each page boundary and
once at the end, EXCLUDING the resumed page so it no longer inflates across
chunks. download_service seeds posts_base from prior chunks and stops touching
the key (the lifecycle now carries the ingester's committed value forward).

#8 (per-media transient/permanent retry) covered only the plain-GET path
(_fetch_to_file); the Mux/video path returned None on any yt-dlp failure with no
retry. Give _run_ytdlp the same split: TimeoutExpired/OSError are transient
(back off + retry up to _MAX_MEDIA_RETRIES), a non-zero exit (CalledProcessError)
is permanent (yt-dlp already did its own network retries) → fail fast to the
per-item/dead-letter path.

Tests: live-posts absolute + resume-page exclusion + tick-doesn't-persist
(test_patreon_ingester); lifecycle-leaves-posts-to-ingester rewrite
(test_download_service); video transient-retry + permanent-fail-fast
(test_patreon_downloader).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:56:26 -04:00
bvandeusenandClaude Opus 4.8 9a2cd569c3 refactor(ingest): extract platform-agnostic Ingester core — roadmap #9 (plan #706)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m0s
Factor the native-ingest orchestration out of PatreonIngester into a reusable
ingest_core.Ingester base, parametrized by client/downloader/ledger-models/
constraints/key/platform/error_base. PatreonIngester becomes a thin adapter:
it resolves the Patreon client/downloader, wires the seen/dead-letter models +
UNIQUE-constraint names + _ledger_key into super().__init__, and overrides
_failure_result with the Patreon exception taxonomy. Behavior-preserving — no
table rename, no migration; the public surface (PatreonIngester, _ledger_key,
DEAD_LETTER_THRESHOLD, verify_patreon_credential) is unchanged.

This is the strategic seam: SubscribeStar/etc. now migrate by writing a
~40-line adapter, not by re-implementing the tick/backfill/recovery walk,
tiered skip, checkpoint, and dead-letter logic.

run() moved to ingest_core, so the budget test's monotonic patch repoints to
ingest_core.time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:24:23 -04:00
bvandeusenandClaude Opus 4.8 d592e0ca02 feat(patreon): within-pass transient retry for media GETs — #8 (was overstated as done)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
Honest completion of roadmap #8. Previously a NON-429 media failure (a
connection reset, a timeout, a truncated stream, a 5xx) was an immediate
terminal "error" for the pass — only retried on the NEXT walk. Now
_fetch_to_file retries TRANSIENT failures in-place with backoff (transport
blips incl. mid-download, 429 honoring Retry-After, and 5xx; up to 3 tries),
while PERMANENT failures (404 gone / 403 forbidden) fail fast straight to the
error → dead-letter path — re-fetching them is pointless. This makes the
transient-vs-permanent split explicit instead of leaning on the next-tick
cycle. (#1's 429 backoff + #7's dead-letter covered most of #8's value; this
is the missing in-pass transient piece I'd loosely marked "folded".)

Tests: a connection blip / a 5xx is retried then succeeds; a 404 errors with
NO retry; an exhausted transient becomes a terminal error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:15:48 -04:00
bvandeusenandClaude Opus 4.8 7a872a3619 feat(patreon): dead-letter ledger for permanently-failing media — #705 step 2 (#7)
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
CI / lint (push) Successful in 2s
A media that fails every walk (404'd CDN, deleted post, geo-blocked Mux,
persistently-corrupt bytes) used to re-error forever and re-burn chunks.
New `patreon_failed_media` table (alembic 0038, chains 0037) records
per-media attempts; once attempts reach DEAD_LETTER_THRESHOLD (3) the
ingester skips it on routine tick/backfill walks (tier-1.5, folded into the
seen/skip predicate). Recovery BYPASSES it (the operator's "try everything
again" re-attempts dead media). A clean download clears the row (recovered);
errors/quarantines upsert-increment it. Surfaced as
run_stats.dead_lettered_count.

- New PatreonFailedMedia model + migration; ingester _dead_keys /
  _record_failures (on_conflict increment) / _clear_failures.
- skip = seen | dead (empty in recovery); failures recorded post-fetch on
  short sessions (same pattern as the seen-ledger).

Tests: a media erroring 3× is dead-lettered + skipped (no download attempt);
recovery re-attempts a dead media and clears it on success; a clean download
clears a sub-threshold failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:04:24 -04:00
bvandeusenandClaude Opus 4.8 4bb11ce7dc feat(patreon): incremental cursor checkpoint mid-walk — #705 step 1 (#6)
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 25s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 2m59s
A worker SIGKILL (hard-time-limit backstop) mid-chunk lost the whole
chunk's walk — the cursor was only persisted at chunk boundaries by phase
3, so the next tick re-walked from the chunk start. Now the ingester
checkpoints _backfill_cursor to the DB at each page boundary (backfill/
recovery only) via an ATOMIC single-key UPDATE (config_overrides::jsonb →
jsonb_set('{_backfill_cursor}') → ::json), so it never clobbers operator
config or other backfill keys. On a crash the last mid-walk cursor
survives → the next chunk resumes near the crash, not the chunk start.
phase 3 still writes the final cursor (same value); this is the safety net.

Tests: a backfill walk leaves the last page's cursor in the DB (written by
the ingester, before any phase 3); a tick never checkpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:58:16 -04:00
bvandeusenandClaude Opus 4.8 e42a86d995 test(patreon): fix #704 — quarantine status + budget-cut cursor assertions
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
CI / frontend-build (push) Successful in 31s
CI / integration (push) Successful in 2m58s
Two test breaks from the structured-results change:
- An existing downloader test pinned a corrupt file to status "error";
  it's now the distinct "quarantined" status (the new behavior). Updated
  it + removed the duplicate I'd added.
- The budget-cut ingester test asserted the checkpoint cursor was the last
  FULLY-processed page (CUR1); it's actually the page we were cut on (CUR2,
  entered + cursor emitted before the budget check), matching the prior
  parse_last_cursor(last) semantics. Corrected the assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:46:02 -04:00
bvandeusenandClaude Opus 4.8 b2e59e7e17 feat(subscriptions): live posts-processed progress on backfill/recovery — #704 step 2
CI / frontend-build (push) Successful in 22s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Failing after 26s
CI / integration (push) Failing after 3m0s
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>
2026-06-05 23:43:23 -04:00
bvandeusenandClaude Opus 4.8 e53f8959af feat(patreon): structured ingester results + quarantine surfacing — #704 step 1
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Failing after 25s
CI / frontend-build (push) Successful in 37s
CI / integration (push) Failing after 3m0s
The native ingester faked gallery-dl stdout (`Cursor:` lines, summary) and
phase 3 regex-scraped it back — so Patreon run-stats were mostly zero and
quarantine stats blank. We own the ingester, so it now RETURNS structured
data and phase 3 reads it directly.

- DownloadResult gains run_stats/cursor/posts_processed (None/0 on the
  gallery-dl path, which keeps the text route).
- Ingester builds real run_stats from per-media outcome counts, sets the
  checkpoint cursor structurally (no fake `Cursor:` stdout), and counts
  posts processed. download_service phase 3 uses dl_result.run_stats when
  present; the backfill lifecycle + TIMEOUT→PARTIAL block checkpoint
  dl_result.cursor instead of parse_last_cursor(stdout).
- #4 quarantine: PatreonDownloader reports a distinct "quarantined"
  MediaOutcome (with the _quarantine dest); the ingester surfaces a real
  files_quarantined + quarantined_paths + run_stats.quarantined_count
  (was hardcoded 0). Quarantined media isn't written or marked seen.
- Cleanup: parse_last_cursor + _CURSOR_RE (and the now-unused `import re`)
  removed from gallery_dl — the structured cursor replaced the scrape.

Tests: ingester result carries real run_stats/cursor/posts_processed +
quarantine counts; downloader quarantines an invalid file as "quarantined";
backfill cursor tests pass cursor= structurally; dropped the
parse_last_cursor tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:39:28 -04:00
bvandeusenandClaude Opus 4.8 5b615b7ded feat(sources): pre-flight credential verify on backfill/recovery arm — #703 step 2
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 2m59s
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>
2026-06-05 23:21:05 -04:00
bvandeusenandClaude Opus 4.8 d6c15f4ea0 test(patreon): fake gdl needs real _rate_limit for native pacing — #703 step 1 fix
_run_patreon_ingester reads self.gdl._rate_limit for the native pacing
config (max(0.5, rate_limit/4)); the MagicMock fake gdl broke the
arithmetic. Give it real _rate_limit/_validate_files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:21:05 -04:00
bvandeusenandClaude Opus 4.8 3b2f7a41c3 feat(patreon): ingester rate-limit resilience — #703 step 1
CI / frontend-build (push) Successful in 21s
CI / integration (push) Failing after 3m0s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
A single 429 mid-walk used to fail the run → RATE_LIMITED → platform-wide
cooldown → every Patreon source dark ("testing dead"). The native path also
ignored the operator's existing politeness setting. Fixed both:

- Pacing (avoid 429s): honor download_rate_limit_seconds (gallery-dl's
  `rate_limit`, read off self.gdl) as a pre-download sleep on real media
  downloads only (skips don't pace); pace /api/posts page fetches with the
  per-source sleep_request override, defaulting to max(0.5, rate_limit/4) —
  the same API-pacing default gallery-dl used for `sleep-request`.
- 429 backoff (ride out transient limits): PatreonClient._fetch retries a
  429 with backoff (honor Retry-After, else exponential 2·2^(n-1), capped
  30s, ≤3 tries); only a PERSISTENT 429 propagates as terminal
  RATE_LIMITED. Light 2-retry on a media-GET 429 too.

Threaded via PatreonIngester(rate_limit=, request_sleep=) →
PatreonClient/PatreonDownloader; download_service sources them. Injected
test client/downloader are unaffected (carry their own pacing).

Tests mock time.sleep (no real sleeping): retry-then-success, persistent
429 raises after N, Retry-After honored, request_sleep paces, media pacing
per real download, skips don't pace, media 429 retried.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:13:46 -04:00
bvandeusenandClaude Opus 4.8 218bfebb92 feat(downloads): native Patreon verify + uniform backend dispatch (plan #697)
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 2m59s
The credential Verify button still ran gallery-dl --simulate for Patreon
after the cutover — testing the wrong path (and prone to the vanity
"Failed to extract campaign ID" the native resolver fixes). Wire it to the
native ingester, behind a DRY dispatch so callers never branch on platform.

- services/download_backends.py (new): the ONE place that knows which
  platforms are native vs gallery-dl. `uses_native_ingester(platform)` is
  the shared predicate; `verify_source_credential(...)` is the uniform
  probe (same (ok|None, message) contract for both backends). As a platform
  migrates, it moves into NATIVE_INGESTER_PLATFORMS here and BOTH download
  routing and verify switch together.
- PatreonClient.verify_auth(campaign_id): one authenticated /api/posts
  fetch → True (valid) / False (401/403/HTML-login) / None (drift or
  network — inconclusive, not a credential verdict).
- patreon_ingester.verify_patreon_credential(): resolve campaign id, then
  verify_auth — the verify counterpart to the download path.
- patreon_resolver.resolve_campaign_id_for_source(): extracted the
  override / id:-URL / vanity resolution into ONE helper now shared by the
  download ingester and verify (download_service no longer carries its own
  copy + regex; −`import re`).
- download_service: routes on uses_native_ingester() instead of inline
  `== "patreon"` (3 sites); uses the shared resolver.
- api/credentials: calls verify_source_credential — no platform branch.

Tests: verify_auth mapping, resolve_campaign_id_for_source (override/id:/
vanity/none), the dispatch predicate, verify_patreon_credential glue,
credentials endpoint proves Patreon uses the native path (gallery-dl verify
asserted not-called); repointed the gallery-dl verify test to subscribestar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:49:43 -04:00
bvandeusenandClaude Opus 4.8 ec43e823e1 feat(patreon): recovery UI + gallery-dl cutover — build step 5 (plan #697)
CI / frontend-build (push) Successful in 23s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 10s
CI / integration (push) Successful in 2m59s
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>
2026-06-05 22:18:03 -04:00
bvandeusenandClaude Opus 4.8 682beafbc5 feat(patreon): drift detection + error categorization — build step 4 (plan #697)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 28s
CI / integration (push) Successful in 2m58s
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>
2026-06-05 21:56:58 -04:00
bvandeusenandClaude Opus 4.8 96c30eba13 feat(patreon): phase-2 ingester integration — build step 3 (plan #697)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 31s
CI / integration (push) Successful in 2m59s
Branch download_service phase 2 by platform: Patreon now routes to the
native PatreonIngester (zero per-file HEADs, native cursor/resume, loud
drift detection) instead of gallery-dl; the other 5 platforms are
unchanged. The ingester returns a DownloadResult-shaped object so phase 1
(DB setup) and phase 3 (import → pHash → thumbs → ML) are untouched.

Three modes wired from config_overrides state:
  - tick: skip seen (tier-1 ledger + tier-2 disk), early-out after N
    contiguous already-have-it items.
  - backfill: full-history time-boxed chunk, cursor checkpoint via
    gallery-dl-style "Cursor: <token>" lines in stdout (reuses the #693
    lifecycle + parse_last_cursor verbatim).
  - recovery: backfill that BYPASSES the tier-1 seen-ledger so
    dropped-and-deleted near-dups get re-fetched and re-evaluated under
    the current pHash threshold. Rides the #693 state machine via a
    _backfill_bypass_seen flag, cleared on completion / stop.

The seen-ledger uses short-lived sync sessions (injected sessionmaker),
never held across the walk (avoids the connection-reaping trap). Campaign
id resolves from override, an id: URL, or a vanity lookup; unresolvable =
loud NOT_FOUND, never a silent empty success.

Tests: new test_patreon_ingester.py (modes, ledger skip/idempotency,
budget→PARTIAL, recovery bypass, tier-2 disk, drift). The patreon-oriented
download_service tests now drive the ingester branch via a stub; the
gallery-dl campaign-retry test is replaced by resolution/caching coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:38:42 -04:00
bvandeusenandClaude Opus 4.8 2ec7d86a3b feat(patreon): native media downloader — ingester build step 2b (plan #697)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / integration (push) Successful in 2m58s
CI / frontend-build (push) Successful in 27s
PatreonDownloader.download_post: writes resolved MediaItems to gallery-dl's
exact on-disk layout (<slug>/patreon/<date>_<id>_<title40>/<NN>_<file>) +
a sidecar the importer's find_sidecar/parse_sidecar consume unchanged. Two-tier
skip (injected seen predicate, then disk). Streamed GET (.part→rename) +
file_validator quarantine; Mux/m3u8 video shells out to yt-dlp with Patreon
Referer/Origin. Pure (no DB) — ledger + orchestration land in step 3. Unit
tests stub the session + yt-dlp seams (no network/subprocess).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:39:12 -04:00
bvandeusenandClaude Opus 4.8 6222928746 feat(patreon): seen-ledger table + model — ingester build step 2a (plan #697)
CI / integration (push) Successful in 2m57s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 28s
patreon_seen_media(source_id, filehash, post_id, seen_at), UNIQUE(source_id,
filehash) — our own queryable replacement for gallery-dl's archive.sqlite3.
Routine walks skip seen media; recovery mode bypasses the ledger. filehash is
a 32-hex CDN MD5 or a video:<post>:<media> sentinel (String(128)). alembic
0037 (← 0036). Integration test covers dedup + savepoint recovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:26:57 -04:00
bvandeusenandClaude Opus 4.8 1bdaa04aa2 test(patreon): fix self-contradictory attachment/postfile dedup assertion
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 29s
CI / integration (push) Successful in 2m57s
The fixture gives the attachment and post_file the same filehash, so they
correctly collapse to one item; the test asserted both survival and collapse.
Rewrite to verify the cross-kind dedup (postfile kind covered by video test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:17:56 -04:00
bvandeusenandClaude Opus 4.8 1c2dc7659a feat(patreon): native JSON-API client — ingester build step 1 (plan #697)
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 2m58s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Failing after 12s
PatreonClient: cookie-auth requests session, /api/posts cursor pagination,
JSON:API included flattening, per-post media extraction (images/image_large/
attachments/postfile/content) with filehash dedup, loud drift detection.
Zero per-file HEADs — every media URL+file_name comes from the API. Not yet
wired into download_service (later step). Pure-parsing unit tests + fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:16:20 -04:00
bvandeusenandClaude Opus 4.8 618dafde85 feat(subscriptions): smarter-backfill UI — Start/Stop + state badge
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m58s
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>
2026-06-05 15:06:30 -04:00
bvandeusenandClaude Opus 4.8 96fffaff64 feat(download): smarter backfill — time-boxed chunks, run-until-done (backend)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m58s
Plan #693. Large-catalog backfill (Anduo) no longer sprints to the timeout
wall and dies as an error each run. Builds on the cursor checkpoint (#689).

- Time-boxed chunks: BACKFILL_TIMEOUT_SECONDS(1170)→BACKFILL_CHUNK_SECONDS(600),
  far under the 1350 soft limit. Hitting it = normal chunk boundary (the
  TimeoutExpired path already captures partial output + the cursor), not a
  near-wall death.
- Run-until-done state machine driven by config_overrides[_backfill_state]
  (running/complete/stalled). A running backfill auto-continues in chunks
  across ticks until gallery-dl exits cleanly (rc=0 = reached the bottom →
  'complete'); a safety-cap (BACKFILL_MAX_CHUNKS=200) + the #689 stall-guard
  pause a pathological walk as 'stalled'. Replaces the N-runs counter
  (backfill_runs_remaining repurposed as the cap countdown).
- Progress, not error: a chunk that timed out but advanced (cursor moved
  and/or files written) is reclassified TIMEOUT→PARTIAL (status 'ok').
- Retry storm tamed: gallery-dl retries 3→2, downloader timeout 120→60s, so
  one stuck CDN file fails in ~1-2 min not ~10 (Anduo #40838).
- API: POST /sources/{id}/backfill now takes {action: start|stop}; service
  start_backfill/stop_backfill; new enabled sources auto-arm run-until-done;
  source dict exposes backfill_state + backfill_chunks.

Frontend (Start/Stop control + state badge) lands in the next push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:02:46 -04:00
bvandeusenandClaude Opus 4.8 add1c1ad14 fix(modal): mobile — no tag autofocus + sticky image over scrolling panel
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m58s
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>
2026-06-05 08:35:22 -04:00
bvandeusenandClaude Opus 4.8 593f65c9cc feat(download): cursor-paged Patreon backfill for large catalogs
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 25s
CI / integration (push) Successful in 2m57s
Large Patreon creators (Anduo: weekly 50-120-image Reports back months =
thousands of files) couldn't backfill: each run re-walked newest→oldest
from the top, and gallery-dl's polite ~0.75s/request HEAD walk alone
exceeded the 1170s subprocess budget, so the run died during enumeration
with 0 files written and NO forward progress — re-stranding every time
(event #40411).

Checkpoint gallery-dl's pagination cursor so each backfill window advances
the frontier:

- gallery_dl.py: SourceConfig.resume_cursor; _build_config_for_source sets
  extractor.patreon.cursor=<resume> (PLATFORM_DEFAULTS leave log-only True
  for a fresh run); parse_last_cursor() pulls the last emitted
  'Cursor: <token>' from stdout+stderr — survives a timed-out run since the
  TimeoutExpired path returns partial output.
- download_service.py: phase2 stays in BACKFILL mode while a cursor is
  pending (even after the run budget drains) and threads resume_cursor;
  _apply_backfill_lifecycle() checkpoints the advancing cursor each
  non-completing run, completes on a clean rc=0 finish (walk reached
  bottom), and a stuck-guard clears the cursor after 2 non-advancing runs
  so a wedged walk can't re-strand forever.

patreon-only (sole platform with a resumable cursor); other platforms keep
the simple counter semantics. Cursor state lives in config_overrides JSON
(patreon_campaign_id precedent) — no migration. Time-budget ladder
(1170/1350/1500) unchanged.

Plan #689.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:44:31 -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
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
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
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
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
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
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
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