Compare commits

..
Author SHA1 Message Date
bvandeusen adab33694d Merge pull request 'Make a digest mean something again, and give the refresh somewhere to stand' (#247) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 14s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 2m2s
2026-09-02 14:56:12 -04:00
bvandeusen 3e4d39b111 Merge pull request 'The install path a stranger reads, and a CI probe that never probed' (#246) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 14s
extension / lint (push) Successful in 17s
Build images / build-web (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 1m55s
2026-09-02 11:25:28 -04:00
bvandeusen b93a5fc5b4 Merge pull request 'Collapse alembic 0001..0089 into one baseline' (#245) from dev into main
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 5s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m40s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 7s
Build images / build-ml (push) Successful in 14s
2026-09-01 01:30:52 -04:00
bvandeusen 725bf15f88 Merge pull request 'AGPL-3.0, SECURITY and CONTRIBUTING; the install path pulls :latest, not :dev' (#244) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m43s
2026-08-31 17:01:20 -04:00
bvandeusen b14818303c Merge pull request 'Schema reconciliation + index hygiene, and the weekly base-image refresh' (#243) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 14s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 14s
extension / lint (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m54s
2026-08-31 08:34:55 -04:00
bvandeusen 499720d87e Merge pull request #242 from dev
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 10s
Build images / build-ml (push) Successful in 13s
Build images / build-web (push) Successful in 8s
extension / lint (push) Successful in 22s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 57s
CI / integration (push) Successful in 4m3s
ci: buildx driver, registry layer cache, and a force_build hatch (milestone 326 steps 1-3)
2026-08-29 22:48:47 -04:00
bvandeusen bd24f4e876 Merge pull request #241 from bvandeusen/dev
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 29s
CI / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 18s
Build images / build-web (push) Successful in 1m59s
CI / integration (push) Successful in 3m45s
Milestone 318 step 8 — extension CalVer, and the #3190 mitigation's first main build
2026-08-29 13:53:59 -04:00
bvandeusen 0e15c44c51 Merge pull request '#240 dev→main: milestone 318 steps 5–7 and 9, plus #3202' from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 49s
CI / extension-version (push) Successful in 3s
CI / integration (push) Successful in 3m57s
Build images / build-ml (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 7s
2026-08-29 00:14:33 -04:00
bvandeusen 6d98dfc0ec Merge pull request 'Stop minting identifiers nothing reads: moving channel tags, :c-<sha> on main, and a label-keyed build cache' (#239) from dev into main
Build images / build-agent (push) Successful in 9m23s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 34s
CI / frontend-build (push) Successful in 23s
Build images / build-ml (push) Successful in 3m1s
extension / lint (push) Successful in 21s
CI / integration (push) Successful in 4m11s
Build images / build-web (push) Successful in 3m21s
2026-08-28 15:14:35 -04:00
bvandeusen 6c76f08b69 Merge pull request 'Per-artifact commit-derived versions: pinnable date tags, and no rebuild when nothing changed' (#238) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 5s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 6s
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m42s
2026-08-28 10:14:15 -04:00
bvandeusen 7b1019ba82 Merge pull request 'Extension channels: dev and main each carry their own signed extension' (#237) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
extension / lint (push) Successful in 23s
CI / extension-version (push) Successful in 3s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 3m12s
CI / integration (push) Successful in 5m29s
Build images / build-agent (push) Successful in 9m54s
2026-08-27 12:49:39 -04:00
bvandeusen 0d204e6837 Merge pull request 'Extension packaging: one source of truth, shadow-mode versioning, real XPI verification' (#235) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 34s
extension / lint (push) Successful in 40s
CI / backend-lint-and-test (push) Successful in 2m1s
CI / integration (push) Successful in 4m3s
Build images / build-web (push) Successful in 4m21s
Build images / build-ml (push) Successful in 5m39s
Build images / build-agent (push) Successful in 12m49s
2026-08-03 20:23:27 -04:00
bvandeusen 8300029741 Merge pull request 'Extension: fix credential-push 405, guard the publish path, add a unit suite' (#234) from dev into main
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 32s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 1m14s
Build images / sign-extension (push) Successful in 2m23s
Build images / build-ml (push) Successful in 3m53s
CI / integration (push) Successful in 3m56s
Build images / build-web (push) Successful in 2m34s
Build images / build-agent (push) Successful in 13m33s
2026-08-03 08:29:09 -04:00
bvandeusen b5b437ca80 Merge pull request 'feat(agent): idle-unload GPU models to free VRAM when the queue is idle' (#233) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 34s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 25s
Build images / build-web (push) Successful in 3m20s
Build images / build-ml (push) Successful in 4m29s
CI / integration (push) Successful in 4m26s
Build images / build-agent (push) Successful in 11m55s
2026-07-17 13:07:15 -04:00
bvandeusen ce0dac3524 Merge pull request 'DRY + stability pass — extension, ML/settings backend, frontend cards (#161)' (#232) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 12s
extension / lint (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 50s
CI / integration (push) Successful in 3m47s
2026-07-13 23:08:18 -04:00
bvandeusen 9b5ec86222 Merge pull request 'feat(extension): in-app update prompt + v1.0.9 (#1489)' (#231) from dev into main
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
extension / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
Build images / sign-extension (push) Successful in 3m34s
Build images / build-web (push) Successful in 8s
CI / integration (push) Successful in 3m50s
2026-07-13 18:32:31 -04:00
bvandeusen 89c83ee5de Merge pull request 'fix(extension): Add-to-FC button on subscribed Patreon creators + v1.0.8 (#1485)' (#230) from dev into main
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
extension / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m46s
Build images / sign-extension (push) Successful in 5m8s
Build images / build-web (push) Successful in 10s
2026-07-13 17:56:32 -04:00
bvandeusen d3192f1843 Merge pull request 'chore(deps): web-ext 8→10 (Renovate batch 5) (#1450)' (#229) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
extension / lint (push) Successful in 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m48s
2026-07-13 16:36:26 -04:00
bvandeusen 5a5694f200 Merge pull request 'fix(ui): softer chrome-gradient falloff + segmented media toggle (#1478)' (#228) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m42s
2026-07-13 15:55:02 -04:00
bvandeusen bb1a938cc0 Merge pull request 'fix(ui): measure real nav height (--fc-nav-h) — Explore fits on 1080p, no breadcrumb tuck (#1481)' (#227) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m50s
2026-07-13 15:32:25 -04:00
bvandeusen fc0293029d Merge pull request 'fix(ui): Vuetify-4 review polish — active-tab indicator + one continuous chrome gradient' (#226) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m44s
2026-07-13 14:47:59 -04:00
bvandeusen 7d1c701b67 Merge pull request 'fix(ui): remove redundant app-shell top gap (#1480)' (#225) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 7s
2026-07-13 14:12:41 -04:00
bvandeusen b638382cd5 Merge pull request 'Ship: UI framework batch 4 — Vuetify 4 + vue-router 5 + pinia 3 (#1449)' (#224) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 28s
CI / frontend-build (push) Successful in 34s
CI / backend-lint-and-test (push) Successful in 53s
CI / integration (push) Successful in 3m46s
2026-07-13 13:40:25 -04:00
bvandeusen 17903068b4 Merge pull request 'Ship: system-tag refactor + soft WIP tier + Explore reach (#1464, #1474, #1476)' (#223) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-13 12:13:19 -04:00
bvandeusen 31d400ab0a Merge pull request 'Ship: title-based WIP auto-tagging (#1458)' (#222) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m50s
2026-07-12 19:45:03 -04:00
bvandeusen d45ce5e426 Merge pull request 'Ship: Renovate onboarding + dependency modernization batches 1–3' (#221) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
extension / lint (push) Successful in 16s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m4s
Build images / build-web (push) Successful in 1m24s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m46s
2026-07-11 23:16:33 -04:00
bvandeusen 77bfc32b02 Merge pull request 'feat(translation): tunable acceptance floor (0.90) + per-post sticky override (#155)' (#210) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 45s
CI / integration (push) Successful in 3m45s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 8s
2026-07-10 22:47:45 -04:00
bvandeusen d13efe1b69 Merge pull request 'refactor(tags): unify suggestion source — one canonical DB-tag dropdown (#154)' (#209) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m44s
2026-07-10 15:52:44 -04:00
bvandeusen ca6d26d236 Merge pull request 'feat(translation): acceptance gate on Interpreter detection confidence (#1376)' (#208) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m43s
2026-07-09 17:58:22 -04:00
bvandeusen 5366047d55 Merge pull request 'feat(translation): 8h sweep + drain button + detection probe (#1376)' (#207) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-08 20:19:33 -04:00
bvandeusen 2923257529 Merge pull request 'fix(ui): tag-chip left-edge clipping + kind-coloured border' (#206) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-08 10:37:09 -04:00
bvandeusen 934731e9ef Merge pull request 'feat(translation): per-field language detection for mixed-language posts' (#205) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
2026-07-08 09:25:23 -04:00
bvandeusen 4c67c116b2 Merge pull request 'Bolder auto-tag accept/reject + larger tag chips' (#204) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-07 23:21:26 -04:00
bvandeusen f8b667604f Merge pull request 'Graceful shutdown + resilience & Explore polish (m147)' (#203) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m48s
2026-07-07 22:53:34 -04:00
bvandeusen 11572f469a Merge pull request 'Re-translate on Interpreter model change — artist-scoped + global (m146)' (#202) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m41s
2026-07-07 20:22:40 -04:00
bvandeusen ba3fd2a118 Merge pull request 'Post translation via Interpreter (milestone 143)' (#201) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m44s
2026-07-07 13:05:18 -04:00
bvandeusen 9bf095179a Merge pull request 'Auto-tag accept/reject as an in-pill yes/no pair; confirm refocuses the tag input' (#200) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m44s
2026-07-07 01:13:02 -04:00
bvandeusen cefc064e0f Merge pull request 'Surface auto-hide misfires proactively (review strip no longer gated on Show-hidden)' (#199) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m42s
2026-07-07 00:43:45 -04:00
bvandeusen 06757daf80 Merge pull request 'Presentation-chrome auto-hide (milestone 141) + tagging-UX fixes' (#198) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m46s
2026-07-07 00:24:51 -04:00
bvandeusen a723ef436b Merge pull request 'Soft auto-apply (retract + confirm, no self-training) + tagging UX (reject-rest, tag-input race, modal playlist)' (#197) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m46s
2026-07-06 21:03:30 -04:00
bvandeusen d68f39ca50 Merge pull request 'Suggestions perf + UX polish: incremental CCIP + head training, hover label, scroll/toast fixes, dead-preview removal' (#196) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m43s
2026-07-06 16:41:08 -04:00
bvandeusen a66a695977 Merge pull request 'Tag-hover → crop-region grounding overlay (#1206, milestone #133)' (#195) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 17s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m51s
2026-07-06 13:29:31 -04:00
bvandeusen 5289fa3879 Merge pull request 'Crop-proposer detector config → Settings → Tagging (default-on, lease-announced) (#134)' (#194) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-05 20:10:02 -04:00
bvandeusen ebac34e17b Merge pull request 'Artist identity/storage/display decoupling — rename, move-source, add-time name resolution (#130)' (#193) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-04 22:43:12 -04:00
bvandeusen 4c3ba20198 Merge pull request 'Recapture image→post linking (#1288) + ugoira timings + system suggestion category' (#192) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m36s
2026-07-04 20:36:58 -04:00
bvandeusen d2acec61ae Merge pull request 'Pixiv native ingester (#129) + SubscribeStar .art fix, disable-clears-failure, system-tag suggestion floor' (#191) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m35s
2026-07-03 16:36:56 -04:00
bvandeusen 15ae5ef4fa Merge pull request 'SubscribeStar .art age fix, modal tag-flow sync, system tags / training hygiene (#128)' (#190) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m34s
2026-07-03 09:16:16 -04:00
bvandeusen e3ceefc820 Merge pull request 'Surgical re-fetch for deep posts (ExternalLink reset) — recovery no longer needs deep scans' (#189) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
Build images / build-agent (push) Successful in 14s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 38s
CI / backend-lint-and-test (push) Successful in 2m10s
CI / integration (push) Successful in 3m40s
2026-07-02 21:14:45 -04:00
bvandeusen e52090a4cf Merge pull request 'Agent sleep mode: empty queue sheds to one downloader, lease poll backs off to 15 min' (#188) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 42s
CI / integration (push) Successful in 3m50s
2026-07-02 20:37:48 -04:00
bvandeusen bfba8045e4 Merge pull request 'Admin IA overhaul, cap-aware autoscaler, optional ml-worker, hardened tag reset' (#187) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m29s
2026-07-02 18:01:56 -04:00
bvandeusen a708357436 Merge pull request 'Settings defaults, GPU error tombstones + failure triage/recovery, agent bandwidth cap, approved retirements' (#186) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m33s
2026-07-02 15:48:48 -04:00
bvandeusen d9354ac1e1 Merge pull request 'Agent: short-video sampling fix + "Retry errored jobs" recovery action' (#185) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-07-01 21:13:48 -04:00
bvandeusen 1d48770793 Merge pull request 'Agent: fix Status freeze — conchint.textContent destroyed #capn (root cause)' (#184) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 20:46:28 -04:00
bvandeusen 489e6aaaee Merge pull request 'Agent: server-side throughput rates + killable-on-stop ffmpeg' (#183) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 20:01:00 -04:00
bvandeusen ed20df905b Merge pull request 'Agent: DRY pass + Status rate-metrics + real start/stop state machine' (#182) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 16:30:32 -04:00
bvandeusen 5ba9871ef0 Merge pull request 'fix(agent): stream videos via ffmpeg-from-URL (no full download) — env-agnostic' (#181) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-07-01 14:20:53 -04:00
bvandeusen 2a820d0848 Merge pull request 'fix(agent): log the real fetch/submit failure reason (diagnose #1225)' (#180) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m28s
2026-07-01 12:37:45 -04:00
bvandeusen 2f9aa3d86c Merge pull request 'perf(web): 4 MiB file streaming + 4 hypercorn workers (fix 40s downloads)' (#179) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 29s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / integration (push) Successful in 3m26s
2026-07-01 11:55:35 -04:00
bvandeusen 560a5000a2 Merge pull request 'gallery: sort by earliest post date across all posts (new default)' (#178) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 10:54:41 -04:00
bvandeusen 7ddad231f8 Merge pull request 'agent: stop downloader pool stampeding a slow curator (congestion collapse)' (#177) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / integration (push) Successful in 3m22s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
2026-07-01 10:45:48 -04:00
bvandeusen a78f7eaace Merge pull request 'explore: more variance in the related rail (stronger MMR diversification)' (#176) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-07-01 00:50:59 -04:00
bvandeusen 71337b0ba4 Merge pull request 'agent: temporal video dedup — drop near-duplicate frames before the GPU' (#175) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 00:38:53 -04:00
bvandeusen 3996205f3b Merge pull request 'agent: dedupe near-duplicate crops before the SigLIP embed' (#174) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m26s
2026-07-01 00:24:39 -04:00
bvandeusen 9f9db01456 Merge pull request 'agent: download/GPU producer-consumer pipeline + detector fuse fix' (#173) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 23:39:25 -04:00
bvandeusen 216b7fc743 Merge pull request 'Agent: bound video GPU work (early-exit frame loop at max_regions)' (#172) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-06-30 22:57:33 -04:00
bvandeusen fbb76e6f36 Merge pull request 'Agent: huge-image load + figure/region caps + stale-active fix' (#171) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 22:47:48 -04:00
bvandeusen 5ef1478ade Merge pull request 'Modernise agent base → Ubuntu 24.04 / Python 3.12 / CUDA 12.9' (#170) from dev into main
Build images / build-ml (push) Failing after 3s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 8m44s
2026-06-30 22:30:18 -04:00
bvandeusen 88ff4147e1 Merge pull request 'Fix: agent py3.10 startup crash + submit-path retry + pin agent ruff to py310' (#169) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 22:03:57 -04:00
bvandeusen 1ea02ad44c Merge pull request 'Release: prompt agent stop + lazy curator polling + build marker + agent in CI' (#168) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m23s
2026-06-30 21:42:55 -04:00
bvandeusen 937cfb65b4 Merge pull request 'Release: agent per-stage timing breakdown' (#167) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 5s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m23s
2026-06-30 21:28:57 -04:00
bvandeusen baac851220 Merge pull request 'perf: GPU-job leasing stays O(batch) — partial indexes + two-phase lease' (#166) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
CI / integration (push) Successful in 3m27s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-30 21:18:23 -04:00
bvandeusen a28c33281a Merge pull request 'Release: unfreeze agent status view + smoothed throughput autoscaler + log pane' (#165) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:20:34 -04:00
bvandeusen 40be0a9323 Merge pull request 'Release: agent page no-store cache header' (#164) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Failing after 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:00:36 -04:00
bvandeusen 2c6bf26bfc Merge pull request 'Release: graceful Start/Stop (starting/stopping states) + cached GPU reads' (#163) from dev into main
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 3m24s
2026-06-30 19:38:50 -04:00
bvandeusen 3c1e76bd44 Merge pull request 'Release: stable util-band autoscaler + live GPU meters' (#162) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 5s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m44s
2026-06-30 19:16:29 -04:00
bvandeusen 831c5b1c10 Merge pull request 'Release: agent page — centered column + logs fill to bottom' (#161) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 19:10:50 -04:00
bvandeusen 76b6af4903 Merge pull request 'Release: agent perf (batched crop embeds) + UI (full-width, copy logs, quiet noise) + truncated-image fix' (#160) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m32s
2026-06-30 18:47:55 -04:00
bvandeusen 92494ec4ed Merge pull request 'Release: agent control-page logs console + styling pass' (#159) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 3m26s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-30 18:37:35 -04:00
bvandeusen bdfc17477c Merge pull request 'Release: GPU agent autoscaler (throughput hill-climb, Auto default-on)' (#158) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m24s
2026-06-30 18:23:15 -04:00
bvandeusen 6cd3153bf4 Merge pull request 'Release: SigLIP 2 default (new installs) + embedder model dropdown' (#157) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m26s
2026-06-30 18:01:01 -04:00
bvandeusen 5f2853168a Merge pull request 'Release: GPU re-process trigger (apply new crop detectors to the existing library)' (#156) from dev into main
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 3m35s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
2026-06-30 16:13:36 -04:00
bvandeusen 9a979ee808 Merge pull request 'Release: Explore diversification, model-swap, Camie/centroid retirement, tag-API, crop proposers' (#155) from dev into main
Build images / build-agent (push) Successful in 10m56s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m26s
2026-06-30 16:06:13 -04:00
bvandeusen 3138f912fd Merge pull request 'Release: SigLIP concept crops + max-over-bag + agent redeploy-survival' (#154) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m35s
Build images / build-agent (push) Successful in 7m50s
2026-06-30 08:46:20 -04:00
bvandeusen 9df874e396 Merge pull request 'CCIP automation + reference quality (auto-enqueue, auto-apply, contamination fix, cache)' (#152) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 28s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m20s
Build images / build-ml (push) Successful in 2m40s
Build images / build-agent (push) Successful in 3m17s
CI / integration (push) Successful in 3m27s
2026-06-29 22:29:30 -04:00
bvandeusen 6915a7590a Merge pull request 'fix(tags): refocus tag input after reject/un-reject too' (#151) from dev into main
CI / integration (push) Successful in 3m26s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-29 21:07:21 -04:00
bvandeusen 5e8c28236a Merge pull request 'CCIP match threshold (tunable, 0.85) + Explore → variance' (#150) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m29s
2026-06-29 20:48:14 -04:00
bvandeusen 7e3c0f0b74 Merge pull request 'agent: raise worker cap to 32 + size HTTP pool' (#149) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 1s
CI / backend-lint-and-test (push) Successful in 26s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m47s
Build images / build-agent (push) Successful in 3m11s
CI / integration (push) Successful in 3m31s
2026-06-29 19:42:11 -04:00
bvandeusen 5d0c7ba706 Merge pull request 'fix(agent): flatten transparency onto white before RGB' (#148) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-06-29 19:18:48 -04:00
bvandeusen 18300e1f8a Merge pull request 'Agent: GPU load + live worker tuning + fast orphan recovery' (#147) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m27s
2026-06-29 19:11:22 -04:00
bvandeusen d52ac0a0e2 Merge pull request 'fix(agent): cuDNN base image so onnxruntime-gpu loads' (#146) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m50s
Build images / build-agent (push) Successful in 3m29s
CI / integration (push) Successful in 3m28s
2026-06-29 18:48:24 -04:00
bvandeusen 401fe8213e Merge pull request 'ci: publish the GPU agent image' (#145) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-agent (push) Successful in 3m15s
CI / integration (push) Successful in 3m27s
2026-06-29 14:26:51 -04:00
bvandeusen e8774d7953 Merge pull request 'CCIP characters + crop/region pipeline + desktop GPU agent (#114)' (#144) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 2m8s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m26s
2026-06-29 14:18:57 -04:00
bvandeusen bc0f00c51b Merge pull request 'Earned auto-apply (fire + observability + UI), retrain cadences, Explore arrow-nav' (#143) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m30s
CI / integration (push) Successful in 3m24s
2026-06-29 07:30:41 -04:00
bvandeusen 1bef68aa29 Merge pull request 'Tagging-v2: heads are the suggestion source (learn-from-tags) + rail accept/reject' (#142) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m25s
2026-06-28 11:55:49 -04:00
bvandeusen 1a4bc2f981 Merge pull request 'tag-eval: "keep" records a confirmation so doubts stop resurfacing' (#141) from dev into main
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 2m30s
2026-06-28 01:36:26 -04:00
bvandeusen 862ace69d6 Merge pull request 'fix(tag-eval): stop re-suggesting already-rejected items' (#140) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m19s
2026-06-28 01:10:26 -04:00
bvandeusen abf88b1a15 Merge pull request 'tag-eval: auto-apply operating point + server-side top-N concept discovery' (#139) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m19s
2026-06-28 00:54:14 -04:00
bvandeusen c05dcafbea Merge pull request 'fix(tag-eval): example thumbnail opens the view modal instead of Explore' (#138) from dev into main
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-28 00:11:20 -04:00
bvandeusen 55e8632dab Merge pull request 'Tag-eval review actions: bigger clickable thumbnails + inline confirm/reject' (#137) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m18s
2026-06-27 23:49:12 -04:00
bvandeusen 825e6b90bf Merge pull request 'Tag-eval (heads vs centroid) + focus/provenance/layout/sticky-header fixes + tag-gaps cleanup' (#136) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m31s
CI / integration (push) Successful in 3m21s
2026-06-27 23:03:04 -04:00
bvandeusen fb012c557c Merge pull request 'fix(explore): bound 3-pane grid row so a tall rail can't scroll the page' (#135) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m20s
2026-06-26 23:37:43 -04:00
bvandeusen 66593ab895 Merge pull request 'Explore: focus-everywhere + provenance in rail; tag-gaps cleanup' (#134) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
2026-06-26 21:40:56 -04:00
bvandeusen b266a54ad3 Merge pull request 'feat(explore): auto-focus tag input on every image change' (#133) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m25s
2026-06-26 11:28:32 -04:00
bvandeusen ad803b646f Merge pull request 'fix(modal): place meta + save block under Provenance, above Tags' (#132) from dev into main
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 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
2026-06-26 10:14:15 -04:00
bvandeusen 1f5da3d283 Merge pull request 'Fandom count fix + Explore 3-pane workspace + modal rail layout' (#131) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m21s
2026-06-26 08:24:26 -04:00
bvandeusen 93034f580d Merge pull request 'fix(tags): fandom views aggregate images via their characters' (#130) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m34s
CI / integration (push) Successful in 3m19s
2026-06-26 00:28:21 -04:00
bvandeusen 9b9b12f410 Merge pull request 'Modal suggestions scroll-cap + Celery broker resilience' (#129) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Failing after 2m34s
CI / integration (push) Successful in 3m30s
2026-06-24 08:34:17 -04:00
bvandeusen 376d310693 Merge pull request 'Tagging & viewing roadmap: tag query surface + hygiene projections + Explore view (Clusters A/B/C)' (#128) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m39s
CI / integration (push) Successful in 3m24s
2026-06-23 08:57:34 -04:00
bvandeusen bc69495a16 Merge pull request 'Provenance archive linkage, post reconciliation close-out, and the cleanup/admin DRY pass' (#127) from dev into main
Build images / build-web (push) Successful in 2m2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m22s
2026-06-22 20:22:56 -04:00
bvandeusen 478f898e72 Merge pull request 'feat(settings): Maintenance tab compact tiles + centered Settings (pass 2)' (#126) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 3m3s
CI / integration (push) Successful in 3m26s
2026-06-18 00:11:54 -04:00
bvandeusen 38a5e7f332 Merge pull request 'feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)' (#125) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m19s
2026-06-17 23:49:21 -04:00
bvandeusen 57fe15c267 Merge pull request 'feat(maintenance): reconcile duplicate posts (gallery-dl→native unify)' (#124) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m22s
2026-06-17 22:01:12 -04:00
bvandeusen eb3231ef10 Merge pull request 'fix(subscribestar): port gallery-dl date extraction (wrapped dates) + parse canary' (#123) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m19s
2026-06-17 21:15:43 -04:00
bvandeusen e9af459c0d Merge pull request 'feat(subscribestar): port gallery-dl doc + audio attachment extraction' (#122) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m15s
2026-06-17 16:20:12 -04:00
bvandeusen 6f02806aec Merge pull request 'fix(subscribestar): port gallery-dl content + preview-skip extraction (body bug)' (#121) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m25s
2026-06-17 16:10:56 -04:00
bvandeusen a1d19bd96a Merge pull request 'fix(subscribestar): mirror gallery-dl's full request profile (verify_subscriber gate)' (#120) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
2026-06-17 15:32:28 -04:00
bvandeusen 26827ff38f Merge pull request 'fix(subscribestar): match gallery-dl's generic post delimiter (live-feed drift)' (#119) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m15s
2026-06-17 15:07:59 -04:00
bvandeusen 26dcfaf6c2 Merge pull request 'fix(subscribestar): initial feed GET is a navigation, not XHR (first-run drift)' (#118) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m14s
2026-06-17 14:30:02 -04:00
bvandeusen 9b1b0369cc Merge pull request 'SubscribeStar → native core ingester + native-ingest DRY pass (milestone #71)' (#117) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-17 12:48:29 -04:00
bvandeusen 18123fb9cb Merge pull request 'fix(external): recovery-sweep threshold + queue recording + split fetch timeouts (#883)' (#116) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m14s
2026-06-16 21:24:31 -04:00
bvandeusen 2e806f202f Merge pull request 'test(artist-dir): fix flaky uq_image_record_sha256 collision' (#115) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m17s
2026-06-16 20:34:21 -04:00
bvandeusen 18d5c05639 Merge pull request 'fix(ml): per-task async engine for recompute_centroid (#881)' (#114) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Failing after 3m19s
2026-06-16 20:24:37 -04:00
bvandeusen 11ddfc3876 Merge pull request 'fix(maint): resurface dedup/gated-purge results after navigate-away (#877)' (#113) from dev into main
Build images / sign-extension (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
Build images / build-web (push) Successful in 15s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-16 16:48:52 -04:00
bvandeusen 2b8ce86622 Merge pull request 'Gated Patreon posts: skip on ingest + cleanup tool (#874)' (#112) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m27s
2026-06-16 15:25:34 -04:00
bvandeusen 49bee77cdc Merge pull request 'Video tag quality: cadence sampling + min-frame aggregation + ML thread cap (#747)' (#111) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m15s
2026-06-16 14:08:40 -04:00
bvandeusen c209e3b37e Merge pull request 'Tier-1 video dedup: import-time + retroactive cleanup (#871)' (#110) from dev into main
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 26s
2026-06-16 08:55:38 -04:00
bvandeusen cffdd93418 Merge pull request 'Nested-archive extraction (#718) + post-first ingest (#67) + post-body canary (#862)' (#109) from dev into main
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m19s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
2026-06-15 21:37:39 -04:00
bvandeusen fd84be40dd Merge pull request 'External-attach orphan fix (#859) + image-less post display' (#108) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 40s
Build images / build-web (push) Successful in 10s
CI / integration (push) Successful in 3m19s
2026-06-15 01:55:36 -04:00
bvandeusen 79f510d7f8 Merge pull request 'Merge dev → main: read post body from content_json_string (the empty-body fix) (#842)' (#107) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m24s
Build images / sign-extension (push) Successful in 3s
2026-06-15 00:19:45 -04:00
bvandeusen 59181069da Merge pull request 'Merge dev → main: per-post stdout diagnostics + post-field write DRY (#842/#753)' (#106) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m15s
Build images / build-ml (push) Successful in 2m55s
CI / integration (push) Successful in 3m11s
2026-06-14 23:45:39 -04:00
bvandeusen 428ecd8642 Merge pull request 'Merge dev → main: per-post body-capture diagnostics in the event UI (#842)' (#105) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / integration (push) Successful in 3m11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 37s
2026-06-14 23:14:14 -04:00
bvandeusen ed1e04b831 Merge pull request 'Merge dev → main: recapture body-fetch fix + diagnostics (#842)' (#104) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m15s
2026-06-14 22:31:41 -04:00
bvandeusen f5156bd847 Merge pull request 'Merge dev → main: Recapture mode (#842) — re-grab post bodies/links + localize on-disk inline images' (#103) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 2m30s
CI / integration (push) Successful in 3m29s
Build images / build-ml (push) Successful in 3m27s
2026-06-14 21:21:49 -04:00
bvandeusen dfc3922d24 Merge pull request 'Merge dev → main: #830 rich post capture + external-host downloads (+ #768/#789/#739)' (#102) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 48s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m47s
CI / integration (push) Successful in 3m20s
2026-06-14 19:16:09 -04:00
bvandeusen 3eb08e926b Merge pull request 'fix(aliases): modal raw-key bug + alias visibility/management' (#101) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-12 14:02:00 -04:00
bvandeusen 9e81ced359 Merge pull request 'fix(images): percent-encode original-image URLs ('#' in paths 404'd)' (#100) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m15s
2026-06-12 00:44:56 -04:00
bvandeusen 11e9f5af60 Merge pull request 'fix(browse): tabs and search on one row' (#99) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 11s
CI / integration (push) Successful in 3m13s
2026-06-12 00:28:38 -04:00
bvandeusen 909fa37b15 Merge pull request 'Browse search + series numbering rework + kebab fix' (#98) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m50s
CI / integration (push) Successful in 3m16s
2026-06-12 00:14:31 -04:00
bvandeusen dfab8f65ff Merge pull request 'feat(series): flat sequence + cosmetic dividers + pending staging (#789)' (#97) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m13s
Build images / build-ml (push) Successful in 2m49s
CI / integration (push) Successful in 3m9s
2026-06-11 22:07:57 -04:00
bvandeusen 618f7cdc36 Merge pull request 'feat(ml): drop image_record.tagger_predictions — image_prediction is sole store (#768 step 3)' (#96) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m29s
Build images / build-ml (push) Successful in 3m0s
CI / integration (push) Successful in 3m13s
2026-06-11 19:32:05 -04:00
bvandeusen 028ea33a7c Merge pull request 'fix(migration): make 0045 DDL-only; backfill image_prediction via batched task (#768)' (#95) from dev into main
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m58s
CI / integration (push) Successful in 3m15s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 2m50s
2026-06-11 09:22:22 -04:00
bvandeusen 444c1fb075 Merge pull request 'perf(migration): 0045 streams json_each (no materialize / no temp blowup)' (#94) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m14s
Build images / build-ml (push) Successful in 2m36s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m8s
2026-06-10 22:07:16 -04:00
bvandeusen 26c68b0a75 Merge pull request 'fix(migration): 0045 guards json_each against scalar tagger_predictions' (#93) from dev into main
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m13s
Build images / sign-extension (push) Successful in 2s
Build images / build-web (push) Successful in 6s
Build images / build-ml (push) Successful in 2m40s
2026-06-10 20:33:27 -04:00
bvandeusen e75427b19a Merge pull request '#768 steps 1+2: normalized image_prediction table (read cutover)' (#92) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m8s
2026-06-10 20:15:26 -04:00
bvandeusen 5447fab987 Merge pull request 'Activity search + RecoverySweep fix + tagger_predictions shrink (#762, #764) + backup polish' (#91) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m23s
Build images / build-ml (push) Successful in 2m51s
CI / integration (push) Successful in 3m10s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
2026-06-10 14:33:35 -04:00
bvandeusen bad37e07b2 Merge pull request 'Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps)' (#90) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 44s
Build images / build-ml (push) Successful in 3m2s
CI / integration (push) Successful in 3m15s
Build images / build-web (push) Successful in 2m17s
2026-06-10 00:24:01 -04:00
bvandeusen 2bfc9936a1 Merge pull request 'UI batch: tagging flow, series browse, fandom chips, nav' (#89) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 39s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 26s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m52s
CI / integration (push) Successful in 3m24s
2026-06-09 20:48:19 -04:00
bvandeusen 4c6406ee18 Merge pull request 'fix(posts): link duplicate items to every post + prune bare shells' (#88) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m41s
CI / integration (push) Successful in 3m5s
2026-06-08 19:42:31 -04:00
bvandeusen bb47e80b3e Merge pull request 'fix(cleanup): unused-tags delete must use the same predicate as the preview' (#87) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-08 18:17:49 -04:00
bvandeusen dc1083b5e0 Merge pull request 'Unused-tag fandom fix + ML-worker logging/tuning + unified dropdown Enter' (#86) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 2m21s
2026-06-08 17:27:55 -04:00
bvandeusen e46893fefd Merge pull request 'Migration lock safety + remove the merge's full-table scan (the real 0040-hang fix)' (#85) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m7s
2026-06-08 01:03:06 -04:00
bvandeusen 0666e15211 Merge pull request 'Series manage redesign (FC-6.4) + migration/normalize hardening + UX fixes' (#84) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 1m58s
Build images / build-ml (push) Successful in 2m28s
CI / integration (push) Successful in 3m6s
2026-06-07 21:17:57 -04:00
bvandeusen 747390631d Merge pull request 'FC-6 series authoring + backup/NFS hardening + UX fixes' (#83) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m43s
CI / integration (push) Successful in 3m6s
2026-06-07 19:31:04 -04:00
bvandeusen e0d2a20588 Merge pull request 'Modal focus/keyboard polish, Camie-in-autocomplete, re-extract self-resume' (#82) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m32s
CI / integration (push) Successful in 3m9s
2026-06-07 12:17:43 -04:00
bvandeusen 1d84f67418 Merge pull request 'Modal: large centered spinner + kebab z-index fix' (#81) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m35s
CI / integration (push) Successful in 3m7s
2026-06-07 10:57:00 -04:00
bvandeusen 91265df3d6 Merge pull request 'Maintenance-queue health + modal/tagging keyboard pass' (#80) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m26s
CI / integration (push) Successful in 3m1s
2026-06-07 10:31:01 -04:00
bvandeusen 11acdb0322 Merge pull request 'Patreon: a missing media file_name is a fallback, not API drift' (#79) from dev into main
CI / lint (push) Successful in 2s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 23:14:25 -04:00
bvandeusen 2eb9fd5dd0 Merge pull request 'Patreon: enforce the backfill time-box mid-post (stop soft-limit overruns)' (#78) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m3s
2026-06-06 23:00:29 -04:00
bvandeusen 01e5ce1410 Merge pull request 'Patreon: resolve creator campaign from a single-post URL' (#77) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
2026-06-06 21:38:33 -04:00
bvandeusen 3bb94674cf Merge pull request 'Patreon download concurrency cap + immediate backfill kickoff on create' (#76) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 21:27:22 -04:00
bvandeusen a75c602175 Merge pull request 'Subscriptions UX overhaul + Patreon /cw/ vanity fix' (#75) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m2s
2026-06-06 21:12:02 -04:00
bvandeusen ef8f4f7193 Merge pull request 'Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip' (#74) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 19:59:32 -04:00
bvandeusen ec3d27b219 Merge pull request 'Tag-maintenance sweep + bug-fix batch: #699 #700 #701 #709 #711 #712 #713 #714' (#73) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 16:49:00 -04:00
bvandeusen 03bd3b2eda Merge pull request 'Native Patreon ingester + download-engine ownership (plans #697, #703–#708)' (#72) from dev into main
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 32s
CI / integration (push) Successful in 3m1s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
2026-06-06 12:57:31 -04:00
bvandeusen 7395e77d75 Merge pull request 'Smarter backfill: time-boxed chunks, run-until-done (plan #693)' (#71) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m58s
2026-06-05 16:33:06 -04:00
bvandeusen 575d817919 Merge pull request '#70 dev→main: cursor-paged backfill + mobile modal fixes' from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m59s
2026-06-05 11:10:02 -04:00
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusen 83f8af8090 Merge pull request 'dev→main: surface near-duplicate (pHash) control + reorder import tab' (#68) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 2m56s
2026-06-04 21:39:44 -04:00
bvandeusen 9a2617c1a2 Merge pull request 'dev→main: post-card redesign (images→modal, in-place text expand)' (#67) from dev into main
CI / lint (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m57s
2026-06-04 17:32:50 -04:00
bvandeusen 81688815a0 Merge pull request 'dev→main: similar-search render fix + reset-content-tagging + scan persistence' (#66) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
2026-06-04 16:59:52 -04:00
bvandeusen 773128c3bf Merge pull request 'dev→main: purpose-built mobile layout for subscriptions hub' (#65) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 38s
CI / integration (push) Successful in 2m56s
2026-06-04 13:43:56 -04:00
bvandeusen ce7b154ae9 Merge pull request 'dev→main: subscriptions table mobile card layout' (#64) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
2026-06-04 12:56:58 -04:00
bvandeusen 9430a9d9c3 Merge pull request 'dev→main: gallery similarity search (Phase 3) + UI/mobile polish' (#63) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
2026-06-04 11:21:26 -04:00
bvandeusen 23aee56ce3 Merge pull request 'dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf' (#62) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m0s
2026-06-04 08:27:47 -04:00
bvandeusen 711abea567 Merge pull request 'Gallery speed + fandom editing + filters + pinned filter bar' (#61) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s
2026-06-04 00:07:19 -04:00
bvandeusen 844bb86802 Merge pull request 'fix(download): release DB connections across the gallery-dl subprocess' (#60) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m24s
2026-06-03 22:11:36 -04:00
bvandeusen a8f6a464aa Merge pull request 'fix(download): salvage soft-time-limit kills + fix timeout ladder' (#59) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 21s
CI / frontend-build (push) Successful in 24s
Build images / build-web (push) Successful in 2m37s
Build images / build-ml (push) Successful in 3m21s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m15s
2026-06-03 19:35:45 -04:00
bvandeusen ab9922ad2e Merge pull request 'feat(artist): "new since last visit" badge + banner' (#58) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
Build images / build-ml (push) Successful in 2m53s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 2m9s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m9s
2026-06-03 16:20:54 -04:00
bvandeusen 0533807669 Merge pull request 'feat(ext): verify cookies in-browser before uploading (1.0.7)' (#57) from dev into main
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 3m28s
Build images / build-web (push) Successful in 3m42s
CI / intapi (push) Successful in 9m32s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 14s
Build images / sign-extension (push) Successful in 3m17s
CI / intimp (push) Successful in 4m26s
CI / lint (push) Successful in 3s
CI / intcore (push) Successful in 10m18s
2026-06-03 14:17:42 -04:00
bvandeusen 279dff3fb6 Merge pull request 'feat(ml): normalize Camie suggestion names to human-readable' (#56) from dev into main
Build images / build-ml (push) Successful in 2m55s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m24s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 2m17s
2026-06-03 13:18:44 -04:00
bvandeusen 37e66cddc4 Merge pull request 'chore(modal): drop ?image=N soft-compat — pure overlay' (#55) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m46s
Build images / build-web (push) Successful in 2m36s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m12s
2026-06-02 19:35:04 -04:00
bvandeusen 9cf6b2d363 Merge pull request 'audit-g5 final + ML threshold default + kebab menu fix' (#54) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 27s
Build images / build-web (push) Successful in 3m0s
Build images / build-ml (push) Successful in 3m45s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 8m8s
CI / intcore (push) Successful in 8m34s
2026-06-02 19:09:49 -04:00
bvandeusen 6ef0fed41f Merge pull request 'audit-g5: architectural debt — 4 bundles (A/B/C/D)' (#53) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 18s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m51s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m52s
2026-06-02 18:07:25 -04:00
bvandeusen 89b48f8f35 Merge pull request 'audit-g4: status-enum miss batch' (#52) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 35s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m10s
2026-06-02 16:15:00 -04:00
bvandeusen d60e0b9494 Merge pull request 'audit-g3: lifecycle batch — recovery sweeps, retention, timeouts' (#51) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 36s
CI / intimp (push) Successful in 3m39s
CI / intapi (push) Successful in 7m39s
CI / intcore (push) Successful in 8m10s
2026-06-02 14:49:28 -04:00
bvandeusen 9c27a2d3c7 Merge pull request 'audit-g2: async race / state-leak fixes across eight stores' (#50) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s
2026-06-02 14:17:12 -04:00
bvandeusen 93e37681b7 Merge pull request 'audit-g1: six one-liner drift fixes from 2026-06-02 audit' (#49) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 10s
CI / intapi (push) Successful in 7m28s
CI / intcore (push) Successful in 8m3s
CI / intimp (push) Successful in 3m42s
2026-06-02 13:29:17 -04:00
bvandeusen 64ca858574 Merge pull request 'UX fixes: suggestion-accept chip refresh, showcase endless feed, non-media downloads' (#48) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m20s
2026-06-02 08:26:58 -04:00
bvandeusen 9d0c0b7da8 Merge pull request 'fix(thumbnails): surface backfill counts + tighten validity check' (#47) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 18s
CI / intimp (push) Successful in 3m45s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m4s
2026-06-01 22:34:38 -04:00
bvandeusen 8e4d252ae4 Merge pull request 'fix(downloads): enqueue thumbnail + ML per attached image' (#46) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / intimp (push) Successful in 3m40s
CI / lint (push) Successful in 3s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m46s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 26s
2026-06-01 21:52:42 -04:00
bvandeusen fdd3e01f56 Merge pull request 'ux(failing-sources): visible row separators + clearer hover' (#45) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 20s
CI / frontend-build (push) Successful in 33s
CI / intimp (push) Successful in 3m44s
CI / intapi (push) Successful in 7m48s
CI / intcore (push) Successful in 8m16s
2026-06-01 21:09:28 -04:00
bvandeusen c82fb308b6 Merge pull request 'Post.source_id refactor + tick/backfill modes + PARTIAL classifier + Mux fix + UX' (#44) from dev into main
Build images / build-ml (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / intimp (push) Successful in 3m47s
CI / intapi (push) Successful in 7m52s
CI / intcore (push) Successful in 8m16s
Build images / build-web (push) Successful in 12s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 31s
2026-06-01 20:44:23 -04:00
bvandeusen 8cf8d2ca4d Merge pull request 'Modal Esc/overflow polish, artist-scoped post scroll, failing-sources Logs button' (#43) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 2m45s
Build images / build-ml (push) Successful in 3m31s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m47s
2026-06-01 12:10:11 -04:00
bvandeusen b1d58bc3b8 Merge pull request 'fix(ci): POSIX-safe SHORT_SHA in build.yml' (#42) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 3m24s
CI / intimp (push) Successful in 3m22s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m5s
2026-06-01 08:03:17 -04:00
bvandeusen 65386f02a0 Merge pull request 'View modal batch: autofocus, suggestions UX, post-title click, retire copyright/artist' (#41) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 4s
Build images / build-web (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m17s
CI / intcore (push) Successful in 8m2s
2026-06-01 07:01:42 -04:00
bvandeusen 667b05f14e Merge pull request 'Extension probe-and-add (v1.0.6) + per-commit image tags' (#40) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 22s
extension / lint (push) Successful in 8s
Build images / sign-extension (push) Successful in 1m51s
Build images / build-web (push) Failing after 4s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 7m45s
2026-06-01 01:44:03 -04:00
bvandeusen 856e9104b4 Merge pull request 'Sidecar synthetic anchor cleanup + tier-gated classifier fix' (#39) from dev into main
CI / lint (push) Successful in 5s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m54s
Build images / sign-extension (push) Has been skipped
Build images / build-web (push) Successful in 1m1s
Build images / build-ml (push) Successful in 1m21s
CI / intapi (push) Successful in 7m31s
CI / intcore (push) Successful in 8m0s
2026-06-01 00:16:58 -04:00
bvandeusen 0397642b21 Merge pull request 'Showcase cadence tuning + cooldown-aware bulk retry' (#38) from dev into main 2026-05-30 23:50:36 -04:00
bvandeusen 237575447d Merge pull request 'Thumbnail URL fix + archive daemon fix + batched initial loads' (#37) from dev into main 2026-05-30 22:01:43 -04:00
bvandeusen ed358757dc Merge pull request 'Most-overdue-first scheduling + rich timeout diagnostics' (#36) from dev into main 2026-05-30 14:30:41 -04:00
bvandeusen d181f4afb8 Merge pull request 'Downloads burst-prevention + maintenance-menu fix + gdl timeout' (#35) from dev into main 2026-05-30 11:43:18 -04:00
bvandeusen 2886fa4997 Merge pull request 'Tooltip !important fix — 104cac5 follow-up after Vite CSS reorder' (#34) from dev into main 2026-05-30 00:02:24 -04:00
bvandeusen f256f587ee Merge pull request 'UI batch + I1–I6 service passes + download-event recovery sweep' (#33) from dev into main 2026-05-29 22:46:16 -04:00
bvandeusen 384d8d5e50 Merge pull request 'Dashboard insights + project-wide DRY pass' (#32) from dev into main 2026-05-28 15:38:26 -04:00
bvandeusen 319e8c1d18 Merge pull request 'v26.05.28.0: downloads dashboard + task-resilience overhaul (timeouts, archive split, 3-layer poison-pill defense)' (#31) from dev into main 2026-05-28 00:45:00 -04:00
bvandeusen 9075d8eadd Merge pull request 'v26.05.27.2: subscribestar + HF cookie quirks, platforms package refactor, showcase IR-parity, secure-context audit' (#30) from dev into main 2026-05-27 21:34:02 -04:00
bvandeusen 88e53e5b86 Merge pull request 'v26.05.27.1: subscriptions hub + post-card merge + sidecar audit' (#29) from dev into main 2026-05-27 17:12:48 -04:00
bvandeusen 37e8b796a1 Merge pull request 'v26.05.27.0: PostCard redesign + IR-style tag suffix + drop meta/rating + extension v1.0.4 CSP fix' (#28) from dev into main 2026-05-27 11:31:18 -04:00
bvandeusen 4e82208926 Merge pull request 'v26.05.26.5 — extension CORS unblock + UI gap closes + CI workflow cleanup' (#27) from dev into main 2026-05-26 20:15:07 -04:00
bvandeusen 52fff00353 Merge pull request 'v26.05.26.4 — hotfix: migration 0022 pre-DELETE colliding ImageProvenance before UPDATE' (#26) from dev into main 2026-05-26 18:06:20 -04:00
bvandeusen c14338cbce Merge pull request 'v26.05.26.3 — hotfix: migration 0022 pre-merge across ENTIRE (canonical+others) group' (#25) from dev into main 2026-05-26 17:52:59 -04:00
bvandeusen 8c36dd28b0 Merge pull request 'v26.05.26.2 — hotfix: alembic 0022 Post-collision pre-merge + ci.yml cache continue-on-error' (#24) from dev into main 2026-05-26 16:50:43 -04:00
bvandeusen 88cfb3dd02 Merge pull request 'v26.05.26.1 — thumb backfill, modal redesign, recovery sweep race-safety, artist view redesign, extension fixes' (#23) from dev into main 2026-05-26 16:32:00 -04:00
bvandeusen 5d4f223b71 Merge pull request 'Release v26.05.25.7 — FC-Cleanup tab + UniqueViolation fix + error modal + extension install fix' (#22) from dev into main 2026-05-26 08:26:46 -04:00
bvandeusen 05090c6e85 Merge pull request 'Release v26.05.25.7 — animated-WebP worker fix + FC-Cleanup backend' (#21) from dev into main 2026-05-26 01:48:13 -04:00
bvandeusen 3a577d5ade Merge pull request 'fix(ext-ci): use browser_download_url + curl -f + ZIP magic check (XPI silently corrupt)' (#20) from dev into main 2026-05-26 00:43:02 -04:00
bvandeusen f4fe02e346 Merge pull request 'fix(ext-ci): drop actions/upload-artifact (Forgejo doesn't support v4+ GHES)' (#19) from dev into main 2026-05-25 23:33:40 -04:00
bvandeusen e766197d99 Merge pull request 'fix(ext-ci): jq→python + bump ext to 1.0.3 + rollback-on-upload-failure' (#18) from dev into main 2026-05-25 23:14:51 -04:00
bvandeusen 3872e1dda9 Merge pull request 'fix(ext-ci): web-ext v8 .cjs config workaround' (#17) from dev into main 2026-05-25 22:49:14 -04:00
bvandeusen 9814f3dbaf Merge pull request 'Release v26.05.25.5 — Extension publish refactor, deep-scan IR-parity, archive-import perf, artist Settings tab' (#16) from dev into main 2026-05-25 22:44:59 -04:00
bvandeusen b214460fdb Merge pull request 'Release v26.05.25.4 — importer ext sanitize fix, CI shard split, BrowserExtensionCard on Overview' (#15) from dev into main 2026-05-25 21:11:50 -04:00
bvandeusen ac55d0e8d8 Merge pull request 'fix(ext-ci): match AMO-renamed signed XPI' (#14) from dev into main 2026-05-25 18:22:50 -04:00
bvandeusen 89a89e0ded Merge pull request 'Release v26.05.25.3 — ML embedder SigLIP fix, import-UX, extension publish' (#13) from dev into main 2026-05-25 17:56:50 -04:00
bvandeusen 4e9aac2c05 Merge pull request 'v26.05.25.2: supersede + sidecar enrichment, scan toast feedback, CI uv + pip cache + durations' (#12) from dev into main 2026-05-25 14:30:25 -04:00
bvandeusen 2879ac6f2b Merge pull request 'v26.05.25.1: maintenance sweep + Camie v2 + corrupt-file handling + post-date gallery + clear-stuck escape hatch' (#11) from dev into main 2026-05-25 12:57:46 -04:00
bvandeusen b8dce6c483 Merge pull request 'FC-3h + FC-3k: backup first-class + admin destructive actions' (#10) from dev into main 2026-05-25 01:41:53 -04:00
bvandeusen d1c0b82a22 Merge pull request 'v26.05.24.3: FC-3i System Activity dashboard + migration backup-gate retired + modal Escape' (#9) from dev into main 2026-05-24 21:47:53 -04:00
bvandeusen 5526b8dc78 Merge pull request 'v26.05.24.2: IR Post/Provenance restore + modal artist fallback' (#8) from dev into main 2026-05-24 14:30:06 -04:00
bvandeusen 16eb7075c4 Merge pull request 'v26.05.24.1: FC-3g Firefox extension + worker resilience + UI/migration fixes' (#7) from dev into main 2026-05-24 12:52:31 -04:00
bvandeusen 885dcf64f3 Merge pull request 'v26.05.24.0: TopNav re-fix (flex 1 1 0 side cells)' (#6) from dev into main 2026-05-23 22:49:29 -04:00
bvandeusen f2f6b6d25e Merge pull request 'v26.05.23.3: dogfood UX polish + accurate active-batch stats' (#5) from dev into main 2026-05-23 22:05:59 -04:00
bvandeusen 0822240fde Merge pull request 'v26.05.23.2: serve /images + artist cleanup migrator' (#4) from dev into main 2026-05-23 12:19:16 -04:00
bvandeusen 27f7f3fd01 Merge pull request 'v26.05.23.1: migration durability + dogfood UX' (#3) from dev into main 2026-05-23 11:21:33 -04:00
bvandeusen c5bf564f53 Merge dev: v26.05.23.0 migration follow-ups (#2)
pg_dump + zstd in runtime image, lift Quart body cap to 1 GiB. See PR #2.
2026-05-22 22:37:06 -04:00
bvandeusen 602c7d275d Merge dev: FC-1 → FC-5 v1 build (#1)
First merge of `dev` into `main` for FabledCurator. Brings FC-1 (Foundation) through FC-5 (Migration tooling) onto `main`. See PR #1 body for the full stage rollup.
2026-05-22 14:15:45 -04:00
316 changed files with 4620 additions and 34644 deletions
+1 -29
View File
@@ -34,34 +34,6 @@ DB_PASSWORD=
SECRET_KEY=
# ---------------------------------------------------------------------------
# FIRST BOOT ONLY — then delete this line
# ---------------------------------------------------------------------------
# FabledCurator encrypts your stored platform credentials with a Fernet key it
# keeps at /images/secrets/credential_key.b64 — inside the ./images bind mount,
# so it outlives the container. On a brand-new install that file does not exist
# yet, and the app REFUSES TO START rather than quietly create one:
#
# MissingCredentialKey: Fernet key file not found at
# /images/secrets/credential_key.b64
#
# That refusal is deliberate. Auto-creating a key is indistinguishable from the
# disaster case — a restore that brought the database back but lost
# ./images/secrets — and there it would mint a key that cannot decrypt anything,
# leaving an instance that looks healthy while every paywalled download fails.
# So the choice is yours to make explicitly, once.
#
# Set this for your first `up`, watch the container come up, then DELETE THE
# LINE. Leaving it set disarms the protection permanently, on an instance that
# by then has credentials worth protecting.
#
# BACK UP ./images/secrets/ ALONGSIDE YOUR DATABASE. The key is the only thing
# that can read your stored credentials; a database restored without it needs
# every credential re-entered by hand.
CURATOR_BOOTSTRAP_NEW_KEY=1
# ---------------------------------------------------------------------------
# Optional — defaults are fine
# ---------------------------------------------------------------------------
@@ -93,7 +65,7 @@ DB_NAME=fabledcurator
#
# FabledCurator has no login, no accounts and no permission model. Anything
# that can reach PORT is an administrator and can read the platform session
# cookies the app stores for Patreon and SubscribeStar.
# cookies the app stores for Patreon, SubscribeStar and Pixiv.
#
# Bind it to a trusted network. See "Before you expose it" in README.md and
# the deployment posture section of SECURITY.md.
+6 -7
View File
@@ -79,7 +79,7 @@ jobs:
- name: Resolve the Postgres service and install deps
run: |
set -eux
# Same service-IP dance as build.yml's integration job; see the long
# Same service-IP dance as ci.yml's integration job; see the long
# comment there for why the job name must stay separator-free.
PG=$(docker ps --filter "name=compare" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
test -n "$PG"
@@ -89,7 +89,7 @@ jobs:
echo "DB_HOST=$PG_IP" >> "$GITHUB_ENV"
# Socket probe in python, not bash's /dev/tcp — these steps run under
# `sh -e`, where that path does not exist. Same fix and same reasoning
# as build.yml's integration job; see the comment there.
# as ci.yml's integration job; see the comment there.
pg_ready=""
for i in $(seq 1 60); do
if python -c "import socket,sys; s=socket.socket(); s.settimeout(2); sys.exit(0 if s.connect_ex(('$PG_IP', 5432)) == 0 else 1)"; then
@@ -172,11 +172,10 @@ jobs:
mkdir -p /tmp/versions_held
mv alembic/versions/*.py /tmp/versions_held/ 2>/dev/null || true
DB_NAME=fc_gen alembic revision --autogenerate -m "baseline" || true
# Printed rather than uploaded: the repo dropped actions/upload-artifact
# in 2026-05, when the runner could not run v4+, and the job log is the
# retrieval channel this job has proven. (gitea/runner 3.x runs stock
# upload-artifact now — Scribe snippet #2271 — so an artifact is an
# option if the log ever stops being enough.)
# Printed rather than uploaded: ci-requirements.md records that this
# runner cannot do actions/upload-artifact@v4+, and the repo dropped
# the action entirely in 2026-05, so the job log is the retrieval
# channel actually proven here.
#
# base64, not the raw file. A plain `cat` of the ~33KB candidate was
# TRUNCATED MID-LINE by the runner on run 4964 — it stopped inside
+704 -1322
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
name: CI
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
# - extension-version: the derived version resolves and is a shape AMO takes.
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
# - frontend-build: vitest unit + vite build.
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
on:
push:
branches: [dev, main]
# Renovate opens PRs from `renovate/*` branches into `dev`. Those branches
# never push to dev/main, so the push trigger above gives them NO pre-merge
# CI — a bump could only be validated after it was already merged. This
# pull_request trigger (base `dev` only) validates Renovate PRs before merge.
# It deliberately does NOT fire on dev→main PRs (base `main`), which still
# rely on the dev push run — so no duplicate runs. FC has no fork PRs
# (single-operator Forgejo repo), so secrets-on-PR is not a concern.
pull_request:
branches: [dev]
jobs:
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so
# this runs with NO dependency install and surfaces the most common bounce
# class (lint: I001 / UP037 / ASYNC109 / W293 …) in seconds — instead of
# after the backend job's ~30-60s wheel install. ruff is static analysis,
# so no DB/secret env is needed.
lint:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
- name: Ruff lint
# agent/ included so the GPU-agent is linted before its image is built
# (build.yml only `docker build`s it — this is where it gets checked).
# scripts/ likewise: release_notes.py runs only on a tag push, so a
# syntax or import error there would otherwise surface at the one
# moment nobody wants to debug a workflow.
run: ruff check backend/ tests/ alembic/ agent/ scripts/
- name: Agent syntax check
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
# CI image, so we can't import it — but compileall parses every module,
# catching syntax errors before the image build.
run: python -m compileall -q agent/fc_agent
# The extension version is DERIVED, not hand-maintained (milestone 271 step
# 4): build.yml computes it from the commit TIME of the newest packaged
# extension change and stamps it into manifest.json / package.json at build
# time. The guard that used to live here — "packaged files changed but nobody
# bumped the version" — was therefore checking a fact that had stopped
# existing. Worse than useless: it would have failed this lane on every real
# extension change, demanding a bump that decides nothing. Retired 2026-08-27
# rather than left running beside the new mechanism (rule 22).
#
# Two things are still worth asserting, and this is the only lane that can:
# the extension.yml suite runs on node:24-slim, which is exactly why
# version.spec.js sticks to packaging.sh's git-free subcommands.
# 1. the derivation actually resolves on this commit
# 2. the derived string is one AMO will accept, checked against Mozilla's
# own published grammar rather than a loose "digits and dots"
#
# The MAJOR.MINOR-agreement check that used to be (2) is gone with milestone
# 318 step 8: the committed version no longer seeds anything, so there is no
# hand-set part left for the two files to disagree about.
#
# Deliberately NOT checked here: that the derived value beats what has already
# been signed. That guard belongs in build.yml, where it compares against the
# real ext-* releases. Comparing against origin/main here would be wrong —
# dev legitimately derives a LOWER value whenever main is ahead on the
# extension, and a lane that fails for being behind is a lane people learn to
# ignore.
extension-version:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
with:
# The derivation needs real history: a depth-1 clone sees one commit
# and produces a wrong, too-low value RATHER THAN FAILING. Checking
# that here is half the point of the lane.
fetch-depth: 0
- name: Extension version derives cleanly
run: |
set -eu
# busybox sh on the act_runner — no bashisms (family rule).
VERSION=$(sh extension/scripts/packaging.sh version)
echo "derived: $VERSION"
# Mozilla's published grammar for AMO, transcribed verbatim from
# MDN's manifest.json/version page:
#
# ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
#
# Not the looser `^[0-9]+(\.[0-9]+)*$` this lane used to carry. That
# one passes `2026.08.29.0201`, which AMO REJECTS — a segment must be
# the single digit 0 or start 1-9 — and it also passes five segments,
# where AMO allows four. Both would surface as a failed sign with the
# version already burned: AMO 409s on re-signing, so a rejected value
# cannot be reclaimed and cannot be reused. This lane is the cheap
# place to find out. (#3138, milestone 318 step 8.)
if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$'; then
echo "ERROR: derived version '$VERSION' is not a version AMO accepts."
echo "AMO's grammar: ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$"
echo "Most likely cause: a zero-padded segment (08, 0201). The rest"
echo "of the family pads; the extension must not — see packaging.sh."
exit 1
fi
# ...and the shape this project actually derives. AMO would happily
# take `1.0.3500147` too, so the grammar check alone would not notice
# a regression to the pre-318 shape — which orders BELOW everything
# signed since, and is unrecoverable once Firefox has the higher one.
if ! echo "$VERSION" | grep -qE '^20[0-9][0-9]\.[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,4}$'; then
echo "ERROR: derived version '$VERSION' is not YYYY.M.D.HHMM."
echo "Rule 148's CalVer is what build.yml signs; the old"
echo "1.0.<minutes> shape would order below every ext-2026.* release."
exit 1
fi
echo "OK: derived version $VERSION"
backend-lint-and-test:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
# DB_PASSWORD and SECRET_KEY are required by config.py at import time
# even though unit tests don't actually touch the DB or use the secret.
DB_PASSWORD: ci_unit_test_placeholder
SECRET_KEY: ci_unit_test_placeholder
steps:
- uses: actions/checkout@v4
with:
# Full history for tests/test_artifact_identity.py, which derives
# each artifact's revision to check the identity scheme. On a
# depth-1 clone that derivation either fails or returns the tip sha
# — so the lane would go green while asserting nothing, which is
# the one outcome worse than a red one.
fetch-depth: 0
# Cache step removed 2026-05-26: act_runner's cache backend has been
# broken on this homelab runner since 2026-05-15 (first as request-
# timeout warnings, then as hard "Cannot find module .../dist/restore/
# index.js" failures that tank the whole job). The cache step targeted
# ~/.cache/pip but the install below uses `uv pip install` primarily,
# whose own cache lives at ~/.cache/uv — so the cache step's real
# benefit was marginal even when working. Cost of removal: ~30s of
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
# docker volume at the runner level (skips actions/cache entirely),
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
# pin act_runner version, etc.).
- name: Install Python deps
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
# Dockerfile's RUFF_VERSION). Per FabledRulebook ci-runners.md, toolchain
# versions live on the runner image, not here.
# uv: 5-10x faster wheel resolve than pip for cold caches.
# Falls back to pip install on uv-missing runners (older images).
run: |
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio
else
pip install -r requirements.txt pytest pytest-asyncio
fi
# Ruff moved to the dedicated fast `lint` job above (fails in seconds,
# no dep install). This job is now unit tests only.
- name: Pytest (unit only — integration runs in the integration job)
run: pytest tests/ -v -m "not integration"
frontend-build:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
# No package-lock.json is tracked yet (we don't run npm locally per
# feedback-no-local-runs). Using `npm install` instead of `npm ci`.
# If we want strict lockfile-based reproducibility later, commit a
# package-lock.json and flip this back to `npm ci`.
- run: npm install --no-audit --no-fund
# No type-check step: the frontend is pure JS (no .ts files, no JSDoc),
# so a type-checker has nothing to do. The vue-tsc devDep + its `check`
# script were dropped 2026-07-11 rather than bumped to v3. If we add
# TS/JSDoc later, re-add a tsconfig.json + vue-tsc + a type-check step.
- run: npm run test:unit
- run: npm run build
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
# The shards existed to parallelize ~8.5min of integration tests; once the
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
# runner slots for no wall-clock gain. One job now: spin up once, install
# once, migrate once, run every integration test.
#
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
# containers by job name. act_runner strips underscores from job names when
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
# stays separator-free (`integration`). The step prints `docker ps -a` first
# so a future naming-convention shift surfaces in the log without a
# guess-and-push cycle.
#
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
# per ci-requirements.md, FC is the only Python consumer of that image and the
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
integration:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
DB_USER: fabledcurator
DB_PASSWORD: ci_integration
DB_PORT: "5432"
DB_NAME: fabledcurator_test
SECRET_KEY: ci_integration_placeholder
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: fabledcurator
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: fabledcurator_test
options: >-
--health-cmd "pg_isready -U fabledcurator"
--health-interval 10s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Integration suite (resolve service IPs, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ==="
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
test -n "$PG_IP" && test -n "$RD_IP"
export DB_HOST="$PG_IP"
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
# These steps run under `sh -e`, not bash, so bash's /dev/tcp magic
# path does not exist here — the probe this loop used to run could
# never succeed and simply burned the full 120s on every run, green
# or red, then continued without having established anything. Python
# is in the image and needs no installed package for a socket
# connect, so it is the probe. Exhausting the budget is now a named
# failure rather than a silent fall-through (rule 156): if Postgres
# is genuinely not up, that is what the log should say, instead of
# whatever the first query happens to raise two minutes later.
pg_ready=""
for i in $(seq 1 60); do
if python -c "import socket,sys; s=socket.socket(); s.settimeout(2); sys.exit(0 if s.connect_ex(('$PG_IP', 5432)) == 0 else 1)"; then
pg_ready=1
break
fi
sleep 2
done
if [ -z "$pg_ready" ]; then
echo "postgres at $PG_IP:5432 did not accept a connection within 120s"
exit 1
fi
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt pytest pytest-asyncio
else
pip install -r requirements.txt pytest pytest-asyncio
fi
# Relax durability on the throwaway CI Postgres so the per-test
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
# surprise can't red the job; fabledcurator is the postgres image's
# bootstrap superuser.
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
alembic upgrade head
pytest tests/ -v -m integration --durations=15
+87
View File
@@ -0,0 +1,87 @@
name: extension
# Lint + unit tests. The sign-and-publish dance moved into build.yml's
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
# because sign-extension runs as a build-web dependency in the SAME workflow,
# eliminating the prior race between build.yml and a separate extension.yml.
# Signed XPIs are cached in Forgejo Release Assets named `ext-<version>`.
on:
push:
branches: [dev, main]
paths:
- 'extension/**'
- '.forgejo/workflows/extension.yml'
# test/version.spec.js asserts things ABOUT the other two workflows —
# that neither inlines the packaged-file set, and that build.yml derives
# the shipped version rather than reading it out of the repo. A
# workflow-only edit can therefore break this suite, so it has to trigger
# it. build.yml joined the list at milestone 271 step 5, when the spec
# started asserting against it.
- '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
pull_request:
branches: [main]
paths:
- 'extension/**'
- '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
workflow_dispatch:
jobs:
lint:
runs-on: python-ci
container:
image: node:24-bookworm-slim
steps:
- uses: actions/checkout@v4
# Not --no-save: vitest and web-ext are both real devDependencies now,
# and the suite needs vitest resolvable from node_modules.
- name: Install dev dependencies
run: cd extension && npm install --no-audit --no-fund
- name: Lint
run: cd extension && npm run lint
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
# package version-consistency checks. No browser, no network.
- name: Unit tests
run: cd extension && npm run test:unit
# Everything else about packaging is asserted against our own declaration
# of what ships. This is the only check that asks web-ext what it ACTUALLY
# put in the archive. Until now that was an unverified assumption about
# glob semantics — and a fragile one: `test/**` reaches web-ext intact
# only because callers `set -f` first, so losing that quoting would
# silently start shipping dev files with no other signal.
- name: Verify XPI contents
run: |
set -eu
command -v unzip >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq unzip; }
cd extension
npm run build
ZIP=$(ls web-ext-artifacts/*.zip | head -1)
echo "=== packaged entries in $ZIP ==="
unzip -Z1 "$ZIP" | sort
echo "=== end ==="
ENTRIES=$(unzip -Z1 "$ZIP")
fail=0
# Must NOT ship: repo infrastructure with no business in a user's browser.
for pat in 'test/' 'scripts/' 'vitest.config.js' 'package.json' 'package-lock.json' 'README.md' 'node_modules/' 'web-ext-artifacts/'; do
if echo "$ENTRIES" | grep -q "^$pat"; then
echo "ERROR: '$pat' was packaged into the XPI but must not be"
fail=1
fi
done
# Must ship: if an exclusion pattern ever over-matches, the extension
# breaks at runtime rather than at build time, so assert presence too.
for req in 'manifest.json' 'lib/url.js' 'lib/api.js' 'lib/platforms.js' 'lib/cookies.js'; do
if ! echo "$ENTRIES" | grep -q "^$req$"; then
echo "ERROR: '$req' is missing from the XPI"
fail=1
fi
done
for dir in 'background/' 'popup/' 'options/' 'content/' 'icons/'; do
if ! echo "$ENTRIES" | grep -q "^$dir"; then
echo "ERROR: nothing from '$dir' was packaged"
fail=1
fi
done
[ "$fail" -eq 0 ] || exit 1
echo "XPI contents verified."
-19
View File
@@ -70,22 +70,3 @@ alembic/versions/__pycache__/
*.sqlite
*.sqlite-journal
.superpowers/
# Raw platform captures (milestone 387 C0 and successors). These are real
# authenticated API responses taken from the operator's own account, so they
# carry account data — creator lists, pledge amounts, and (in Patreon's case)
# the account email inside the `card` resources. They are kept locally because
# re-capturing means re-authenticating by hand, and they are the ground truth a
# characterization gets re-checked against.
#
# The whole directory is ignored, not one filename, so a future capture is
# covered by this rule instead of needing a new line somebody has to remember.
#
# SANITIZED fixtures derived from these DO belong in git — put them somewhere
# else (tests/fixtures/, not here), with the account data stripped.
# Ignore the CONTENTS, not the directory: git does not descend into an
# excluded directory, so a negation for a file inside one never takes effect.
# Writing it this way lets README.md be committed while everything else here
# stays out.
tests/fixtures/captures/*
!tests/fixtures/captures/README.md
+3 -103
View File
@@ -28,10 +28,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
zstd \
megatools \
# PID 1 for every role. See the ENTRYPOINT note at the foot of this file:
# without it the image needs `init: true` in whatever runs it, which is a
# deployment remembering a flag for the image to behave correctly.
tini \
libjpeg62-turbo \
libwebp7 \
libpng16-16 \
@@ -40,59 +36,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
COPY requirements.txt requirements-ml.txt ./
COPY requirements.txt ./
RUN pip install -r requirements.txt
# --- ML, merged from Dockerfile.ml (milestone 422 step 6) --------------------
#
# ONE image now serves every lane. It was two because the ML lane ran in its
# own container; with the single-container layout (step 5) running every lane
# in one process tree, a second image would mean the `ml` lane could never be
# enabled from the UI — there would be no worker in this container to enable.
#
# THE COST, MEASURED from run 7273 rather than guessed — and it is far
# smaller than the estimate this comment first carried, which said "everyone
# pulls ~4GB":
#
# torch 2.12.1+cpu wheel 192.3 MB
# torchvision 0.27.1+cpu 1.8 MB
# transformers / onnxruntime / opencv / sklearn and friends (opencv and
# onnxruntime since dropped, #1451 — nothing here imported them)
# 62.0, 35.3, 23.6, 16.7, 12.3, 9.2, 6.9 MB
# largest newly-pushed layer 222.07 MB
#
# So the ML code adds a few hundred MB to the pull, not gigabytes. The CPU
# index is what makes that true: the default PyPI torch wheel bundles the
# NVIDIA CUDA runtime and is ~2GB on its own.
#
# The GIGABYTES are in the MODEL — ~3.5GB of SigLIP weights — and those are
# NOT in this image. They arrive only when the operator enables the lane,
# which is what lets rule 164 permit a runtime fetch at all ("optional and
# clearly off"). That also settles the trade this step was asked to weigh:
# baking the weights in would add ~3.5GB to every pull for a feature many
# adopters never enable, against ~350MB for the code that makes the switch
# available. Off-by-default wins by an order of magnitude, which was NOT
# obvious before measuring — the estimate had the two costs within 15% of
# each other.
#
# `--index-url`, not `--extra-index-url`: the latter would let pip resolve a
# +cu wheel anyway, and the whole saving above depends on it not doing that.
#
# CPU-only torch from the PyTorch CPU index. Nothing here uses a GPU — the
# GPU agent is a separate service with its own image.
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
"torch>=2.14" "torchvision>=0.29"
RUN pip install -r requirements-ml.txt
# Where the model lands. Deliberately NOT a VOLUME instruction: that mints an
# anonymous volume when nobody mounts one, which survives `docker rm` and
# accumulates 3.5GB copies nobody can find. The compose files mount it
# explicitly instead, so an unmounted run simply re-downloads — visible, and
# recoverable.
ENV HF_HOME=/models/.huggingface \
TRANSFORMERS_CACHE=/models/.huggingface \
ML_MODEL_DIR=/models
COPY backend/ ./backend/
COPY alembic/ ./alembic/
COPY alembic.ini ./
@@ -126,51 +72,5 @@ ENV FC_VERSION=${FC_VERSION}
EXPOSE 8080
# ONE healthcheck for every role, because the image knows which role it is
# running and a deployment should not have to repeat it. `healthcheck` reads
# the role entrypoint.sh recorded and asks the right question: HTTP for web,
# a self-addressed celery ping for a worker lane, both-for-every-lane for the
# consolidated `all`.
#
# start-period covers the SLOWEST role, which is `all`: alembic, then
# hypercorn, then four celery workers registering with the broker. A web-only
# container is ready long before this; the cost of the shared number is that
# a broken one takes a little longer to be called broken.
#
# A service may still declare its own healthcheck and docker will prefer it —
# the escape hatch for a deployment that wants something different.
HEALTHCHECK --interval=30s --timeout=15s --start-period=90s --retries=3 \
CMD ["python", "-m", "backend.app.scripts.healthcheck"]
# tini is PID 1, and the image brings its own rather than asking the
# deployment for one.
#
# PID 1 carries a duty no other process has: every orphaned process in the
# container reparents to it and must be reaped, or it stays a zombie holding
# a PID slot. This app makes orphans in normal operation — six service
# modules shell out (gallery-dl, ffmpeg, pg_dump, the external fetchers) and
# celery's prefork pool forks children that spawn them.
#
# Whatever the role, something that is not an init ends up as PID 1:
# supervisord for `all`, hypercorn for `web`, celery for a worker. The fix
# was `init: true` in the compose/stack file, which is out of the norm and
# put correct process handling in the hands of whoever deploys the image —
# the same mistake as declaring the healthcheck per service. A flag that is
# silently dropped (an older Swarm, a `docker run` without it) costs reaping
# with no signal at all.
#
# So the image owns it. `docker run <image>` is correct on its own, and
# nothing downstream has to know. The smoke asserts /proc/1/comm is tini.
ENTRYPOINT ["/usr/bin/tini", "--", "./entrypoint.sh"]
# The DEFAULT is the whole application, not one lane of it.
#
# `docker run fabledcurator` with no command starts hypercorn plus every
# worker lane under supervisord — the shape an adopter wants and the shape the
# consolidated stack runs. It was `web`, which meant the single-container
# layout only worked if you knew to ask for it by name, and a compose file
# that forgot `command:` got a web server with nothing processing its queues:
# a gallery that loads, accepts an import, and never finishes one.
#
# The multi-service stack is unaffected — every service there names its role
# explicitly, which is exactly what makes it the multi-service stack.
CMD ["all"]
ENTRYPOINT ["./entrypoint.sh"]
CMD ["web"]
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.25
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
HF_HOME=/models/.huggingface \
TRANSFORMERS_CACHE=/models/.huggingface \
ML_MODEL_DIR=/models
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libpq5 \
libjpeg62-turbo \
libwebp7 \
libpng16-16 \
libgl1 \
libglib2.0-0 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements-ml.txt requirements.txt ./
# CPU-only torch: the default PyPI wheel bundles the CUDA runtime (~5.6GB
# layer); this pipeline never uses a GPU. --index-url (not --extra-index-url)
# guarantees only +cpu wheels are considered, so no nvidia-*-cu12 deps.
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
"torch>=2.12,<3.0" "torchvision>=0.27,<0.28"
RUN pip install -r requirements-ml.txt
COPY backend/ ./backend/
COPY alembic/ ./alembic/
COPY alembic.ini ./
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
# Models self-heal into /models on first start (FC-2 implements this)
VOLUME ["/models"]
ENTRYPOINT ["./entrypoint.sh"]
CMD ["ml-worker"]
+18 -55
View File
@@ -1,5 +1,3 @@
<img src="frontend/public/logo.svg" alt="" width="132" align="right" />
# FabledCurator
<!-- overview:start -->
@@ -13,12 +11,11 @@ tags it, and gives you something better than a folder full of images to look
through afterwards.
- **Gallery and browsing.** Images, videos and multi-page works, organised by
artist, tag, post and series. A newest-first feed of what just arrived as the
front page, a random Showcase, a filterable gallery, a similarity-driven
Explore view, and a page-turning reader for series.
- **Subscriptions.** Follows creators on Patreon, SubscribeStar, Discord and
HentaiFoundry, on a schedule. Handles paywalled posts using your own
logged-in session.
artist, tag, post and series. A Showcase front page, a filterable gallery, a
similarity-driven Explore view, and a page-turning reader for series.
- **Subscriptions.** Follows creators on Patreon, SubscribeStar, Pixiv and
anything `gallery-dl` supports, on a schedule. Handles paywalled posts using
your own logged-in session.
- **ML tagging.** Runs image models in-container to suggest tags, group
characters, find near-duplicates and power similarity search. Suggestions are
reviewable — it proposes, you confirm, and it learns which proposals you keep
@@ -39,8 +36,8 @@ is no config file to edit beyond a handful of bootstrap environment variables.
permission model. Anything that can reach the port is an administrator.
That matters more here than it would in most self-hosted apps, because of what
this one stores: **live platform session cookies for Patreon and
SubscribeStar** — accounts that usually have a payment method attached. Whoever reaches
this one stores: **live platform session cookies for Patreon, SubscribeStar and
Pixiv** — accounts that usually have a payment method attached. Whoever reaches
the port can read them, alongside your entire library.
So:
@@ -93,31 +90,7 @@ If you forget it, the symptom is a long build instead of a quick pull.
The database schema is created automatically on first start — the web container
runs its migrations before serving. Nothing to initialise by hand.
**One thing does need a deliberate act, and the app will not start without it.**
FabledCurator encrypts your stored platform credentials with a key it keeps at
`./images/secrets/credential_key.b64`. On a brand-new install that file does not
exist, and rather than quietly creating one the app stops:
```
MissingCredentialKey: Fernet key file not found at /images/secrets/credential_key.b64
```
Set `CURATOR_BOOTSTRAP_NEW_KEY=1` in your `.env` for the first `up`, then delete
the line once the container is running. `.env.example` ships it with that
instruction attached.
The refusal is deliberate, and worth understanding rather than working around:
auto-creating a key is indistinguishable from the disaster case — a restore that
brought the database back but lost `./images/secrets` — where it would mint a key
that cannot decrypt anything, leaving an instance that looks healthy while every
paywalled download fails. Making you say so once, on an empty install, is the
price of that not happening silently later.
**Which means: back up `./images/secrets/` alongside your database.** It is the
only thing that can read your stored credentials. A database restored without it
needs every credential entered again by hand.
A few other things are worth knowing about the first few minutes:
A few things are worth knowing about the first few minutes:
- **The ML worker downloads its model weights on first boot**, several GB from
HuggingFace into `./models`. Until that finishes, tagging is queued rather
@@ -245,9 +218,9 @@ reasoning is note #3127 §5). Rolling back is `docker pull …:c-<sha>`.
Each artifact still has a version, derived rather than chosen: the commit time
of the newest change to that artifact's *own* shipped files, as
`YYYY.MM.DD.HHMM` UTC (rule 148). Three artifacts, three independent versions
— a push touching only `agent/` re-versions the agent and leaves web and the
extension alone, and CI skips the builds whose content did not move.
`YYYY.MM.DD.HHMM` UTC (rule 148). Four artifacts, four independent versions —
a push touching only `agent/` re-versions the agent and leaves web and ml
alone, and CI skips the builds whose content did not move.
Because no registry name carries it, the running instance's own report is the
only answer to "which build is this?". The foot of Settings shows
@@ -260,32 +233,22 @@ commits since the previous tag; it builds no image.
## What's in here
Four deployable pieces, built by `.forgejo/workflows/build.yml`:
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
| Piece | Built from | Image | Role |
| --- | --- | --- | --- |
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`, `ml-worker`, or `all` (every lane under supervisord, the single-container layout). The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`. The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
| **ML worker** | `Dockerfile.ml` | `fabledcurator-ml` | Same app, plus `requirements-ml.txt` — tagging and embedding models that run in-container. |
| **GPU agent** | `agent/Dockerfile` | `fabledcurator-agent` | Optional desktop-GPU worker (`agent/`). Leases jobs over **HTTP only** — never touches the database or Redis. See `agent/README.md`. |
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both `dev` and `main` (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See `extension/README.md`. |
| **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
## CI / Forgejo setup
Two workflows that matter here: `build.yml` (the six verification lanes — lint,
extension-version check, backend unit tests, frontend build, extension lint +
vitest + XPI content check, integration — and then sign + publish), and
`release.yml`, which runs only on a `v*` tag and publishes a changelog without
building anything. The extension lane was its own `extension.yml` until
milestone 429, which let a red extension suite sign and ship the XPI anyway.
**The lanes and the publish are one workflow on purpose.** They were two
(`ci.yml` and `build.yml`) until 2026-09-23, on the same push trigger, which
meant the build could not see the tests' verdict and published whatever it
built — a red unit lane and a fresh `:dev` image, in the same minute. A
`needs:` edge only exists inside one workflow graph, so the two are one graph
and the gate is that edge: a lane that fails, **or that merely skips**, leaves
the publishing jobs unrun. Pull-request runs (Renovate bumps into `dev`) are
the lanes and nothing else.
Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
content verification), `build.yml` (sign + publish), and `release.yml`, which
runs only on a `v*` tag and publishes a changelog without building anything.
**The toolchain each job runs in is its `container.image`, not its `runs-on`
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
+2 -2
View File
@@ -26,7 +26,7 @@ FabledCurator is self-hosted and holds things worth stating plainly, because
they shape what counts as a serious bug here:
- **Platform credentials.** The app captures and stores session cookies for
third-party subscription sites (Patreon, SubscribeStar) so it can
third-party subscription sites (Patreon, SubscribeStar, Pixiv) so it can
download on the operator's behalf. These are live credentials for accounts
that usually carry a payment method. Anything that discloses them, decrypts
them, or lets one user of a shared instance read another's is high severity.
@@ -56,7 +56,7 @@ reverse proxy. It also does not authenticate anyone — see above. These are
documented design decisions, not oversights.
Putting this on the public internet, with or without TLS, hands whoever finds
it your Patreon and SubscribeStar sessions. A reverse proxy that adds
it your Patreon, SubscribeStar and Pixiv sessions. A reverse proxy that adds
TLS but not an authentication layer does not change that.
Reports that reduce to "the application is served over HTTP", "there is no
+10 -40
View File
@@ -1,21 +1,10 @@
# FabledCurator GPU agent — runs on the desktop with the GPU.
#
# The `base` flavour, not `cudnn-runtime`: CUDA and cuDNN arrive as the
# `nvidia-*` pip packages torch and onnxruntime-gpu depend on, so the base only
# has to hand the container the driver (it sets NVIDIA_VISIBLE_DEVICES /
# NVIDIA_DRIVER_CAPABILITIES for the Container Toolkit). Until #1451 this was
# `12.9.2-cudnn-runtime` under a `torch==2.6.0+cu124` — and requirements.txt then
# REPLACED that torch with PyPI's CUDA-13 build (ultralytics pulls torchvision,
# which pulls its matching torch), beside a CUDA-13 onnxruntime-gpu. The image
# ran CUDA 13 on a CUDA-12 base, carrying ~3 GB of base libraries and a ~3 GB
# torch nothing loaded: 10 GB compressed.
#
# 13.0 because that is the line both wheels are built for (torch's cu130 index,
# onnxruntime-gpu's `nvidia-cuda-runtime~=13.0`). Needs an NVIDIA driver that
# supports CUDA 13 (580+); fc_agent/accel.py logs at startup whether torch and
# onnxruntime actually got the GPU, since both fall back to the CPU silently.
# ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
FROM nvidia/cuda:13.0.3-base-ubuntu24.04
# CUDA 12.9 + cuDNN 9 runtime so onnxruntime-gpu can use the card (it needs
# cuDNN 9 — the plain -runtime image lacks it: "libcudnn.so.9: cannot open
# shared object file"); ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
# Stays on the CUDA-12 / cuDNN-9 line the default onnxruntime-gpu + torch are
# built against (CUDA 13 has only nascent ONNX Runtime support).
FROM nvidia/cuda:12.9.2-cudnn-runtime-ubuntu24.04
# PIP_BREAK_SYSTEM_PACKAGES: Ubuntu 24.04 marks its system Python as externally
# managed (PEP 668), so a global `pip install` errors without this. It's a
@@ -27,12 +16,10 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# torch AND torchvision from the cu130 index, together and first. Installing
# torch alone is what let the next step swap it out: ultralytics needs
# torchvision, PyPI's torchvision pins its own torch, and pip replaced ours to
# match. With both present, requirements.txt finds them satisfied.
RUN pip3 install --no-cache-dir --index-url https://download.pytorch.org/whl/cu130 \
torch torchvision
# torch from the CUDA-12.4 wheel index; its wheels bundle their own CUDA + cuDNN
# so they run on the 12.9 base and coexist with onnxruntime-gpu. Installed first
# + separately so the GPU build of torch is deterministic and layer-cached.
RUN pip3 install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY fc_agent ./fc_agent
@@ -40,23 +27,6 @@ COPY fc_agent ./fc_agent
# imgutils ONNX models + the transformers SigLIP weights both cache here; mount
# a volume to persist them across restarts (the SigLIP download is ~3.5 GB once).
ENV HF_HOME=/models
# Declared LAST on purpose, exactly as the web Dockerfile does: an ARG/ENV
# invalidates every layer below it, and these are the only values that differ
# between builds of otherwise identical source. Any earlier and the ~6.3 GB
# CUDA + torch layers could never be shared between the dev and main builds of
# one commit — which is the cost #3114 measured at 9m26s cold.
#
# Three values, never folded together (rule 149) — the NAME a person reads, the
# CHANNEL it came from, and the REVISION that identifies the content. See
# fc_agent/build_info.py; CI derives all three from scripts/artifacts.sh.
ARG FC_CHANNEL=""
ENV FC_CHANNEL=${FC_CHANNEL}
ARG FC_VERSION=""
ENV FC_VERSION=${FC_VERSION}
ARG FC_REVISION=""
ENV FC_REVISION=${FC_REVISION}
EXPOSE 8770
# The control UI; the worker is started from it (or POST /start).
+2 -17
View File
@@ -15,28 +15,13 @@ sudo pacman -S nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# verify:
docker run --rm --gpus all nvidia/cuda:13.0.3-base-ubuntu24.04 nvidia-smi
# the header's CUDA version must be 13.0 or later (driver 580+)
```
### After a driver update: regenerate the CDI spec
If the agent's first log lines say `accel: torch is NOT on the GPU` or report
`cudaGetDeviceCount: unknown error (999)` while `nvidia-smi` still works, the
toolkit's saved device list (`/etc/cdi/nvidia.yaml`) is out of date. The
`nvidia-uvm` device number changes between driver versions, and a spec
generated before the update hands the container a device node that no longer
exists (2026-09-24: host `511,0`, container `235,0`). Compare
`ls -l /dev/nvidia-uvm` on the host with the same inside the container, then:
```sh
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
# if your toolkit ships it, this keeps it current on every driver update:
sudo systemctl enable --now nvidia-cdi-refresh.path
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
## 1. Get a token
In FC: **Settings → Tagging → GPU agent → Generate token** (or Rotate). Copy it.
## 2. Pull (CI publishes it alongside the web image)
## 2. Pull (CI publishes it alongside the web/ml images)
```sh
docker pull git.fabledsword.com/bvandeusen/fabledcurator-agent:latest
```
-124
View File
@@ -1,124 +0,0 @@
"""Which accelerator each runtime actually got — reported once, at startup.
The agent has two GPU runtimes and both fall back to the CPU without raising:
torch when the driver is too old for its CUDA build, and onnxruntime (the imgutils
detector + CCIP models) when its CUDA provider cannot load its libraries. A
fallback shows up only as slower work, and nothing reported it. On 2026-09-24 the
image turned out to be running a CUDA-13 torch and onnxruntime on a CUDA-12 base
(#1451), and whether the ONNX half was on the GPU could not be answered from
anything the agent had ever logged.
Also the fix for the likeliest way the ONNX half misses: onnxruntime-gpu's CUDA
provider finds libcudart/cuBLAS/cuDNN only on the loader path, and in this image
they live in the `nvidia-*` pip packages torch installs. `preload_dlls()` (ORT
1.21+) loads them from there, so the provider resolves them by soname.
Stdlib-only at import, so the unit suite can import it — torch and onnxruntime
are imported inside the functions.
"""
from __future__ import annotations
import ctypes
import importlib
import logging
from pathlib import Path
log = logging.getLogger("fc_agent.accel")
# Filled by report(); /status carries it so the page can show it too.
LAST: dict = {}
def torch_status(imp=importlib.import_module) -> dict:
try:
torch = imp("torch")
except Exception as e:
return {"device": "unavailable", "error": str(e)}
out = {"version": torch.__version__, "cuda_build": torch.version.cuda}
if torch.cuda.is_available():
out["device"] = "cuda"
out["gpu"] = torch.cuda.get_device_name(0)
else:
out["device"] = "cpu"
return out
def onnx_status(imp=importlib.import_module, load=ctypes.CDLL) -> dict:
try:
ort = imp("onnxruntime")
except Exception as e:
return {"device": "unavailable", "error": str(e)}
out = {"version": ort.__version__, "providers": list(ort.get_available_providers())}
if "CUDAExecutionProvider" not in out["providers"]:
out["device"] = "cpu"
return out
preload = getattr(ort, "preload_dlls", None)
if preload is not None:
try:
preload()
except Exception as e:
out["preload_error"] = str(e)
# "Available" only means the build HAS the provider. Loading its library is
# what resolves libcudart/cuBLAS/cuDNN — the step that fails when they are
# missing, and the one a session would otherwise fail silently on.
capi = Path(ort.__file__).parent / "capi"
try:
load(str(capi / "libonnxruntime_providers_shared.so"), mode=ctypes.RTLD_GLOBAL)
load(str(capi / "libonnxruntime_providers_cuda.so"))
except OSError as e:
out["device"] = "cpu"
out["error"] = str(e)
return out
# Loading proves the libraries resolve, NOT that a GPU can be used: on
# 2026-09-24 this reported "onnx on GPU" beside torch failing cuInit with
# "CUDA unknown error" (a driver update awaiting a reboot). Asking the CUDA
# runtime for a device initialises the driver the provider would use.
error = _cuda_device_error(load)
out["device"] = "cpu" if error else "cuda"
if error:
out["error"] = error
return out
def _cuda_device_error(load=ctypes.CDLL) -> str | None:
"""None when the CUDA runtime can reach a device, else why it cannot."""
try:
cudart = load("libcudart.so.13")
except OSError as e:
return str(e)
count = ctypes.c_int(0)
rc = cudart.cudaGetDeviceCount(ctypes.byref(count))
if rc != 0:
cudart.cudaGetErrorString.restype = ctypes.c_char_p
return f"cudaGetDeviceCount: {cudart.cudaGetErrorString(rc).decode()} ({rc})"
return None if count.value > 0 else "no CUDA device visible"
def summary() -> dict | None:
"""The report as FabledCurator stores it: each runtime's device, and why
when it is not the GPU. Sent on every lease and heartbeat, so the System
view can call a running agent that fell back to the CPU "degraded" rather
than "running" — the 2026-09-24 fallback went unseen for weeks because
only this agent's own log said so. None before report() has run."""
if not LAST:
return None
out = {}
for name, s in LAST.items():
entry = {"device": s.get("device")}
if s.get("error"):
entry["error"] = str(s["error"])[:200]
out[name] = entry
return out
def report() -> dict:
"""Check both runtimes, log the result, and keep it for /status."""
LAST.clear()
LAST.update(torch=torch_status(), onnx=onnx_status())
for name, s in LAST.items():
if s.get("device") == "cuda":
log.info("accel: %s on GPU (%s)", name, s)
else:
log.warning("accel: %s is NOT on the GPU — work runs on the CPU (%s)", name, s)
return dict(LAST)
+11 -55
View File
@@ -11,22 +11,17 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from . import accel, logbuf
from .build_info import FC_CHANNEL, FC_REVISION, FC_VERSION, build_id, display_version
from . import logbuf
from .config import Config
from .gpu import read_gpu
from .worker import Worker
log = logging.getLogger("fc_agent.app")
# DERIVED at image build time, not hand-maintained — see build_info. This was a
# literal an author was asked to bump on every agent change, and the September
# image printed the same "2026-07-17.1" as the July one, so the surface meant to
# answer "did my pull work?" answered the same either way.
#
# Two values with two jobs, kept apart (rule 149): the page SHOWS the version
# and COMPARES the build id. /status reports both, plus the raw fields, so a
# reader never has to take a formatted string apart to get at one of them.
# Bump on every agent change. The page embeds this and /status reports it; the UI
# warns to reload when they differ — so a stale browser-cached page can't be
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle the GPU models release their VRAM and reload on the next job (env IDLE_UNLOAD_SECONDS, 0=off) · sleep mode sheds to one downloader"
logbuf.install()
cfg = Config.from_env()
@@ -47,9 +42,6 @@ async def _no_store(request, call_next):
@app.on_event("startup")
def _maybe_autostart() -> None:
# Before the worker: the report also preloads the CUDA libraries the ONNX
# models need, and it says in the log which runtimes landed on the GPU.
accel.report()
# With AUTO_START set, a container restart (host reboot, or `restart:
# unless-stopped` after a crash) resumes the worker on its own — the slots
# then ride out a still-down curator via lease backoff. Lets the agent
@@ -60,14 +52,7 @@ def _maybe_autostart() -> None:
@app.get("/", response_class=HTMLResponse)
def index() -> str:
# Two substitutions, not one: `__VERSION__` is what a person reads in the
# meta line, `__BUILD_ID__` is what the script compares against /status to
# notice the page is a cached copy from a previous build.
return (
_PAGE
.replace("__VERSION__", display_version())
.replace("__BUILD_ID__", build_id())
)
return _PAGE.replace("__BUILD__", VERSION)
@app.post("/start")
@@ -132,15 +117,7 @@ def status():
s["fc_url"] = cfg.fc_url
s["configured"] = bool(cfg.token)
s["queue"] = worker.latest_queue()
# `build` is the comparison token the page checks — see build_info.
# `version`/`channel`/`revision` ride BESIDE it rather than inside it, so a
# reader wanting the version never has to parse it back out of something
# else. Absent rather than empty when the image carries no stamp.
s["build"] = build_id()
s["version"] = FC_VERSION or None
s["channel"] = FC_CHANNEL or None
s["revision"] = FC_REVISION or None
s["accel"] = accel.LAST or None
s["build"] = VERSION
return JSONResponse(s)
@@ -192,11 +169,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
width:30px;height:32px;font:700 16px system-ui;cursor:pointer}
.step:hover{border-color:var(--acc)}
#conc,#bw{width:3.4rem;height:32px;text-align:center;font:700 16px system-ui;background:#11151a;
color:var(--fg);border:1px solid var(--bd);border-radius:8px;appearance:textfield;-moz-appearance:textfield}
/* The browser's own spin arrows, hidden: the − / + beside each field are the
control, styled like the rest of the page (operator, 2026-09-24). */
#conc::-webkit-inner-spin-button,#conc::-webkit-outer-spin-button,
#bw::-webkit-inner-spin-button,#bw::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
color:var(--fg);border:1px solid var(--bd);border-radius:8px}
.unit{color:var(--mut);font-size:12px;font-weight:600}
.hint{color:var(--mut);font-size:12px;margin-top:12px}
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
@@ -230,12 +203,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div>
<div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div>
</header>
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__VERSION__</code></p>
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__BUILD__</code></p>
<div id=verbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3">
a newer agent version is running — reload this page (Ctrl+Shift+R) to update the controls
</div>
<div id=accelbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3"></div>
<div id=banner class=banner style=display:none>
curator unreachable — holding work + retrying, resumes on its own (no restart needed)
</div>
@@ -253,9 +225,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<button class=step onclick=setc(1)>+</button>
</div>
<div class=stepper title="aggregate download cap, downloads + video streams combined — 0 = unlimited">
<button class=step onclick=stepbw(-1)>−</button>
<input id=bw type=number min=0 step=1 value=8 onchange="setbw(this.value)">
<button class=step onclick=stepbw(1)>+</button>
<span class=unit>MB/s</span>
</div>
</div>
@@ -292,7 +262,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
</section>
</div>
<script>
const PAGE_BUILD="__BUILD_ID__"
const PAGE_BUILD="__BUILD__"
let CAP=8
// Optimistic transitional state on click, then apply the POST's own status
// response (it returns worker.status()) for instant feedback — don't wait on the
@@ -323,14 +293,6 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
await fetch('/auto',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:on})});refresh()
}
function stepbw(d){ setbw((parseFloat(bw.value)||0)+d) }
// Runtimes that did NOT get the GPU, from the startup report. Both fall back
// to the CPU without raising, so this banner and the pill are the only place
// on this page a slow, CPU-bound agent announces itself.
function cpuOnly(s){
const a=s.accel||{}
return Object.keys(a).filter(k=>a[k] && a[k].device!=='cuda')
}
async function setbw(v){
v=Math.max(0,parseFloat(v)||0); bw.value=v
await fetch('/bandwidth',{method:'POST',headers:{'Content-Type':'application/json'},
@@ -401,17 +363,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
// unreachable curator; grey when stopped; red with no token.
let dc='dot', lbl='stopped'
if(!ok){ dc='dot red'; lbl='no token' }
else if(st==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable'
if(s.queue && cpuOnly(s).length){ dc='dot amber'; lbl='running · CPU only (degraded)' } }
else if(st==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable' }
else if(st==='starting'){ dc='dot amber'; lbl='starting…' }
else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' }
dot.className=dc; connlbl.textContent=lbl
banner.style.display=(st==='running' && !s.queue)?'block':'none'
const slow=cpuOnly(s)
accelbanner.style.display=slow.length?'block':'none'
accelbanner.textContent=slow.length?('degraded — '+slow.join(' + ')+' not on the GPU, so that work runs on the CPU: '
+slow.map(k=>k+': '+(s.accel[k].error||s.accel[k].device)).join(' · ')
+'. After a driver update, regenerate the CDI spec (agent README).'):''
queue.textContent=s.queue?('queue · pending '+s.queue.pending+' · in flight '+s.queue.leased+' · done '+s.queue.done+' · errored '+s.queue.error):'queue · unreachable'
}
}
-79
View File
@@ -1,79 +0,0 @@
"""What this agent build IS — stamped at image build time, not configurable.
The mirror of `backend/app/build_info.py`, for the same reasons and with the
same posture. Kept as its own module rather than as constants in `app.py`
because it is stdlib-only and therefore importable by the test suite, which
cannot import `app` (torch, transformers and ultralytics are not in the CI
image — see build.yml's "Agent syntax check").
## Why this replaced a hand-written string
`app.VERSION` used to be a literal an author was asked to bump, carrying a
version AND a changelog in one string:
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle ..."
Nobody bumped it. The September image printed the identical string to the July
one, so the one surface that was supposed to answer *"did my pull work?"*
answered *"2026-07-17.1"* either way. An artifact that cannot identify itself
is worse than one that says nothing, because the stale value reads as an
answer.
The values are now derived by `scripts/artifacts.sh` from the commit its
shipped files last changed in — the same derivation the web image has used
since milestone 313, and the same one the reuse check already ran for the
agent and discarded.
## Three values, never folded together (rule 149)
* `FC_VERSION` — the NAME, `YYYY.MM.DD.HHMM` UTC, derived from COMMIT time.
For people to read and quote. Identical on `dev` and `main` for the same
source, which is the property that makes "am I running the same code as
production?" answerable at a glance.
* `FC_CHANNEL` — a SIBLING field, never a suffix inside the name.
* `FC_REVISION` — the 12-char commit sha, the artifact's IDENTITY. This is
what the reuse check already keys on as the `fc.revision` image label.
**Absent rather than empty when unknown.** A locally built image has no
stamp, and neither does any image predating this module. One spelling of
"cannot say" instead of two.
"""
import os
FC_VERSION = os.environ.get("FC_VERSION", "").strip()
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
FC_REVISION = os.environ.get("FC_REVISION", "").strip()
def display_version() -> str:
"""The build, as a line for a human: `2026.09.24.1052 (dev)`.
`unknown` rather than a blank when unstamped — an empty slot in the meta
line reads as "no version", which is a different and false claim from "this
build does not carry one".
"""
if not FC_VERSION:
return "unknown"
return f"{FC_VERSION} ({FC_CHANNEL})" if FC_CHANNEL else FC_VERSION
def build_id() -> str:
"""The token the control page compares against `/status` to notice it is
showing a CACHED page from a previous build.
Deliberately NOT `display_version()`. That is the value for reading; this
is the value for deciding, and folding the two is what rule 149 is about.
The revision is the better discriminator of the two — two builds of the
same commit ARE the same agent and should not prompt a reload, and two
different commits always differ here even when they land in the same
minute and derive one version name.
A locally built image falls through to a constant, so the reload banner
cannot fire for it. That is honest rather than a gap: nothing in an
unstamped image knows what source it was built from, and a per-process
nonce would make every ordinary container RESTART claim a new version had
arrived — a false positive on the exact surface the banner exists to keep
trustworthy.
"""
return FC_REVISION or FC_VERSION or "local"
+2 -9
View File
@@ -7,8 +7,6 @@ import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from . import accel
class FcClient:
def __init__(self, base_url: str, token: str, agent_id: str):
@@ -74,10 +72,7 @@ class FcClient:
def lease(self, batch_size: int) -> list[dict]:
r = self.s.post(
f"{self.base}/api/gpu/jobs/lease",
json={
"agent_id": self.agent_id, "batch_size": batch_size,
"accel": accel.summary(),
},
json={"agent_id": self.agent_id, "batch_size": batch_size},
timeout=30,
)
r.raise_for_status()
@@ -95,9 +90,7 @@ class FcClient:
})
def heartbeat(self, job_ids: list[int]) -> None:
self._post_quiet(
"/api/gpu/jobs/heartbeat", {"job_ids": job_ids, "accel": accel.summary()},
)
self._post_quiet("/api/gpu/jobs/heartbeat", {"job_ids": job_ids})
def fail(self, job_id: int, error: str) -> None:
self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error})
+5 -34
View File
@@ -342,44 +342,15 @@ class Worker:
# --- background loops ---------------------------------------------------
def _heartbeat_loop(self) -> None:
"""Keep every held lease alive, and say we are here even when holding none.
Leases: buffered jobs waiting on the GPU would otherwise be reclaimed by
curator's 180s TTL. Errors are swallowed by client.heartbeat; a reclaimed
lease just re-leases elsewhere — never fatal.
## Why this sends with an EMPTY list rather than skipping
Curator's roster records a check-in on this call (and on `lease`), and
calls an agent stopped after 300s of silence. This loop used to be
gated on `if ids:` — so an agent holding no leases sent nothing at all,
and the only check-in left was the lease poll, which sleep mode backs
off exponentially to a 900s ceiling (see IDLE_POLL_MAX_SECONDS).
900 against 300: an IDLE agent was structurally guaranteed to read as
stopped. Operator, 2026-09-23: *"I'm running the gpu agent on my device
and it currently reads as 'offline' but it's running and has checked in
recently."* It had — twelve minutes ago, partway up the backoff ladder.
The two halves were written ten weeks apart and never reconciled: sleep
mode landed 2026-07-02, and the roster adopted the lease as its
check-in on 2026-09-02 without noticing the call it was piggybacking on
had been deliberately slowed.
An empty heartbeat extends nothing (`id.in_([])` matches no rows) and
costs one small POST every 45s — against the 6/min lease poll sleep
mode exists to avoid, that is not a cadence worth protecting, and it is
what makes "is the agent alive" answerable at all.
Still gated on `self._running`: a worker that has been stopped is not
checking in for work, and reporting it as present would be a different
lie.
"""
"""Keep every held lease alive so buffered jobs waiting on the GPU aren't
reclaimed by curator's 180s TTL. Errors are swallowed by client.heartbeat;
a reclaimed lease just re-leases elsewhere — never fatal."""
while True:
if self._running:
with self._held_lock:
ids = list(self._held)
self.client.heartbeat(ids)
if ids:
self.client.heartbeat(ids)
time.sleep(HEARTBEAT_INTERVAL)
def _queue_poll_loop(self):
+4 -6
View File
@@ -1,12 +1,10 @@
# CCIP + figure detection (ONNX models, auto-downloaded from HuggingFace).
dghs-imgutils>=0.4
# GPU inference for the ONNX models. Swap to onnxruntime (CPU) for a slow
# server-side fallback run. The extras declare the CUDA/cuDNN pip packages its
# CUDA provider loads (fc_agent/accel.py preloads them) rather than relying on
# torch happening to install the same ones.
onnxruntime-gpu[cuda,cudnn]
# The crop EMBEDDER (concept bag). torch + torchvision are installed separately
# in the Dockerfile from the cu130 wheel index, so pip never swaps them out;
# server-side fallback run.
onnxruntime-gpu
# The crop EMBEDDER (concept bag). torch is installed separately in the
# Dockerfile from the CUDA-12.4 wheel index so the GPU build is deterministic;
# transformers loads whatever SigLIP-family model the server announces.
transformers>=4.45
# Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic
-64
View File
@@ -1,64 +0,0 @@
"""service_seen — the learned roster that makes a stopped part observable.
Milestone 365. Nothing in FabledCurator knew what was SUPPOSED to be running:
`celery inspect` reports the workers that answer, so a dead worker was a
shorter list rather than a red light, and the only surface that could tell an
operator otherwise was Portainer. This table is the memory that turns an
absence into something the app can see.
Keyed on the queue set for a celery role and on agent_id for the GPU agent —
NOT on the celery worker name, which here is `celery@<container id>` and is
minted fresh on every deploy. See the model docstring for why that choice is
the whole design.
## First migration on the collapsed baseline
0089 is the single generated baseline that replaced revisions 0001..0089
(milestone 328). This is the first revision written on top of it, so it is
also the first evidence that the chain steps forward from the collapse rather
than merely reproducing the schema — which nothing had demonstrated yet.
An existing install is at 0089 because it ran the real 0089; a fresh one is at
0089 because it ran the baseline. Both arrive here identically, which was the
property the collapse was designed around.
Revision ID: 0090
Revises: 0089
Create Date: 2026-09-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0090"
down_revision: Union[str, None] = "0089"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"service_seen",
sa.Column("key", sa.String(length=128), nullable=False),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("display_name", sa.String(length=64), nullable=False),
sa.Column(
"first_seen_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"last_seen_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column("details", sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint("key", name=op.f("pk_service_seen")),
)
# No secondary indexes, deliberately: one row per moving part means every
# read is a handful of rows and an index would be write cost buying
# nothing (#3301 removed seven of exactly that shape).
def downgrade() -> None:
op.drop_table("service_seen")
@@ -1,81 +0,0 @@
"""platform_membership — the learned roster of what the account actually pays for.
Milestone 387, phase C. FC knows which creators it was told to follow and
nothing about which ones the operator is subscribed to; this table is the
memory that makes the drift in both directions observable. See the model
docstring for why the roster is learned rather than looked up live, and why
`status` holds the platform's own word rather than a normalised FC value.
## Nothing populates this yet, on purpose
The sweep that fills it (C3) depends on a client seam (C2) that depends on
characterising Patreon's real membership response from a captured sample (C0),
which needs the operator's authenticated browser session. The table's SHAPE
does not wait on that: it is deliberately free-form where C0's findings would
otherwise dictate a column — `status` is an unconstrained String and `details`
keeps the raw payload — so no capture can invalidate what is created here.
An empty table is the correct intermediate state. It is not dead code: C5 reads
it to explain a tier-limited source, and C4 reads it to reconcile.
Revision ID: 0091
Revises: 0090
Create Date: 2026-09-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0091"
down_revision: Union[str, None] = "0090"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"platform_membership",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("platform", sa.String(length=64), nullable=False),
# Text, not a bounded String: an opaque upstream identifier we do not
# mint, and guessing a ceiling for one is how a walk dies on a silent
# truncation.
sa.Column("external_campaign_id", sa.Text(), nullable=False),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("url", sa.Text(), nullable=True),
# No CHECK, deliberately (rule 36 considered and declined): the
# vocabulary is each platform's own and is not ours to fix before C0
# has characterised even one of them. The service owns the whitelist.
sa.Column("status", sa.String(length=32), nullable=True),
sa.Column("tier_names", sa.JSON(), nullable=True),
sa.Column("amount_cents", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=8), nullable=True),
sa.Column(
"first_seen_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"last_seen_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column("details", sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_platform_membership")),
# The upsert's conflict target. Named explicitly because
# touch_membership references it by name in ON CONFLICT — an
# autogenerated name would make that call break on a rename nobody
# connected to it.
sa.UniqueConstraint(
"platform", "external_campaign_id",
name="uq_platform_membership_platform_campaign",
),
)
# No secondary indexes. This table holds one row per subscription — tens,
# not millions — so every query against it is a short scan and an index
# would be write cost buying nothing (#3301 removed seven of that shape).
# The unique constraint above already backs the only lookup that matters.
def downgrade() -> None:
op.drop_table("platform_membership")
-92
View File
@@ -1,92 +0,0 @@
"""Synthetic posts — FC authors a post for content that arrived as chat.
Milestone 388, step E2. Discord is a delivery channel, not a publisher: one
message is not one post, and today every message becomes its own `post` row
competing with authored work for the same surface. This adds the three columns
that let FC group a creator's variant drop into a post it wrote itself, while
keeping that fact visible and the grouping reversible.
## Why a flag and a back-pointer rather than a separate table
A synthetic post has to BE a post — same row, same columns — or every existing
surface (feed, provenance, translation, attachments, series) would need a
second code path for it. `synthesized_by` marks the ones FC authored;
`absorbed_by_post_id` points a member message-post at the post that replaced
it in the feed. The members are not deleted: they remain the images' true
origin, and destroying them would make the grouping un-auditable at exactly
the moment somebody wants to check it.
Reversal is one DELETE. `absorbed_by_post_id` is ON DELETE SET NULL, so
removing a synthetic post releases its members and they return to the feed
unaided.
Revision ID: 0092
Revises: 0091
Create Date: 2026-09-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0092"
down_revision: Union[str, None] = "0091"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# No CHECK on synthesized_by (rule 36 considered and declined): there is one
# grouper today and a second would be a new VALUE, not a new invariant —
# matching source.error_type and service_seen.kind.
op.add_column("post", sa.Column("synthesized_by", sa.String(length=32), nullable=True))
op.add_column("post", sa.Column("synthesis_details", sa.JSON(), nullable=True))
op.add_column(
"post", sa.Column("absorbed_by_post_id", sa.Integer(), nullable=True),
)
op.create_index(
op.f("ix_post_absorbed_by_post_id"), "post", ["absorbed_by_post_id"],
)
# SET NULL, not CASCADE: deleting the synthetic post must RELEASE its
# members, never take them with it. The members are the real capture.
op.create_foreign_key(
"fk_post_absorbed_by_post_id_post", "post", "post",
["absorbed_by_post_id"], ["id"], ondelete="SET NULL",
)
# Grouping tunables. Every one of these is operator-facing (project rule
# 25) because the quality bar here is a judgement call no test can settle:
# too greedy merges distinct pieces, too shy leaves a drop scattered.
op.add_column(
"ml_settings",
sa.Column(
"discord_grouping_enabled", sa.Boolean(),
server_default="true", nullable=False,
),
)
op.add_column(
"ml_settings",
sa.Column(
"discord_group_max_distance", sa.Float(),
server_default=sa.text("0.10"), nullable=False,
),
)
op.add_column(
"ml_settings",
sa.Column(
"discord_group_window_minutes", sa.Float(),
server_default=sa.text("60"), nullable=False,
),
)
def downgrade() -> None:
op.drop_column("ml_settings", "discord_group_window_minutes")
op.drop_column("ml_settings", "discord_group_max_distance")
op.drop_column("ml_settings", "discord_grouping_enabled")
op.drop_constraint("fk_post_absorbed_by_post_id_post", "post", type_="foreignkey")
op.drop_index(op.f("ix_post_absorbed_by_post_id"), table_name="post")
op.drop_column("post", "absorbed_by_post_id")
op.drop_column("post", "synthesis_details")
op.drop_column("post", "synthesized_by")
-86
View File
@@ -1,86 +0,0 @@
"""An open grouping — a synthetic post that a later drop can still join.
Milestone 388, step E3. E2's synthetic post was sealed at creation: a creator
who added two more variants the next day started a second post. These two
columns let the group stay open and absorb the follow-up, without the post
either freezing or thrashing the feed.
## Why openness is derived rather than stored
There is no `closed_at` here on purpose. A group is open if it grew (or
started) within `ml_settings.discord_group_close_after_hours`, so openness is a
comparison rather than a state — which means lowering the setting closes old
groups and raising it reopens them, with nothing to repair either way. A stored
flag would need its own sweep to set it and its own repair path to ever change
the policy, for no gain.
## Why `resurfaced_at` is separate from `last_grew_at`
They answer different questions. `last_grew_at` is when the group last
absorbed something — it decides how long the group stays joinable and is what
the card shows. `resurfaced_at` is the FEED POSITION, advanced only when the
anti-thrash rule fires, so a group that gains one image a day updates in place
while a genuine second wave moves once. Folding them together would make every
addition a bump, which is the annoyance this step exists to avoid.
Both are NULL on every ordinary post, so the feed's sort key can COALESCE
through `resurfaced_at` without moving anything that is not a grouping.
Revision ID: 0093
Revises: 0092
Create Date: 2026-09-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0093"
down_revision: Union[str, None] = "0092"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"post", sa.Column("last_grew_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"post", sa.Column("resurfaced_at", sa.DateTime(timezone=True), nullable=True),
)
# No index on either. The feed already sorts on an unindexed
# COALESCE(post_date, downloaded_at) expression, so adding resurfaced_at to
# that COALESCE changes nothing about how the query plans — and inventing a
# functional index here would be guessing at the fix for a cost nobody has
# measured. Measuring it is step B2's job.
op.add_column(
"ml_settings",
sa.Column(
"discord_group_close_after_hours", sa.Float(),
server_default=sa.text("168"), nullable=False,
),
)
op.add_column(
"ml_settings",
sa.Column(
"discord_group_resurface_min_images", sa.Integer(),
server_default="2", nullable=False,
),
)
op.add_column(
"ml_settings",
sa.Column(
"discord_group_resurface_cooldown_hours", sa.Float(),
server_default=sa.text("24"), nullable=False,
),
)
def downgrade() -> None:
op.drop_column("ml_settings", "discord_group_resurface_cooldown_hours")
op.drop_column("ml_settings", "discord_group_resurface_min_images")
op.drop_column("ml_settings", "discord_group_close_after_hours")
op.drop_column("post", "resurfaced_at")
op.drop_column("post", "last_grew_at")
-124
View File
@@ -1,124 +0,0 @@
"""post_association — "this Patreon post announced that Discord drop".
Milestone 388, step E5.
Two of the operator's artists post a deliberately cropped fragment on Patreon
to signal that the real thing has landed in their Discord. This table holds the
proposed and accepted links between the announcement and the drop.
Directional and confirm-only. The pair is asymmetric (the teaser announces the
drop, not the reverse), the two posts are never merged (the creator published
twice, deliberately — flattening that hides the behaviour being modelled), and
nothing is linked until the operator accepts, following the FC-6.3 series
matcher. A wrongly-asserted association tells them two different pieces are
one, which is worse than no link at all.
Dismissed rows are KEPT. The row is what remembers the rejection, and
re-proposing a rejected pair on every scan is what makes a review queue get
ignored.
Revision ID: 0094
Revises: 0093
Create Date: 2026-09-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0094"
down_revision: Union[str, None] = "0093"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"post_association",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("announcement_post_id", sa.Integer(), nullable=False),
sa.Column("payload_post_id", sa.Integer(), nullable=False),
sa.Column("score", sa.Float(), nullable=False),
sa.Column("signals", sa.JSON(), nullable=True),
# No CHECK on status (rule 36 considered and declined), matching
# series_suggestion.status — the same review-queue vocabulary, and the
# same check-existing-enums lesson.
sa.Column(
"status", sa.String(length=16), server_default="pending", nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_post_association")),
# CASCADE on both sides: an association to a post that no longer exists
# is not a fact worth keeping, and E3's reversal path (delete the
# grouping) must not leave a dangling proposal behind.
sa.ForeignKeyConstraint(
["announcement_post_id"], ["post.id"], ondelete="CASCADE",
name=op.f("fk_post_association_announcement_post_id_post"),
),
sa.ForeignKeyConstraint(
["payload_post_id"], ["post.id"], ondelete="CASCADE",
name=op.f("fk_post_association_payload_post_id_post"),
),
sa.UniqueConstraint(
"announcement_post_id", "payload_post_id",
name="uq_post_association_pair",
),
)
op.create_index(
op.f("ix_post_association_announcement_post_id"),
"post_association", ["announcement_post_id"],
)
op.create_index(
op.f("ix_post_association_payload_post_id"),
"post_association", ["payload_post_id"],
)
op.create_index(
op.f("ix_post_association_status"), "post_association", ["status"],
)
op.add_column(
"import_settings",
sa.Column(
"discord_link_enabled", sa.Boolean(),
server_default="true", nullable=False,
),
)
# 0.60 sits ABOVE the largest single signal weight on purpose — see
# post_association_service.WEIGHTS. That is what makes "time proximity
# alone is never sufficient" arithmetic rather than aspirational.
op.add_column(
"import_settings",
sa.Column(
"discord_link_threshold", sa.Float(),
server_default="0.60", nullable=False,
),
)
op.add_column(
"import_settings",
sa.Column(
"discord_link_window_hours", sa.Float(),
server_default="24", nullable=False,
),
)
def downgrade() -> None:
op.drop_column("import_settings", "discord_link_window_hours")
op.drop_column("import_settings", "discord_link_threshold")
op.drop_column("import_settings", "discord_link_enabled")
op.drop_index(op.f("ix_post_association_status"), table_name="post_association")
op.drop_index(
op.f("ix_post_association_payload_post_id"), table_name="post_association",
)
op.drop_index(
op.f("ix_post_association_announcement_post_id"), table_name="post_association",
)
op.drop_table("post_association")
-63
View File
@@ -1,63 +0,0 @@
"""membership_sync — whether the roster actually synced, and when.
Milestone 387, step C3.
`platform_membership` (0091) records what was SEEN. This records whether
looking happened at all — a different fact, and the one that makes an empty
roster readable.
Without it, three situations collapse into one: the account subscribes to
nothing, the sweep never ran, or the sweep failed. All three leave zero rows
in `platform_membership`. "You are tracking 12 sources you no longer subscribe
to" is correct in the first case and an invitation to cancel things the
operator is actively paying for in the other two, which is why C4 gates its
CONCLUSIONS on `last_success_at` rather than merely displaying it.
Two timestamps rather than one, deliberately: `last_attempt_at` moves every
run, `last_success_at` only on a clean walk, and the gap between them is what
lets the UI say "last synced 3 days ago, tried 20 minutes ago, failing".
Revision ID: 0095
Revises: 0094
Create Date: 2026-09-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0095"
down_revision: Union[str, None] = "0094"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"membership_sync",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("platform", sa.String(length=64), nullable=False),
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_count", sa.Integer(), nullable=True),
# No CHECK: this carries an exception class name, and the vocabulary is
# whatever the client raises — same call as source.error_type.
sa.Column("last_error_type", sa.String(length=64), nullable=True),
sa.Column("last_error_message", sa.Text(), nullable=True),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_membership_sync")),
# The upsert's conflict target, named explicitly because the service
# references it by name in ON CONFLICT.
sa.UniqueConstraint("platform", name="uq_membership_sync_platform"),
)
# No secondary indexes: one row per platform, so every read is a short scan
# and an index would be write cost buying nothing (#3301 removed seven of
# that shape). Same reasoning as platform_membership in 0091.
def downgrade() -> None:
op.drop_table("membership_sync")
@@ -1,106 +0,0 @@
"""artist_membership_suggestion — proposing that a creator and a membership match.
Milestone 388, step E4.
## What this migration deliberately does NOT add
No association table between Artist and Source, and no schema change to either.
E4's first job was to verify what was actually missing, and the answer was
neither the model nor the flows: `Source.artist_id` is a plain FK so many
sources per artist already works, `POST /api/sources` already takes an
`artist_id`, the add-source dialog already attaches to an EXISTING artist, and
`SourceService.reassign` already moves a source between artists with post and
image re-attribution. Building a parallel association table for a relationship
the schema already expresses would have been the mistake rule 28 names.
What was missing is the SUGGESTION, and that is all this table holds.
Accepting a suggestion adds a SOURCE under the existing artist — it never
merges two artists. Adding a source is trivially undone; a wrong merge silently
mixes two creators' work and corrupts tagging, series and provenance with
nothing left to separate them by.
Revision ID: 0096
Revises: 0095
Create Date: 2026-09-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0096"
down_revision: Union[str, None] = "0095"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"artist_membership_suggestion",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("platform_membership_id", sa.Integer(), nullable=False),
sa.Column("artist_id", sa.Integer(), nullable=False),
sa.Column("score", sa.Float(), nullable=False),
sa.Column("signals", sa.JSON(), nullable=True),
# No CHECK on status (rule 36 considered and declined), matching
# series_suggestion and post_association — the same review-queue
# vocabulary and the same check-existing-enums lesson.
sa.Column(
"status", sa.String(length=16), server_default="pending", nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_artist_membership_suggestion")),
# CASCADE both ways: a suggestion about a membership or an artist that
# no longer exists is not a fact worth keeping, and a dangling proposal
# would render as a broken row in the review queue.
sa.ForeignKeyConstraint(
["platform_membership_id"], ["platform_membership.id"],
ondelete="CASCADE",
name=op.f("fk_artist_membership_suggestion_membership"),
),
sa.ForeignKeyConstraint(
["artist_id"], ["artist.id"], ondelete="CASCADE",
name=op.f("fk_artist_membership_suggestion_artist_id_artist"),
),
sa.UniqueConstraint(
"platform_membership_id", "artist_id",
name="uq_artist_membership_suggestion_pair",
),
)
op.create_index(
op.f("ix_artist_membership_suggestion_platform_membership_id"),
"artist_membership_suggestion", ["platform_membership_id"],
)
op.create_index(
op.f("ix_artist_membership_suggestion_artist_id"),
"artist_membership_suggestion", ["artist_id"],
)
op.create_index(
op.f("ix_artist_membership_suggestion_status"),
"artist_membership_suggestion", ["status"],
)
def downgrade() -> None:
op.drop_index(
op.f("ix_artist_membership_suggestion_status"),
table_name="artist_membership_suggestion",
)
op.drop_index(
op.f("ix_artist_membership_suggestion_artist_id"),
table_name="artist_membership_suggestion",
)
op.drop_index(
op.f("ix_artist_membership_suggestion_platform_membership_id"),
table_name="artist_membership_suggestion",
)
op.drop_table("artist_membership_suggestion")
@@ -1,68 +0,0 @@
"""Disable sources on retired platforms, so the scheduler stops selecting them.
Milestone #406, phase 1 (switch pixiv off). Rule #171 records the scope decision.
## Why this is a migration and not a button
The live instance had one pixiv source still ENABLED when pixiv was retired
(read 2026-09-13, step 1) even though the operator believed it gone. Unregistering
a platform removes it from code; it does not touch the `source` rows that name it.
Left enabled, that row keeps being picked by the scheduler every interval, and
`download_backends` now refuses it with `unsupported_url` — forever, as a
climbing failure count on a source the operator has already given up.
A migration reaches the live instance on deploy without depending on anyone
finding the row and clicking it. The `run_download` guard is what makes a stale
enabled row SAFE; this is what makes it QUIET.
## Deliberately NOT done here
- **No rows are deleted.** Deleting a source sets its posts' `source_id` to NULL
(FK `ON DELETE SET NULL`), and `uq_post_artist_external_id_null_source` can
reject that if a source-less copy of one of those posts already exists. That
needs checking against real data first, which is phase 2's job (step 6). A
disable cannot collide with anything.
- **No posts or images are touched.** The art stays.
- **deviantart is included** because #3069 retired it and nothing disabled its
rows either. The read found none, so for it this is a no-op — written anyway,
so the statement names every retired platform rather than just the latest one.
## Hardcoded platform names
A migration is a record of one event, frozen in time, so it names the platforms
it acted on rather than importing today's registry — the registry will keep
changing and this revision must not.
Revision ID: 0097
Revises: 0096
Create Date: 2026-09-13
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0097"
down_revision: Union[str, None] = "0096"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Clears the failure state the same way `SourceService.update` does when a
# source is disabled through the app (issue #1285), so a retired source
# does not keep showing as failing after it stops being polled. A disable
# done here and one done by clicking must leave identical rows.
op.execute(
"UPDATE source SET enabled = false, last_error = NULL, "
"error_type = NULL, consecutive_failures = 0 "
"WHERE enabled AND platform IN ('pixiv', 'deviantart')"
)
def downgrade() -> None:
# Irreversible by design: which of these rows were enabled before is not
# recorded, and re-enabling every retired-platform source would resume
# polling services the product no longer supports. Rule #22 owes no
# migration story back to a dropped platform.
pass
@@ -1,90 +0,0 @@
"""Widen image_record.phash to 256-bit and re-hash the library (issue #4223).
The operator reported a 15-image variant pack landing as 3 records, and then
that variants were STILL being dropped with `phash_threshold` at 0. Zero was
already the floor of the dial, so no setting could have fixed it: at
`hash_size=8` a pHash is 64 bits of coarse light/dark layout, and variant
artwork sharing a composition produces the SAME 64 bits. Distance 0 meant
"identical hash", never "identical image".
`utils/phash.py` moves to `hash_size=16` (256 bits, what ImageRepo always
used) and adds an aspect-ratio gate plus a pixel-level confirm, so a merge is
accepted on the files rather than on the hash.
## Why this NULLs every phash
Widening the column does not correct the values already in it. Every stored
hash is a 64-bit hash of an image the app will now hash at 256 bits, and the
two cannot be compared — `find_similar` skips a mismatched-length candidate
rather than guessing, so leaving them would silently mean "no dedup, forever,
for everything imported before today". NULL is the state `backfill_phash`
already knows how to repair: it is NULL-only, keyset-paginated and
restart-safe, and the beat schedule runs it daily.
Until that backfill finishes, image dedup degrades to sha256 only —
duplicates may be kept. That is the safe direction, and the only one
available: the alternative is comparing hashes of different widths, which
would drop artwork. NOTHING here deletes or supersedes a file.
## Why the threshold is reset rather than carried over
`phash_threshold` counts bits, and the denominator went from 64 to 256. The
stored number would keep its value while meaning something four times
tighter. There is no honest carry-over, so every row goes to the new default
of 24 — including the operator's 0, which was a workaround for the bug this
revision fixes.
Revision ID: 0098
Revises: 0097
Create Date: 2026-09-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0098"
down_revision: Union[str, None] = "0097"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# varchar(32) -> varchar(64): widening a length limit is a catalog-only
# change in Postgres, so this does not rewrite the table or its index.
op.alter_column(
"image_record", "phash",
existing_type=sa.String(32),
type_=sa.String(64),
existing_nullable=True,
)
op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL")
op.alter_column(
"import_settings", "phash_threshold",
existing_type=sa.Integer(),
server_default="24",
existing_nullable=False,
)
op.execute("UPDATE import_settings SET phash_threshold = 24")
def downgrade() -> None:
# The 64-bit hashes this replaced are gone, and a 64-char value does not
# fit back into varchar(32) — so the column is cleared again on the way
# down and left for backfill_phash to refill at whatever HASH_SIZE the
# code is running. Rule #22: no legacy to preserve.
op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL")
op.alter_column(
"image_record", "phash",
existing_type=sa.String(64),
type_=sa.String(32),
existing_nullable=True,
)
op.alter_column(
"import_settings", "phash_threshold",
existing_type=sa.Integer(),
server_default="10",
existing_nullable=False,
)
op.execute("UPDATE import_settings SET phash_threshold = 10")
@@ -1,99 +0,0 @@
"""library_placement_run — the placement reconciler's plan/apply/undo ledger.
Milestone #421 step 3. The survey (#4245) measured 33,789 ImageRecord rows
sitting outside their artist's canonical directory, across 56 artists. This
table holds one run of the sweep that trues them up: the plan, what it did,
and where every file came from.
## Why the moves live in a table rather than a log line
`ImageRecord.path` is the only pointer at the bytes, so a move rewrites the
row. Once that write lands, the previous location exists nowhere — unless it
was recorded first. `moves` is that record, which is what makes a 33,789-file
operation something the operator can undo per artist after looking at the
result, rather than a one-way door.
An `applied` row is therefore HISTORY, not state (lesson #4226). Any future
retention on this table may prune `ready`, `cancelled` and `error` runs; an
`applied` one is only disposable once someone decides undo is no longer
wanted. That is deliberately not a timer's decision, and no pruning is added
here.
Revision ID: 0099
Revises: 0098
Create Date: 2026-09-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0099"
down_revision: Union[str, None] = "0098"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"library_placement_run",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"status", sa.String(length=16), server_default="running",
nullable=False,
),
# SET NULL, not CASCADE: deleting an artist must not destroy the
# record of where their files were moved.
sa.Column("artist_id", sa.Integer(), nullable=True),
sa.Column(
"started_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"planned_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"moved_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"refused_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"moves", postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"), nullable=False,
),
sa.Column(
"refusals", postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"), nullable=False,
),
sa.Column("error", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["artist_id"], ["artist.id"],
name="fk_library_placement_run_artist_id", ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_library_placement_run_status", "library_placement_run", ["status"],
)
op.create_index(
"ix_library_placement_run_artist_id", "library_placement_run",
["artist_id"],
)
def downgrade() -> None:
# Dropping this table destroys the only record of where moved files came
# from. That is correct for a downgrade — the code that reads it is going
# away too — but it is worth saying out loud rather than discovering.
op.drop_index(
"ix_library_placement_run_artist_id",
table_name="library_placement_run",
)
op.drop_index(
"ix_library_placement_run_status", table_name="library_placement_run",
)
op.drop_table("library_placement_run")
@@ -1,109 +0,0 @@
"""Drop library_placement_run — the placement reconciler is removed.
Milestone #421 built a sweep that compared each image's `artist_id` to the
name of the directory its file sat in, and called every mismatch a misplaced
file. On the operator's library that reported 33,789 of 63,605 images as
wrongly filed.
That number was an artefact of the comparison, not a fact about the library:
- **97.1%** of it was one artist's own folder spelled differently —
`Telepurte/` versus `telepurte/`. Same artist, same art, nothing wrong.
- Of the 1% that sat in a differently-named folder, querying `ImageProvenance`
— which records the post and source each file was actually downloaded from —
showed 87 where provenance agreed with the FOLDER and not the record, and 40
genuinely posted by several creators. The sweep would have misfiled or
arbitrarily picked for roughly 41% of that set.
The system already knows where every file came from. The reconciler inferred
it from a column and a directory name instead, and manufactured work out of a
naming convention. Operator's call, 2026-09-21: *"the current system
consistently records where items are and where they came from this is just
complicating something works and doesn't need fixing."*
Rule #22 — no legacy to preserve. The table goes with the code.
## What is deliberately kept
`utils.paths.canonical_subdir` stays: new filesystem imports derive their
directory from the artist's slug, matching what the downloader has always
done. It is not part of this tool and removing it would be churn for no fix.
Run 1's 327 moved files (`InsoUwu/` -> `insouwu/`) also stay where they are —
same artist either way, and the gallery renders them correctly.
Revision ID: 0100
Revises: 0099
Create Date: 2026-09-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0100"
down_revision: Union[str, None] = "0099"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index(
"ix_library_placement_run_artist_id",
table_name="library_placement_run",
)
op.drop_index(
"ix_library_placement_run_status", table_name="library_placement_run",
)
op.drop_table("library_placement_run")
def downgrade() -> None:
# Recreates the table only. The three runs it held (one applied, two
# planned-and-never-run) are not restored and are not worth restoring —
# the code that reads them is gone.
op.create_table(
"library_placement_run",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"status", sa.String(length=16), server_default="running",
nullable=False,
),
sa.Column("artist_id", sa.Integer(), nullable=True),
sa.Column(
"started_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"planned_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"moved_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"refused_count", sa.Integer(), server_default="0", nullable=False,
),
sa.Column(
"moves", postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"), nullable=False,
),
sa.Column(
"refusals", postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"), nullable=False,
),
sa.Column("error", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["artist_id"], ["artist.id"],
name="fk_library_placement_run_artist_id", ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_library_placement_run_status", "library_placement_run", ["status"],
)
op.create_index(
"ix_library_placement_run_artist_id", "library_placement_run",
["artist_id"],
)
@@ -1,66 +0,0 @@
"""Clear failure state on sources that are disabled (#4279).
`failing_sources_clause()` now means "enabled AND erroring", so a disabled
source no longer counts as failing. That fixes what the surfaces REPORT; it
does not touch what the rows already CARRY, and the rows are the reason the
operator saw a banner for six days with no way to act on it (lesson #4202 —
a guard does not undo the value already stored).
## The row this exists for
Ebi77 (source 19): the membership sweep stopped it as `former_patron` at
02:50 on 2026-09-15 and correctly cleared its failure state. A deep scan was
armed twenty minutes later — `/backfill` had no `enabled` guard, which this
release also fixes — and could not complete without access, so the recovery
sweep stranded it:
consecutive_failures = 1
last_error = "stranded by recovery sweep (no terminal status after time_limit)"
Nothing could clear that. A disabled source is never scheduled, so no
successful run resets the counter; `SourceService.update` clears failure
state only on an explicit disable, and the source was already disabled; and
the card's Retry routes to `/check`, which refuses a disabled source.
## Why every disabled source, not just that one
The clear matches what `SourceService.update` already does when a source is
disabled through the app — "disable the subs you're not paying for without
them lingering as failing" — so this brings rows disabled by any OTHER path
(the membership sweep, a retired platform in 0097) into line with the rows
disabled by hand. Same shape as 0097: a repair migration reaches the live
instance on deploy rather than waiting for someone to find the row.
Enabled sources are untouched — a real failure on a live source must keep
showing.
Revision ID: 0101
Revises: 0100
Create Date: 2026-09-21
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0101"
down_revision: Union[str, None] = "0100"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"UPDATE source SET last_error = NULL, error_type = NULL, "
"consecutive_failures = 0 "
"WHERE NOT enabled "
"AND (last_error IS NOT NULL OR error_type IS NOT NULL "
" OR consecutive_failures <> 0)"
)
def downgrade() -> None:
# Irreversible by design: the cleared strings and counts are not recorded
# anywhere, and restoring a failure state nobody can act on would only
# re-create the banner this removes. Rule #22 owes no story backwards.
pass
@@ -1,138 +0,0 @@
"""Drop pixiv's ledgers, and delete credentials for platforms FC no longer has.
Milestone #406 step 6 (with issue #3980 folded in). Phase 1 unregistered pixiv
and the commit alongside this one deleted its client, downloader, ingester and
models. This removes the data those models described, and the stored secrets of
every platform that has been retired.
## The two ledger tables
`pixiv_seen_media` and `pixiv_failed_media` are the per-source seen / dead-letter
ledgers for a downloader that no longer exists. They were created in
`0089_baseline.py`, so dropping them needs a new revision rather than an edit
there.
## The credentials
Written as *delete every credential whose platform is not registered* rather
than as `platform = 'pixiv'`, at the explicit ask in this step's plan. That is
what makes one migration cover two retirements:
- **pixiv** — a live OAuth refresh token for a service FC no longer talks to.
- **deviantart** — issue #3980. #3069 retired DeviantArt in code on 2026-08-27
and left its stored session behind; seven weeks later it was still there.
And it is the only way either row can go. The credentials UI
(`subscriptions/SettingsTab.vue`) renders one card per platform returned by
`/api/platforms`, then looks the credential up by key — so a row whose platform
is unregistered has no card, no Remove button, and no way for the operator to
reach it. `CredentialService.list()` would return it; nothing asks.
The registered set is written out literally instead of importing
`known_platform_keys()`. A migration is a statement about one moment in the
schema's history: if it imported the live registry, retiring a fifth platform
in 2027 would silently change what this 2026 revision did on a fresh database.
The list below is the registry as of 2026-09-21.
## What is deliberately kept
**Every pixiv `Source` row.** The original plan deleted them; the operator's
call on 2026-09-21 was to keep them, and the reason is that `platform` is
stored ONLY on `Source` — neither `Post` nor `ImageRecord` carries it. Both
FKs are `ON DELETE SET NULL`, so a delete would not lose the art, but it would
drop every pixiv image into the gallery's `__unsourced__` bucket and strip the
platform chip off every pixiv post. The rows stay disabled (0097) and their
platform is unregistered, so nothing schedules them, nothing downloads through
them, and `POST /api/sources` will not make another. Keeping them costs
nothing and keeps the attribution the milestone's goal — *"the art already
downloaded from pixiv stays"* — is actually about.
Every pixiv `Post` and `ImageRecord` is likewise untouched.
Revision ID: 0102
Revises: 0101
Create Date: 2026-09-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0102"
down_revision: Union[str, None] = "0101"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# services/platforms/__init__.py's PLATFORMS as of this revision. See the
# docstring for why this is a literal and not an import.
_REGISTERED_PLATFORMS = ("patreon", "subscribestar", "hentaifoundry", "discord")
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM credential WHERE platform NOT IN :registered"
).bindparams(
sa.bindparam("registered", value=_REGISTERED_PLATFORMS, expanding=True)
)
)
op.drop_index("ix_pixiv_failed_media_source_id", table_name="pixiv_failed_media")
op.drop_table("pixiv_failed_media")
op.drop_index("ix_pixiv_seen_media_source_id", table_name="pixiv_seen_media")
op.drop_table("pixiv_seen_media")
def downgrade() -> None:
# The tables come back empty, and the credentials do not come back at all:
# they were encrypted blobs, this migration does not copy them anywhere,
# and restoring a live token for a platform FC cannot talk to would only
# re-create the liability. Rule #22 owes no story backwards.
op.create_table(
"pixiv_seen_media",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("filehash", sa.String(length=128), nullable=False),
sa.Column("url", sa.Text(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"],
name=op.f("fk_pixiv_seen_media_source_id_source"), ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_pixiv_seen_media")),
sa.UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_seen_media_source_id",
),
)
op.create_index(
op.f("ix_pixiv_seen_media_source_id"), "pixiv_seen_media", ["source_id"],
unique=False,
)
op.create_table(
"pixiv_failed_media",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("filehash", sa.String(length=128), nullable=False),
sa.Column("url", sa.Text(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("attempts", sa.Integer(), server_default="1", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"],
name=op.f("fk_pixiv_failed_media_source_id_source"), ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_pixiv_failed_media")),
sa.UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_failed_media_source_id",
),
)
op.create_index(
op.f("ix_pixiv_failed_media_source_id"), "pixiv_failed_media", ["source_id"],
unique=False,
)
@@ -1,121 +0,0 @@
"""worker_lane — settings-backed slots for each celery lane.
Milestone 422 step 1. One row per lane, holding only what an operator can
change: how many slots it runs, the ceiling they have set for themselves, and
whether it consumes its queues at all.
## What is deliberately not a column
**The queues.** They are decided by `celery_app.py`'s `task_routes`, not by
preference, so a stored copy could contradict the routing table with nothing
to notice until a queue had no consumer. They live in
`services/worker_lanes.py`.
**The derived ceiling.** Computed from the container's cgroup limits on every
read. A row written on a 32GB host and later run in a 4GB container must be
bounded by the 4GB; a stored ceiling would quietly authorise what the box can
no longer hold.
## The seeded values
Written out literally rather than imported from `worker_lanes.LANES`. A
migration is a statement about one moment in the schema's history — if it
imported the live defaults, changing them in 2027 would silently change what
this 2026 revision does on a fresh database. The two are allowed to diverge
afterwards, and that is correct: `LANES` supplies defaults for a lane added
later, this file records what was seeded today.
lane slots cap enabled
worker 1 4 yes
scheduler 1 2 yes
maintenance_long 1 2 yes
ml 0 1 NO
One of each, per the operator (2026-09-22: *"that starting value should be one
of each"*), and far below their own production numbers — worker 8 and ml 2 are
tuned for their hardware and are not a sane first boot for a stranger.
**ml ships at zero and disabled**, which is milestone 422 step 6's requirement
arriving early: enabling the lane is what triggers the SigLIP download, and
rule 164 permits a runtime fetch only for a feature that is "optional and
clearly off". Seeding it on would make every fresh install reach HuggingFace.
The caps start low on purpose. A cap that begins at the ceiling is a rubber
stamp; starting at 4/2/2/1 means raising slots within the cap is ordinary and
raising the cap is a deliberate act.
## Existing installs
Nothing is migrated FROM. The `CELERY_QUEUES` / `CELERY_CONCURRENCY` env vars
stay exactly as they are and remain the baseline each lane boots at; these
rows are the adjustment applied on top (step 3). So this migration changes no
behaviour on a running stack — it only makes the numbers storable.
Revision ID: 0103
Revises: 0102
Create Date: 2026-09-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0103"
down_revision: Union[str, None] = "0102"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (name, slots, slots_cap, enabled) — see the docstring for why these are
# literals and not an import.
_SEED = (
("worker", 1, 4, True),
("scheduler", 1, 2, True),
("maintenance_long", 1, 2, True),
("ml", 0, 1, False),
)
def upgrade() -> None:
worker_lane = op.create_table(
"worker_lane",
sa.Column("name", sa.String(length=32), nullable=False),
sa.Column("slots", sa.Integer(), nullable=False),
sa.Column("slots_cap", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.PrimaryKeyConstraint("name", name=op.f("pk_worker_lane")),
# Bare constraint names: Base.metadata's naming convention prepends
# ck_worker_lane_, and pre-prefixing doubles it — the defect alembic
# 0088 had to rename four constraints for (#3275). op.f() marks these
# as already-final so autogenerate does not propose renaming them.
sa.CheckConstraint("slots >= 0", name=op.f("ck_worker_lane_slots_non_negative")),
sa.CheckConstraint("slots_cap >= 0", name=op.f("ck_worker_lane_cap_non_negative")),
# The invariant that makes the cap mean anything, in the database
# rather than only in the service: a row violating it is not a
# rejected request, it is a lane that step 3's reconcile will drive UP
# to a number the operator capped.
sa.CheckConstraint("slots <= slots_cap", name=op.f("ck_worker_lane_slots_within_cap")),
)
# No index beyond the primary key, deliberately — four rows, forever. Same
# reasoning as service_seen, and the lesson of #3301, which removed seven
# indexes that were write cost buying nothing.
op.bulk_insert(
worker_lane,
[
{"name": name, "slots": slots, "slots_cap": cap, "enabled": enabled}
for name, slots, cap, enabled in _SEED
],
)
def downgrade() -> None:
# The rows go with the table. They are settings with shipped defaults, not
# operator data that predates this revision — a downgrade returns the stack
# to reading its concurrency from env, which is where it reads it from
# today anyway.
op.drop_table("worker_lane")
@@ -1,51 +0,0 @@
"""worker_lane.autoscale — may this lane grow itself?
Milestone 422 step 7. One boolean, defaulting FALSE on every existing row and
on every new one.
## Why the default is false and not "sensible"
This is the only part of the milestone that acts without anyone watching. The
manual dial (step 4) and the reconcile (step 3) both do exactly what someone
asked for; this one decides. Shipping it on would mean every install starts
with a process that changes its own resource usage based on a heuristic tuned
against nobody's workload.
Off also makes the failure mode benign: if the signal is wrong, nothing
happens until an operator opts a lane in, and they opted in while watching.
## Why per lane and not one global switch
The lanes are not alike in what a slot costs. A `worker` slot is a process;
an `ml` slot is another copy of a ~3.5GB model. A global switch would enable
growth on a lane whose behaviour under load nobody has observed, and the one
it would hurt most is the one whose cost is least visible.
Revision ID: 0104
Revises: 0103
Create Date: 2026-09-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0104"
down_revision: Union[str, None] = "0103"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"worker_lane",
sa.Column(
"autoscale", sa.Boolean(),
server_default=sa.text("false"), nullable=False,
),
)
def downgrade() -> None:
op.drop_column("worker_lane", "autoscale")
@@ -1,133 +0,0 @@
"""worker_lane — one number: the cap. `slots`, `enabled` and `autoscale` go.
Milestone 422, reshaped by the operator 2026-09-23:
"auto should be always on, not a setting, so that idle instances quiet
down when not running. the number that is visible and something the user
can tweak and manage should be the cap itself the number of running
workers is handled by the autoscaling function which is always on."
## What each dropped column was, and why it is not needed
**`slots`** — how many workers the lane should run. That is a MEASUREMENT,
not a preference: the autoscaler moves the live pool between one and the cap
according to the backlog, and reads it back from the worker every minute.
Storing it made it look like something to keep in agreement with the cap,
which is exactly what the operator had to do.
**`autoscale`** — whether the lane was allowed to size itself. It gated the
mechanism behind a per-lane opt-in, so a lane nobody enabled simply never
gave its slots back. Always on now, which is the only way "idle instances
quiet down" can be true of an install nobody has configured.
**`enabled`** — whether the lane consumes its queues. Derived from `cap > 0`.
It and `slots = 0` were two spellings of one fact and were free to disagree;
this migration picks the one an operator can see.
## Why the caps are rewritten rather than preserved
The old defaults were 4 / 2 / 2 / 1, chosen when the number meant "the most
you may raise SLOTS to" — a bound on a manual control, deliberately loose
because moving within it was the ordinary act. The number now means "the most
workers this lane may actually use", which is a different promise, and
carrying the old figure over would silently quadruple the worker lane on
every existing install at the moment this deploys.
So every row is reset to the new defaults: **one for each required lane, zero
for ML.** That loses whatever an operator had set — which is the honest
trade, because what they set was an answer to a different question. The UI
now tells a busy lane's operator to raise its cap, which is how the number
gets back up on an install that needs it.
ML at zero also keeps rule 164's carve-out intact: no consumers, so no model
download until someone raises the cap.
Revision ID: 0105
Revises: 0104
Create Date: 2026-09-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0105"
down_revision: Union[str, None] = "0104"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (name, cap) — the same values `services/worker_lanes.LANES` declares. Seeded
# here as literals rather than imported: a migration must describe the schema
# at ITS point in history, and importing the live table would make this file
# change meaning every time that table does.
_CAPS = (
("worker", 1),
("scheduler", 1),
("maintenance_long", 1),
("ml", 0),
)
def upgrade() -> None:
# The constraints go first: they name `slots`, so dropping the column out
# from under them fails on Postgres.
#
# `op.f()` around each name, and it is load-bearing. Without it alembic
# runs the name through Base.metadata's naming convention, which prepends
# `ck_worker_lane_` to a string that already carries it — and the DROP
# goes looking for `ck_worker_lane_ck_worker_lane_slots_within_cap`, which
# no database has. That is #3275 exactly, from the other direction:
# alembic 0088 had to RENAME four constraints created with the same
# doubling. Caught here by the integration lane, run 7365.
op.drop_constraint(
op.f("ck_worker_lane_slots_within_cap"), "worker_lane", type_="check",
)
op.drop_constraint(
op.f("ck_worker_lane_slots_non_negative"), "worker_lane", type_="check",
)
op.drop_column("worker_lane", "slots")
op.drop_column("worker_lane", "enabled")
op.drop_column("worker_lane", "autoscale")
# Reset to the new meaning. See the docstring: the old value answered a
# different question, and carrying it over would raise every lane.
for name, cap in _CAPS:
op.execute(
sa.text("UPDATE worker_lane SET slots_cap = :cap WHERE name = :name")
.bindparams(cap=cap, name=name)
)
# A lane the old seed never wrote — or one an operator added by hand — is
# left alone rather than guessed at. `_rows_by_name` creates any missing
# row at the lane's default on first read.
def downgrade() -> None:
op.add_column(
"worker_lane",
sa.Column("slots", sa.Integer(), nullable=False, server_default="1"),
)
op.add_column(
"worker_lane",
sa.Column(
"enabled", sa.Boolean(), nullable=False, server_default=sa.text("true"),
),
)
op.add_column(
"worker_lane",
sa.Column(
"autoscale", sa.Boolean(), nullable=False, server_default=sa.text("false"),
),
)
# Restore the pre-0105 invariants. `slots` comes back as 1 everywhere and
# the caps are 1/1/1/0, so a lane at cap 0 would violate `slots <= cap` —
# hence the clamp before the constraint is added.
op.execute(sa.text("UPDATE worker_lane SET slots = 0 WHERE slots_cap = 0"))
op.execute(sa.text("UPDATE worker_lane SET enabled = (slots_cap > 0)"))
op.create_check_constraint(
op.f("ck_worker_lane_slots_non_negative"), "worker_lane", "slots >= 0",
)
op.create_check_constraint(
op.f("ck_worker_lane_slots_within_cap"), "worker_lane", "slots <= slots_cap",
)
@@ -1,70 +0,0 @@
"""service_seen — delete the roster rows the fixed code can no longer write.
Operator, 2026-09-23: *"clean up the stale service_seen rows"*. They were not
stale. They were PHANTOMS, written on purpose by code that identified a celery
worker from the queues it was consuming.
A lane at cap 0 has its consumers cancelled, so it answers `active_queues()`
with an empty list. The roster grouped on that empty set, wrote it under the
key `celery:` and rendered `role_display_name(())` as the display name — a row
called **`Worker ()`**, reported as running, beside the real lane's row going
stale because nothing updated it any more.
`worker_lanes.lane_for_node` fixes the cause: a worker is attributed by its
NODE NAME, which survives having no consumers. Nothing will write `celery:`
again.
## Why a migration and not a retention sweep
Lesson #4202: a guard that refuses to produce a bad value does not undo the
bad value already stored. The row is the thing that has to change.
And it must be deleted rather than aged out, because the roster deliberately
NEVER forgets — *"anything that has run at least once stays listed, that is
what lets a stopped one be noticed rather than simply vanishing"*. A row that
merely goes quiet is exactly what the roster is for. Only a row that cannot
correspond to anything real is safe to remove, and `celery:` is precisely
that: the empty queue set, which no correctly-attributed worker can produce.
## What is deliberately NOT deleted
**Celery rows with a real but unmatched queue set.** A deployment slicing
`CELERY_QUEUES` differently is supported and its rows are true. It is not this
migration's business to decide that somebody else's worker is obsolete.
**Agent rows, including a possible `agent:agent` from a build that omitted
`agent_id`.** Nothing here can tell an abandoned agent id from a second agent
that is currently down, and deleting a real one would hide a genuinely dead
GPU agent — the one thing the roster exists to show. If such a row is present
it needs a person to look at it, not a migration guessing.
Revision ID: 0106
Revises: 0105
Create Date: 2026-09-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0106"
down_revision: Union[str, None] = "0105"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Exactly the one key the empty queue set produced. Matched literally
# rather than by a LIKE or a prefix: `celery:` with nothing after it is
# the phantom, and `celery:ml` is a real lane.
op.execute(
sa.text("DELETE FROM service_seen WHERE key = :key").bindparams(key="celery:")
)
def downgrade() -> None:
# Nothing. The row carried no information — an empty queue set and a
# timestamp — and the roster re-learns anything real on its next refresh.
# Re-creating it would put a phantom back.
pass
@@ -1,63 +0,0 @@
"""worker_lane_sample — where the sizing sweep leaves what it measured.
Operator, 2026-09-23, on the System tab: *"there is a repull every time this
page loads — is there a reason this info isn't being tracked in the
background and stored in some way?"*
`/api/system/workers` ran a full celery inspect on every call — four
broadcasts on an eleven-second budget — and the page polls it every fifteen
seconds. `size_worker_lanes` was already inspecting on a timer to decide pool
sizes, computing exactly these numbers and discarding them. This table is
where they land instead, and the endpoint becomes a plain read.
## Why a new table rather than columns on `worker_lane`
`worker_lane` holds the one number an operator sets. Putting a measurement
beside it is the mistake alembic 0105 undid: `slots` sat next to `slots_cap`,
and a measurement next to a preference reads as a second preference.
No backfill. A row appears when the sweep first runs (within its period), and
until then the lane reads as not-yet-measured, which is true.
Revision ID: 0107
Revises: 0106
Create Date: 2026-09-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0107"
down_revision: Union[str, None] = "0106"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"worker_lane_sample",
sa.Column("lane", sa.String(length=32), primary_key=True),
# Nullable=False with no server_default: the sweep writes every column
# on every upsert, so a row only ever exists complete.
sa.Column("present", sa.Boolean(), nullable=False),
sa.Column("replicas", sa.Integer(), nullable=False),
# Nullable on purpose — unknown, never zero. A worker that answered
# without reporting its pool, and a queue the broker did not answer
# for, must not be summed as empty.
sa.Column("pool", sa.Integer(), nullable=True),
sa.Column("active", sa.Integer(), nullable=False),
sa.Column("reserved", sa.Integer(), nullable=False),
sa.Column("queue_depth", sa.Integer(), nullable=True),
sa.Column(
"measured_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("worker_lane_sample")
@@ -1,54 +0,0 @@
"""download_revisit_days — how far back a tick keeps looking for EDITED posts.
Operator, 2026-09-23, pointing at a Floppystack post: *"this post has been
updated as he implements hot fixes — any chance we have a way to scan for or
see updated posts so we can update ours to match and pull the new attachments
and pictures etc."*
A tick stopped after 20 contiguous already-have-it items. That is the right
instinct and the wrong unit: a post edited three days after publication sits
well below twenty seen items, so the walk turned around before reaching it. The
walk now needs BOTH a run of seen items and a post older than this many days
before it stops.
A settings row rather than a constant (rule 25) because the right window is a
property of the CREATOR, not of FabledCurator — one artist appends hotfix
builds for a fortnight, another never touches a post again. 0 turns the revisit
off entirely and restores the pure count early-out.
30 days is the operator's own number, 2026-09-23.
Revision ID: 0108
Revises: 0107
Create Date: 2026-09-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0108"
down_revision: Union[str, None] = "0107"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# server_default so the existing single settings row gets the window without
# a data migration — and so an install that predates this column reads 30
# rather than 0. 0 is a real, meaningful value here (revisit off), so the
# column must never be allowed to arrive at it by omission.
op.add_column(
"import_settings",
sa.Column(
"download_revisit_days",
sa.Integer(),
nullable=False,
server_default="30",
),
)
def downgrade() -> None:
op.drop_column("import_settings", "download_revisit_days")
@@ -1,44 +0,0 @@
"""discord_link_auto — whether FC links a conclusive pair without asking.
Operator, 2026-09-24: *"I don't want this to be manual that defeats the
convenience that I'm going for."*
Confirm-only was right while every signal was circumstantial. Time proximity
and a body that mentions Discord can never be more than suggestive, so asking
was the honest response. A shared working name is different in kind: when the
creator's own name for a piece appears in exactly these two posts and nowhere
else in their library, there is nothing left for the operator to adjudicate,
and asking is just a chore FC invented for them.
Defaults ON, which is a real change of posture and deliberate. It only governs
the conclusive band — weaker evidence still queues — and a link is a row the
operator can dismiss, so the reversal is a click rather than a migration.
Revision ID: 0109
Revises: 0108
Create Date: 2026-09-24
"""
import sqlalchemy as sa
from alembic import op
revision = "0109"
down_revision = "0108"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"import_settings",
sa.Column(
"discord_link_auto",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
def downgrade():
op.drop_column("import_settings", "discord_link_auto")
@@ -1,59 +0,0 @@
"""The unified post card — fold window, family window, and who linked a pair.
Milestone 388, #4402 and #4401. A Patreon teaser's card shows the Discord drop
it announced, and the rest of that piece's variants, by REFERENCE: nothing is
absorbed, nothing changes owner, and every Discord post keeps its own place.
Three columns:
* `import_settings.discord_link_fold_hours` — a linked drop leaves the feed
only when it is this close to its teaser (the same release, shown twice).
* `import_settings.discord_family_window_days` — how far from the teaser the
card reaches for variants. 60 is measured: named families spread up to 44
days on artist 8, every collision found over 500.
* `post_association.linked_by` — "fc" or "operator", so a link FC made by
itself can say so on the card and offer the undo the operator asked for.
Revision ID: 0110
Revises: 0109
Create Date: 2026-09-24
"""
import sqlalchemy as sa
from alembic import op
revision = "0110"
down_revision = "0109"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"import_settings",
sa.Column(
"discord_link_fold_hours",
sa.Float(),
nullable=False,
server_default=sa.text("24"),
),
)
op.add_column(
"import_settings",
sa.Column(
"discord_family_window_days",
sa.Float(),
nullable=False,
server_default=sa.text("60"),
),
)
op.add_column(
"post_association",
sa.Column("linked_by", sa.String(length=16), nullable=True),
)
def downgrade():
op.drop_column("post_association", "linked_by")
op.drop_column("import_settings", "discord_family_window_days")
op.drop_column("import_settings", "discord_link_fold_hours")
@@ -1,75 +0,0 @@
"""Discord native ingester ledgers — seen and dead-letter, per source.
Milestone 428, #4415. Discord moves off gallery-dl onto the native core, which
keeps its memory of what a source has already fetched in these two tables
instead of gallery-dl's archive. Same shape as the SubscribeStar pair.
Revision ID: 0111
Revises: 0110
Create Date: 2026-09-24
"""
import sqlalchemy as sa
from alembic import op
revision = "0111"
down_revision = "0110"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"discord_seen_media",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("filehash", sa.String(length=128), nullable=False),
sa.Column("post_id", sa.String(length=64), nullable=True),
sa.Column(
"seen_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"],
name=op.f("fk_discord_seen_media_source_id_source"), ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_discord_seen_media")),
sa.UniqueConstraint("source_id", "filehash", name="uq_discord_seen_media_source_id"),
)
op.create_index(
op.f("ix_discord_seen_media_source_id"), "discord_seen_media", ["source_id"],
)
op.create_table(
"discord_failed_media",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("filehash", sa.String(length=128), nullable=False),
sa.Column("attempts", sa.Integer(), server_default="1", nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column(
"first_failed_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.Column(
"last_failed_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"],
name=op.f("fk_discord_failed_media_source_id_source"), ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_discord_failed_media")),
sa.UniqueConstraint(
"source_id", "filehash", name="uq_discord_failed_media_source_id",
),
)
op.create_index(
op.f("ix_discord_failed_media_source_id"), "discord_failed_media", ["source_id"],
)
def downgrade():
op.drop_index(op.f("ix_discord_failed_media_source_id"), table_name="discord_failed_media")
op.drop_table("discord_failed_media")
op.drop_index(op.f("ix_discord_seen_media_source_id"), table_name="discord_seen_media")
op.drop_table("discord_seen_media")
-4
View File
@@ -38,10 +38,8 @@ def all_blueprints() -> list[Blueprint]:
from .suggestions import suggestions_bp
from .system_activity import system_activity_bp
from .system_backup import system_backup_bp
from .system_health import system_health_bp
from .tags import tags_bp
from .thumbnails import thumbnails_bp
from .workers import workers_bp
return [
api_bp,
attachments_bp,
@@ -53,8 +51,6 @@ def all_blueprints() -> list[Blueprint]:
showcase_bp,
settings_bp,
system_activity_bp,
workers_bp,
system_health_bp,
system_backup_bp,
admin_bp,
cleanup_bp,
-14
View File
@@ -475,20 +475,6 @@ async def trigger_reclaim_attachments():
return _queued(async_result)
@admin_bp.route("/maintenance/repair-discord-downloads", methods=["POST"])
async def trigger_repair_discord_downloads():
"""Clean re-download of the Discord files broken by the `None` naming
(#3999). Body {"dry_run": bool}; dry_run is the DEFAULT, because the apply
deletes files and makes gallery-dl forget every Discord download. Returns the
Celery task id — poll /maintenance/task-result/<id> for the summary."""
from ..tasks.admin import repair_discord_downloads_task
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True))
async_result = repair_discord_downloads_task.delay(dry_run=dry_run)
return _queued(async_result)
@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
async def trigger_dedup_videos():
"""Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
-10
View File
@@ -65,16 +65,6 @@ async def autocomplete():
])
@artists_bp.route("/names", methods=["GET"])
async def names():
"""Every artist, id + name + slug, alphabetical. For filter pickers that
list artists before anything is typed; `autocomplete` deliberately returns
nothing for an empty query."""
async with get_session() as session:
rows = await ArtistService(session).all_names()
return jsonify([{"id": i, "name": n, "slug": s} for i, n, s in rows])
@artists_bp.route("/directory", methods=["GET"])
async def directory():
"""FC-3f: cursor-paginated artists directory.
+4 -28
View File
@@ -19,7 +19,6 @@ from ..models import AppSetting
from ..services.extension_service import (
ExtensionService,
InvalidUrlError,
UnknownArtistError,
UnknownPlatformError,
)
from ..services.source_service import KNOWN_PLATFORMS
@@ -88,16 +87,10 @@ async def probe_source():
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
from .credentials import _get_crypto
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
# crypto lets a Discord probe name the server and channel with the
# stored token; every other platform ignores it.
result = await ExtensionService(session, _get_crypto()).probe(
url, names=request.args.get("names") in ("1", "true"),
)
result = await ExtensionService(session).probe(url)
return jsonify(result)
@@ -109,18 +102,6 @@ async def quick_add_source():
url = body.get("url")
if not isinstance(url, str) or not url.strip():
return _bad("invalid_body", detail="url is required")
# Optional: connect the new source to an existing artist (artist_id) or to
# the artist of that name (artist_name). A Discord channel names no
# creator, so the extension's Add panel always sends one of them.
artist_id = body.get("artist_id")
if artist_id is not None and (isinstance(artist_id, bool) or not isinstance(artist_id, int)):
return _bad("invalid_body", detail="artist_id must be an integer")
artist_name = body.get("artist_name")
if artist_name is not None and not isinstance(artist_name, str):
return _bad("invalid_body", detail="artist_name must be a string")
# Patreon is canon: adding a Patreon source to an existing artist can take
# the creator's Patreon display name (name only; the slug never moves).
use_platform_name = body.get("use_platform_name") is True
from .credentials import _get_crypto
@@ -128,14 +109,9 @@ async def quick_add_source():
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
try:
# crypto lets an add resolve the artist's display name via the
# stored credential (else it falls back to the URL handle). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source(
url, artist_id=artist_id, artist_name=artist_name,
use_platform_name=use_platform_name,
)
except UnknownArtistError as exc:
return _bad("not_found", detail=str(exc), status=404)
# crypto lets a pixiv add resolve the artist's display name via the
# stored OAuth token (else it falls back to the numeric id). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
except UnknownPlatformError as exc:
return _bad(
"unknown_platform",
-49
View File
@@ -21,7 +21,6 @@ from ..services.gallery_service import image_url
from ..services.ml.gpu_jobs import GpuJobService, error_dedupe_statements
from ..services.ml.gpu_triage import classify_reason, recover_defective_image
from ..services.ml.regions import RegionService
from ..services.service_roster import touch_service
gpu_bp = Blueprint("gpu", __name__, url_prefix="/api/gpu")
@@ -245,29 +244,6 @@ async def errors_recover(image_id: int):
# --- Agent (bearer token): lease / submit / heartbeat / fail ------------
def _accel_detail(body: dict) -> dict:
"""The agent's own report of which runtime got the GPU, kept on its roster
row so the System view can call a CPU-bound agent degraded (#4410).
Only a dict of {runtime: {device, error?}} is kept, and each value is
reduced to those two short strings: this is written on every lease, by a
client the server does not control. An agent that sends nothing (an
older build) simply has no `accel`, which reads as not-yet-reported.
"""
raw = body.get("accel")
if not isinstance(raw, dict):
return {}
accel = {}
for name, entry in list(raw.items())[:4]:
if not isinstance(entry, dict):
continue
clean = {"device": str(entry.get("device") or "")[:16]}
if entry.get("error"):
clean["error"] = str(entry["error"])[:200]
accel[str(name)[:16]] = clean
return {"accel": accel} if accel else {}
@gpu_bp.route("/jobs/lease", methods=["POST"])
async def lease():
body = await request.get_json(silent=True) or {}
@@ -280,21 +256,6 @@ async def lease():
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
# The agent cannot be polled — it is HTTP-only and pulls from here, so
# web never dials it. A lease IS the check-in, and until milestone 365
# it was thrown away: an agent sitting idle with nothing to lease left
# no trace at all and was indistinguishable from one switched off a
# week ago. Recorded on the call that was already happening.
await touch_service(
session,
key=f"agent:{agent_id}",
kind="agent",
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
details={
"agent_id": agent_id, "last_call": "lease", "leased": len(jobs),
**_accel_detail(body),
},
)
ml = await MLSettings.load(session)
# image rows for url/mime in one shot
ids = [j.image_record_id for j in jobs]
@@ -368,16 +329,6 @@ async def heartbeat():
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
n = await GpuJobService(session).heartbeat(agent_id, job_ids)
await touch_service(
session,
key=f"agent:{agent_id}",
kind="agent",
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
details={
"agent_id": agent_id, "last_call": "heartbeat", "extended": n,
**_accel_detail(body),
},
)
await session.commit()
return jsonify({"extended": n})
-29
View File
@@ -48,17 +48,6 @@ _EDITABLE = (
"process_conflict_threshold",
"embedder_model_name",
"embedder_model_version",
# Discord drop grouping (#388 E2). Operator-facing because the quality bar
# is a judgement no test can settle: too greedy merges distinct pieces, too
# shy leaves a drop scattered.
"discord_grouping_enabled",
"discord_group_max_distance",
"discord_group_window_minutes",
# E3: how long a grouping stays open, and the anti-thrash rule that keeps
# a growing one from monopolising the feed.
"discord_group_close_after_hours",
"discord_group_resurface_min_images",
"discord_group_resurface_cooldown_hours",
*_DETECTOR_FIELDS,
)
@@ -159,24 +148,6 @@ def _validate(p: dict) -> str | None:
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
return "process_conflict_threshold must be between 0 and 1"
# Discord drop grouping (#388 E2). max_distance is a cosine DISTANCE, so
# unlike the *_threshold family above it is not on the auto-apply scale:
# 0 is identical and 1 is unrelated, and both ends are legal. The upper
# bound is 1.0 rather than AUTO_APPLY_THRESHOLD_MAX for that reason.
if not (0.0 <= float(p["discord_group_max_distance"]) <= 1.0):
return "discord_group_max_distance must be between 0 and 1"
if float(p["discord_group_window_minutes"]) <= 0:
return "discord_group_window_minutes must be > 0"
# A group must stay open at least as long as the drop window it was cut
# with, or the joiner could never reach a message the grouper deferred —
# the two would fight, and the symptom (drops that never grow) would look
# like the predicate failing rather than a settings contradiction.
if float(p["discord_group_close_after_hours"]) * 60 < float(p["discord_group_window_minutes"]):
return "discord_group_close_after_hours must be at least the drop window"
if int(p["discord_group_resurface_min_images"]) < 1:
return "discord_group_resurface_min_images must be >= 1"
if float(p["discord_group_resurface_cooldown_hours"]) < 0:
return "discord_group_resurface_cooldown_hours must be >= 0"
# Embedder model swap (#1190): both must be non-empty. Changing them means a
# different embedding space — the operator must re-embed + retrain after.
for key in ("embedder_model_name", "embedder_model_version"):
-47
View File
@@ -5,8 +5,6 @@ from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import ImportSettings, Post
from ..services import interpreter_client as ic
from ..services.post_association_service import PostAssociationService
from ..services.post_association_service import rescan as association_rescan
from ..services.post_feed_service import PostFeedService
from ..services.source_service import KNOWN_PLATFORMS
from ..utils.text import html_to_plain
@@ -167,48 +165,3 @@ async def set_translation_override(post_id: int):
"translated_source_lang": post.translated_source_lang,
"applied": applied,
})
# --- #388 E5: the announcement review queue -------------------------------
#
# Confirm-only, following the series-suggestion routes (api/tags.py). Nothing
# here links anything on its own: the matcher proposes, the operator decides.
@posts_bp.route("/associations", methods=["GET"])
async def list_associations():
async with get_session() as session:
return jsonify({"items": await PostAssociationService(session).list_pending()})
@posts_bp.route("/associations/<int:association_id>/accept", methods=["POST"])
async def accept_association(association_id: int):
async with get_session() as session:
result = await PostAssociationService(session).accept(association_id)
if result is None:
return _bad("association not found", 404)
await session.commit()
return jsonify(result)
@posts_bp.route("/associations/<int:association_id>/dismiss", methods=["POST"])
async def dismiss_association(association_id: int):
async with get_session() as session:
result = await PostAssociationService(session).dismiss(association_id)
if result is None:
return _bad("association not found", 404)
await session.commit()
return jsonify(result)
@posts_bp.route("/associations/rescan", methods=["POST"])
async def rescan_associations():
"""Manual re-scan. The beat sweep only looks at recent posts (a pair has to
be within the window to exist at all); this is the button for a first run
over a library that predates the feature."""
async with get_session() as session:
# full=True: the button reaches the whole history, which the hourly
# sweep's 48-hour horizon never does.
result = await association_rescan(session, full=True)
await session.commit()
return jsonify(result)
-40
View File
@@ -36,17 +36,9 @@ _EDITABLE_FIELDS = (
"download_validate_files",
"download_schedule_default_seconds",
"download_event_retention_days",
"download_revisit_days",
"download_failure_warning_threshold",
"series_suggest_enabled",
"series_suggest_threshold",
# #388 E5 — the announcement matcher (Patreon teaser ↔ Discord drop).
"discord_link_enabled",
"discord_link_threshold",
"discord_link_window_hours",
"discord_link_auto",
"discord_link_fold_hours",
"discord_family_window_days",
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
@@ -117,12 +109,6 @@ async def update_import_settings():
v = body["download_schedule_default_seconds"]
if not isinstance(v, int) or isinstance(v, bool) or v < 60 or v > 86400:
return _bad_int("download_schedule_default_seconds", 60, 86400)
# 0 is a real value (revisit off), so the floor is 0, not 1 — and the
# ceiling is a year, past which a "tick" is a backfill wearing a hat.
if "download_revisit_days" in body:
v = body["download_revisit_days"]
if not isinstance(v, int) or isinstance(v, bool) or v < 0 or v > 365:
return _bad_int("download_revisit_days", 0, 365)
if "download_event_retention_days" in body:
v = body["download_event_retention_days"]
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 3650:
@@ -164,32 +150,6 @@ async def update_import_settings():
return jsonify(
{"error": "series_suggest_threshold must be a number in [0, 1]"}
), 400
if "discord_link_enabled" in body and not isinstance(
body["discord_link_enabled"], bool
):
return jsonify({"error": "discord_link_enabled must be a boolean"}), 400
if "discord_link_auto" in body and not isinstance(
body["discord_link_auto"], bool
):
return jsonify({"error": "discord_link_auto must be a boolean"}), 400
if "discord_link_threshold" in body:
v = body["discord_link_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify(
{"error": "discord_link_threshold must be a number in [0, 1]"}
), 400
if "discord_link_window_hours" in body:
v = body["discord_link_window_hours"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v <= 0:
return jsonify(
{"error": "discord_link_window_hours must be a positive number"}
), 400
# Zero is meaningful for both: fold nothing, or reference no variants.
for key in ("discord_link_fold_hours", "discord_family_window_days"):
if key in body:
v = body[key]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0:
return jsonify({"error": f"{key} must be a number >= 0"}), 400
if "wip_title_tagging_enabled" in body and not isinstance(
body["wip_title_tagging_enabled"], bool
):
+2 -177
View File
@@ -1,15 +1,10 @@
"""FC-3a: CRUD over Source rows. FC-3c adds POST /<id>/check."""
from quart import Blueprint, jsonify, request
from sqlalchemy import func, select
from sqlalchemy import select
from ..extensions import get_session
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
from ..services.artist_membership_service import ArtistMembershipService
from ..services.artist_membership_service import rescan as membership_rescan
from ..services.artist_service import ArtistService
from ..services.membership_reconcile import reconcile_all
from ..services.membership_roster import roster_is_fresh, source_for_membership
from ..models import DownloadEvent, Source
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
from ..services.source_service import (
KNOWN_PLATFORMS,
@@ -201,16 +196,6 @@ async def set_backfill(source_id: int):
rec = await SourceService(session).get(source_id)
if rec is None:
return _bad("not_found", status=404)
# A disabled source must not be armable for a deep walk — the same
# rule /check has carried all along (see `source_disabled` below).
# Arming one anyway is how #4279 happened: the membership sweep had
# stopped Ebi77 as `former_patron`, a deep scan was armed twenty
# minutes later, the walk could not complete without access, and
# the recovery sweep stranded it with a failure count no surface
# could clear — a disabled source is never scheduled again, and
# Retry routes to /check, which refuses it.
if not rec.enabled:
return _bad("source_disabled", detail="enable the source first")
native = uses_native_ingester(rec.platform)
if native:
cred = CredentialService(session, _get_crypto())
@@ -303,163 +288,3 @@ async def check_source(source_id: int):
download_source.delay(source_id)
return jsonify({"download_event_id": event_id, "status": "pending"}), 202
# --- #387 C3: the membership roster's sync state --------------------------
#
# Rule 164's visibility requirement lives here. A roster that failed to sync,
# or never has, must be DISTINGUISHABLE from an account that subscribes to
# nothing — otherwise the reconciliation this unlocks would tell the operator
# to cancel sources they are actively paying for.
@sources_bp.route("/membership-sync", methods=["GET"])
async def membership_sync_status():
async with get_session() as session:
rows = (await session.execute(select(MembershipSync))).scalars().all()
counts = dict(
(await session.execute(
select(PlatformMembership.platform, func.count())
.group_by(PlatformMembership.platform)
)).all()
)
return jsonify({"platforms": [
{
"platform": r.platform,
"last_attempt_at": r.last_attempt_at.isoformat() if r.last_attempt_at else None,
# NULL here means NEVER, and the UI must say so in words. Rendering
# it as 0 or as "-" is the exact conflation this endpoint exists to
# prevent.
"last_success_at": r.last_success_at.isoformat() if r.last_success_at else None,
"last_count": r.last_count,
"last_error_type": r.last_error_type,
"last_error_message": r.last_error_message,
# Whether a CONCLUSION may be drawn from this roster — not merely
# whether it looks recent. C4 gates on this, and it is computed
# server-side so no caller can forget to.
"fresh": roster_is_fresh(r),
"known_memberships": counts.get(r.platform, 0),
}
for r in sorted(rows, key=lambda r: r.platform)
]})
@sources_bp.route("/membership-sync", methods=["POST"])
async def trigger_membership_sync():
"""Run the roster sweep now.
The beat schedule runs daily, which is right for a billing-cycle fact but
far too slow when the operator has just connected a credential and wants to
see whether it works. Queued rather than run inline: it crosses the network
to an external service and the request path is not where that belongs.
"""
from ..tasks.maintenance import sync_memberships
sync_memberships.delay()
return jsonify({"queued": True})
# --- #388 E4: creator/membership suggestions ------------------------------
#
# Confirm-only. Accepting ADDS A SOURCE under the existing artist — it never
# merges two artists, because adding a source is trivially undone and a wrong
# merge silently mixes two creators' work with nothing left to separate them by.
@sources_bp.route("/membership-suggestions", methods=["GET"])
async def list_membership_suggestions():
async with get_session() as session:
return jsonify({"items": await ArtistMembershipService(session).list_pending()})
@sources_bp.route("/membership-suggestions/<int:sid>/accept", methods=["POST"])
async def accept_membership_suggestion(sid: int):
async with get_session() as session:
result = await ArtistMembershipService(session).accept(sid)
if result is None:
return _bad("suggestion_not_found", status=404)
await session.commit()
return jsonify(result)
@sources_bp.route("/membership-suggestions/<int:sid>/dismiss", methods=["POST"])
async def dismiss_membership_suggestion(sid: int):
async with get_session() as session:
result = await ArtistMembershipService(session).dismiss(sid)
if result is None:
return _bad("suggestion_not_found", status=404)
await session.commit()
return jsonify(result)
@sources_bp.route("/membership-suggestions/rescan", methods=["POST"])
async def rescan_membership_suggestions():
async with get_session() as session:
result = await membership_rescan(session)
await session.commit()
return jsonify(result)
# --- #387 C4: reconciling the roster against what FC actually tracks -------
#
# Asymmetric on purpose. The "you subscribe but FC doesn't follow it" direction
# carries a per-row action, because adding a source is the reversible half. The
# "FC follows it but your roster doesn't show it" direction is REPORT ONLY by
# the operator's decision (2026-09-11): it says what it sees and links to the
# Subscriptions row, and offers no one-click disable.
@sources_bp.route("/reconciliation", methods=["GET"])
async def reconciliation():
async with get_session() as session:
return jsonify(await reconcile_all(session))
@sources_bp.route("/reconciliation/adopt", methods=["POST"])
async def adopt_membership():
"""Start tracking a creator the roster says the account already pays for.
One row, one click, never a sweep side effect: adding a source commits disk,
worker time and rate budget, and unwinding it means deleting files.
"""
body = await request.get_json()
if not isinstance(body, dict):
return _bad("invalid_body", status=400)
membership_id = body.get("membership_id")
if not isinstance(membership_id, int):
return _bad("membership_id_required", status=400)
async with get_session() as session:
membership = await session.get(PlatformMembership, membership_id)
if membership is None:
return _bad("membership_not_found", status=404)
if not membership.url:
return _bad("membership_has_no_url", status=400)
existing = await source_for_membership(session, membership)
if existing is not None:
# The operator got there by another route between the page load and
# the click. That is them being ahead of us, not an error.
return jsonify({"already_tracked": existing.id})
# The sweep already captured the creator's real display name, so the
# artist gets its true name with NO lookup on the request path. Task
# #1293 asked for `resolve_display_name` here; the roster satisfies that
# concern earlier in the pipeline than #1293 expected, which also keeps
# this route off the network entirely (rule 164). The vanity is the
# fallback, never the preferred value.
name = membership.display_name or membership.vanity_or_none()
if not name:
return _bad("membership_has_no_name", status=400)
artist, _created = await ArtistService(session).find_or_create(name)
try:
record = await SourceService(session).create(
artist_id=artist.id,
platform=membership.platform,
url=membership.url,
)
except DuplicateSourceError as exc:
return jsonify({"already_tracked": exc.existing_id})
artist_id = artist.id
return jsonify({"source_id": record.id, "artist_id": artist_id}), 201
+7 -11
View File
@@ -21,22 +21,18 @@ from ..config import get_config
from ..extensions import get_session
from ..models import TaskRun
from ..services.scheduler_service import scheduler_status
from ..services.worker_lanes import LANES
system_activity_bp = Blueprint(
"system_activity", __name__, url_prefix="/api/system/activity",
)
# Every queue, grouped by the lane that consumes it. DERIVED from
# `worker_lanes.LANES` (milestone 422 step 1) rather than written out:
# this was a hand-kept third copy of "which queues exist", alongside
# celery_app.task_routes and service_roster.ROLE_NAMES, and its own comment
# admitted the coupling — "must match celery_app.task_routes".
#
# The rendered ORDER changes with this: lane order rather than the previous
# hand-chosen one. That is the better grouping for a lane-oriented UI, and
# queues with no LLEN response still show as null rather than absent.
_QUEUE_NAMES = tuple(q for lane in LANES for q in lane.queues)
# Canonical queue order — must match celery_app.task_routes. UI renders
# in this order; queues with no LLEN response show as null rather than
# absent.
_QUEUE_NAMES = (
"default", "import", "thumbnail", "ml",
"download", "scan", "maintenance", "maintenance_long",
)
# Cache module-level so all requests share the cache between polls.
# Tests can reset via direct dict mutation if needed.
-243
View File
@@ -1,243 +0,0 @@
"""Is every part of FabledCurator running? One verdict, one endpoint.
Milestone 365. The nav indicator and the System page both read this and
nothing else — composing a verdict is this module's job, not the UI's.
## Two kinds of part, answered two different ways
**Learned** — celery roles and the GPU agent, from `service_seen`. The
question is "how long since it checked in", and these are the parts that can
be ABSENT, which is the whole point: `celery inspect` alone reports presence,
so a dead worker is a shorter list rather than a red light.
**Probed live** — Postgres and Redis. Always expected, never learned, and a
last-seen for them would be actively misleading: that Redis answered thirty
seconds ago says nothing about now.
## This endpoint must never fail because something it checks has failed
The inversion is easy to write by accident and it destroys the feature exactly
when it is needed — a 500 when Redis is down, instead of `redis: down`. Every
probe is wrapped, every wait has a deadline (rule 156), and the roster refresh
swallows its own errors. The worst case is a part reported `unknown`, which is
a true statement.
"""
from __future__ import annotations
import asyncio
import time
from datetime import UTC, datetime
from quart import Blueprint, jsonify
from sqlalchemy import select, text
from ..config import get_config
from ..extensions import get_session
from ..models import ServiceSeen
from ..services.worker_lanes import SWEEP_PERIOD_SECONDS
system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system")
# How long a learned part may go quiet before it is doubted, then disbelieved.
#
# These are deliberately generous, and the reason is a deploy rather than a
# worker: `docker compose up -d` rolls start-first, so a role is briefly served
# by two containers and then by neither while the old one drains. Thresholds
# tight enough to catch a crash in seconds would paint the page red every time
# the stack is updated, and an alarm that cries wolf on every deploy is one
# nobody reads. Tune down only after watching a real deploy pass through.
STALE_AFTER_SECONDS = 90
DOWN_AFTER_SECONDS = 300
# The celery roster is written by `size_worker_lanes` and by nothing else, so
# these thresholds are only meaningful against ITS cadence. Asserted at import
# rather than left to a reader, because this is precisely the comparison that
# was never made for the GPU agent: its lease poll backed off to 900s while
# the roster called it stopped at 300s, and both numbers were individually
# correct, in different directions, in different files (lesson #4355).
#
# Two clear sweeps before a part is even called STALE. One missed tick is
# routine — the sweep rides the maintenance queue and does an inspect that can
# take eleven seconds — and must not turn the page yellow.
_SWEEPS_BEFORE_STALE = 2
assert STALE_AFTER_SECONDS >= SWEEP_PERIOD_SECONDS * _SWEEPS_BEFORE_STALE, (
f"a {SWEEP_PERIOD_SECONDS}s sweep cannot keep a roster fresh against a "
f"{STALE_AFTER_SECONDS}s stale threshold: raise the threshold or shorten "
f"the sweep"
)
# Probes cross a process boundary, so they carry deadlines. A hung Postgres
# must make this endpoint say "postgres: down", not hang alongside it.
PROBE_TIMEOUT_SECONDS = 2.0
_OK, _STALE, _DOWN, _UNKNOWN = "ok", "stale", "down", "unknown"
# Checking in, but working at a fraction of its speed: a GPU agent whose
# runtimes fell back to the CPU (#4410). Below stale — a part that may have
# stopped is the more urgent question — and above unknown, because this one
# IS known to be wrong.
_DEGRADED = "degraded"
# Worst-first, so an overall verdict is just the max.
_SEVERITY = {_OK: 0, _UNKNOWN: 1, _DEGRADED: 2, _STALE: 3, _DOWN: 4}
def _age_state(age_seconds: float) -> str:
if age_seconds >= DOWN_AFTER_SECONDS:
return _DOWN
if age_seconds >= STALE_AFTER_SECONDS:
return _STALE
return _OK
def _describe_learned(name: str, state: str, age: float, details: dict) -> str:
"""Say what the state MEANS. A red chip tells an operator less than a
sentence does at the moment they are deciding whether to go and look."""
if state == _OK:
replicas = details.get("replicas")
if replicas and replicas > 1:
return f"{name} is running ({replicas} replicas)"
return f"{name} is running"
mins = int(age // 60)
ago = f"{mins} min" if mins else f"{int(age)}s"
if state == _STALE:
return f"{name} has not checked in for {ago}"
return f"{name} has not checked in for {ago} — treat it as stopped"
def _cpu_runtimes(details: dict) -> list[str]:
"""The runtimes an agent reported as NOT on the GPU, with why.
Both torch and onnxruntime fall back to the CPU without raising, so an
agent in that state leases, works and checks in exactly like a healthy
one. On 2026-09-24 one had been doing so since a driver update left a
stale CDI spec; the only sign was a line in the agent's own log.
"""
accel = details.get("accel")
if not isinstance(accel, dict):
return []
out = []
for name, entry in sorted(accel.items()):
if not isinstance(entry, dict) or entry.get("device") == "cuda":
continue
why = entry.get("error") or entry.get("device") or "unknown"
out.append(f"{name} ({why})")
return out
def _learned_state(name: str, state: str, age: float, details: dict) -> tuple[str, str]:
"""A roster row's state and its sentence, degraded included."""
if state == _OK:
cpu = _cpu_runtimes(details)
if cpu:
return _DEGRADED, (
f"{name} is running on the CPU — not on the GPU: {'; '.join(cpu)}. "
"After a driver update, regenerate the agent host's CDI spec "
"(agent README)."
)
return state, _describe_learned(name, state, age, details)
async def _probe_postgres(session) -> dict:
started = time.monotonic()
try:
await asyncio.wait_for(
session.execute(text("SELECT 1")), timeout=PROBE_TIMEOUT_SECONDS
)
except Exception as exc: # noqa: BLE001 — a probe reports, it never raises
return {
"key": "postgres", "kind": "datastore", "name": "PostgreSQL",
"state": _DOWN, "detail": f"not answering: {type(exc).__name__}",
}
return {
"key": "postgres", "kind": "datastore", "name": "PostgreSQL", "state": _OK,
"detail": "answering", "latency_ms": round((time.monotonic() - started) * 1000, 1),
}
def _ping_redis_sync() -> None:
import redis # local import; mirrors system_activity's pattern
client = redis.Redis.from_url(
get_config().celery_broker_url,
socket_connect_timeout=PROBE_TIMEOUT_SECONDS,
socket_timeout=PROBE_TIMEOUT_SECONDS,
)
client.ping()
async def _probe_redis() -> dict:
started = time.monotonic()
try:
await asyncio.wait_for(
asyncio.to_thread(_ping_redis_sync), timeout=PROBE_TIMEOUT_SECONDS * 2
)
except Exception as exc: # noqa: BLE001
return {
"key": "redis", "kind": "datastore", "name": "Redis",
"state": _DOWN,
"detail": f"not answering: {type(exc).__name__} — queues and workers "
f"cannot be reached either",
}
return {
"key": "redis", "kind": "datastore", "name": "Redis", "state": _OK,
"detail": "answering", "latency_ms": round((time.monotonic() - started) * 1000, 1),
}
@system_health_bp.route("/health", methods=["GET"])
async def system_health():
"""Every part, its state, and one overall verdict.
Response: {overall, parts: [{key, kind, name, state, detail, last_seen_at,
…}], checked_at}
"""
parts: list[dict] = []
now = datetime.now(UTC)
async with get_session() as session:
# Postgres first, and if it is unreachable nothing else can be read —
# say so rather than failing, because "the database is down" is the
# single most useful thing this endpoint can ever report.
pg = await _probe_postgres(session)
parts.append(pg)
if pg["state"] == _OK:
# A PURE READ since 2026-09-23. This used to refresh the celery
# roster here, rate-limited to once per 20s — so the roster only
# advanced while somebody had a browser open, and a broadcast rode
# on a request. `size_worker_lanes` writes it now, on a timer, and
# the assertion below is what keeps that cadence honest.
rows = (
await session.execute(select(ServiceSeen).order_by(ServiceSeen.display_name))
).scalars().all()
for row in rows:
age = (now - row.last_seen_at).total_seconds()
state, detail = _learned_state(
row.display_name, _age_state(age), age, row.details or {},
)
parts.append({
"key": row.key,
"kind": row.kind,
"name": row.display_name,
"state": state,
"detail": detail,
"last_seen_at": row.last_seen_at.isoformat(),
"first_seen_at": row.first_seen_at.isoformat(),
**{k: v for k, v in (row.details or {}).items() if k != "agent_id"},
})
parts.append(await _probe_redis())
overall = max((p["state"] for p in parts), key=lambda s: _SEVERITY[s], default=_UNKNOWN)
return jsonify({
"overall": overall,
"parts": sorted(parts, key=lambda p: (-_SEVERITY[p["state"]], p["name"])),
"checked_at": now.isoformat(),
# So the UI can explain a `stale` without hard-coding the same numbers
# in a second place.
"thresholds": {
"stale_after_seconds": STALE_AFTER_SECONDS,
"down_after_seconds": DOWN_AFTER_SECONDS,
},
})
-170
View File
@@ -1,170 +0,0 @@
"""Worker lanes: what each is doing, and the dial that changes it.
Milestone 422 step 2. The write half of a surface `api/system_activity.py`
only reads.
## Why this is a separate blueprint
`system_activity` says in its own first line that it is read-only, and it
answers a different question: its `/workers` is keyed on celery HOSTNAME and
reports which nodes answered. That stays as it is — the existing
SystemActivityTab consumes it.
This is keyed on LANE, joins the stored cap to the live pool, and accepts
writes. Two endpoints answering "which celery processes exist" and "how much
work is each lane allowed to do" are not the same endpoint, and folding the
second into the first would make a read-only module a write one.
"""
from __future__ import annotations
from datetime import UTC, datetime
from functools import partial
from quart import Blueprint, current_app, jsonify, request
from ..extensions import get_session
from ..services.worker_control import (
LaneUpdateRefused,
lane_settings,
lane_view,
push_lane_cap,
store_lane_cap,
)
from ..services.worker_lanes import (
LANES_BY_NAME,
SWEEP_PERIOD_SECONDS,
Lane,
derived_ceiling,
)
from ._responses import error_response as _bad
workers_bp = Blueprint("workers", __name__, url_prefix="/api/system/workers")
@workers_bp.route("", methods=["GET"])
async def list_lanes():
"""Every lane: its cap, the ceiling above it, and what is live.
Response: {lanes: [...], fetched_at: iso8601}
One database read, and NO broker call. Operator, 2026-09-23: *"there is a
repull every time this page loads — is there a reason this info isn't
being tracked in the background and stored in some way?"*
It used to inspect the broker here, four broadcasts on an eleven-second
budget, four times a minute per open tab — while `size_worker_lanes` was
already inspecting on a timer and discarding the same numbers. The sweep
stores them now (`worker_lane_sample`) and this reads them.
So the live figures are up to `SWEEP_PERIOD_SECONDS` old, and each lane
carries the `measured_at` that says so. `sweep_period_seconds` is returned
alongside, so the UI can explain the age without hard-coding the cadence
in a second place.
"""
async with get_session() as session:
settings = await lane_settings(session)
return jsonify({
"lanes": lane_view(settings),
"fetched_at": datetime.now(UTC).isoformat(),
"sweep_period_seconds": SWEEP_PERIOD_SECONDS,
})
@workers_bp.route("/<name>", methods=["POST"])
async def update_lane(name: str):
"""Set a lane's cap. Stores it, answers, and makes the lane follow after.
ONE field, since 2026-09-23. It used to take `slots`, `slots_cap`,
`enabled` and `autoscale`; how many workers are running is now a
measurement the sizing pass owns, and `enabled` is `cap > 0`.
## The reply does not wait for the lane
Operator, 2026-09-23: *"when the number is changed the change should be
queued so that it isn't blocking of the webui or the system itself. we
shouldn't have to wait for the validation live."*
So the request does exactly one thing that can be slow — a row update —
and hands the broker work to a background task. Turning a lane off is
four `cancel_consumer` messages and a resize; lowering a cap is an
`inspect` on an eleven-second budget. Both used to happen between the
click and the response, with the stepper disabled the whole time.
Nothing is lost by not waiting: the cap in the database is what the
system obeys, the sizing pass re-reads it every minute, and the table
polls, so the live columns catch up on their own. If the web process dies
before the background task runs, that sweep is the backstop — which is
the same guarantee the awaited version had, since a push could fail
there too.
Refusals still happen inline, because they are decided from the value and
the machine's ceiling alone and never touch the broker:
* **400** — the value is not allowed (negative, or above what this
container can hold). Nothing was stored. The body carries `detail`,
which is the sentence the UI shows; a refused control with no reason
reads as a bug.
"""
lane = LANES_BY_NAME.get(name)
if lane is None:
return _bad("unknown_lane", detail=name, known=sorted(LANES_BY_NAME))
body = await request.get_json()
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
if "slots_cap" not in body:
return _bad("invalid_body", detail="give slots_cap")
value = body["slots_cap"]
# Rejected rather than coerced: `True` is an int in Python, and silently
# reading it as a cap of 1 would be a control that appears to work and
# sets something nobody asked for.
if not isinstance(value, int) or isinstance(value, bool):
return _bad("invalid_body", detail="slots_cap must be an integer")
# Store, close the session, THEN hand off. The session must not be held
# across broker work — that is what made this page block the whole site
# (see `worker_control.LaneSettings`) — and now the request does not wait
# for that work either.
async with get_session() as session:
try:
was_cap = await store_lane_cap(session, lane, value)
except LaneUpdateRefused as exc:
return _bad("refused", detail=str(exc))
_schedule_push(lane, value, was_cap)
return jsonify({
"name": lane.name,
"slots_cap": value,
"ceiling": derived_ceiling(lane),
"enabled": value > 0,
# The value is stored; the live lane is being told separately. The UI
# patches its row from this and lets the next poll bring the live
# columns, rather than refetching and paying for an inspect it just
# avoided.
"queued": True,
# Raising the cap off zero is what downloads the model (step 6), and
# the background task does it. Reported here so the UI can say a
# download has started rather than leaving the operator to wonder why
# a lane they just turned on is busy.
"fetching_models": value > 0 and was_cap == 0 and bool(lane.models),
})
def _schedule_push(lane: Lane, slots_cap: int, was_cap: int) -> None:
"""Run the live push after the response has gone out.
A seam, not an abstraction: it is one call, and it exists so the tests can
hold the push still — a background task that outlived a test's patches
would reach the real broker during teardown.
Quart tracks the task on the app and awaits it at shutdown, so an
in-flight push survives a graceful restart. `partial` rather than passing
`was_cap=` through `add_background_task`, so nothing depends on how that
forwards keyword arguments.
"""
current_app.add_background_task(
partial(push_lane_cap, lane, slots_cap, was_cap=was_cap)
)
-77
View File
@@ -1,77 +0,0 @@
"""Celery beat that remembers when each job last ran — from task_run, not a file.
Celery's default PersistentScheduler keeps its memory in a shelve file in the
working directory. Nothing mounts that directory, so every container recreate
forgets it, and a scheduler that remembers nothing seeds every entry with
`last_run_at = now`: each job waits a FULL interval after startup. A daily job
therefore needs 24 hours without a redeploy to fire. Since the one-container
image (172e33d) every redeploy restarts beat, and on 2026-09-24 no daily or
weekly job had run since the 21st (#4408).
task_run already records every task that starts (celery_signals), indexed on
(task_name, started_at DESC), and prune_task_runs keeps the newest row of each
task however old it is. So on startup each entry takes its last_run_at from
there:
- a job that is overdue runs at once;
- a job that is not due waits only the remainder of its interval;
- a job that has never run is due now.
Beat keeps last_run_at in memory from then on, as the default scheduler does;
only the startup seed changes. If the database cannot be read, the entries keep
Celery's own default rather than beat failing to start.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from celery.beat import Scheduler
from sqlalchemy import func, select
log = logging.getLogger(__name__)
# Seed for a job with no recorded run: far enough back that any interval or
# crontab reads as due.
NEVER = datetime(2000, 1, 1, tzinfo=UTC)
def last_runs(session, task_names: list[str]) -> dict[str, datetime]:
"""The latest recorded start of each task, by task name."""
from .models import TaskRun
if not task_names:
return {}
rows = session.execute(
select(TaskRun.task_name, func.max(TaskRun.started_at))
.where(TaskRun.task_name.in_(sorted(set(task_names))))
.group_by(TaskRun.task_name)
).all()
return dict(rows)
def seed(entries, last: dict[str, datetime]) -> None:
"""Set each entry's last_run_at from `last`; a task with none is due now."""
for entry in entries:
entry.last_run_at = last.get(entry.task, NEVER)
class TaskRunScheduler(Scheduler):
"""An in-memory beat seeded from task_run history at startup."""
def setup_schedule(self):
super().setup_schedule()
try:
from .tasks._sync_engine import sync_session_factory
with sync_session_factory()() as session:
last = last_runs(session, [e.task for e in self.schedule.values()])
except Exception:
log.exception("beat: could not read task_run; every job waits a full interval")
return
seed(self.schedule.values(), last)
due = sum(1 for e in self.schedule.values() if e.is_due()[0])
log.info(
"beat: seeded %d job(s) from task_run, %d due now",
len(self.schedule), due,
)
-67
View File
@@ -14,7 +14,6 @@ Queues:
from celery import Celery
from .config import get_config
from .services.worker_lanes import SWEEP_PERIOD_SECONDS
def make_celery() -> Celery:
@@ -62,13 +61,6 @@ def make_celery() -> Celery:
# can never starve the quick self-healing sweeps (operator-flagged
# 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours).
"backend.app.tasks.maintenance.*": {"queue": "maintenance"},
# The one long job in maintenance.py: a whole-library phash
# recompute (35 min hard limit; the library was cleared for
# re-hashing by migration 0098). On the quick lane it held a
# scheduler process for its whole run, and the minute ticks queued
# up behind it (2026-09-24: 7 waiting, "all workers busy for 18
# minutes"). An exact name wins over the glob above.
"backend.app.tasks.maintenance.backfill_phash": {"queue": "maintenance_long"},
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
@@ -119,34 +111,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
"schedule": 300.0, # every 5 minutes
},
"size-worker-lanes": {
"task": "backend.app.tasks.maintenance.size_worker_lanes",
"schedule": SWEEP_PERIOD_SECONDS,
#
# The number lives in `services/worker_lanes` because three
# places must agree on it: this schedule, the freshness of the
# sample the System tab reads, and the roster staleness
# thresholds in `api/system_health` — which now depend on this
# sweep rather than on a browser being open, and assert their
# headroom over it at import.
#
# ONE entry, replacing `autoscale-worker-lanes` (60s) and
# `reconcile-worker-lanes` (300s) on 2026-09-23. They were two
# sweeps over one number and most of the autoscaler's design
# existed to stop the reconcile undoing its work; with the
# stored `slots` gone there is nothing to disagree about.
#
# Fast enough to react to a BACKLOG — a five-minute reaction to
# a queue filling up is no reaction. It also carries what the
# reconcile was for: a worker restarted at its ENV concurrency
# is corrected on the next tick rather than after five.
#
# Cheap when settled: one inspect plus one LLEN sweep, and no
# control messages at all once every lane matches. It is also
# now the ONLY thing that inspects — nothing on a request path
# does — so this is the whole broker cost of the System tab,
# whether nobody or ten tabs are watching.
},
"cleanup-old-tasks": {
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
"schedule": 86400.0, # daily
@@ -156,14 +120,6 @@ def make_celery() -> Celery:
"schedule": 86400.0, # daily — sweep .part/.partial left by a
# download/import killed mid-write (graceful-shutdown fallout)
},
"backfill-phash-daily": {
"task": "backend.app.tasks.maintenance.backfill_phash",
"schedule": 86400.0, # daily — NULL-only, so a no-op once the
# library is hashed. This is what makes migration 0098's
# re-hash happen on its own: 0098 NULLs every phash, and
# without a scheduled refill the library would sit
# dedup-disabled until someone ran a deep scan (#4223).
},
"train-heads-nightly": {
"task": "backend.app.tasks.ml.scheduled_train_heads",
"schedule": 86400.0, # passive cadence; manual retrain stays available
@@ -244,26 +200,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
"schedule": 86400.0,
},
"group-discord-drops-hourly": {
"task": "backend.app.tasks.maintenance.group_discord_drops",
"schedule": 3600.0, # hourly. Not daily: the grouping signal is
# the SigLIP embedding, which lands asynchronously AFTER import
# (#388 E2), so this sweep is what picks up a drop once its
# vectors have caught up. No-op unless discord_grouping_enabled.
},
"match-post-associations-hourly": {
"task": "backend.app.tasks.maintenance.match_post_associations",
"schedule": 3600.0, # hourly, and AFTER the grouper's own cadence
# by construction: a pair cannot be proposed until the drop it
# points at exists as a grouping (#388 E5). No-op unless
# discord_link_enabled.
},
"sync-memberships-daily": {
"task": "backend.app.tasks.maintenance.sync_memberships",
"schedule": 86400.0, # daily — memberships change on a BILLING
# cycle, not a download cadence (#387 C3). No-op per platform
# when the client lacks the seam or no credential exists.
},
"integrity-verify-weekly": {
"task": "backend.app.tasks.maintenance.verify_integrity",
"schedule": 604800.0, # weekly
@@ -360,9 +296,6 @@ def make_celery() -> Celery:
},
},
timezone="UTC",
# Beat's memory of when each job last ran comes from task_run, not a
# shelve file nothing persists — see beat_scheduler (#4408).
beat_scheduler="backend.app.beat_scheduler:TaskRunScheduler",
)
# FC-3i: register task_run signal handlers (side-effect import).
from . import celery_signals # noqa: F401
+4 -18
View File
@@ -2,14 +2,11 @@
from .app_setting import AppSetting
from .artist import Artist
from .artist_membership_suggestion import ArtistMembershipSuggestion
from .artist_visit import ArtistVisit
from .backup_run import BackupRun
from .base import Base
from .character_prototype import CcipPrototypeState, CharacterPrototype
from .credential import Credential
from .discord_failed_media import DiscordFailedMedia
from .discord_seen_media import DiscordSeenMedia
from .download_event import DownloadEvent
from .external_link import ExternalLink
from .gpu_job import GpuJob
@@ -24,19 +21,17 @@ from .import_batch import ImportBatch
from .import_settings import ImportSettings
from .import_task import ImportTask
from .library_audit_run import LibraryAuditRun
from .membership_sync import MembershipSync
from .ml_settings import MLSettings
from .patreon_failed_media import PatreonFailedMedia
from .patreon_seen_media import PatreonSeenMedia
from .platform_membership import PlatformMembership
from .pixiv_failed_media import PixivFailedMedia
from .pixiv_seen_media import PixivSeenMedia
from .post import Post
from .post_association import PostAssociation
from .post_attachment import PostAttachment, attachment_download_url
from .presentation_review import PresentationReview
from .series_chapter import SeriesChapter
from .series_page import SeriesPage
from .series_suggestion import SeriesSuggestion
from .service_seen import ServiceSeen
from .source import Source
from .subscribestar_failed_media import SubscribeStarFailedMedia
from .subscribestar_seen_media import SubscribeStarSeenMedia
@@ -46,34 +41,28 @@ from .tag_head import TagHead
from .tag_positive_confirmation import TagPositiveConfirmation
from .tag_suggestion_rejection import TagSuggestionRejection
from .task_run import TaskRun
from .worker_lane import WorkerLane
from .worker_lane_sample import WorkerLaneSample
__all__ = [
"Base",
"AppSetting",
"Artist",
"ArtistMembershipSuggestion",
"ArtistVisit",
"BackupRun",
"Source",
"Credential",
"DiscordFailedMedia",
"DiscordSeenMedia",
"PatreonFailedMedia",
"PatreonSeenMedia",
"PixivFailedMedia",
"PixivSeenMedia",
"SubscribeStarFailedMedia",
"SubscribeStarSeenMedia",
"Post",
"PostAssociation",
"PostAttachment",
"attachment_download_url",
"PresentationReview",
"SeriesChapter",
"SeriesPage",
"SeriesSuggestion",
"PlatformMembership",
"ServiceSeen",
"ImageRecord",
"ImageProvenance",
"ImageRegion",
@@ -87,7 +76,6 @@ __all__ = [
"ImportTask",
"ImportSettings",
"LibraryAuditRun",
"MembershipSync",
"MLSettings",
"HeadAutoApplyRun",
"HeadMetric",
@@ -100,6 +88,4 @@ __all__ = [
"TagPositiveConfirmation",
"TagSuggestionRejection",
"TaskRun",
"WorkerLane",
"WorkerLaneSample",
]
@@ -1,83 +0,0 @@
"""artist_membership_suggestion — "this creator and that membership are the same".
Milestone 388, step E4.
## What was NOT needed here
E4's first job was to check what is actually missing, and the answer was: not
the schema, and not the flows. `Source.artist_id` is a plain FK, so many
sources per artist is already the data model; `POST /api/sources` already takes
an `artist_id`; the add-source dialog already has an artist autocomplete that
attaches to an EXISTING artist; and `SourceService.reassign` already moves a
source between artists WITH post and image re-attribution. A sweep for
one-source-per-artist assumptions found only `func.count()` calls, which are
the opposite of assuming one.
So no parallel association table was built for a relationship the schema
already expresses (rule 28). What was missing is the SUGGESTION — FC proposing
the link from the roster instead of waiting to be told.
## Confirm-only, and what "accept" actually does
Accepting adds a SOURCE for the membership's platform under the artist that
already has the other channel. It does NOT merge two artists. That distinction
is the whole safety margin: adding a source is trivially undone, whereas a
wrong artist merge silently mixes two creators' work and corrupts tagging,
series and provenance downstream — with nothing left to tell them apart by.
Dismissed rows are kept, not deleted, for the same reason as every other review
queue here: the row is what remembers the rejection, and re-proposing a
rejected pair on every scan is what makes a queue get ignored.
"""
from datetime import datetime
from sqlalchemy import (
JSON,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ArtistMembershipSuggestion(Base):
__tablename__ = "artist_membership_suggestion"
__table_args__ = (
UniqueConstraint(
"platform_membership_id", "artist_id",
name="uq_artist_membership_suggestion_pair",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
platform_membership_id: Mapped[int] = mapped_column(
ForeignKey("platform_membership.id", ondelete="CASCADE"),
nullable=False, index=True,
)
artist_id: Mapped[int] = mapped_column(
ForeignKey("artist.id", ondelete="CASCADE"), nullable=False, index=True
)
score: Mapped[float] = mapped_column(Float, nullable=False)
# Per-signal strengths as scored. Without it, "why was this suggested" is
# unanswerable the moment a weight or the threshold moves.
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# pending | linked | dismissed. Plain String, no CHECK — same call as
# series_suggestion.status and post_association.status.
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="pending", index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
server_default=func.now(), onupdate=func.now(),
)
-34
View File
@@ -1,34 +0,0 @@
"""DiscordSeenMedia — per-source ledger of Discord files already downloaded.
Mirror of SubscribeStarSeenMedia for the native Discord ingester (milestone
428). `filehash` holds the ingester's per-file key, `<message_id>:<media_id>`:
the attachment id, or for an embed a hash of its URL path. Not the file's
position in the message — an edit that removes a file renumbers the rest
(see `discord_client.MediaItem`). The message record's own gate is the
synthetic `message:<id>` key in the same column.
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import DateTime
from .base import Base
class DiscordSeenMedia(Base):
__tablename__ = "discord_seen_media"
__table_args__ = (
UniqueConstraint("source_id", "filehash", name="uq_discord_seen_media_source_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+1 -5
View File
@@ -64,11 +64,7 @@ class ImageRecord(Base):
# that 0001 also built was an exact duplicate of it — dropped in 0089
# (#3301). Lookups by sha256 use the constraint's index.
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
# 64 hex chars = the 256-bit hash utils.phash emits at hash_size=16. Was
# String(32) (64-bit) until migration 0098; the narrow column was the
# reason for the undersized hash, and the undersized hash was collapsing
# variant artwork into one record (#4223).
phash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
phash: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime: Mapped[str] = mapped_column(String(64), nullable=False)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
+1 -87
View File
@@ -42,12 +42,7 @@ class ImportSettings(Base):
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95, server_default="0.95")
single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30, server_default="30")
# Hamming distance over a 256-bit pHash (utils.phash, hash_size=16). The
# unit CHANGED in migration 0098 — it used to be bits out of 64 — so the
# old default of 10 is not this scale's 10, and 0098 resets every row.
# This is now the cheap PRE-FILTER: the aspect + pixel gates decide, which
# is what lets it be generous enough to catch a re-encoded rescale.
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=24, server_default="24")
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10, server_default="10")
# FC-3c downloader knobs
download_rate_limit_seconds: Mapped[float] = mapped_column(
@@ -68,16 +63,6 @@ class ImportSettings(Base):
Integer, nullable=False, default=90,
server_default="90",
)
# How far back a routine tick keeps looking after it has run out of new
# posts, so a creator who EDITS an older post to attach a hotfix build is
# still reached (ingest_core.DEFAULT_REVISIT_DAYS carries the reasoning).
# A knob rather than a constant because how long a creator keeps editing is
# a property of the creator, not of FabledCurator: 0 turns the revisit off
# and restores the pure count early-out.
download_revisit_days: Mapped[int] = mapped_column(
Integer, nullable=False, default=30,
server_default="30",
)
download_failure_warning_threshold: Mapped[int] = mapped_column(
Integer, nullable=False, default=5,
server_default="5",
@@ -112,77 +97,6 @@ class ImportSettings(Base):
server_default="0.5",
)
# Milestone 388 E5 — the announcement matcher: "this Patreon post announced
# that Discord drop". Lives here rather than in MLSettings, with the series
# matcher it is modelled on, because it runs no inference: the signals are
# time proximity and whether the post says so.
discord_link_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
server_default="true",
)
# The weighted-score cut-off. 0.60 is not arbitrary: it is deliberately set
# ABOVE the largest single signal weight, which is what makes "time
# proximity alone must never be sufficient" an ARITHMETIC property rather
# than a hope. On a busy day an artist posts several times; if proximity
# could carry a pair by itself, every one of those days would produce false
# pairs and the review queue would be abandoned. See
# post_association_service.WEIGHTS — a guard test pins the relationship.
discord_link_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.60,
server_default="0.60",
)
# How far apart the announcement and the drop may be. The Patreon post
# exists IN ORDER TO announce the drop, so they are minutes-to-hours apart;
# a day is generous and still excludes "same week".
discord_link_window_hours: Mapped[float] = mapped_column(
Float, nullable=False, default=24.0,
server_default="24",
)
# Whether FC links a CONCLUSIVE pair without asking.
#
# Operator, 2026-09-24: *"I don't want this to be manual that defeats the
# convenience that I'm going for."* Confirm-only was the right default
# while the only signals were circumstantial — proximity and a body that
# mentions Discord can never be more than suggestive, and asking was the
# honest response to that. A shared working name is different in kind: when
# the name appears in exactly these two posts and nowhere else in the
# artist's library, there is nothing for the operator to adjudicate.
#
# Only the conclusive band is affected (post_association_service.
# AUTO_LINK_FLOOR). Everything weaker still queues, and an accepted link is
# a row the operator can dismiss, so this is reversible in the UI rather
# than only in the database.
discord_link_auto: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
server_default="true",
)
# The unified card (#4402). A linked Discord drop is NOT absorbed into its
# teaser — it keeps its own post, date and provenance, and the teaser's card
# shows it by REFERENCE. Operator, 2026-09-24: *"discord 'posts' land as
# normal and only hidden from the post view they're posted the same day."*
#
# So the drop's own card leaves the feed only when it sits within this many
# hours of the teaser that references it — the adjacency that reads as the
# same thing twice. Hours rather than a calendar day: a teaser at 23:00 and
# its drop at 01:00 are one release, and "the same day" has no timezone
# the server can know.
discord_link_fold_hours: Mapped[float] = mapped_column(
Float, nullable=False, default=24.0,
server_default="24",
)
# How far from the teaser the card reaches for the rest of a piece's
# variants — the wips, alts and censor passes a creator trickles out under
# one working name (#4401). Measured on artist 8: named families spread a
# median 5 days and up to 44, while every name collision found spreads
# over 500. A reference, not a regrouping, so a generous value costs one
# extra thumbnail at worst — never a post moved or hidden.
discord_family_window_days: Mapped[float] = mapped_column(
Float, nullable=False, default=60.0,
server_default="60",
)
# #830 off-platform file-host downloads — per-host enable lever (default on,
# rule #26). Column names are extdl_<host>_enabled so the worker reads them
# via getattr(settings, f"extdl_{host}_enabled", True).
-77
View File
@@ -1,77 +0,0 @@
"""membership_sync — did the roster actually sync, and when.
Milestone 387, step C3.
`platform_membership` records what was SEEN. This records whether looking
happened at all, and that is a different fact — the one that makes an empty
roster readable.
## Why this table has to exist
Without it, three very different situations are one indistinguishable state:
* the account genuinely subscribes to nothing,
* the sweep has never run,
* the sweep ran and failed.
All three produce zero rows in `platform_membership`. Telling the operator
"you are tracking 12 sources you do not subscribe to" is correct in the first
case and catastrophic in the other two — it is an invitation to cancel things
they are actively paying for. C4 must therefore gate its CONCLUSIONS on
`last_success_at`, not merely display it.
`MAX(platform_membership.last_seen_at)` was the tempting shortcut and does not
work: it cannot distinguish "synced fine, found nothing" from "never synced".
`task_run` was the other candidate and is worse — its retention prunes ok rows
after 24h, so a sweep that last succeeded three days ago would leave no trace
at all.
## Separate attempt and success timestamps, deliberately
`last_attempt_at` moves every run; `last_success_at` moves only on a clean
walk. The GAP between them is the staleness signal, and keeping them apart is
what lets the UI say "last synced 3 days ago, last tried 20 minutes ago,
failing" — which is a different message from either half alone.
"""
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class MembershipSync(Base):
__tablename__ = "membership_sync"
__table_args__ = (
UniqueConstraint("platform", name="uq_membership_sync_platform"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
platform: Mapped[str] = mapped_column(String(64), nullable=False)
# Moves on EVERY run, success or not — so "we are trying" is visible even
# while "we are succeeding" is not.
last_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Moves only on a COMPLETE walk. This is the freshness signal C4 gates its
# conclusions on; NULL means never — which must never be rendered as zero.
last_success_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# How many memberships the last SUCCESSFUL walk saw. Paired with
# last_success_at so "0" is only ever readable as a real zero.
last_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Cleared on success. Plain String, no CHECK — this carries an exception
# class name (PatreonAuthError, PatreonDriftError, ...) and the vocabulary
# is whatever the client raises, exactly as source.error_type works.
last_error_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
server_default=func.now(), onupdate=func.now(),
)
-63
View File
@@ -252,69 +252,6 @@ class MLSettings(Base):
Integer, nullable=False, default=64,
server_default="64",
)
# -- Discord drop grouping (milestone 388) -----------------------------
# FC authors a post out of a creator's variant drop. The predicate is three
# axes ANDed together, and the time one does the real work: SIMILARITY
# ALONE OVER-GROUPS. Any two pieces of the same character by the same
# artist sit close in SigLIP space, so a cosine-only rule collapses a month
# of one character into a single "post". What makes a variant set a set is
# that it was dropped TOGETHER.
discord_grouping_enabled: Mapped[bool] = mapped_column(
# ON by default, matching the operator's standing opt-OUT preference for
# automatic behaviour (2026-06-29, recorded on the head/ccip auto-apply
# switches). Safe to default on because the act is reversible by one
# DELETE: removing a synthetic post un-absorbs its members.
Boolean, nullable=False, default=True,
server_default="true",
)
# Cosine DISTANCE, not similarity — this is the units gallery_service's
# `cosine_distance` already speaks, and converting at the query site is a
# step to get backwards. Lower = stricter. 0.10 is deliberately TIGHT: the
# two failure modes are not symmetric. Grouping too shy leaves a drop
# scattered, which is visible and fixable by raising this; grouping too
# greedy merges distinct pieces into a post that claims they belong
# together, which is the failure that would discredit the feature.
discord_group_max_distance: Mapped[float] = mapped_column(
Float, nullable=False, default=0.10,
server_default=text("0.10"),
)
# The gap that ENDS a drop, measured between CONSECUTIVE messages rather
# than from the first — an artist trickling variants out over an evening is
# one drop, and a window anchored on the first message would cut it in half
# at an arbitrary point.
discord_group_window_minutes: Mapped[float] = mapped_column(
Float, nullable=False, default=60.0,
server_default=text("60"),
)
# How long a synthetic post keeps accepting new members (#388 E3). This is
# NOT the drop window above: the window cuts one sweep's messages into
# drops, this decides how long a finished drop can still be REJOINED when a
# creator adds variants days later. A week by default — long enough for the
# "and here is the alt outfit" follow-up that motivated the feature, short
# enough that a group does not still be open when the same character comes
# round again months later and gets absorbed by mistake.
#
# Openness is DERIVED from this, not stored: a group is open if it grew (or
# started) within this period. So lowering it closes old groups and raising
# it reopens them, which is comprehensible and reversible — the alternative,
# a stored closed_at, would need its own repair path to ever change.
discord_group_close_after_hours: Mapped[float] = mapped_column(
Float, nullable=False, default=168.0,
server_default=text("168"),
)
# Anti-thrash (#388 E3). An updated post SHOULD be visible — that is the
# point of keeping it open — but a group gaining one image a day must not
# monopolise the feed. Growth smaller than this never moves the post, and
# no group moves more than once per cooldown, so a drip-feed updates in
# place while a real second wave resurfaces exactly once.
discord_group_resurface_min_images: Mapped[int] = mapped_column(
Integer, nullable=False, default=2,
server_default="2",
)
discord_group_resurface_cooldown_hours: Mapped[float] = mapped_column(
Float, nullable=False, default=24.0,
server_default=text("24"),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -1,9 +1,16 @@
"""DiscordFailedMedia — per-source dead-letter ledger of Discord files that
keep failing to download or validate.
"""PixivFailedMedia — per-source dead-letter ledger of Pixiv media that keeps
failing to download/validate.
Mirror of SubscribeStarFailedMedia. After `attempts` reaches the dead-letter
threshold a routine walk skips the file (recovery still retries it); a later
clean download clears the row. `filehash` is the seen-ledger's key.
Mirror of PatreonFailedMedia/SubscribeStarFailedMedia. Media that fails every
walk (404'd pximg URL, deleted work, persistently-corrupt bytes) would
otherwise re-error forever and re-burn backfill chunks. After ``attempts``
reaches the dead-letter threshold the ingester skips it on routine
tick/backfill walks (recovery still re-attempts). A later clean download
clears the row.
`filehash` is the same synthesized ``<illust_id>:p<num>`` /
``<illust_id>:ugoira`` key the seen-ledger uses. UNIQUE (source_id, filehash)
is the upsert key.
"""
from datetime import datetime
@@ -15,10 +22,12 @@ from sqlalchemy.types import DateTime
from .base import Base
class DiscordFailedMedia(Base):
__tablename__ = "discord_failed_media"
class PixivFailedMedia(Base):
__tablename__ = "pixiv_failed_media"
__table_args__ = (
UniqueConstraint("source_id", "filehash", name="uq_discord_failed_media_source_id"),
UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_failed_media_source_id"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
+42
View File
@@ -0,0 +1,42 @@
"""PixivSeenMedia — per-source ledger of Pixiv media already
downloaded+processed.
Mirror of PatreonSeenMedia/SubscribeStarSeenMedia for the Pixiv native
ingester (replacing gallery-dl). One queryable row per (source, media) so
routine walks skip media we've already ingested; recovery mode bypasses the
ledger to re-walk.
Pixiv original URLs carry no content hash, so `filehash` is always the
synthesized ``<illust_id>:p<num>`` (page) / ``<illust_id>:ugoira`` (frame
zip) key — stable across any URL-shape drift. String(128) matches the sibling
ledgers.
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import DateTime
from .base import Base
class PixivSeenMedia(Base):
__tablename__ = "pixiv_seen_media"
__table_args__ = (
# Dedup key the downloader upserts against: one ledger row per
# (source, media). A second sighting of the same media is a no-op.
UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_seen_media_source_id"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-152
View File
@@ -1,152 +0,0 @@
"""platform_membership — the learned roster of what the account actually pays for.
Milestone 387, phase C. FabledCurator knows which creators it has been TOLD to
follow (`source`), and nothing about which ones the operator is actually
subscribed to. Those two sets drift in both directions and the app cannot
currently see either drift:
* A subscription the operator pays for that FC does not track is content they
believe they are archiving and are not.
* A source FC keeps walking after the subscription lapsed is requests spent on
a wall, reported as a creator who has gone quiet.
This table is the memory that makes both visible — every membership the account
has been observed to hold, and when it was last seen.
## Why a learned roster rather than a live lookup
Same reasoning as `service_seen` (milestone 365), and the same shape: an
absence is only observable against a record of presence. A membership that
stops appearing in a sweep is the signal — "you were subscribed to this, now
you aren't" — and there is nowhere to read that from a live call, because a
live call returns what IS, never what stopped being.
It also means the reconciliation surface keeps working when Patreon is
unreachable, degraded to a stale roster with a visible age rather than an empty
page (rule 164).
## Roster truth, NOT per-post truth
The single most important thing about this table: `tier_names` says which tiers
the account holds. It does **not** say which posts those tiers unlock. A
creator can gate a post behind an access rule that maps onto no tier name at
all.
`current_user_can_view` — read per post by `patreon_client.post_is_gated` — is
the authoritative signal, and phase A already turned it into a durable
per-source state. This roster EXPLAINS that state ("you are no longer a patron"
vs "your tier doesn't cover these posts"). It must never be used to decide
whether to fetch something. Getting that backwards would make FC silently stop
fetching content the operator is paying for, which is the worst failure
available in this milestone.
## status is a plain String, and deliberately the platform's own word
Not a Postgres ENUM, not CHECK-gated — matching `service_seen.kind`,
`gpu_job.status` and `source.error_type`. Two reasons, and the first is the
real one:
1. **The vocabulary is not ours to invent.** Patreon says `active_patron` /
`former_patron` / `declined_patron`; SubscribeStar and FANBOX will say
something else. Storing each platform's own word verbatim and mapping to
FC's meaning at the READ site keeps this table a record of what was
observed rather than a lossy translation of it. A lowest-common-denominator
enum picked before any platform has been characterised (step C0) would be a
guess baked into the schema.
2. A constraint swap per new value (rule 36) would be cost with no invariant
behind it, exactly as `service_seen.kind` records.
The service layer owns the whitelist and the mapping; the column owns the
evidence.
## Retention: aged out, never deleted on disappearance
A membership that stops appearing in a sweep is NOT removed. Its disappearance
is the fact the reconciliation surface reads, and deleting the row would
destroy the signal at the moment it became interesting. `last_seen_at` is what
makes "gone" decidable, and a retention policy ages rows out on time rather
than on absence.
"""
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PlatformMembership(Base):
__tablename__ = "platform_membership"
__table_args__ = (
# The natural key the sweep's upsert conflicts on. Named explicitly
# because `touch_membership` references it by name in ON CONFLICT.
UniqueConstraint(
"platform", "external_campaign_id",
name="uq_platform_membership_platform_campaign",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
platform: Mapped[str] = mapped_column(String(64), nullable=False)
# The platform's own id for the thing subscribed to — a Patreon campaign
# id, whatever SubscribeStar and FANBOX call theirs. Text rather than a
# bounded String: these are opaque upstream identifiers and guessing a
# ceiling for a value we do not mint is how a walk dies on a truncation.
external_campaign_id: Mapped[str] = mapped_column(Text, nullable=False)
# For the reconciliation UI, and for matching against Source.url — the
# vanity/URL is what the two sides actually have in common.
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
url: Mapped[str | None] = mapped_column(Text, nullable=True)
# The platform's own word. See the module docstring — this is evidence,
# not a normalised FC status.
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Nullable throughout: a free follow has no tier and no money attached, and
# a platform may not expose an amount at all. Absent must stay
# distinguishable from zero — "free" and "we don't know" are different
# answers to "what is this costing".
tier_names: Mapped[list | None] = mapped_column(JSON, nullable=True)
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
# NEVER updated after insert. The one field that answers "has this ever
# been true", which is what makes a disappearance readable rather than
# indistinguishable from never having existed. `touch_membership`
# deliberately excludes it from the ON CONFLICT update set.
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
# The raw membership as the platform returned it, so a later question can
# be answered without re-fetching — and so a field we did not think to
# model is not lost. Displayed and never queried, like service_seen.details.
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
def vanity_or_none(self) -> str | None:
"""The platform's URL slug for this creator, if it can be known.
NOT a column, and that is C1's design working as intended rather than
an omission: the roster was modelled before any platform had been
characterised, so `details` exists precisely to carry the fields we did
not know to model. The vanity turned out to be one of them (#3886), and
it is reachable without a migration.
Falls back to the URL's last segment, which is what a vanity IS on
every platform seen so far — but only as a fallback, because the
platform's own word for it is the better answer when present.
"""
campaign = (self.details or {}).get("campaign") or {}
vanity = campaign.get("vanity")
if isinstance(vanity, str) and vanity:
return vanity
if self.url:
tail = self.url.rstrip("/").rsplit("/", 1)[-1]
return tail or None
return None
-58
View File
@@ -102,61 +102,3 @@ class Post(Base):
downloaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# -- Synthetic posts (milestone 388). ----------------------------------
# Discord is a delivery CHANNEL, not a publisher: one message is not one
# post. So FC authors the post itself, grouping a creator's variant drop
# into a single row (services/discord_grouping.py).
#
# NULL for every post a creator actually wrote — which is all of them until
# a grouper runs. Non-NULL names the grouper that authored this row, and is
# the ONE flag the UI keys off to say so. The honesty rule is the whole
# point: a synthetic post must never present itself as authored, and a
# column that is absent-or-a-name makes "was this us?" answerable from the
# row rather than inferred from its shape.
#
# Plain String, no CHECK (rule 36 considered and declined) — same reasoning
# as source.error_type and service_seen.kind. There is exactly one grouper
# today; a second would be a value, not an invariant.
synthesized_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
# What it was built from, so the operator can audit a grouping FC invented:
# member post ids, message count, and the thresholds in force when the
# decision was made. That last part matters — the thresholds are operator-
# tunable, so "why did it group these" is unanswerable a month later
# without recording the values that produced it.
synthesis_details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Set on a MEMBER post, pointing at the synthetic post that absorbed it.
# The feed hides absorbed posts (they are the chat lines the synthetic post
# replaced); every other surface still reaches them by id, because they
# remain the image's true origin and the grouping has to be inspectable.
#
# Self-FK, ON DELETE SET NULL: deleting a synthetic post un-absorbs its
# members and they return to the feed on their own. That is the reversal
# path, and it is one DELETE — nothing to undo by hand.
absorbed_by_post_id: Mapped[int | None] = mapped_column(
ForeignKey("post.id", ondelete="SET NULL"), nullable=True, index=True
)
# -- An OPEN grouping (milestone 388 E3) -------------------------------
# A synthetic post is not sealed at creation: a creator who adds two more
# variants the next day extends the existing post rather than starting a
# new one. These two columns are what make that possible without the post
# either freezing or thrashing the feed.
#
# `last_grew_at` is when the group last absorbed something. It answers two
# questions: how long the group stays JOINABLE (a group closes after a
# quiet period — artists reuse characters for years, and a group left open
# forever will eventually absorb something it shouldn't), and what the card
# shows as "updated N ago". NULL means it has never grown since creation.
last_grew_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# The feed position, and ONLY set when the anti-thrash rule fires — see
# discord_grouping.should_resurface. A group that gains one image a day
# must not sit permanently at the top of the feed, so growth updates the
# post without necessarily moving it; a genuine second wave moves it once.
#
# NULL on every ordinary post, which is why the feed's sort key can
# COALESCE through it without changing where anything else lands.
resurfaced_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
-107
View File
@@ -1,107 +0,0 @@
"""PostAssociation — "this Patreon post announced that Discord drop".
Milestone 388, step E5, and the point of the milestone rather than its tail.
Two of the operator's artists post a deliberately CROPPED fragment on Patreon
to signal that the real thing has landed in their Discord. The Patreon post is
the announcement; the Discord grouping (milestone 388 E2) is the payload. This
row is the link between them.
## Directional, and NOT a merge
`announcement` → `payload` is asymmetric on purpose. The teaser announces the
drop; the drop does not announce the teaser, and a symmetric "related posts"
edge would lose the only thing that makes the pair interesting.
Nor are the two collapsed into one post. The creator published twice,
deliberately, on two platforms with different audiences — flattening that
hides the very behaviour being modelled, and would destroy the operator's
ability to see that the Patreon post is a teaser at all.
## Confirm-only, following FC-6.3 (task 737)
`status` starts at `pending` and nothing is linked until the operator accepts.
A wrongly-asserted association tells them two different pieces are one, which
is worse than no link: no link leaves them where they already are, a wrong one
actively misinforms. Same reason the series matcher writes to a review queue
instead of filing posts on its own.
`status` is a plain String, no CHECK — matching `series_suggestion.status`,
which records the same check-existing-enums lesson.
## Why pHash could not do this, and the correction matters
The original plan claimed this link was already sitting in `image_provenance`
via pHash dedup. It is not. `compute_phash` is `imagehash.phash` at
`hash_size=8` — a DCT hash over the WHOLE image, robust to rescaling and
recompression but NOT to cropping, because a crop changes the global
signature. Cross-platform provenance still links straight re-posts; it does
nothing for a cropped teaser and its full version, which is precisely the pair
the operator described. Hence a scored proposal rather than a lookup.
"""
from datetime import datetime
from sqlalchemy import (
JSON,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PostAssociation(Base):
__tablename__ = "post_association"
__table_args__ = (
UniqueConstraint(
"announcement_post_id", "payload_post_id",
name="uq_post_association_pair",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# The teaser — a real post the creator wrote (Patreon, today).
announcement_post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
# What it announced — a synthetic Discord grouping, today. CASCADE on both
# sides: an association to a post that no longer exists is not a fact worth
# keeping, and E3's reversal path (delete the grouping) should not leave a
# dangling proposal behind.
payload_post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
score: Mapped[float] = mapped_column(Float, nullable=False)
# Per-signal strengths as scored, so a proposal stays explicable after the
# weights or the threshold are tuned. Without it, "why was this suggested"
# is unanswerable the moment anything moves.
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# pending | linked | dismissed. A DISMISSED row is kept, not deleted — it
# is what stops the matcher proposing the same rejected pair on every
# subsequent scan, which is the behaviour that makes a review queue
# unusable.
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="pending", index=True
)
# WHO linked it: "fc" when the matcher linked a conclusive pair by itself
# (discord_link_auto), "operator" when a person accepted it. The card needs
# this to be honest — a link FC asserted on its own says so and offers an
# undo, which the operator chose over a silent merge (#4402). NULL on a row
# that is not linked, and on rows linked before the column existed, all of
# which an operator accepted: auto-linking shipped in the same release.
linked_by: Mapped[str | None] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
server_default=func.now(), onupdate=func.now(),
)
-88
View File
@@ -1,88 +0,0 @@
"""service_seen — the learned roster of FabledCurator's own moving parts.
Nothing else in this application knows what is SUPPOSED to be running.
`celery inspect` reports the workers that answer, so a stopped worker is a
shorter list rather than a red light, and Postgres and Redis have no
representation at all. That is why the only place an operator could see a
dead service was Portainer, which knows the intended set (milestone 365).
This table is the memory that makes an absence observable: every part that
has ever checked in, and when it last did. A row that stops advancing is a
part that stopped.
## Why the key is not the hostname
`_read_workers_sync()` returns celery's worker names, which here are
`celery@<container id>`. Those are minted fresh on every deploy. Keyed on
them, this table would record a death and a birth every time the stack is
updated — and a status page that goes red on every deploy is a status page
nobody reads, which is worse than not having one.
So a celery role is keyed on its **queue set**, which is assigned per role in
docker-compose.yml (`CELERY_QUEUES`) and survives container replacement:
default,import,thumbnail,download -> worker
maintenance,scan -> scheduler (celery worker --beat)
ml -> ml-worker
Two replicas of one role share a queue set and are therefore ONE row — which
is right, because the question being answered is "is that role being served",
not "how many containers exist". The replica count and their hostnames go in
`details`, where they can change without the identity changing.
The GPU agent is keyed on its `agent_id`, the identity its lease protocol
already uses (`api/gpu.py`).
## What is NOT in here
Postgres and Redis. They are always expected and never learned, and a
last-seen for them would be actively misleading — that one answered thirty
seconds ago says nothing about now. They are probed live at request time.
## kind
Plain `String`, not a Postgres ENUM and not CHECK-gated, matching
`gpu_job.status` and `backup_run.status`. The value set here is expected to
grow as parts are added, and a constraint swap per new kind (rule 36) would
be cost with no invariant behind it.
celery — a worker role, keyed on its queue set
agent — a GPU agent, keyed on its agent_id
"""
from datetime import datetime
from sqlalchemy import JSON, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ServiceSeen(Base):
__tablename__ = "service_seen"
# No indexes beyond the primary key, deliberately. This table holds one row
# per moving part — a handful, forever — so every query against it is a
# full read of a few rows and an index would be write cost buying nothing
# (the lesson of #3301, which removed seven redundant ones).
key: Mapped[str] = mapped_column(String(128), primary_key=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
# What to call it in the UI. Derived from the queue set where it is
# recognised, and falling back to the raw queue list where it is not — a
# deployment that slices its queues differently should still show something
# true rather than a name this code invented for it.
display_name: Mapped[str] = mapped_column(String(64), nullable=False)
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
# The parts that change without changing identity: replica hostnames,
# active task counts, the queues actually being served. Kept as a blob
# because it is displayed and never queried — giving it columns would
# invite filtering on it, which is what the activity endpoints are for.
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
-85
View File
@@ -1,85 +0,0 @@
"""worker_lane — the most workers the operator will let each lane use.
Milestone 422 step 1, reshaped 2026-09-23. One row per lane in
`services/worker_lanes.LANES`, and ONE COLUMN an operator sets.
## Why there is only one number now
There were three — `slots`, `slots_cap` and `autoscale` — because the manual
dial was built first and the autoscaler arrived last, beside a control that
already existed rather than in place of it.
Operator: *"auto should be always on, not a setting, so that idle instances
quiet down when not running. the number that is visible and something the
user can tweak and manage should be the cap itself the number of running
workers is handled by the autoscaling function which is always on."*
So `slots` is gone. How many workers a lane is running right now is a
MEASUREMENT — read live from the worker, moved by the autoscaler, never
stored. Storing it made it look like a preference, which meant the operator
had to keep two numbers in agreement and the autoscaler had to be told it was
allowed to touch one of them.
`autoscale` is gone for the same reason: it gated the mechanism behind a
choice, and a lane nobody opted in simply never gave its slots back.
`enabled` is gone too, and is now DERIVED: a cap of zero means no consumers.
"Off" and "may use no workers" were two spellings of one fact, free to
disagree.
## What remains
1 <= live pool <= slots_cap <= derived_ceiling
(autoscaler) (this row) (computed)
The floor is one PROCESS, not zero: billiard will not run an empty pool, and
the parked process is what `add_consumer` lands on when the cap goes back up.
The DERIVED CEILING is deliberately absent from this table. It is computed
from the container's cgroup limits on every read, so a row written on a 32GB
host and later run in a 4GB container is bounded by the 4GB — a stored
ceiling would quietly authorise what the box can no longer hold.
"""
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
Integer,
String,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class WorkerLane(Base):
__tablename__ = "worker_lane"
__table_args__ = (
# Bare name — Base.metadata's naming convention prepends
# ck_worker_lane_. Pre-prefixing here doubles it, which is what
# alembic 0088 had to rename four constraints for (#3275).
CheckConstraint("slots_cap >= 0", name="cap_non_negative"),
)
# The lane name from services/worker_lanes.LANES — never a container
# hostname. See models/service_seen.py for why: celery's worker names here
# are `celery@<container id>`, minted fresh on every deploy.
name: Mapped[str] = mapped_column(String(32), primary_key=True)
# The most workers this lane may use. Zero means off — no consumers, so
# the lane takes no work and (for ML) downloads no model.
#
# There is no upper CHECK here, because the bound it would need is the
# derived ceiling, and no column holds that: it depends on the cgroup the
# container is running in right now. Enforced at write instead.
slots_cap: Mapped[int] = mapped_column(Integer, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
-83
View File
@@ -1,83 +0,0 @@
"""worker_lane_sample — the last thing the sizing sweep measured about a lane.
Milestone 422, 2026-09-23. A MEASUREMENT table, deliberately separate from
`worker_lane`, which holds the one number an operator sets.
## Why this exists
Operator, 2026-09-23, looking at the System tab: *"there is a repull every
time this page loads — is there a reason this info isn't being tracked in the
background and stored in some way?"*
There was not a good one. `/api/system/workers` ran a full celery inspect —
four broadcast round trips on an eleven-second budget — on every call, and
the page polls it every fifteen seconds. Meanwhile `size_worker_lanes` was
already inspecting on a timer to decide pool sizes, computing exactly these
numbers, using them, and throwing them away. The browser then asked the
broker for them again.
So the sweep writes what it saw here, and the endpoint reads this table. The
request path makes no broker call at all any more.
## Why NOT columns on `worker_lane`
Because that is the mistake this milestone already made once and undid. That
table used to carry `slots` — how many workers were running — beside
`slots_cap`, and a measurement sitting next to a preference reads as a second
preference: the operator had to keep two numbers in agreement, and the
autoscaler had to be granted permission to move one of them.
The distinction is the whole design, so it is a table boundary. Nothing an
operator sets lives here; nothing here is ever an input to a decision about
what they wanted.
## Freshness is a value, not an assumption
`measured_at` is returned to the UI, which says how old the reading is rather
than implying it is live. A sample is a fact about a moment, and a page that
presents a one-minute-old number as current is how an operator ends up
mistrusting the whole surface.
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class WorkerLaneSample(Base):
__tablename__ = "worker_lane_sample"
# The lane name from services/worker_lanes.LANES. One row per lane,
# overwritten in place: this is the LATEST reading, not a history. A time
# series would be a different table with a different retention problem,
# and nothing has asked for one.
lane: Mapped[str] = mapped_column(String(32), primary_key=True)
# Whether anything answered for this lane. NOT the same as "zero workers"
# — an unswept absence is not a verdict (snippet #3969). False here means
# the inspect came back without this lane, so every count below is
# meaningless and the UI must say "not answering" rather than "0".
present: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
replicas: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Pool size of ONE process, nullable because a worker that answered
# without reporting its pool is unknown rather than empty.
pool: Mapped[int | None] = mapped_column(Integer, nullable=True)
active: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
reserved: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Redis LLEN across the lane's queues. Nullable for the same reason as
# `pool`: a queue the broker did not answer for is unknown, and summing it
# as zero would report a buried lane as idle.
queue_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
measured_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
-227
View File
@@ -1,227 +0,0 @@
"""Emit a supervisord config for the single-container layout.
Milestone 422 step 5. Writes to stdout; `entrypoint.sh all` redirects it to a
file and execs supervisord against it.
## Why this is generated and not a checked-in .conf
A static config would spell out each lane's `-Q` list, and that would be a
FIFTH hand-kept copy of the queue names — after `celery_app.task_routes`, and
the three collapsed in steps 1, 2 and 4 (`service_roster.ROLE_NAMES`,
`system_activity._QUEUE_NAMES`, and the Activity filter). Every one of those
had already drifted by the time it was found.
Generating from `worker_lanes.LANES` makes a stronger guarantee than "they
match today": the processes this container runs and the lanes the application
believes in are the same list, so a lane added to `LANES` gets a process
without anyone remembering to add one, and a queue can never end up with no
consumer because a config file was missed.
## Why supervisord
It is one pip dependency on an image that is already Python, and it does the
four things this needs without being clever: restart a program that exits,
give each one its OWN stop timeout, signal the process GROUP rather than the
leader, and put every program's output on one stdout.
The process-group part is not a detail. Celery's prefork pool forks children,
and a TERM delivered only to the parent leaves them running — which is how a
"graceful" shutdown turns into orphaned workers holding tasks. `stopasgroup`
and `killasgroup` are both set for every program.
s6-overlay is the other standard answer and would work; it needs a build-time
download and a second mental model, and its advantage (correct PID-1 signal
and zombie handling) is available here from `init: true` in compose, which
puts tini in front of supervisord. Neither choice reaches the application —
nothing in FC talks to the supervisor — so this is reversible without touching
a line of product code.
## Every lane, including ml
Step 6 merged the images, so this one carries torch and the ML requirements
and the `ml` lane gets a program like any other. It starts at one slot with
its consumers CANCELLED — `enabled=false` in the seeded settings — so it
holds a process and no model. That matters: `add_consumer` needs a running
worker to reach, and without one the UI switch would have nothing to switch.
Nothing is downloaded by starting it. The model fetch is enqueued when the
lane is enabled, which is what lets rule 164 permit a runtime fetch at all.
"""
from __future__ import annotations
import argparse
import shlex
import sys
from ..services.worker_lanes import LANES, MIN_POOL_SLOTS, Lane
# One number for the whole container, and it must cover the SLOWEST lane —
# docker gives the container a single stop timeout, where compose today gives
# each service its own (90/60/180/120s). `maintenance_long` is the 180s one:
# DB backups, library audits and translation backfill. Anything less turns a
# routine restart into a SIGKILL mid-backup.
#
# Per-program values below are the old per-service ones, preserved: supervisord
# waits `stopwaitsecs` for each, and they stop in parallel, so the container's
# own timeout needs to cover the max rather than the sum.
STOP_WAIT_SECONDS: dict[str, int] = {
"worker": 90,
"scheduler": 60,
"maintenance_long": 180,
"ml": 120,
}
DEFAULT_STOP_WAIT = 60
def _program(lane: Lane, *, slots: int) -> str:
"""One [program:x] block.
`stdout_logfile=/dev/fd/1` with maxbytes 0 puts the lane's output straight
on the container's stdout unbuffered, so `docker logs` shows every lane
interleaved rather than supervisord swallowing them into rotated files.
The output is prefixed through `sed` so a line can be attributed to a lane
— four celery workers and hypercorn on one stream are otherwise
indistinguishable. The shell that the pipe requires is exactly why
`stopasgroup` matters: the signal has to reach the celery process, not the
`sh` holding the pipeline.
"""
inner = f"./entrypoint.sh {lane.entrypoint_role}"
prefixed = f"{inner} 2>&1 | sed -u 's/^/[{lane.name}] /'"
stop_wait = STOP_WAIT_SECONDS.get(lane.name, DEFAULT_STOP_WAIT)
return "\n".join([
f"[program:{lane.name}]",
f"command=sh -c {shlex.quote(prefixed)}",
# QUOTED, and that is load-bearing. supervisord parses `environment`
# as a COMMA-separated KEY=VALUE list, so an unquoted queue list reads
# as CELERY_QUEUES=default followed by three malformed entries — and
# the lane would consume only its first queue. Silent: the worker
# starts, reports healthy, and simply never picks up `import`.
f'environment=CELERY_QUEUES="{",".join(lane.queues)}",'
f"CELERY_CONCURRENCY={slots},"
# A UNIQUE celery node name per lane, and the reason is not cosmetic.
# These processes share one hostname, so celery's default
# `celery@<hostname>` made all four the SAME node: inspect collapsed
# their replies, three lanes read as absent, and which three varied
# per call (run 7319). The healthcheck could never pass, and
# pool_grow's `destination` would have addressed an arbitrary lane.
f"CELERY_NODENAME={lane.name}",
"autostart=true",
"autorestart=true",
# A lane that dies instantly and repeatedly is a broken image, not a
# transient fault. Backing off stops it burning a core in a restart
# loop while still recovering from a one-off crash.
"startretries=3",
"startsecs=5",
f"stopwaitsecs={stop_wait}",
"stopasgroup=true",
"killasgroup=true",
"stdout_logfile=/dev/fd/1",
"stdout_logfile_maxbytes=0",
"redirect_stderr=true",
"",
])
# supervisord's control socket. /tmp for the same reason the generated config
# lives there — writable by every role, and per-container by nature.
SOCKET_PATH = "/tmp/supervisor.sock"
def _web_program() -> str:
"""hypercorn. Started FIRST (priority) because its role runs
`alembic upgrade head`, and a worker that boots against an un-migrated
schema fails in a way that looks like application breakage."""
prefixed = "./entrypoint.sh web 2>&1 | sed -u 's/^/[web] /'"
return "\n".join([
"[program:web]",
f"command=sh -c {shlex.quote(prefixed)}",
"priority=1",
"autostart=true",
"autorestart=true",
"startretries=3",
"startsecs=5",
# Short: HTTP requests and the occasional file download. Matches the
# 30s the operator's production stack gives the web service.
"stopwaitsecs=30",
"stopasgroup=true",
"killasgroup=true",
"stdout_logfile=/dev/fd/1",
"stdout_logfile_maxbytes=0",
"redirect_stderr=true",
"",
])
def render() -> str:
parts = [
"\n".join([
"[supervisord]",
# PID 1 in the container, so it must not daemonise.
"nodaemon=true",
# supervisord's OWN log. /dev/fd/1 keeps it on the container's
# stdout beside the programs rather than in a file nobody reads.
"logfile=/dev/fd/1",
"logfile_maxbytes=0",
"loglevel=info",
"",
]),
# THE CONTROL SOCKET, and it is not optional furniture.
#
# Without these three sections supervisord runs perfectly and
# `supervisorctl` cannot talk to it at all:
#
# Error: .ini file does not include supervisorctl section
#
# Which is the first thing anyone reaches for when a lane misbehaves
# in the consolidated container — `docker exec <c> supervisorctl
# status` to see which processes are up, or `restart ml` to bounce one
# without taking the whole application down with it. Consolidation
# took away `docker ps` as the way to see the lanes; this is what
# replaces it, and shipping without it would have left an operator
# with one container, five processes inside it, and no way to ask
# about any of them.
#
# Found by the smoke's own diagnostic line on run 7322, which printed
# this error instead of a process list. It was behind `|| true`, so it
# cost nothing and said so anyway — the argument for printing evidence
# even where nothing depends on it.
#
# /tmp, like the generated config itself: writable by every role
# without assuming a volume, and per-container state that must not
# outlive the container.
"\n".join([
"[unix_http_server]",
f"file={SOCKET_PATH}",
"chmod=0700",
"",
"[rpcinterface:supervisor]",
"supervisor.rpcinterface_factory = "
"supervisor.rpcinterface:make_main_rpcinterface",
"",
"[supervisorctl]",
f"serverurl=unix://{SOCKET_PATH}",
"",
]),
_web_program(),
]
# Lanes after web, in LANES order, so the log reads in a stable sequence.
for lane in LANES:
# A lane configured at zero slots still gets a PROCESS, at one slot
# with its consumers cancelled by the reconcile. Without a running
# worker there is nothing for `add_consumer` to reach, so enabling the
# lane from the UI could not work at all — the process has to exist for
# the switch to have something to switch.
parts.append(_program(lane, slots=MIN_POOL_SLOTS))
return "\n".join(parts)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.parse_args(argv)
sys.stdout.write(render())
return 0
if __name__ == "__main__":
raise SystemExit(main())
-181
View File
@@ -1,181 +0,0 @@
"""The container's healthcheck. Picks the right check from the role it runs.
Exit 0 healthy, non-zero unhealthy.
## Why this is in the IMAGE and not in every compose file
Because the image is the only thing that knows what it is running. A
deployment had to declare a healthcheck per service, which meant every
compose file, stack file and README repeated the same knowledge:
web -> curl /api/health
worker -> celery inspect ping -d celery@$HOSTNAME
all -> both, for every lane
Three checks, written out by hand, once per service, in every file anyone
ever wrote — and none of them wrong until a role changed. Operator, 2026-09-23:
*"why isn't the healthcheck built into the image or base on what command runs
if one is passed in."* There was no reason. The role is already a fact the
container holds; asking the deployment to restate it is the same duplication
the lane table exists to remove one level down.
So `entrypoint.sh` records the role it started, the Dockerfile declares ONE
`HEALTHCHECK` that runs this, and a stack file says nothing at all. Declaring
one anyway still works — docker lets a service override the image's — which
is the escape hatch for a deployment that genuinely wants something else.
## What each role is asked
* **web** — hypercorn answers `/api/health`. No database: the endpoint is a
no-DB 200 that proves the app booted and is serving after `alembic upgrade
head`, which is what a rolling deploy needs to know.
* **worker / scheduler / ml-worker** — THIS container's celery node answers a
ping over the broker. Not "some worker answered": the node name is pinned
to this container, or a healthy sibling would keep a dead one looking alive.
* **all** — both halves, for every lane in the table. The failure mode
consolidation creates is that docker can no longer see the lanes as
separate services, so a web-only check reports a healthy container with
every worker dead.
* **shell / alembic / anything else** — nothing to check. These are one-shot
or interactive; a liveness probe on them has no meaning, so it passes
rather than inventing a verdict.
## An unrecorded role passes rather than failing
If the role file is missing, the entrypoint did not run — someone used
`--entrypoint` or ran a bare command. That is a debugging shape, and a
healthcheck that cannot tell what it is looking at must not assert that the
thing is broken (snippet #3969: an unswept read is not a verdict). It says so
on stdout and exits 0.
"""
from __future__ import annotations
import os
import socket
import sys
import urllib.error
import urllib.request
# Written by entrypoint.sh at boot. /tmp because it is the one path writable
# by every role without assuming a volume, and the value is per-container
# state that must NOT survive into a new container.
ROLE_FILE = os.environ.get("FC_ROLE_FILE", "/tmp/fc-role")
WEB_URL = "http://localhost:8080/api/health"
WEB_TIMEOUT = 5.0
# A broker round trip, so it gets a deadline (rule 156). Generous relative to
# `inspect`'s 2s elsewhere: this runs every 30s with retries, and a transient
# blip flagging a worker unhealthy would roll back a deployment that is fine.
PING_TIMEOUT = 10.0
CELERY_ROLES = {"worker", "scheduler", "ml-worker"}
# Roles with nothing to probe. Listed rather than treated as the default, so
# an unknown role takes the "I cannot tell" path and says so.
NO_CHECK_ROLES = {"shell", "bash", "alembic"}
def current_role() -> str | None:
"""The role this container was started with, or None if nothing recorded."""
env = os.environ.get("FC_ROLE")
if env:
return env.strip()
try:
with open(ROLE_FILE) as fh:
return fh.read().strip() or None
except OSError:
return None
def _web_ok() -> tuple[bool, str]:
try:
with urllib.request.urlopen(WEB_URL, timeout=WEB_TIMEOUT) as resp:
if resp.status == 200:
return True, ""
return False, f"web returned {resp.status}"
except (urllib.error.URLError, OSError) as exc:
return False, f"web unreachable: {exc}"
def _this_node_ok() -> tuple[bool, str]:
"""Ping THIS container's celery node, by name.
Pinned to this node deliberately. A bare `ping()` is answered by any
worker on the broker, so in a stack with several replicas a dead one
would go on reporting healthy for as long as a sibling was alive — the
healthcheck would be measuring the cluster, not the container it is in.
"""
from ..celery_app import celery as celery_app
node = f"{os.environ.get('CELERY_NODENAME', 'celery')}@{socket.gethostname()}"
try:
replies = celery_app.control.ping(destination=[node], timeout=PING_TIMEOUT)
except Exception as exc: # noqa: BLE001 — a probe reports, never raises
return False, f"could not reach the broker: {exc}"
if not replies:
return False, f"{node} did not answer a ping"
return True, ""
def _lanes_ok() -> tuple[bool, str]:
"""Every lane in the table is answering.
Deliberately ignores whether a lane is ON: a lane at cap 0 still runs its
process with its consumers cancelled, so it answers `inspect` and is
healthy. Health is "is the process alive"; whether it should be consuming
is a settings question the sizing pass owns, and conflating them would
make turning a lane off mark the container unhealthy.
That was not merely a risk — it was happening. Until 2026-09-23 a worker
was attributed to its lane by the queues it was CONSUMING, and a lane with
its consumers cancelled reports none, so it read as absent and this check
failed. ML ships at cap 0, so a fresh install was permanently unhealthy
and Swarm restarts an unhealthy task forever. The docstring above said the
right thing while the code did the opposite; `worker_lanes.lane_for_node`
is what makes it true.
"""
from ..services.worker_control import inspect_lanes_sync
from ..services.worker_lanes import LANES
live = inspect_lanes_sync()
missing = sorted(lane.name for lane in LANES if not live[lane.name].present)
if missing:
return False, "lanes not answering: " + ", ".join(missing)
return True, ""
def main(argv: list[str] | None = None) -> int:
role = current_role()
if role is None:
# Not a failure. See the module docstring: the entrypoint did not run,
# so there is no role to check against and no basis for a verdict.
print("no role recorded; nothing to check")
return 0
if role in NO_CHECK_ROLES:
print(f"{role}: nothing to check")
return 0
checks = []
if role == "all":
checks = [_web_ok, _lanes_ok]
elif role == "web":
checks = [_web_ok]
elif role in CELERY_ROLES:
checks = [_this_node_ok]
else:
print(f"unknown role {role!r}; nothing to check")
return 0
for check in checks:
ok, detail = check()
if not ok:
print(detail, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-146
View File
@@ -1,146 +0,0 @@
"""Block until Postgres and Redis accept connections. Exit 0 ready, 1 timed out.
## Why the container has to do this itself
Compose has `depends_on: {condition: service_healthy}`, and **Swarm ignores
it**. `docker stack deploy` has no ordering primitive at all: every service in
the stack starts at once, so FabledCurator races Postgres on every cold
deploy and always has.
The multi-service stack hid how sharp that is. `web` ran `alembic upgrade
head`, failed against a Postgres that was still doing `initdb`, and the task
died — but Swarm restarts a failed task forever, so the service came up a few
seconds later and nobody saw a problem worth naming.
Consolidation removes that safety net. supervisord gives each program
`startretries=3`, so a web program that fails three times in the first
seconds goes FATAL and **stays** FATAL: supervisord keeps running, the
container keeps running, and the application never starts. The healthcheck
catches it — but as a container that is permanently unhealthy for a reason
that has nothing to do with the image, on a stack whose database simply took
twenty seconds to initialise.
Operator, 2026-09-23: *"it's a single container that need to connect
successfully to redis and postgres before starting work shouldn't that simply
be a check (with retries) at the start of the container."* Yes.
## A TCP connect, not a query
The same probe `build.yml`'s integration lane and the build smoke already use.
It answers the question that is actually being asked — is something listening
— and it cannot fail for a reason that retrying will never fix.
A real query would be a stronger readiness signal and a worse gate: a wrong
password or a missing database is not a transient condition, and a loop that
waits for one to heal turns a five-second misconfiguration into a two-minute
timeout with a misleading message. Those belong to alembic, which runs
seconds later and says exactly what is wrong.
The Postgres image is well behaved here: during `initdb` it serves on a unix
socket only and opens TCP when it is ready for clients, so the connect is a
good proxy for "ready" rather than merely "process exists".
"""
from __future__ import annotations
import argparse
import os
import socket
import sys
import time
from urllib.parse import urlparse
# Long enough for a first-ever `initdb` on a slow disk, which is the worst
# case this exists for and is measured in tens of seconds, not minutes. A
# deploy that is genuinely misconfigured should fail while someone is still
# watching it rather than hold the container open for a quarter of an hour.
DEFAULT_TIMEOUT = 120.0
CONNECT_TIMEOUT = 2.0
RETRY_DELAY = 1.0
# Progress every N attempts. `docker logs` on a container that is waiting must
# say what it is waiting for — silence is indistinguishable from a hang.
REPORT_EVERY = 5
def _target(url: str | None, default_port: int) -> tuple[str, int] | None:
"""(host, port) from a connection URL, or None if there is nothing to wait for."""
if not url:
return None
parsed = urlparse(url)
if not parsed.hostname:
return None
return parsed.hostname, parsed.port or default_port
def targets() -> list[tuple[str, tuple[str, int]]]:
"""What this container must reach, read from the same env the app reads.
Derived rather than passed in, so the wait cannot drift from what the
application will actually connect to — a gate that checks a different
host than the app uses is worse than no gate.
"""
out: list[tuple[str, tuple[str, int]]] = []
host = os.environ.get("DB_HOST")
if host:
out.append(("postgres", (host, int(os.environ.get("DB_PORT") or 5432))))
broker = _target(os.environ.get("CELERY_BROKER_URL"), 6379)
if broker:
out.append(("redis", broker))
return out
def _accepts(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=CONNECT_TIMEOUT):
return True
except OSError:
return False
def wait(
name: str, host: str, port: int, deadline: float, now=time.monotonic,
) -> bool:
attempt = 0
while True:
if _accepts(host, port):
print(f"[wait] {name} at {host}:{port} is accepting connections")
return True
attempt += 1
if now() >= deadline:
print(
f"[wait] TIMEOUT: {name} at {host}:{port} never accepted a "
f"connection ({attempt} attempts)",
file=sys.stderr,
)
return False
if attempt % REPORT_EVERY == 0:
left = int(deadline - now())
print(f"[wait] {name} at {host}:{port} not ready yet, {left}s left")
time.sleep(RETRY_DELAY)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Wait for Postgres and Redis.")
ap.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
args = ap.parse_args(argv)
wanted = targets()
if not wanted:
# Nothing configured to wait for. Not an error: `shell` and one-off
# runs are legitimate, and refusing to start would make this gate the
# reason a debugging container will not boot.
print("[wait] no database or broker configured; nothing to wait for")
return 0
deadline = time.monotonic() + args.timeout
for name, (host, port) in wanted:
if not wait(name, host, port, deadline):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,337 +0,0 @@
"""Proposing that a creator FC tracks and a membership it found are the same.
Milestone 388, step E4. An instance of the confirm-only matcher shape
(snippet #3842), and a sibling of `post_association_service`.
## What E4 turned out NOT to need
The step's own first instruction was to verify before building, and the
verification said: not the schema, not the flows. `Source.artist_id` is a plain
FK so many sources per artist already works; `POST /api/sources` already takes
an `artist_id`; the add-source dialog already has an artist autocomplete that
attaches to an EXISTING artist; `SourceService.reassign` already moves a source
between artists WITH post and image re-attribution; and a sweep for
one-source-per-artist assumptions found only `func.count()` calls, which are
the opposite of assuming one.
So the association a Discord source and a Patreon source share is already
expressible today. What was missing is FC OFFERING it.
## Accept adds a SOURCE — it never merges artists
The asymmetry that sets the whole posture: adding a source is trivially undone.
A wrong artist merge silently mixes two creators' work and corrupts tagging,
series and provenance downstream, with nothing left to tell the two apart by.
So the accepted action is "add the missing channel to this artist", and merging
is not offered at all.
## The signals
1. **Name.** The roster's `display_name` and `vanity`, slugified, against the
artist's `slug`. Graded rather than boolean — an exact match is strong
evidence, a containment match is a hint.
2. **Declared.** A post already under this artist whose body links to
`patreon.com/<vanity>` for this exact membership. A creator pointing at
their own Patreon from their own Discord is close to a statement.
Signal 2 is NOT read from `ExternalLink`, and that correction is worth keeping:
`link_extract.SUPPORTED_HOSTS` is file hosts only (mega/gdrive/mediafire/
dropbox/pixeldrain) and `host_for()` returns None for patreon.com, so no
`ExternalLink` row is ever written for one. The same trap already caught E5 for
Discord invites.
## Weights, and what they make impossible
name 0.65 · declared 0.35, cut at 0.60
Chosen so the arithmetic encodes the judgement rather than a code path doing it:
* an EXACT name match alone (0.65) proposes — same slug on both sides is
strong, and requiring corroboration would mean proposing almost nothing;
* a CONTAINMENT name match alone (0.6 * 0.65 = 0.39) does not — "art" inside
"artgirl" is a coincidence generator, and it needs the declaration;
* the declaration ALONE (0.35) never proposes, at any setting at or above
0.60 — a creator may link another creator's Patreon, and a link is not a
claim of identity.
A guard test pins all three against WEIGHTS directly, so they survive a
refactor of the scorer.
"""
from __future__ import annotations
import logging
import re
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
Artist,
ArtistMembershipSuggestion,
PlatformMembership,
Post,
Source,
)
from ..utils.slug import slugify
from ..utils.text import html_to_plain
from .membership_roster import source_for_membership
log = logging.getLogger(__name__)
WEIGHTS = {"name": 0.65, "declared": 0.35}
DEFAULT_THRESHOLD = 0.60
NAME_EXACT = 1.0
# Containment is a hint, not a match: "art" sits inside "artgirl", and slugs
# are short enough that coincidental containment is common.
NAME_CONTAINS = 0.6
# Below this many characters, containment is noise rather than signal — a
# 3-character slug is inside a great many longer ones.
_MIN_CONTAINMENT_LEN = 5
MAX_CANDIDATES = 25
def name_signal(membership: PlatformMembership, artist: Artist) -> float:
"""Graded slug agreement between a membership and an artist.
Both the display name and the vanity are tried, because creators routinely
differ between the two ("Team Melon Collie" vs "MelonCollieStudios") and
either may be the one the operator typed when they created the artist.
"""
artist_slug = slugify(artist.name or "") if artist.name else ""
if not artist_slug or artist_slug == "untitled":
return 0.0
candidates = {
slugify(v) for v in (membership.display_name, membership.vanity_or_none())
if v
}
candidates.discard("untitled")
if not candidates:
return 0.0
if artist_slug in candidates:
return NAME_EXACT
for c in candidates:
if len(c) < _MIN_CONTAINMENT_LEN or len(artist_slug) < _MIN_CONTAINMENT_LEN:
continue
if c in artist_slug or artist_slug in c:
return NAME_CONTAINS
return 0.0
def declared_signal(body: str | None, vanity: str | None) -> float:
"""Does this post body point at THIS membership's Patreon page?
Matched against the RAW body, not the stripped text: these links live in an
anchor's `href`, and `html_to_plain` discards attributes — the same trap
that caught E5's invite detection. The stripped text is checked too, for
bodies that paste the URL as plain text.
"""
if not body or not vanity:
return 0.0
pattern = re.compile(
r"patreon\.com/(?:c/|cw/|checkout/)?" + re.escape(vanity) + r"\b", re.I
)
if pattern.search(body):
return 1.0
return 1.0 if pattern.search(html_to_plain(body) or "") else 0.0
def weighted_score(signals: dict) -> float:
return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4)
class ArtistMembershipService:
def __init__(self, session: AsyncSession):
self.session = session
async def _decided(self, membership_id: int) -> set[int]:
"""Artists already proposed for this membership, in ANY status.
Dismissed included: the row is what remembers the rejection, and
re-proposing a rejected pair every scan is what makes a queue ignored.
"""
rows = (await self.session.execute(
select(ArtistMembershipSuggestion.artist_id).where(
ArtistMembershipSuggestion.platform_membership_id == membership_id
)
)).scalars().all()
return set(rows)
async def _candidate_artists(self, membership: PlatformMembership) -> list[Artist]:
"""Artists that have SOME source but none for this membership's platform.
A hard filter, not a scored signal. An artist FC already tracks on this
platform needs no suggestion — the link exists — and an artist with no
sources at all is not a creator FC is following through another channel,
which is the whole case this step is about.
"""
# `select(...).exists()` rather than a bare `exists().where(...)`: the
# latter has no FROM to correlate against and does not reliably render.
has_any = select(Source.id).where(Source.artist_id == Artist.id).exists()
has_this = (
select(Source.id)
.where(
Source.artist_id == Artist.id,
Source.platform == membership.platform,
)
.exists()
)
return (await self.session.execute(
select(Artist).where(has_any, ~has_this).limit(MAX_CANDIDATES)
)).scalars().all()
async def _declared_for(self, artist_id: int, vanity: str | None) -> float:
if not vanity:
return 0.0
# Bounded scan: the newest posts are where a creator's current links
# live, and an unbounded body scan per (artist, membership) pair would
# be the expensive part of this sweep.
bodies = (await self.session.execute(
select(Post.description)
.where(Post.artist_id == artist_id, Post.description.is_not(None))
.order_by(func.coalesce(Post.post_date, Post.downloaded_at).desc())
.limit(50)
)).scalars().all()
for body in bodies:
if declared_signal(body, vanity) > 0:
return 1.0
return 0.0
async def match_membership(
self, membership_id: int, *, threshold: float = DEFAULT_THRESHOLD,
) -> int:
membership = await self.session.get(PlatformMembership, membership_id)
if membership is None:
return 0
# The shared identity join (C4), used here as the NEGATIVE check. A
# membership FC already has a source for is tracked — whoever it happens
# to be filed under — and proposing it to some OTHER artist would be
# exactly the wrong link this service exists to avoid making.
# `_candidate_artists` only knows whether a GIVEN artist has a source on
# the platform, which cannot see a source sitting under someone else.
if await source_for_membership(self.session, membership) is not None:
return 0
already = await self._decided(membership_id)
made = 0
for artist in await self._candidate_artists(membership):
if artist.id in already:
continue
signals = {
"name": name_signal(membership, artist),
"declared": await self._declared_for(
artist.id, membership.vanity_or_none()
),
}
score = weighted_score(signals)
if score < threshold:
continue
self.session.add(ArtistMembershipSuggestion(
platform_membership_id=membership.id,
artist_id=artist.id,
score=score,
signals=signals,
status="pending",
))
made += 1
return made
async def list_pending(self) -> list[dict]:
rows = (await self.session.execute(
select(ArtistMembershipSuggestion, PlatformMembership, Artist)
.join(
PlatformMembership,
PlatformMembership.id
== ArtistMembershipSuggestion.platform_membership_id,
)
.join(Artist, Artist.id == ArtistMembershipSuggestion.artist_id)
.where(ArtistMembershipSuggestion.status == "pending")
.order_by(
ArtistMembershipSuggestion.score.desc(),
ArtistMembershipSuggestion.id.desc(),
)
)).all()
return [
{
"id": s.id,
"score": s.score,
"signals": s.signals,
"artist": {"id": a.id, "name": a.name, "slug": a.slug},
"membership": {
"id": m.id,
"platform": m.platform,
"display_name": m.display_name,
"url": m.url,
},
}
for s, m, a in rows
]
async def accept(self, suggestion_id: int) -> dict | None:
"""Add the missing channel to the artist. NEVER merges two artists.
Returns the created source's id, or `already_linked` when a source for
that platform appeared between the proposal and the click — which is
not an error, it is the operator having done it by hand.
"""
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
if s is None:
return None
membership = await self.session.get(PlatformMembership, s.platform_membership_id)
if membership is None or not membership.url:
return None
existing = (await self.session.execute(
select(Source.id).where(
Source.artist_id == s.artist_id,
Source.platform == membership.platform,
)
)).scalars().first()
if existing is not None:
s.status = "linked"
return {"id": s.id, "status": s.status, "already_linked": existing}
# Through SourceService, NOT a bare Source() insert. It carries the
# platform/URL validation, the duplicate check and the #693
# backfill-arming that a hand-added source gets — building a second,
# quieter way to create a source is how the two drift until one of them
# is subtly broken (rule 28: repurpose the existing surface).
from .source_service import DuplicateSourceError, SourceService
try:
record = await SourceService(self.session).create(
artist_id=s.artist_id,
platform=membership.platform,
url=membership.url,
)
except DuplicateSourceError as exc:
# The same URL already exists for this artist — the operator got
# there first by a different route. Not an error.
s.status = "linked"
return {"id": s.id, "status": s.status, "already_linked": exc.existing_id}
s.status = "linked"
return {"id": s.id, "status": s.status, "source_id": record.id}
async def dismiss(self, suggestion_id: int) -> dict | None:
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
if s is None:
return None
# Kept, not deleted — the row is what remembers the rejection.
s.status = "dismissed"
return {"id": s.id, "status": s.status}
async def rescan(session: AsyncSession, *, threshold: float = DEFAULT_THRESHOLD) -> dict:
"""Offer every known membership to the artists FC already tracks."""
ids = (await session.execute(select(PlatformMembership.id))).scalars().all()
svc = ArtistMembershipService(session)
proposed = 0
for mid in ids:
proposed += await svc.match_membership(mid, threshold=threshold)
log.info(
"artist/membership matcher: scanned %d membership(s), proposed %d pair(s)",
len(ids), proposed,
)
return {"scanned": len(ids), "proposed": proposed}
+7 -34
View File
@@ -300,48 +300,21 @@ class ArtistService:
await self.session.commit()
return artist
async def all_names(self) -> list[tuple[int, str, str]]:
"""Every artist as (id, name, slug), alphabetical.
For pickers that should show a full list before anything is typed (the
Latest feed's artist filter). Three columns and no joins, so it stays
cheap on a library of thousands of artists.
"""
rows = (await self.session.execute(
select(Artist.id, Artist.name, Artist.slug).order_by(func.lower(Artist.name))
)).all()
return [(r.id, r.name, r.slug) for r in rows]
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
cleaned = (prefix or "").strip()
if not cleaned:
return []
low = cleaned.lower()
like = f"%{low}%"
prefix_like = f"{low}%"
# Spacing- and punctuation-insensitive too, so "Tamada Heijun" finds
# "TamadaHeijun" and "sabu_art" finds "Sabu Art" — the same creator is
# spelled differently on every platform, and the browser extension's
# Add panel matches a Discord server name against these (milestone 429).
# [[:alnum:]] keeps non-Latin letters; a query with none (all
# punctuation) skips this arm rather than matching every artist.
squashed = "".join(ch for ch in low if ch.isalnum())
name_squashed = func.regexp_replace(func.lower(Artist.name), "[^[:alnum:]]", "", "g")
matches = [func.lower(Artist.name).like(like)]
if squashed:
matches.append(name_squashed.like(f"%{squashed}%"))
# Rank: exact (0) < exact ignoring spacing (1) < prefix (2) <
# substring (3) < substring ignoring spacing (4).
like = f"%{cleaned.lower()}%"
prefix_like = f"{cleaned.lower()}%"
# Rank: exact (0) < prefix (1) < substring (2).
rank = case(
(func.lower(Artist.name) == low, 0),
(name_squashed == squashed, 1),
(func.lower(Artist.name).like(prefix_like), 2),
(func.lower(Artist.name).like(like), 3),
else_=4,
(func.lower(Artist.name) == cleaned.lower(), 0),
(func.lower(Artist.name).like(prefix_like), 1),
else_=2,
).label("rank")
rows = (await self.session.execute(
select(Artist, rank)
.where(or_(*matches))
.where(func.lower(Artist.name).like(like))
.order_by(rank, Artist.name.asc())
.limit(limit)
)).all()
+2 -23
View File
@@ -24,25 +24,6 @@ from pathlib import Path
_BACKUPS_DIRNAME = "_backups"
# Excluded from the images tarball, and each for its own reason (#4233, #4234):
#
# _backups — the archive would otherwise contain every previous archive.
# This is not hypothetical: the 2026-05-23/24 runs, taken before
# this exclude existed, grew 43G -> 107G -> ... -> 2123G as each
# swallowed its predecessors, and cost 4.3T of the images
# filesystem until they were reclaimed on 2026-09-21.
# _quarantine — holds files deliberately pulled OUT of the library.
# secrets — `credential_key.b64`, the key that decrypts the stored
# Patreon/SubscribeStar session credentials.
# cookies — those session cookies themselves.
#
# The last two are the ones worth stating plainly: an images tarball is a media
# archive, and a media archive that carries the key to the operator's accounts
# is a credential leak wearing a backup's name. Encryption at rest buys nothing
# when the key rides along in the same file. A restore therefore does NOT
# re-establish credentials — you sign in again, which is the correct outcome.
_IMAGES_EXCLUDED_DIRNAMES = ("_backups", "_quarantine", "secrets", "cookies")
# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The Celery
# soft limit signals the Python process; subprocess.Popen in a blocking syscall
# ignores that signal, so these bound the worst case directly. Each sits just
@@ -192,10 +173,8 @@ def backup_images(
[
"tar", "--zstd", "-cf", str(tar_path),
"-C", str(images_root.parent), images_root.name,
*(
f"--exclude={images_root.name}/{name}"
for name in _IMAGES_EXCLUDED_DIRNAMES
),
f"--exclude={images_root.name}/_backups",
f"--exclude={images_root.name}/_quarantine",
],
_IMAGES_SUBPROCESS_TIMEOUT_S,
)
+3 -53
View File
@@ -24,7 +24,6 @@ rows undecryptable (recovery = delete the rows and re-upload).
import logging
import os
import tempfile
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
@@ -81,59 +80,10 @@ class CredentialCrypto:
parent = self._key_path.parent
parent.mkdir(parents=True, exist_ok=True)
os.chmod(parent, 0o700)
# Written to a temp file and LINKED into place, not written directly.
#
# hypercorn starts several worker processes and each one builds the
# app, so on a first boot they all reach this at once. A plain
# `write_bytes` creates the file at size zero and fills it a moment
# later, which gives a second process an `exists()` of True and a
# `read_bytes()` of b"" — and the app dies with
#
# ValueError: Fernet key must be 32 url-safe base64-encoded bytes.
#
# Seen on run 7368's smoke, and it is a race rather than a certainty:
# the same image had booted cleanly on the three runs before it. A
# first boot that fails one time in five is worse than one that fails
# every time, because it looks like the deployment rather than the code.
#
# `os.link` is the atomic part: it either creates the name or raises
# FileExistsError, and it cannot expose a half-written file. NOT
# `os.replace`, which would succeed — so two processes that both
# generated a key would each think they had won, and the loser's key
# would overwrite the one the winner had already handed to Fernet.
# `mkstemp`, not a pid-derived name. The first cut spelled the temp
# file `.credential_key.b64.<pid>.tmp`, which assumes one bootstrap per
# process — and the test that exercises this with eight THREADS shares
# one pid, so all eight raced the same filename and six died with
# FileNotFoundError when another had already unlinked it. The
# assumption held for hypercorn's workers and would have held in
# production; it was still an assumption the code did not need to make.
key = Fernet.generate_key()
fd, tmp_name = tempfile.mkstemp(
dir=parent, prefix=f".{self._key_path.name}.", suffix=".tmp",
)
tmp = Path(tmp_name)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(key)
os.chmod(tmp, 0o600)
try:
os.link(tmp, self._key_path)
except FileExistsError:
# Another process created it between our `exists()` check and
# here. Theirs is as good as ours, and using it is what keeps
# every worker on ONE key.
log.info(
"another process created %s first; using that key",
self._key_path,
)
finally:
tmp.unlink(missing_ok=True)
# Read back rather than returning `key`: on the losing branch the file
# holds somebody else's, and returning ours would leave this worker
# encrypting with a key no other worker can read.
return self._key_path.read_bytes()
self._key_path.write_bytes(key)
os.chmod(self._key_path, 0o600)
return key
def encrypt(self, plaintext: str) -> bytes:
return self._fernet.encrypt(plaintext.encode("utf-8"))
+1 -46
View File
@@ -16,13 +16,10 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from sqlalchemy import Select, and_
from sqlalchemy import Select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Source
from .gallery_dl import ErrorType
async def get_or_create[T](
session: AsyncSession,
@@ -53,45 +50,3 @@ async def get_or_create[T](
except IntegrityError:
await sp.rollback()
return (await session.execute(select_stmt)).scalar_one(), False
# --- shared Source health predicates ----------------------------------------
#
# The subscriptions rollup, the front-door status ribbon and the list endpoint
# all have to agree on what "failing" and "no access" MEAN, or the ribbon says
# 3 and the card it links to shows 4. Same reasoning as get_or_create above:
# divergent copies of one predicate are how the drift creeps in. Defined here
# rather than in source_service because scheduler_service needs them too, and
# source_service already imports scheduler_service (the other direction would
# be a cycle).
def failing_sources_clause():
"""A source is FAILING when it is ENABLED and its runs are erroring.
Deliberately not `last_error IS NOT NULL` — a tier-limited source clears
last_error and keeps a chip, and must never be counted as broken.
The `enabled` half was folded in 2026-09-21 (#4279). A disabled source is
one FC deliberately stopped — most often because the membership sweep saw
`former_patron` — and "stopped because you no longer subscribe" is not
"failing". Worse, it is a failure nobody can clear: a disabled source is
never scheduled, so no successful run ever resets the counter, and the
card's Retry button routes to `/check`, which refuses a disabled source
outright. Ebi77 sat in the banner for six days with no action available.
This also settles a disagreement the two callers already had. The
scheduler's status count paired this clause with `enabled.is_(True)`;
`SourceService.list(failing=True)` did not. One counted Ebi77, the other
did not — the exact drift the note above this function warns about, which
is why the `enabled` test belongs IN the predicate rather than beside it.
"""
return and_(Source.enabled.is_(True), Source.consecutive_failures > 0)
def no_access_sources_clause():
"""A source we can't see the content of: the walk works, the tier doesn't
grant it (#874 / milestone #387 phase A). Not a failure — kept separate
from failing_sources_clause on purpose, and the two are disjoint because
an informational class only ever rides an otherwise-OK run."""
return Source.error_type == ErrorType.TIER_LIMITED
-562
View File
@@ -1,562 +0,0 @@
"""Native Discord read client — the Discord counterpart to subscribestar_client.
Mirrors gallery-dl 1.32.13's `extractor/discord.py` (rule 130: gallery-dl is the
known-working base), adapted to the native core's client contract
(`ingest_core` module docstring): `iter_posts` / `extract_media`, plus the
post-first `post_record_key` and the `post_meta` date the revisit window reads.
What is mirrored exactly, because drift in any of it changes what we fetch or
where it lands on disk:
- API v10, `Authorization: <user token>` (a USER token, not a bot token).
- gallery-dl's request profile: its date-derived Firefox User-Agent,
`Accept: */*`, `Accept-Language`, `Referer: https://discord.com/`.
- `GET /channels/{id}/messages?limit=100&before=<last id>`, newest first,
stopping on a short page. Message types {0, 19, 21} only.
- The walk: a text/news channel's own messages then its threads, a forum's
threads, a category's children, a server's text/news/forum channels.
- Files: attachments, then embeds of type image/gifv/video (FC configures
`embeds: all`, which for files is the same three plus rich/link embeds that
carry an image), then forwarded `message_snapshots`, numbered from 1 across
the lot — the `num` in `{date}_{message_id}_{num}_{filename}`.
- Text: `content`, rich-embed author/title/description/fields/footer, poll.
Two deliberate departures, both about the walk order, neither about content:
- Threads are walked newest-CREATED first (by id), not by last-message time.
A backfill resumes from a checkpointed channel; last-message order shifts
between chunks whenever someone posts, which can move an unwalked thread
above the resume point and skip it. Creation order only ever grows at the
front, where the next tick finds it.
- A 403 on a thread or a nested channel skips that feed instead of failing
the walk. gallery-dl skips only nested channels; one private thread the
token cannot read would otherwise stop every channel after it.
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
"""
from __future__ import annotations
import hashlib
import logging
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date
from urllib.parse import unquote
import requests
from .native_ingest_common import (
NativeAuthError,
NativeDriftError,
NativeIngestError,
retry_after_seconds,
)
log = logging.getLogger(__name__)
API_ROOT = "https://discord.com/api/v10"
_ROOT = "https://discord.com"
_TIMEOUT_SECONDS = 60.0
_MESSAGES_BATCH = 100
_THREADS_BATCH = 25
# gallery-dl retries a 429 up to its default 4 retries, waiting
# `request_interval_429` (60s) between them. Discord's Retry-After is exact, so
# it is honoured when present; 60s is the fallback and the cap.
_MAX_429_RETRIES = 4
_429_WAIT_SECONDS = 60.0
# https://discord.com/developers/docs/resources/message#message-object-message-types
# DEFAULT, REPLY, CHAT_INPUT_COMMAND — the ones that carry user content.
MESSAGE_TYPES = frozenset({0, 19, 21})
# https://discord.com/developers/docs/resources/channel#channel-object-channel-types
_TEXT = frozenset({0, 5}) # text, announcement: messages + threads
_DIRECT = frozenset({1, 3, 10, 11, 12}) # DMs and threads: messages only
_FORUM = frozenset({15, 16}) # forum, media: threads only
_CATEGORY = 4
_SERVER_WALK = _TEXT | _FORUM
_EMBED_TYPES = frozenset({"image", "gifv", "video"})
_URL_RE = re.compile(
r"^(?:https?://)?(?:www\.|ptb\.|canary\.)?discord(?:app)?\.com/channels/"
r"(?P<server>@me|\d+)(?:/(?:\d+/threads/)?(?P<channel>\d+))?(?P<rest>/.*)?/?$"
)
class DiscordAPIError(NativeIngestError):
"""Base for native Discord client failures."""
class DiscordAuthError(DiscordAPIError, NativeAuthError):
"""401 (the token is invalid or expired) or a 403 on the channel the
source names. The fix is a new token, not a new client."""
class DiscordDriftError(DiscordAPIError, NativeDriftError):
"""A response did not have the shape the walk depends on."""
def firefox_user_agent(today: date | None = None) -> str:
"""gallery-dl's default User-Agent: a Firefox whose version advances every
four weeks (`util._ff_ver`, "147 on 2026-01-13"). Computed the same way so
the profile keeps matching the gallery-dl this replaced."""
ver = ((today or date.today()).toordinal() - 735_513) // 28
return (
f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{ver}.0) "
f"Gecko/20100101 Firefox/{ver}.0"
)
def nameext_from_url(url: str) -> tuple[str, str]:
"""gallery-dl's `text.nameext_from_url`: the URL's last path segment,
unquoted, split at the last dot when the extension is at most 16 chars
(lowercased); otherwise the whole name and no extension."""
filename = unquote(url.partition("?")[0].rpartition("/")[2])
name, _, ext = filename.rpartition(".")
if name and len(ext) <= 16:
return name, ext.lower()
return filename, ""
def parse_source_url(url: str) -> tuple[str | None, str | None]:
"""`(server_id, channel_id)` from a Discord channel/server URL. `server_id`
is None for a DM (`@me`); `channel_id` is None for a whole server. Raises
DiscordAPIError for anything else, including a link to a single message —
a message is not something a source can subscribe to."""
m = _URL_RE.match((url or "").strip())
if not m or (m.group("rest") or "").strip("/"):
raise DiscordAPIError(
f"Not a Discord channel or server link: {url!r} "
"(expected https://discord.com/channels/<server>[/<channel>])"
)
server = m.group("server")
channel = m.group("channel")
if server == "@me":
if not channel:
raise DiscordAPIError(f"A DM link needs a channel id: {url!r}")
return None, channel
return server, channel
def message_text(message: dict) -> str:
"""gallery-dl's `extract_message_text`: the body plus the text of rich
embeds and polls, newline-joined, empties dropped."""
parts = [message.get("content") or ""]
for embed in message.get("embeds") or []:
if embed.get("type") != "rich":
continue
parts.append((embed.get("author") or {}).get("name") or "")
parts.append(embed.get("title") or "")
parts.append(embed.get("description") or "")
for fld in embed.get("fields") or []:
parts.append(fld.get("name") or "")
parts.append(fld.get("value") or "")
parts.append((embed.get("footer") or {}).get("text") or "")
poll = message.get("poll")
if poll:
parts.append(((poll.get("question") or {}).get("text")) or "")
for answer in poll.get("answers") or []:
parts.append(((answer.get("poll_media") or {}).get("text")) or "")
return "\n".join(p for p in parts if p)
@dataclass
class MediaItem:
"""One file of a Discord message. `filename`/`extension` are gallery-dl's
split of the URL; `num` is its 1-based position across the message's files,
which is what names it on disk.
`media_id` is what the seen-ledger keys on, and it is deliberately NOT
`num`: an edit that removes a file renumbers the ones after it, and a
positional key would then call a different file seen. It is the
attachment's id, or for an embed (which has none) a hash of its URL path —
the query string is a signature that changes on every fetch. `filehash` is
always None; nothing in a signed CDN URL is a content hash."""
url: str
filename: str
extension: str
kind: str
post_id: str
num: int
media_id: str
filehash: str | None = None
class DiscordClient:
"""Synchronous Discord API v10 read client for one user token."""
def __init__(
self,
token: str | None,
*,
request_sleep: float = 0.0,
max_retries: int = _MAX_429_RETRIES,
session: requests.Session | None = None,
):
self._session = session or requests.Session()
self._session.headers.update({
"User-Agent": firefox_user_agent(),
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Referer": _ROOT + "/",
})
if token:
self._session.headers["Authorization"] = token
self._token = token
self._request_sleep = request_sleep or 0.0
self._max_retries = max_retries
self._server: dict = {}
self._channels: dict[str, dict] = {}
self._skip_feed = False
# -- request -----------------------------------------------------------
def _get(self, endpoint: str, params: dict | None = None):
if not self._token:
raise DiscordAuthError("No Discord token is configured for this source")
if self._request_sleep > 0:
time.sleep(self._request_sleep)
url = API_ROOT + endpoint
attempt = 0
while True:
try:
resp = self._session.get(url, params=params, timeout=_TIMEOUT_SECONDS)
except requests.RequestException as exc:
raise DiscordAPIError(f"Discord request failed ({endpoint}): {exc}") from exc
if resp.status_code == 429 and attempt < self._max_retries:
attempt += 1
delay = retry_after_seconds(
resp, attempt, base=_429_WAIT_SECONDS, cap=_429_WAIT_SECONDS,
)
log.warning(
"Discord 429 (%s) — waiting %.1fs (retry %d/%d)",
endpoint, delay, attempt, self._max_retries,
)
time.sleep(delay)
continue
break
if resp.status_code == 401:
raise DiscordAuthError(
"Discord rejected the token (HTTP 401) — it is invalid or has "
"expired; copy a fresh one from the browser",
status_code=401,
)
if resp.status_code != 200:
raise DiscordAPIError(
f"Discord returned HTTP {resp.status_code} ({endpoint})",
status_code=resp.status_code,
retry_after=_retry_after(resp),
)
try:
return resp.json()
except ValueError as exc:
raise DiscordDriftError(
f"Discord returned non-JSON for {endpoint} ({len(resp.content)} bytes)"
) from exc
# -- metadata (gallery-dl parse_server / parse_channel) -----------------
def _load_server(self, server_id: str) -> None:
server = self._get(f"/guilds/{server_id}")
if not isinstance(server, dict) or "id" not in server:
raise DiscordDriftError(f"Discord server {server_id} came back without an id")
self._server = {
"server": server.get("name") or "",
"server_id": str(server["id"]),
"owner_id": server.get("owner_id"),
}
channels = self._get(f"/guilds/{server_id}/channels")
if not isinstance(channels, list):
raise DiscordDriftError(f"Discord server {server_id} channel list is not a list")
# Categories first, so every child can name its parent.
for channel in sorted(channels, key=lambda ch: ch.get("type") != _CATEGORY):
self._parse_channel(channel)
def _parse_channel(self, channel: dict) -> dict:
parent_id = channel.get("parent_id")
meta = {
"channel": channel.get("name") or "",
"channel_id": str(channel.get("id")),
"channel_type": channel.get("type"),
"channel_topic": channel.get("topic") or "",
"parent_id": parent_id,
"is_thread": "thread_metadata" in channel,
}
parent = self._channels.get(parent_id) if parent_id else None
if parent:
meta["parent"] = parent["channel"]
meta["parent_type"] = parent["channel_type"]
if meta["channel_type"] in {1, 3}:
recipients = channel.get("recipients") or []
meta["channel"] = "DMs"
meta["recipients"] = [u.get("username") for u in recipients]
meta["recipients_id"] = [u.get("id") for u in recipients]
self._channels[meta["channel_id"]] = meta
return meta
def _channel_meta(self, channel_id: str) -> dict:
if channel_id not in self._channels:
self._parse_channel(self._get(f"/channels/{channel_id}"))
return self._channels[channel_id]
def _threads(self, channel_id: str) -> list[dict]:
"""Every thread of a channel or forum, newest-created first (see the
module docstring for why not last-message order)."""
threads: list[dict] = []
offset = 0
while True:
data = self._get(f"/channels/{channel_id}/threads/search", {
"sort_by": "last_message_time",
"sort_order": "desc",
"limit": _THREADS_BATCH,
"offset": offset,
})
batch = (data.get("threads") or []) if isinstance(data, dict) else []
threads.extend(batch)
if len(batch) < _THREADS_BATCH:
break
offset += len(batch)
threads.sort(key=lambda t: int(t.get("id") or 0), reverse=True)
return threads
# -- the walk ------------------------------------------------------------
def _feeds(self, channel_id: str, *, safe: bool) -> Iterator[tuple[str, bool]]:
"""`(channel_id, safe)` for every message feed under `channel_id`, in
gallery-dl's order. `safe` feeds are skipped on a 403."""
try:
ctype = self._channel_meta(channel_id)["channel_type"]
except DiscordAPIError as exc:
if exc.status_code != 403:
raise
if not safe:
raise DiscordAuthError(
f"The Discord token cannot see channel {channel_id} (HTTP 403)",
status_code=403,
) from exc
log.info("Discord: no access to channel %s — skipped", channel_id)
return
if ctype in _TEXT or ctype in _DIRECT:
yield channel_id, safe
if ctype in _TEXT or ctype in _FORUM:
try:
threads = self._threads(channel_id)
except DiscordAPIError as exc:
if exc.status_code != 403:
raise
log.info("Discord: cannot list threads of %s — skipped", channel_id)
threads = []
for thread in threads:
yield self._parse_channel(thread)["channel_id"], True
elif ctype == _CATEGORY:
for child in list(self._channels.values()):
if child.get("parent_id") == channel_id:
yield from self._feeds(child["channel_id"], safe=True)
elif ctype not in _DIRECT and not safe:
raise DiscordAPIError(
f"Discord channel {channel_id} is of type {ctype}, which has no messages"
)
def _source_feeds(self, url: str) -> Iterator[tuple[str, bool]]:
server_id, channel_id = parse_source_url(url)
self._server, self._channels = {}, {}
if server_id is not None:
self._load_server(server_id)
if channel_id is not None:
yield from self._feeds(channel_id, safe=False)
return
for meta in list(self._channels.values()):
if meta["channel_type"] in _SERVER_WALK:
yield from self._feeds(meta["channel_id"], safe=True)
def skip_feed(self) -> None:
"""Optional core seam (#4413): end the current channel and go on to the
next one. A tick's early-out means THIS channel has nothing new, not
that the server has nothing new."""
self._skip_feed = True
def iter_posts(
self, campaign_id: str, cursor: str | None = None
) -> Iterator[tuple[dict, dict, str | None]]:
"""Yield `(message, channel_meta, page_cursor)` for every content
message the source reaches, channel by channel, each newest first.
`campaign_id` is the source URL. The cursor is `<channel_id>:<before>`
— the channel and the `before` id that fetched the page (empty for a
channel's first page) — so a backfill resumes inside the right channel
and re-fetches the page it was cut in. A cursor naming a channel the
walk no longer reaches (a deleted thread) restarts from the top rather
than walking nothing.
"""
resume_channel, _, resume_before = (cursor or "").partition(":")
resuming = bool(resume_channel)
feeds = list(self._source_feeds(campaign_id)) if resuming else None
if feeds is not None and resume_channel not in {cid for cid, _ in feeds}:
log.warning(
"Discord: resume channel %s is no longer in %s — restarting",
resume_channel, campaign_id,
)
resuming = False
for channel_id, safe in feeds if feeds is not None else self._source_feeds(campaign_id):
before = None
if resuming:
if channel_id != resume_channel:
continue
resuming = False
before = resume_before or None
yield from self._iter_channel(channel_id, before, safe=safe)
def _iter_channel(
self, channel_id: str, before: str | None, *, safe: bool
) -> Iterator[tuple[dict, dict, str | None]]:
self._skip_feed = False
meta = {**self._server, **self._channels.get(channel_id, {})}
while True:
page_cursor = f"{channel_id}:{before or ''}"
try:
messages = self._get(
f"/channels/{channel_id}/messages",
{"limit": _MESSAGES_BATCH, "before": before},
)
except DiscordAPIError as exc:
if exc.status_code != 403:
raise
if not safe:
raise DiscordAuthError(
f"The Discord token cannot read channel {channel_id} (HTTP 403)",
status_code=403,
) from exc
log.info("Discord: no access to messages of %s — skipped", channel_id)
return
if not isinstance(messages, list):
raise DiscordDriftError(
f"Discord messages of {channel_id} came back as "
f"{type(messages).__name__}, not a list"
)
for message in messages:
if message.get("type") not in MESSAGE_TYPES:
continue
message["_meta"] = meta
yield message, meta, page_cursor
if self._skip_feed:
return
if len(messages) < _MESSAGES_BATCH:
return
before = str(messages[-1]["id"])
# -- per-message -------------------------------------------------------
@staticmethod
def extract_media(post: dict, included: dict | None = None) -> list[MediaItem]:
"""gallery-dl's file list for one message: attachments, then the first
of video/image/thumbnail `proxy_url` of each file-bearing embed, then
the same for every forwarded snapshot; numbered from 1 across them."""
mid = str(post.get("id") or "")
snapshots = [post] + [
(s or {}).get("message") or {}
for s in post.get("message_snapshots") or []
if ((s or {}).get("message") or {}).get("type", 0) in MESSAGE_TYPES
]
found: list[tuple[str, str, str | None]] = []
for snap in snapshots:
for att in snap.get("attachments") or []:
if att.get("url"):
aid = att.get("id")
found.append((att["url"], "attachment", str(aid) if aid else None))
for embed in snap.get("embeds") or []:
if embed.get("type") not in _EMBED_TYPES:
continue
for fld in ("video", "image", "thumbnail"):
url = (embed.get(fld) or {}).get("proxy_url")
if url:
found.append((url, "embed", None))
break
items = []
for num, (url, kind, fid) in enumerate(found, start=1):
name, ext = nameext_from_url(url)
if fid is None:
path = url.partition("?")[0].encode()
fid = "u" + hashlib.sha1(path, usedforsecurity=False).hexdigest()[:32]
items.append(MediaItem(
url=url, filename=name, extension=ext, kind=kind, post_id=mid,
num=num, media_id=fid,
))
return items
@staticmethod
def post_meta(post: dict) -> dict:
"""No title (Discord has none); `date` is the message timestamp, ISO
with an offset — what the core's revisit window reads."""
return {"title": None, "date": post.get("timestamp")}
@classmethod
def post_record_key(cls, post: dict) -> tuple[str, str] | None:
"""`(message:<id>, <id>)` — gates the message record through the seen
ledger, like `post:<id>` on the other platforms.
None for a message with no files. gallery-dl wrote a sidecar only
beside a file, so a text-only chat line never became a post, and the
drop grouping (discord_grouping) is built on that: a channel's chatter
recorded as posts would bury the drops it exists to surface."""
mid = post.get("id")
mid = str(mid) if mid is not None else ""
if not mid or not cls.extract_media(post):
return None
return (f"message:{mid}", mid)
# -- verify ------------------------------------------------------------
def describe(self, server_id: str | None, channel_id: str | None) -> dict:
"""The display names behind a server/channel pair, for the browser
extension's Add panel. Best-effort per name: one that can't be read
comes back None, and the other is still returned."""
out: dict = {"server": None, "channel": None, "parent": None}
if server_id:
try:
out["server"] = (self._get(f"/guilds/{server_id}") or {}).get("name") or None
except DiscordAPIError:
pass
if channel_id:
try:
meta = self._parse_channel(self._get(f"/channels/{channel_id}"))
out["channel"] = meta.get("channel") or None
except (DiscordAPIError, AttributeError):
pass
return out
def verify_auth(self, url: str) -> tuple[bool | None, str]:
"""Is the token valid, and can it see what the source names?"""
try:
server_id, channel_id = parse_source_url(url)
except DiscordAPIError as exc:
return None, str(exc)
try:
me = self._get("/users/@me")
if channel_id is not None:
self._get(f"/channels/{channel_id}")
elif server_id is not None:
self._get(f"/guilds/{server_id}")
except DiscordAuthError as exc:
return False, f"Discord rejected the token — {exc}"
except DiscordAPIError as exc:
if exc.status_code in (403, 404):
return False, (
"The token is valid, but its account cannot see "
f"{'this channel' if channel_id else 'this server'} "
f"(HTTP {exc.status_code})"
)
return None, f"Couldn't verify (network/HTTP issue): {exc}"
who = (me or {}).get("username") if isinstance(me, dict) else None
return True, f"Token valid{f' ({who})' if who else ''} — the source is readable."
def _retry_after(resp: requests.Response) -> float | None:
hdr = resp.headers.get("Retry-After")
try:
return float(hdr) if hdr else None
except (TypeError, ValueError):
return None
-212
View File
@@ -1,212 +0,0 @@
"""Native Discord media downloader — the Discord counterpart to
subscribestar_downloader.
Writes files exactly where gallery-dl wrote them, so a cutover finds every
existing file on disk (`skipped_disk`) instead of fetching it again:
<images>/<artist>/discord/<channel>/<YYYYMMDD>_<message_id>_<NN>_<name>.<ext>
That is what FC's gallery-dl config produced (directory `{channel}`, filename
`{date:%Y%m%d}_{message_id}_{num:>02}_{filename}.{extension}`, under the
per-source base directory `<images>/<artist>/<platform>`), retired from that
config once Discord moved here; tests/test_discord_naming.py pins the match
against a real gallery-dl sidecar. The name is cleaned the way gallery-dl cleans it
on Linux — `/` becomes `_` and control characters are removed, nothing else
(`path-restrict: auto`, `path-remove` defaults). It is NOT `sanitize_segment`,
whose Windows set would turn a `:` in a channel or file name into `_` and miss
the file gallery-dl wrote.
Post-first (rule 120): each file gets a minimal sidecar named like it minus the
extension (what `find_sidecar` pairs first), and the message itself gets one
record, `<YYYYMMDD>_<message_id>_post.json`, carrying gallery-dl's metadata keys
— `message_id` for the post id, `server_id`/`channel_id` for the permalink,
`message` for the body, `date` — so `parse_sidecar` reads it exactly as it read
the gallery-dl sidecars. Neither file carries an `id` or `post_id` key: both
outrank `message_id` in the post-id chain (`platforms.base`).
PURE: no DB; the seen-skip is an injected predicate.
"""
from __future__ import annotations
import json
import logging
import re
import time
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
import requests
from .discord_client import firefox_user_agent, message_text
from .native_ingest_common import (
BaseNativeDownloader,
MediaOutcome,
PostRecordOutcome,
make_session,
)
log = logging.getLogger(__name__)
PLATFORM = "discord"
_CONTROL = re.compile("[\x00-\x1f\x7f]")
# gallery-dl falls back to the response's type for a URL with no extension;
# we never see the response before naming, and such URLs do not occur for
# Discord attachments or embed proxies in practice.
_NO_EXTENSION = "bin"
def gdl_clean(segment: str) -> str:
"""One path segment as gallery-dl writes it on Linux."""
return _CONTROL.sub("", segment.replace("/", "_"))
def message_date(post: dict) -> datetime | None:
raw = post.get("timestamp")
if not isinstance(raw, str) or not raw:
return None
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
return (dt if dt.tzinfo else dt.replace(tzinfo=UTC)).astimezone(UTC)
def channel_dir(images_root: Path, artist_slug: str, post: dict) -> Path:
"""gallery-dl's `{channel}` directory; an empty name adds no segment."""
base = Path(images_root) / artist_slug / PLATFORM
channel = gdl_clean(((post.get("_meta") or {}).get("channel") or "").strip())
return base / channel if channel else base
def media_stem(post: dict, media) -> str:
"""`<YYYYMMDD>_<message_id>_<NN>_<name>` — the file's name minus `.<ext>`."""
when = message_date(post)
day = f"{when:%Y%m%d}" if when else "None"
return gdl_clean(f"{day}_{post.get('id')}_{media.num:>02}_{media.filename}")
class DiscordDownloader(BaseNativeDownloader):
"""Download a message's files to gallery-dl's layout. The CDN gets
gallery-dl's browser profile and no token — gallery-dl sends the token only
to the API, and the CDN URLs are pre-signed."""
def __init__(
self,
images_root: Path,
cookies_path: str | None = None,
*,
validate: bool = True,
rate_limit: float = 0.0,
session: requests.Session | None = None,
):
super().__init__(
images_root, None, platform=PLATFORM,
validate=validate, rate_limit=rate_limit,
session=session if session is not None else make_session(None, extra_headers={
"User-Agent": firefox_user_agent(),
"Accept-Language": "en-US,en;q=0.5",
"Referer": "https://discord.com/",
}),
)
def download_post(
self,
post: dict,
media_items: list,
artist_slug: str,
*,
is_seen: Callable[[object], bool] = lambda m: False,
should_stop: Callable[[], bool] = lambda: False,
recapture: bool = False,
) -> list[MediaOutcome]:
"""Every file of one message; per-file outcomes, one failure isolated."""
folder = channel_dir(self.images_root, artist_slug, post)
outcomes: list[MediaOutcome] = []
for media in media_items:
if should_stop():
break
try:
outcomes.append(self._download_one(
post, media, folder, artist_slug, is_seen, recapture=recapture,
))
except Exception as exc: # resilient: isolate one item's failure
log.warning(
"Discord media failed (message %s, file %d): %s",
post.get("id"), media.num, exc,
)
outcomes.append(
MediaOutcome(media=media, status="error", path=None, error=str(exc))
)
return outcomes
def _download_one(
self,
post: dict,
media,
folder: Path,
artist_slug: str,
is_seen: Callable[[object], bool],
*,
recapture: bool = False,
) -> MediaOutcome:
seen = is_seen(media)
if seen and not recapture:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
stem = media_stem(post, media)
path = folder / f"{stem}.{media.extension or _NO_EXTENSION}"
if path.exists(): # tier-2: gallery-dl (or an earlier walk) wrote it
return MediaOutcome(media=media, status="skipped_disk", path=path, error=None)
if seen: # recapture never re-fetches a seen file that is gone
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
folder.mkdir(parents=True, exist_ok=True)
if self._rate_limit > 0:
time.sleep(self._rate_limit)
out = self._fetch_get(media.url, path)
reason, quarantined = self._validate_path(out, artist_slug, media.url)
if reason is not None:
return MediaOutcome(media=media, status="quarantined", path=quarantined, error=reason)
sidecar = {"category": PLATFORM, "message_id": str(post.get("id") or "")}
sidecar["source_url"] = media.url
(folder / f"{stem}.json").write_text(json.dumps(sidecar, indent=2))
return MediaOutcome(media=media, status="downloaded", path=out, error=None)
def write_post_record(
self, post: dict, artist_slug: str, *, revisit: bool = False,
) -> PostRecordOutcome:
"""The message record — the one writer of a Discord post's body, date
and permalink ids. `revisit` re-reads a message already captured (an
edit); an empty re-read writes nothing, so it never blanks a body."""
mid = str(post.get("id") or "")
body = message_text(post)
if not mid or (revisit and not body.strip()):
return PostRecordOutcome(path=None, post_type=None, title=None, body_chars=0)
meta = post.get("_meta") or {}
author = post.get("author") or {}
record = {
"category": PLATFORM,
"message_id": mid,
"server": meta.get("server"),
"server_id": meta.get("server_id"),
"channel": meta.get("channel"),
"channel_id": meta.get("channel_id") or post.get("channel_id"),
"parent": meta.get("parent"),
"is_thread": meta.get("is_thread"),
"author": author.get("username"),
"author_id": author.get("id"),
"message": body,
"date": post.get("timestamp"),
}
folder = channel_dir(self.images_root, artist_slug, post)
folder.mkdir(parents=True, exist_ok=True)
when = message_date(post)
day = f"{when:%Y%m%d}" if when else "None"
path = folder / f"{day}_{mid}_post.json"
path.write_text(json.dumps(
{k: v for k, v in record.items() if v is not None}, indent=2,
))
return PostRecordOutcome(
path=path, post_type=None, title=None, body_chars=len(body),
)
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
"""Native Discord ingester — the Discord ADAPTER over `ingest_core.Ingester`.
Thin counterpart to subscribestar_ingester (milestone 428). The walk's modes,
both ledgers, cursor checkpointing and the post-first capture live in the core;
this wires in the Discord client, downloader, ledger models and key.
Two things differ from the cookie platforms:
- Discord authenticates with a user TOKEN, so `auth_token` is the credential
here rather than an argument accepted and ignored.
- The body canary is off. It fails a walk whose first 30+ captured posts all
came back without text, on the theory that a creator nearly always writes
something; a Discord drop is routinely files and nothing else, so on
Discord that is an ordinary backfill, not a broken parser.
`campaign_id` is the source URL (a server, channel, thread or category link).
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
from ..models import DiscordFailedMedia, DiscordSeenMedia
from .discord_client import DiscordAPIError, DiscordClient, MediaItem
from .discord_downloader import DiscordDownloader
from .ingest_core import Ingester
_LEDGER_KEY_MAX = 128
def _ledger_key(media: MediaItem) -> str:
"""`<message_id>:<media_id>` — stable across edits (see MediaItem)."""
return f"{media.post_id}:{media.media_id}"[:_LEDGER_KEY_MAX]
class DiscordIngester(Ingester):
"""Walk a Discord source's channels, download unseen files, return a
`DownloadResult`. `client` / `downloader` are injectable for tests."""
def __init__(
self,
images_root: Path,
cookies_path: str | None,
session_factory: Callable[[], object],
*,
validate: bool = True,
rate_limit: float = 0.0,
request_sleep: float = 0.0,
auth_token: str | None = None,
client: DiscordClient | None = None,
downloader: DiscordDownloader | None = None,
):
del cookies_path # Discord authenticates by token (uniform signature)
self.images_root = Path(images_root)
super().__init__(
client=client if client is not None else DiscordClient(
auth_token, request_sleep=request_sleep,
),
downloader=downloader if downloader is not None else DiscordDownloader(
self.images_root, validate=validate, rate_limit=rate_limit,
),
session_factory=session_factory,
seen_model=DiscordSeenMedia,
failed_model=DiscordFailedMedia,
seen_constraint="uq_discord_seen_media_source_id",
failed_constraint="uq_discord_failed_media_source_id",
ledger_key=_ledger_key,
platform="discord",
error_base=DiscordAPIError,
drift_label="Discord API",
body_canary=False,
)
async def verify_discord_credential(url: str, auth_token: str | None) -> tuple[bool | None, str]:
"""The uniform `(ok, message)` probe: is the token valid, and can its
account see the channel or server the source names?"""
if not auth_token:
return False, "No Discord token is saved — add one under Credentials."
client = DiscordClient(auth_token)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, client.verify_auth, url)
-183
View File
@@ -1,183 +0,0 @@
"""Repair the Discord downloads made before the naming fix (issue #3999).
Until dc840fe, gallery-dl's Discord patterns asked for keys the extractor never
emits, so every Discord download landed as
`<artist>/discord/None/<date>_None_<original name>`, next to a sidecar named
after the attachment's ORIGINAL name. That broke two things:
* **No Post, no date.** `find_sidecar` can never pair those names, so these
files were imported as loose images with no Post, and a card shows the
download time.
* **No trustworthy metadata to relink from.** Every `image.png` in a channel
wrote the same `image.json`, so the surviving sidecar describes whichever
message was written last. The message id is gone from the filename too.
The operator chose a clean re-download (2026-09-13) over relinking in place:
delete the broken files and their records, make gallery-dl forget it fetched
them, and backfill every Discord source again under the fixed naming.
## Why the archive is cleared for ALL of Discord
gallery-dl records a download as `discord{message_id}_{num}` (upstream
`DiscordExtractor.archive_fmt`, prefixed with the category). The broken files
lost their message ids, so there is no way to forget one source's entries and
not another's. Every Discord download made before the fix is broken, so
forgetting all of them is exactly right. Anything downloaded AFTER the fix still
exists on disk under its correct name, and gallery-dl's `skip` sees the file and
does not fetch it again.
## What it does not touch
Discord posts FC grouped itself (#388 E2) are built from Posts, and these files
never had one, so there is nothing grouped to unwind. Images outside a
`discord/None/` directory are never selected: the path filter requires both the
`None` directory and the `_None_` filename, the pair only the bug produced.
Operator-triggered only (Settings, preview first). Never on a beat.
"""
from __future__ import annotations
import logging
import sqlite3
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..models import ImageRecord, Source
from .cleanup_service import delete_images
from .gallery_dl import archive_path
from .source_service import arm_backfill
log = logging.getLogger(__name__)
# `%` and `_` are LIKE wildcards, so the literal underscores around None are
# escaped. `________` is the eight-digit date prefix the old pattern wrote.
_BROKEN_PATH_LIKE = r"%/discord/None/________\_None\_%"
# Upstream keys asset downloads as `asset_{server_id}_{id}`. FC never fetches
# server assets, but excluding them keeps this to exactly the message
# attachments the bug mangled.
_ARCHIVE_SQL_MATCH = r"entry LIKE 'discord%' AND entry NOT LIKE 'discordasset\_%' ESCAPE '\'"
_COUNT_SQL = "SELECT COUNT(*) FROM archive WHERE " + _ARCHIVE_SQL_MATCH
_DELETE_SQL = "DELETE FROM archive WHERE " + _ARCHIVE_SQL_MATCH
def broken_directories(images_root: Path) -> list[Path]:
"""Every `<artist>/discord/None` directory. Artist folders that differ only by
case (`Conto` and `conto`) are separate directories and both are found."""
return sorted(d for d in Path(images_root).glob("*/discord/None") if d.is_dir())
def count_archive_entries(archive: Path) -> int:
return _archive(archive, delete=False)
def forget_archive_entries(archive: Path) -> int:
return _archive(archive, delete=True)
def _archive(archive: Path, *, delete: bool) -> int:
if not archive.is_file():
return 0
# A download running at the same moment holds this file briefly. Waiting
# 30s for its lock beats failing the repair over a transient contention.
conn = sqlite3.connect(str(archive), timeout=30)
try:
has_table = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='archive'"
).fetchone()
if not has_table:
return 0
if not delete:
return conn.execute(_COUNT_SQL).fetchone()[0]
cur = conn.execute(_DELETE_SQL)
conn.commit()
return cur.rowcount
finally:
conn.close()
def _sweep_directory(directory: Path) -> tuple[int, bool]:
"""Remove what the record deletes left behind: the collided sidecars, plus any
file that never became a record (a quarantined or rejected download). Then
the directory itself, if it is empty. Returns (files removed, dir removed)."""
removed = 0
for f in directory.iterdir():
if f.is_file():
try:
f.unlink()
removed += 1
except OSError as exc:
log.warning("discord repair: could not remove %s: %s", f, exc)
try:
directory.rmdir()
return removed, True
except OSError:
return removed, False
def repair_discord_downloads(
session: Session, *, images_root: Path, dry_run: bool,
) -> dict:
images_root = Path(images_root)
archive = archive_path(images_root)
broken = select(ImageRecord.id, ImageRecord.size_bytes).where(
ImageRecord.path.like(_BROKEN_PATH_LIKE, escape="\\")
)
rows = session.execute(broken).all()
image_ids = [r.id for r in rows]
directories = broken_directories(images_root)
sources = session.execute(
select(Source).where(Source.platform == "discord")
).scalars().all()
summary = {
"images": len(image_ids),
"bytes": sum(r.size_bytes or 0 for r in rows),
"directories": len(directories),
"sources": len(sources),
"enabled_sources": sum(1 for s in sources if s.enabled),
}
if dry_run:
summary["archive_entries"] = count_archive_entries(archive)
return summary
deleted = delete_images(session, image_ids=image_ids, images_root=images_root)
swept = 0
directories_removed = 0
for d in directories:
n, gone = _sweep_directory(d)
swept += n
directories_removed += int(gone)
# Only after the files are gone. Forgetting first and failing half way would
# leave gallery-dl free to re-fetch into a directory still full of the old
# copies.
forgotten = forget_archive_entries(archive)
for source in sources:
arm_backfill(source)
session.commit()
remaining = session.execute(
select(func.count(ImageRecord.id)).where(
ImageRecord.path.like(_BROKEN_PATH_LIKE, escape="\\")
)
).scalar_one()
summary.update(
images_deleted=deleted["images_deleted"],
files_failed=deleted["files_failed"],
leftover_files_removed=swept,
directories_removed=directories_removed,
archive_entries=forgotten,
backfills_started=len(sources),
remaining=remaining,
)
log.info("discord repair applied: %s", summary)
return summary
+27 -59
View File
@@ -23,41 +23,17 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from .discord_ingester import DiscordIngester
from .gallery_dl import DownloadResult, ErrorType
from .ingest_core import DEFAULT_REVISIT_DAYS
from .patreon_ingester import PatreonIngester
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
from .platforms import known_platform_keys
from .pixiv_client import user_id_from_url
from .pixiv_ingester import PixivIngester
from .subscribestar_ingester import SubscribeStarIngester
# Platforms whose download + verify go through the native ingester rather than
# gallery-dl. gallery-dl still serves the rest (hentaifoundry) until it
# migrates too. Discord joined in milestone 428.
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "discord"})
# Native platforms whose feed id IS the source URL, so there is nothing to
# resolve: SubscribeStar's creator page, Discord's server/channel link.
_URL_IS_FEED = frozenset({"subscribestar", "discord"})
def _unsupported_platform_message(platform: str) -> str | None:
"""Why `platform` may not be downloaded or verified, or None if it may.
A source can outlive its platform. Retiring one (DeviantArt #3069, pixiv
#406) unregisters it, but its `Source` rows — and the `enabled` flag on
them — are data, and data survives a deploy. So this refuses at the two
functions every download and every credential probe pass through, instead
of trusting the scheduler's `enabled` filter and every future caller to
agree.
Without it a retired platform does not fail: it falls through to the
gallery-dl branch, which is precisely where a platform lands once it is no
longer native — and gallery-dl still has an extractor for it.
"""
if platform in known_platform_keys():
return None
return f"{platform!r} is not a supported platform (retired or unknown)"
# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord) until
# they migrate too.
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "pixiv"})
# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
# messages so the operator sees the exact lookup endpoint that was hit.
@@ -71,8 +47,8 @@ def _native_ingester_cls(platform: str):
dispatch pick up the replacement."""
return {
"patreon": PatreonIngester,
"pixiv": PixivIngester,
"subscribestar": SubscribeStarIngester,
"discord": DiscordIngester,
}[platform]
@@ -90,7 +66,6 @@ async def run_download(
mode: str | None,
gdl,
sync_session_factory,
revisit_days: int = DEFAULT_REVISIT_DAYS,
) -> tuple[DownloadResult, str | None]:
"""Uniform download across backends — the download counterpart to
`verify_source_credential`, so this module is the ONE place that knows how
@@ -105,16 +80,9 @@ async def run_download(
backfill state machine and owns phase 3.
"""
platform = ctx["platform"]
refusal = _unsupported_platform_message(platform)
if refusal is not None:
return DownloadResult(
success=False, url=ctx["url"], artist_slug=ctx["artist_slug"],
platform=platform,
error_type=ErrorType.UNSUPPORTED_URL, error_message=refusal,
), None
if uses_native_ingester(platform):
return await _run_native_ingester(
ctx, source_config, mode, gdl, sync_session_factory, revisit_days
ctx, source_config, mode, gdl, sync_session_factory
)
result = await gdl.download(
url=ctx["url"],
@@ -132,17 +100,25 @@ async def _resolve_native_campaign_id(
platform: str, url: str, cookies_path: str | None, overrides: dict,
) -> tuple[str | None, str | None]:
"""`(campaign_id, resolved_campaign_id)` for a native source. SubscribeStar's
and Discord's feed id IS the source URL (no lookup → resolved None). Patreon
resolves the campaign id from the vanity URL (resolved non-None when a lookup
actually ran, so phase 3 caches it)."""
if platform in _URL_IS_FEED:
feed id IS the creator URL; Pixiv's is the numeric user id parsed straight
from it (no lookup → resolved None either way). Patreon resolves the
campaign id from the vanity URL (resolved non-None when a lookup actually ran,
so phase 3 caches it)."""
if platform == "subscribestar":
return url, None
if platform == "pixiv":
return user_id_from_url(url), None
return await resolve_campaign_id_for_source(url, cookies_path, overrides)
def _campaign_resolution_error(platform: str, url: str) -> str:
"""Operator-facing message for a native source whose campaign id could not
be resolved — names the platform's own lookup mechanism."""
if platform == "pixiv":
return (
f"Could not extract a pixiv user id. source_url={url!r} — expected "
"a URL like https://www.pixiv.net/users/<id>."
)
vanity = extract_vanity(url)
return (
f"Could not resolve Patreon campaign id. source_url={url!r}; "
@@ -154,7 +130,6 @@ def _campaign_resolution_error(platform: str, url: str) -> str:
async def _run_native_ingester(
ctx: dict, source_config, mode: str | None, gdl, sync_session_factory,
revisit_days: int = DEFAULT_REVISIT_DAYS,
) -> tuple[DownloadResult, str | None]:
"""Run the native ingester for a native platform in a worker thread (sync
requests/subprocess). Patreon resolves a campaign id from the vanity URL;
@@ -170,8 +145,8 @@ async def _run_native_ingester(
platform, ctx["url"], ctx["cookies_path"], overrides
)
if not campaign_id:
# Patreon: vanity lookup failed. (SubscribeStar's campaign id is the
# URL itself — never lands here.)
# Patreon: vanity lookup failed. Pixiv: no numeric user id in the URL.
# (SubscribeStar's campaign id is the URL itself — never lands here.)
url = ctx["url"]
return (
DownloadResult(
@@ -203,7 +178,7 @@ async def _run_native_ingester(
validate=gdl._validate_files,
rate_limit=rate_limit,
request_sleep=request_sleep,
# Uniform across adapters: a token platform would authenticate with
# Uniform across adapters: token platforms (pixiv) authenticate with
# it, cookie platforms accept-and-ignore — so this construction stays
# platform-agnostic.
auth_token=ctx["auth_token"],
@@ -219,9 +194,6 @@ async def _run_native_ingester(
mode=mode,
resume_cursor=source_config.resume_cursor,
time_budget_seconds=source_config.timeout,
# How far back a tick keeps looking for EDITED posts. The ingester
# applies it to ticks only; a backfill ignores it.
revisit_days=revisit_days,
posts_base=int(overrides.get("_backfill_posts", 0)),
# plan #709: live progress writes to this running event mid-walk.
event_id=ctx.get("event_id"),
@@ -245,23 +217,19 @@ async def verify_source_credential(
network / nothing to test). Callers don't branch on platform — they call
this and render the result.
"""
refusal = _unsupported_platform_message(platform)
if refusal is not None:
# Inconclusive rather than False: nothing was probed, so nothing was
# rejected. False would tell the operator their credential is bad.
return None, refusal
if uses_native_ingester(platform):
# Native ingester platforms verify via their own lightweight auth probe.
# SubscribeStar's probe takes the creator URL directly; Patreon's
# resolves the campaign id first.
# resolves the campaign id first; Pixiv's is one OAuth refresh (the
# exact call that fails when the token is bad — no feed walk).
if platform == "subscribestar":
from .subscribestar_ingester import verify_subscribestar_credential
return await verify_subscribestar_credential(url, cookies_path, config_overrides)
if platform == "discord":
from .discord_ingester import verify_discord_credential
if platform == "pixiv":
from .pixiv_ingester import verify_pixiv_credential
return await verify_discord_credential(url, auth_token)
return await verify_pixiv_credential(auth_token)
from .patreon_ingester import verify_patreon_credential
return await verify_patreon_credential(url, cookies_path, config_overrides)
+7 -36
View File
@@ -34,12 +34,9 @@ from .gallery_dl import (
GalleryDLService,
SourceConfig,
extract_errors_warnings,
is_informational,
truncate_log,
walk_completed,
)
from .importer import Importer
from .ingest_core import DEFAULT_REVISIT_DAYS
from .platforms import auth_type_for
from .scheduler_service import set_platform_cooldown
@@ -61,7 +58,6 @@ class DownloadService:
importer: Importer,
cred_service: CredentialService,
sync_session_factory=None,
revisit_days: int = DEFAULT_REVISIT_DAYS,
):
self.async_session = async_session
self.sync_session = sync_session
@@ -73,12 +69,6 @@ class DownloadService:
# the multi-minute walk — see PatreonIngester). Only the patreon branch
# of phase 2 uses it; gallery-dl sources leave it None.
self.sync_session_factory = sync_session_factory
# ImportSettings.download_revisit_days — how far back a tick keeps
# looking for EDITED posts (ingest_core.DEFAULT_REVISIT_DAYS). Passed in
# rather than read here because the task already loads the settings row
# for rate_limit/validate_files, and a second load on every download
# would be the same row twice for one number.
self.revisit_days = revisit_days
async def download_source(self, source_id: int) -> int:
"""Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is."""
@@ -186,7 +176,6 @@ class DownloadService:
return await run_download(
ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode,
gdl=self.gdl, sync_session_factory=self.sync_session_factory,
revisit_days=self.revisit_days,
)
async def _phase1_setup(self, source_id: int) -> dict[str, Any]:
@@ -423,13 +412,6 @@ class DownloadService:
await loop.run_in_executor(None, _upsert)
# Only now is it safe to call this walk's media seen: every file above
# has been through the importer. Had the run died before here they stay
# unmarked, and the next walk imports them from disk (ingest_core).
mark_seen = getattr(dl_result, "mark_seen_after_import", None)
if mark_seen is not None:
await loop.run_in_executor(None, mark_seen)
# #830 recapture: backfill source_filehash on EXISTING on-disk images so
# their post-body inline <img src=CDN> remaps to the local copy. A
# SEPARATE non-deleting channel (NOT the import list — that would unlink
@@ -570,13 +552,11 @@ class DownloadService:
# page is no longer double-counted. new_overrides (read fresh above)
# carries the ingester's committed value forward untouched.
# Shared with the result path so the two halves can't disagree about
# what "finished" means. Note it admits an INFORMATIONAL error_type: a
# fully-paywalled creator's backfill really did reach the bottom, and
# treating it as unfinished would re-walk that wall every chunk until
# the stall counter tripped — the creator we can see least becoming the
# one we fetch most.
completed = walk_completed(dl_result)
completed = (
dl_result.success
and dl_result.error_type is None
and dl_result.return_code == 0
)
if completed:
new_overrides["_backfill_state"] = "complete"
new_overrides.pop("_backfill_cursor", None)
@@ -645,17 +625,8 @@ class DownloadService:
if status == "ok":
source.consecutive_failures = 0
source.last_error = None
# alembic 0032 — clear the failure-class chip on success, EXCEPT an
# informational class. tier_limited rides an otherwise-successful
# run: failures stay 0 and last_error stays clear (the run did not
# fail and must not earn a backoff), but "there is content here we
# aren't allowed to see" is a durable fact about the SOURCE, not
# about this run. Clearing it here is what left FailingSourcesCard's
# `tier_limited` palette entry unreachable — the chip was wiped by
# the very success that produced it.
source.error_type = (
error_type if is_informational(error_type) else None
)
# alembic 0032 — clear the failure-class chip on success.
source.error_type = None
elif status == "error":
source.consecutive_failures = (source.consecutive_failures or 0) + 1
source.last_error = error_message
+56 -250
View File
@@ -21,9 +21,6 @@ from .source_service import BACKFILL_MAX_CHUNKS
log = logging.getLogger(__name__)
# The probe runs while the chip is drawing; names that take longer are skipped.
_NAME_LOOKUP_SECONDS = 6.0
class UnknownPlatformError(Exception):
"""URL didn't match any platform pattern."""
@@ -33,10 +30,6 @@ class InvalidUrlError(Exception):
"""URL was empty or missing a scheme."""
class UnknownArtistError(Exception):
"""quick-add named an `artist_id` that does not exist."""
# Mirrored byte-for-byte from extension/lib/platforms.js
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
# reviewers catch drift.
@@ -62,127 +55,43 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
re.IGNORECASE,
)),
# A Discord URL names a server or a channel, never a creator, so the slug is
# `<server>` or `<server>/<channel>` and the artist is chosen, not derived.
# A trailing message id (a jump link) still names its channel. DMs (`@me`)
# are not sources; thread links (`/threads/`) are left to the manual form.
("discord", re.compile(
r"^https?://(?:www\.|ptb\.|canary\.)?discord\.com/channels/"
r"(?P<slug>\d+(?:/\d+)?)(?:/\d+)?/?(?:[?#].*)?$",
("pixiv", re.compile(
r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P<slug>\d+)",
re.IGNORECASE,
)),
]
DISCORD = "discord"
def canonical_source_url(platform: str, url: str, slug: str) -> str:
"""The URL a new source is stored under. Discord's is rebuilt from the ids
— the form the manual Add form and the ingester use — so a jump link, a
ptb/canary host or a trailing slash never makes a second source for the
same channel. Every other platform keeps the URL as given."""
if platform == DISCORD:
return f"https://discord.com/channels/{slug}"
return url
def _discord_ids(url: str) -> tuple[str | None, str | None] | None:
"""`(server_id, channel_id)` of a stored Discord source URL, None if it
does not parse (a DM or thread link, or an old malformed row)."""
from .discord_client import DiscordAPIError, parse_source_url
try:
return parse_source_url(url)
except DiscordAPIError:
return None
class ExtensionService:
def __init__(self, session: AsyncSession, crypto=None) -> None:
self.session = session
# Optional decryptor for resolving a platform's display name at
# add-time. None → skip resolution, fall back to the handle.
# Optional decryptor for resolving a token-auth platform's display name
# (pixiv) at add-time. None → skip resolution, fall back to the handle.
self._crypto = crypto
async def quick_add_source(
self,
url: str,
*,
artist_id: int | None = None,
artist_name: str | None = None,
use_platform_name: bool = False,
) -> dict:
"""Add `url` as a source. `artist_id` connects it to an existing
artist, `artist_name` to that artist (created if new); with neither,
the artist is resolved from the platform as before.
`use_platform_name` applies the operator's convention that the Patreon
name is canon: a Patreon source added to an existing artist renames
that artist to the creator's Patreon display name. Name only — the
slug, and every path keyed off it, never moves (#130). Ignored on every
other platform, and when the name can't be read."""
async def quick_add_source(self, url: str) -> dict:
platform, raw_slug = self._derive(url)
url = canonical_source_url(platform, url, raw_slug)
renamed_from = None
# Identity by SOURCE handle (#130): an existing (platform, url) source
# keeps its artist on re-add — even if that artist was since renamed (its
# frozen slug no longer matches the current name), and even when the
# add named a different artist. Only a genuinely new source
# resolves/creates an artist.
existing = await self._existing_source(platform, url)
# frozen slug no longer matches the current name). Only a genuinely new
# source resolves/creates an artist.
existing = (await self.session.execute(
select(Source).where(Source.platform == platform, Source.url == url)
)).scalar_one_or_none()
if existing is not None:
artist = (await self.session.execute(
select(Artist).where(Artist.id == existing.artist_id)
)).scalar_one()
return self._shape(existing, artist, created_source=False, created_artist=False)
if artist_id is not None:
artist = (await self.session.execute(
select(Artist).where(Artist.id == artist_id)
)).scalar_one_or_none()
if artist is None:
raise UnknownArtistError(f"no artist with id {artist_id}")
created_artist = False
if use_platform_name and platform == "patreon":
renamed_from = await self._adopt_patreon_name(artist, raw_slug, url)
else:
name = (artist_name or "").strip()
if not name:
# Name the artist properly by resolving the real display name
# from the platform (falls back to the URL handle).
name = await self._resolve_artist_name(platform, raw_slug, url)
artist, created_artist = await self._find_or_create_artist(name)
# New source → name the artist properly by resolving the real display
# name from the platform (falls back to the URL handle).
name = await self._resolve_artist_name(platform, raw_slug, url)
artist, created_artist = await self._find_or_create_artist(name)
source, created_source = await self._find_or_create_source(
artist_id=artist.id, platform=platform, url=url,
)
shaped = self._shape(source, artist, created_source, created_artist)
if renamed_from is not None:
shaped["renamed_from"] = renamed_from
return shaped
async def _adopt_patreon_name(self, artist, raw_slug: str, url: str) -> str | None:
"""Rename `artist` to the Patreon display name; the old name when it
changed, else None. Unreadable name → no rename, never the handle."""
name = await self._platform_display_name("patreon", raw_slug, url)
if not name or name == artist.name:
return None
old = artist.name
artist.name = name
await self.session.commit()
return old
async def _existing_source(self, platform: str, url: str) -> Source | None:
"""The source this URL already is, whichever artist owns it. Discord
compares ids, not strings, so a row stored before canonicalisation (a
ptb host, a trailing slash) is still found."""
if platform != DISCORD:
return (await self.session.execute(
select(Source).where(Source.platform == platform, Source.url == url)
)).scalars().first()
want = _discord_ids(url)
rows = (await self.session.execute(
select(Source).where(Source.platform == DISCORD).order_by(Source.id)
)).scalars().all()
return next((s for s in rows if _discord_ids(s.url) == want), None)
return self._shape(source, artist, created_source, created_artist)
@staticmethod
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
@@ -207,38 +116,31 @@ class ExtensionService:
self, platform: str, raw_slug: str, url: str
) -> str:
"""The real display name for a new artist, resolved from the platform at
add-time (#130). Our native platforms each have a name source — patreon
the campaigns API, subscribestar the profile page (both cookies). Other
platforms (and any failure — no credential, network error) fall back to
the URL handle, which is already readable.
add-time (#130). Our native platforms each have a name source — pixiv the
app API (token), patreon the campaigns API, subscribestar the profile
page (both cookies). Other platforms (and any failure — no credential,
network error) fall back to the URL handle, which is already readable.
The resolvers are sync, so they run in an executor."""
if platform == DISCORD:
# The server's name: what the operator knows the community as.
server_id = raw_slug.split("/", 1)[0]
names = await self._discord_names(server_id, None)
return names.get("server") or f"Discord {server_id}"
return await self._platform_display_name(platform, raw_slug, url) or raw_slug
async def _platform_display_name(
self, platform: str, raw_slug: str, url: str
) -> str | None:
"""The creator's display name as Patreon or SubscribeStar shows it, read
with the stored cookies; None when it can't be read (no credential, a
network error, a slow answer, any other platform). None, not the handle,
so a caller can tell a real name from a fallback — a rename to the
Patreon name must never rename to a URL handle instead."""
if self._crypto is None or platform not in ("patreon", "subscribestar"):
return None
if self._crypto is None or platform not in ("pixiv", "patreon", "subscribestar"):
return raw_slug
import asyncio
from .credential_service import CredentialService
cred = CredentialService(self.session, self._crypto)
loop = asyncio.get_running_loop()
try:
if platform == "patreon":
if platform == "pixiv":
token = await cred.get_token("pixiv")
if not token:
return raw_slug
from .pixiv_client import PixivClient
name = await loop.run_in_executor(
None, PixivClient(token).resolve_display_name, raw_slug
)
elif platform == "patreon":
cookies = await cred.get_cookies_path("patreon")
from .patreon_resolver import resolve_display_name
call = loop.run_in_executor(
name = await loop.run_in_executor(
None, resolve_display_name, raw_slug,
str(cookies) if cookies else None,
)
@@ -246,14 +148,15 @@ class ExtensionService:
cookies = await cred.get_cookies_path("subscribestar")
from .subscribestar_client import SubscribeStarClient
client = SubscribeStarClient(str(cookies) if cookies else None)
call = loop.run_in_executor(None, client.resolve_display_name, url)
name = await asyncio.wait_for(call, timeout=_NAME_LOOKUP_SECONDS)
name = await loop.run_in_executor(
None, client.resolve_display_name, url
)
except Exception as exc: # resolution is best-effort — never block the add
log.warning("artist display-name resolution failed (%s): %s", platform, exc)
return None
return (name or "").strip() or None
return raw_slug
return name or raw_slug
async def probe(self, url: str, *, names: bool = False) -> dict:
async def probe(self, url: str) -> dict:
"""Read-only resolution of a creator-page URL against the FC DB.
Returns one of:
- {state: 'unknown_platform'} — URL didn't match any
@@ -269,30 +172,21 @@ class ExtensionService:
— exact (artist, platform,
url) Source already exists
`names` (the Add panel asks, the chip does not) adds `display_name`:
the creator's name as Patreon/SubscribeStar shows it, or None. It costs
a request to the platform, so a plain page view never pays it.
Side-effect-free: two SELECTs at most, plus that one lookup.
Side-effect-free: two SELECTs at most.
"""
try:
platform, raw_slug = self._derive(url)
except (UnknownPlatformError, InvalidUrlError):
return {"state": "unknown_platform"}
if platform == DISCORD:
return await self._probe_discord(raw_slug)
slug = slugify(raw_slug)
result: dict = {"platform": platform, "slug": slug}
if names:
result["display_name"] = await self._platform_display_name(
platform, raw_slug, url,
)
artist = (await self.session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
return {"state": "new", **result}
return {"state": "new", "platform": platform, "slug": slug}
artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug}
source = (await self.session.execute(
select(Source).where(
@@ -302,114 +196,26 @@ class ExtensionService:
)
)).scalar_one_or_none()
if source is None:
return {"state": "artist_match", **result, "artist": self._artist_payload(artist)}
return {
"state": "artist_match",
"platform": platform,
"slug": slug,
"artist": artist_payload,
}
return {
"state": "source_match",
**result,
"artist": self._artist_payload(artist),
"source": self._source_payload(source),
}
async def _probe_discord(self, raw_slug: str) -> dict:
"""probe for a Discord server or channel. The states mean what they
mean elsewhere, but the artist is never read off the URL:
- source_match: this channel is a source — or the whole server is
(`covered_by_server`), which already walks every channel;
- artist_match: another source on this server belongs to an artist,
the one this channel most likely belongs to too (a suggestion the
Add panel preselects, not a decision);
- new: nothing on this server yet.
`discord` carries the ids, both canonical URLs and the display names,
read with the stored token; a name that can't be read is None."""
server_id, _, channel_id = raw_slug.partition("/")
channel_id = channel_id or None
rows = (await self.session.execute(
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.where(Source.platform == DISCORD)
.order_by(Source.id)
)).all()
exact = server_whole = on_server = None
for source, artist in rows:
ids = _discord_ids(source.url)
if ids is None or ids[0] != server_id:
continue
if ids[1] == channel_id and exact is None:
exact = (source, artist)
elif ids[1] is None and server_whole is None:
server_whole = (source, artist)
if on_server is None:
on_server = (source, artist)
names = await self._discord_names(server_id, channel_id)
base = f"https://discord.com/channels/{server_id}"
result: dict = {
"platform": DISCORD,
"slug": raw_slug,
"discord": {
"server_id": server_id,
"channel_id": channel_id,
"server_name": names.get("server"),
"channel_name": names.get("channel"),
"server_url": base,
"channel_url": f"{base}/{channel_id}" if channel_id else None,
"platform": platform,
"slug": slug,
"artist": artist_payload,
"source": {
"id": source.id,
"artist_id": source.artist_id,
"platform": source.platform,
"url": source.url,
"enabled": source.enabled,
},
}
hit = exact or server_whole
if hit is not None:
source, artist = hit
result.update(
state="source_match",
artist=self._artist_payload(artist),
source=self._source_payload(source),
covered_by_server=exact is None,
)
elif on_server is not None:
result.update(state="artist_match", artist=self._artist_payload(on_server[1]))
else:
result["state"] = "new"
return result
async def _discord_names(self, server_id: str | None, channel_id: str | None) -> dict:
"""Server/channel display names via the stored Discord token. Never
raises and never waits out a rate limit: it runs while the operator
looks at a page, so a slow or missing answer just means no names."""
if self._crypto is None:
return {}
import asyncio
from .credential_service import CredentialService
from .discord_client import DiscordClient
try:
token = await CredentialService(self.session, self._crypto).get_token(DISCORD)
if not token:
return {}
client = DiscordClient(token, max_retries=0)
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(None, client.describe, server_id, channel_id),
timeout=_NAME_LOOKUP_SECONDS,
)
except Exception as exc: # names are decoration — never fail the call
log.info("Discord name lookup failed: %s", exc)
return {}
@staticmethod
def _artist_payload(artist) -> dict:
return {"id": artist.id, "name": artist.name, "slug": artist.slug}
@staticmethod
def _source_payload(source) -> dict:
return {
"id": source.id,
"artist_id": source.artist_id,
"platform": source.platform,
"url": source.url,
"enabled": source.enabled,
}
def _derive(self, url: str) -> tuple[str, str]:
if not isinstance(url, str) or not url.strip():
+24 -95
View File
@@ -17,7 +17,6 @@ import subprocess
import sys
import tempfile
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
@@ -95,16 +94,6 @@ BACKFILL_CHUNK_SECONDS = 600
_DEFAULT_GDL_TIMEOUT_SECONDS = 870
def archive_path(images_root: Path) -> Path:
"""gallery-dl's download archive: the record of what it has already fetched.
One definition, because the Discord repair (services/discord_repair.py) has
to find the same file the downloader writes, without constructing a service
whose __init__ creates directories.
"""
return Path(images_root) / ".gallery-dl" / "archive.sqlite3"
@dataclass
class SourceConfig:
"""Per-source overrides loaded from Source.config_overrides JSON.
@@ -189,13 +178,6 @@ class DownloadResult:
# the platform cooldown matches the hint instead of a flat default. None when
# unknown (no header, or not a rate-limit failure).
retry_after_seconds: float | None = None
# Native ingester only: marks this walk's fetched media seen in its ledger.
# Phase 3 calls it AFTER the import loop, never before — a file marked seen
# but not yet imported is invisible to every later walk, so a run killed in
# between orphaned it for good (TamadaHeijun's 12PCG post lost 7 of 13
# images to a stranded run, 2026-09-24). Unmarked, the next walk finds the
# file on disk with no ImageRecord and imports it. None on gallery-dl.
mark_seen_after_import: Callable[[], None] | None = None
def extract_errors_warnings(stderr: str) -> str:
@@ -279,72 +261,6 @@ def make_run_stats(
}
# --- tier-gated classification, shared by BOTH backends ---------------------
#
# These three live together because the native ingester and the gallery-dl
# subprocess must reach the same verdict from the same number. They did not:
# gallery-dl classified TIER_LIMITED while ingest_core counted gated posts and
# threw the count away, so the platforms FC owns reported a paywalled creator as
# a silent one (#874 follow-up). One predicate, spread into both, rather than
# the condition re-derived per backend.
def classify_tier_gated(tier_gated_count: int) -> ErrorType | None:
"""TIER_LIMITED when a walk saw tier-gated posts and nothing else failed.
Deliberately NOT conditioned on `downloaded == 0`. A creator whose top-tier
posts we cannot see is tier-limited even in a week we did get their cheaper
ones — the fact the operator needs ("there is content here you are not
paying for") is true either way. gallery-dl has classified it this way since
the paywall-as-"needs attention" complaint (see `_categorize_error`), and the
native path now matches rather than inventing a stricter rule.
Callers must apply this only AFTER the real error categories (auth, rate
limit, drift, …) have had their turn; tier-gating is the weakest signal and
must never mask a genuine failure.
"""
return ErrorType.TIER_LIMITED if tier_gated_count else None
def tier_gated_message(count: int) -> str:
"""The one wording for the tier-gated verdict, so the two backends can't
describe the same state differently in the Logs UI."""
return (
f"Subscription tier does not grant access to "
f"{count} post{'s' if count != 1 else ''}"
)
# `Source.error_type` doubles as the failure-class chip, and a status of "ok"
# CLEARS it (alembic 0032). TIER_LIMITED breaks that assumption: it rides an
# otherwise-successful run, so without an exemption the chip is wiped the moment
# it is set and `FailingSourcesCard`'s `tier_limited` palette entry can never
# render. Informational classes are the exemption — they describe the source,
# not a failure of the run.
INFORMATIONAL_ERROR_TYPES = frozenset({ErrorType.TIER_LIMITED.value})
def is_informational(error_type) -> bool:
"""True for a class that reports a state rather than a failure. Accepts an
ErrorType or the plain string persisted on Source.error_type."""
return error_type is not None and str(error_type) in INFORMATIONAL_ERROR_TYPES
def walk_completed(result: DownloadResult) -> bool:
"""Did this walk reach the bottom cleanly?
The backfill lifecycle's completion test. An informational error_type still
counts as complete: a fully-paywalled creator's backfill DID finish, and
treating it as unfinished re-walks the same wall until the stall counter
trips — the creator we can see least becoming the one we fetch most.
"""
return (
result.success
and result.return_code == 0
and (result.error_type is None or is_informational(result.error_type))
)
class GalleryDLService:
"""Service for executing gallery-dl downloads."""
@@ -383,16 +299,24 @@ class GalleryDLService:
# (services/patreon_ingester.py), not gallery-dl.
PLATFORM_DEFAULTS = {
# subscribestar removed — native-ingester platform now (#71); pixiv
# removed likewise (#129); discord likewise (milestone 428, whose
# downloader keeps this config's on-disk naming); deviantart removed at
# #3069 as a dropped platform, not a migrated one. HentaiFoundry is the
# one platform left here, by the operator's choice not to migrate it.
# removed likewise (#129); deviantart removed at #3069 as a dropped
# platform, not a migrated one. The remaining entries are the
# gallery-dl platforms not yet migrated.
"hentaifoundry": {
"content_types": ["all"],
"directory": [],
"filename": "{category}_{index:>03}_{title[:50]}.{extension}",
"include": "all",
},
"discord": {
"content_types": ["all"],
"directory": ["{channel[name]}"],
"filename": "{date:%Y%m%d}_{id}_{filename}.{extension}",
"embeds": "all",
"stickers": True,
"reactions": False,
"threads": True,
},
}
def __init__(
@@ -412,7 +336,7 @@ class GalleryDLService:
config = {
"extractor": {
"base-directory": str(self.images_root),
"archive": str(archive_path(self.images_root)),
"archive": str(self._config_dir / "archive.sqlite3"),
"skip": True,
"sleep": self._rate_limit,
"sleep-request": max(0.5, self._rate_limit / 4),
@@ -607,12 +531,12 @@ class GalleryDLService:
line for line in combined.split("\n")
if "][warning]" in line and "not allowed to view post" in line
]
# Same predicate + wording the native path uses, so the two backends
# can't drift on what counts as tier-gated or how it reads.
count = len(tier_gated_lines)
gated = classify_tier_gated(count)
if gated is not None:
return (gated, tier_gated_message(count))
if tier_gated_lines:
count = len(tier_gated_lines)
return (
ErrorType.TIER_LIMITED,
f"Subscription tier does not grant access to {count} post{'s' if count != 1 else ''}",
)
# Partial-success: the subprocess exited non-zero (typically because
# the wall-clock timeout fired mid-walk), but it had downloaded ≥1
@@ -754,6 +678,9 @@ class GalleryDLService:
if cookies_path:
config["extractor"]["cookies"] = cookies_path
if auth_token and platform == "discord":
config["extractor"].setdefault("discord", {})
config["extractor"]["discord"]["token"] = auth_token
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
@@ -937,6 +864,8 @@ class GalleryDLService:
config = self._build_config_for_source(platform, source_config, artist_slug)
if cookies_path:
config["extractor"]["cookies"] = cookies_path
if auth_token and platform == "discord":
config["extractor"].setdefault("discord", {})["token"] = auth_token
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
+2 -7
View File
@@ -45,7 +45,7 @@ from .tag_query import (
# provenance (filesystem imports). Returned by facets() as a null-valued
# bucket; the frontend maps that null back to this sentinel in the URL so the
# bucket is selectable. Underscore-wrapped so it can't collide with a real
# gallery-dl platform name (patreon/hentaifoundry/...).
# gallery-dl platform name (patreon/pixiv/...).
UNSOURCED_PLATFORM = "__unsourced__"
@@ -322,7 +322,7 @@ def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
]
def _diversify_similar(src, rows, limit, *, dup_threshold=32, lam=0.40):
def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
"""Trim a nearest-cosine candidate pool down to `limit` diverse picks.
1. pHash collapse: drop any candidate whose perceptual hash is within
@@ -338,11 +338,6 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=32, lam=0.40):
2026-07-01 — dropped 0.55→0.40, dup 6→8, paired with a wider pool in
`similar()`).
`dup_threshold` counts Hamming bits, so it moved 8→32 when the pHash went
from 64 to 256 bits (#4223, migration 0098) — the same fraction of the
hash, i.e. the tuning the operator chose, unchanged. This collapse is
DISPLAY-only: it hides a near-dup from one rail, it never drops a record.
Falls back to nearest-order (`rows[:limit]`) on any failure or a small pool.
"""
if len(rows) <= 1:
+5 -60
View File
@@ -33,14 +33,13 @@ from ..models import (
)
from ..utils import safe_probe
from ..utils.paths import (
canonical_subdir,
derive_subdir,
derive_top_level_artist,
filehash_from_url,
hash_suffixed_name,
safe_ext,
)
from ..utils.phash import compute_phash, find_similar, fingerprint_path, fingerprints_match
from ..utils.phash import compute_phash, find_similar
from ..utils.sidecar import find_sidecar, parse_sidecar
from ..utils.slug import slugify
from .archive_extractor import extract_archive, is_archive
@@ -235,38 +234,6 @@ class Importer:
(phash, width or 0, height or 0, image_id)
)
def _pixel_confirmer(self, source: Path):
"""Build `find_similar`'s gate-3 callback for an incoming file.
pHash proposes; this accepts. A candidate is a duplicate only if its
file really is the same picture as `source` at a different size —
which is the only merge the operator asked for (#4223). Everything
else (a missing file, an unreadable one, a deleted row) returns
False: the destructive outcomes here are dropping a download and
overwriting a kept file, so an unanswerable question must not read
as "yes".
Both sides' fingerprints are computed lazily and cached, so an
import that matches nothing costs no I/O at all and an archive
member that keeps hitting the same candidate pays for it once.
"""
new_fp: list = []
cand_fps: dict[int, object] = {}
def confirm(candidate_id: int) -> bool:
if not new_fp:
new_fp.append(fingerprint_path(source))
if new_fp[0] is None:
return False
if candidate_id not in cand_fps:
rec = self.session.get(ImageRecord, candidate_id)
cand_fps[candidate_id] = (
fingerprint_path(Path(rec.path)) if rec and rec.path else None
)
return fingerprints_match(new_fp[0], cand_fps[candidate_id])
return confirm
def _get_or_create(self, stmt, factory):
"""Race-safe find-or-create. Run `stmt` (scalar_one_or_none); if a
row exists, return it. Otherwise open a savepoint and INSERT
@@ -895,7 +862,6 @@ class Importer:
rel, match_id = find_similar(
phash, width or 0, height or 0,
candidates, self.settings.phash_threshold,
confirm=self._pixel_confirmer(source),
)
if rel == "larger_exists":
# Enrich-on-duplicate (parity with attach_in_place).
@@ -945,7 +911,7 @@ class Importer:
)
return ImportResult(status="superseded", image_id=match_id)
dest = self._copy_to_library(source, sha, attribution_path, path_artist)
dest = self._copy_to_library(source, sha, attribution_path)
record = ImageRecord(
path=str(dest),
@@ -1275,7 +1241,6 @@ class Importer:
rel, match_id = find_similar(
phash, width or 0, height or 0,
candidates, self.settings.phash_threshold,
confirm=self._pixel_confirmer(path),
)
if rel == "larger_exists":
# Enrich-on-duplicate: link the near-dup's post to the
@@ -1601,8 +1566,7 @@ class Importer:
)
def _copy_to_library(
self, source: Path, sha: str, attribution_path: Path,
artist: Artist | None = None,
self, source: Path, sha: str, attribution_path: Path
) -> Path:
"""Copy `source` to its final library path. Returns the destination.
@@ -1610,18 +1574,8 @@ class Importer:
_import_media (filesystem scan) and _supersede (when new_path is
not passed). FC-3c's attach_in_place skips this helper entirely
— the file is already at its final home.
`artist`, when resolved, decides the top-level directory: the
library is keyed on the Artist row's slug, NOT on however the
import folder happened to be capitalised. Without that, an import
from `/import/Conto/` and a download for the same artist write to
`Conto/` and `conto/` respectively and the library grows a second
home for one artist (milestone #421).
"""
subdir = canonical_subdir(
derive_subdir(attribution_path, self.import_root),
artist.slug if artist else None,
)
subdir = derive_subdir(attribution_path, self.import_root)
dest_dir = self.images_root / subdir if subdir else self.images_root
dest_dir.mkdir(parents=True, exist_ok=True)
dest_name = hash_suffixed_name(source.stem, sha, source.suffix)
@@ -1656,16 +1610,7 @@ class Importer:
that path (FC-3c attach_in_place case) — skip the copy step.
Otherwise the file is copied via _copy_to_library."""
if new_path is None:
# The KEPT row's artist decides the destination, not the incoming
# file's folder — a supersede rewrites `existing.path`, so writing
# it anywhere but that artist's canonical directory would move a
# row OUT of the tree milestone #421 is consolidating. ImageRecord
# carries `artist_id` with no relationship attribute, so this is a
# lookup rather than `existing.artist`.
kept_artist = artist
if kept_artist is None and existing.artist_id is not None:
kept_artist = self.session.get(Artist, existing.artist_id)
dest = self._copy_to_library(source, sha, source, kept_artist)
dest = self._copy_to_library(source, sha, source)
else:
dest = new_path
+26 -287
View File
@@ -31,19 +31,11 @@ import json
import logging
import time
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import ImageRecord
from .gallery_dl import (
DownloadResult,
ErrorType,
classify_tier_gated,
make_run_stats,
tier_gated_message,
)
from .gallery_dl import DownloadResult, ErrorType, make_run_stats
from .native_ingest_common import NativeAuthError, NativeDriftError
log = logging.getLogger(__name__)
@@ -53,33 +45,6 @@ log = logging.getLogger(__name__)
# per-file HEADs. Headroom against paywalled/undownloadable items interleaving.
_TICK_SEEN_THRESHOLD = 20
# How far back a tick keeps looking even once everything is already-have-it —
# the REVISIT WINDOW. Operator, 2026-09-23, holding up a Floppystack post:
# *"this post has been updated as he implements hot fixes — any chance we have a
# way to scan for or see updated posts so we can update ours to match and pull
# the new attachments and pictures etc."*
#
# A creator who edits a three-day-old post to append a hotfix build was
# structurally unreachable: that post sits twenty-odd already-seen items down
# the feed, so the count early-out above fired before the walk ever got to it.
# Not a bug in the early-out — a COUNT cannot express "recent".
#
# So the early-out now needs BOTH conditions: the run of already-seen items AND
# a post published before the horizon. Strictly a widening. Two properties this
# shape has and a plain "walk the last N days" would not:
#
# * window 0 is exactly the old behaviour, so the feature has an off switch
# that costs nothing to reason about;
# * no window can make a tick stop EARLIER than it used to. A source paused
# for months has an unseen backlog stretching well past any horizon, and
# the walk still runs to the end of it — the horizon is a FLOOR on how far
# to look, never a ceiling.
#
# The live value is `ImportSettings.download_revisit_days` (rule 25 — an
# operator tuning how far back their creators edit should not need a redeploy).
# This is the fallback for a caller that passes none.
DEFAULT_REVISIT_DAYS = 30
# plan #705 #7: after this many failed download/validate attempts a media is
# "dead-lettered" and skipped on routine tick/backfill walks (recovery still
# re-attempts it). Stops a permanently-broken media re-erroring forever.
@@ -106,44 +71,6 @@ _LIVE_PROGRESS_INTERVAL = 5.0
# recapture (the operator's schema-test flow) reaches the sample.
_CANARY_MIN_SAMPLE = 30
# The walk's time budget covers only the walk, but phase 3 runs in the SAME
# Celery task, under the same soft limit (tasks/download.py: 1350s). A walk that
# finds a lot of work for phase 3 must stop early and leave it to the next chunk,
# or phase 3 is killed mid-import: TamadaHeijun's recapture, 2026-09-24, walked
# for ~2 min and then spent 20 min importing 431 orphans and relinking ~3000
# on-disk files, and died at the soft limit.
#
# So the walk also stops when its elapsed time PLUS phase 3's estimated cost
# would pass CHUNK_TOTAL_SECONDS. Costs measured on the live instance: 431
# imports took 976s (~2.3s each: hash, pHash, sidecar, provenance); a relink is
# a sha256 over NFS, 0.15s for the 8.8 MB average file, plus a lookup.
# test_download_source_task pins CHUNK_TOTAL_SECONDS under the soft limit.
CHUNK_TOTAL_SECONDS = 1200.0
PHASE3_IMPORT_SECONDS = 2.5
PHASE3_RELINK_SECONDS = 0.25
def _parse_published(raw: object) -> datetime | None:
"""An ISO-8601 post date from either native client, as aware UTC.
Patreon's `published_at` is tz-aware with a `Z` or `+00:00` offset;
SubscribeStar's is NAIVE (`_parse_ss_datetime` renders a parsed local
timestamp with no zone). A naive value is read as UTC — the alternative is
discarding it, and a post whose date we refuse to read is a post the revisit
window can never reach.
Anything unparseable returns None, which reads downstream as "not provably
recent" and leaves the walk on its pre-revisit behaviour. Never raises: a
date we cannot read must not fail a walk that is otherwise working.
"""
if not isinstance(raw, str) or not raw.strip():
return None
try:
parsed = datetime.fromisoformat(raw.strip().replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
class Ingester:
"""Generic native-ingest orchestration. Subclass with a platform adapter
@@ -180,9 +107,9 @@ class Ingester:
# (e.g. "Patreon API", "SubscribeStar markup").
self._drift_label = drift_label or platform
# #862 canary opt-out: platforms whose posts legitimately have empty
# bodies across large samples would false-positive the
# zero-bodies-means-drift alarm; their clients catch drift structurally
# (response-shape checks) instead. The
# bodies across large samples (pixiv — caption-less artists are common)
# would false-positive the zero-bodies-means-drift alarm; their clients
# catch drift structurally (response-shape checks) instead. The
# "bodies X/N" summary line still surfaces the ratio either way.
self._body_canary = body_canary
@@ -199,17 +126,11 @@ class Ingester:
resume_cursor: str | None = None,
time_budget_seconds: float = 870.0,
seen_threshold: int = _TICK_SEEN_THRESHOLD,
revisit_days: int = DEFAULT_REVISIT_DAYS,
posts_base: int = 0,
event_id: int | None = None,
) -> DownloadResult:
"""Walk + download for one source, returning a gallery-dl-shaped result.
`revisit_days` is the tick's revisit window (see DEFAULT_REVISIT_DAYS):
inside it a tick neither early-outs nor trusts the post-record gate, so
a post edited after we first captured it is re-read and its new
attachments downloaded. 0 turns the window off.
`mode` is "tick" | "backfill" | "recovery" | "recapture". Recovery
bypasses the tier-1 seen-ledger AND the dead-letter ledger (tier-2 disk
still skips kept files). Recapture (#830) is the cheap "re-grab post
@@ -252,29 +173,6 @@ class Ingester:
# no media download, no post-record stub. Absent on stub/not-yet-migrated
# clients → nothing is ever treated as gated.
post_is_gated = getattr(self.client, "post_is_gated", None)
# The revisit window (see DEFAULT_REVISIT_DAYS). `post_meta` is an
# existing client seam — both native clients already implement it, for
# a preview sample whose caller has since gone, so this needed no new
# contract, only a live consumer for one. Absent seam, an unreadable
# date or a window of 0 → `horizon` never matches and the walk behaves
# exactly as it did before 2026-09-23.
#
# The window applies to TICKS only. A backfill is gated on purpose
# (capture each post once) and `recapture` mode already exists for the
# operator-driven "re-read every body" pass; a horizon there would be a
# third overlapping answer to a question that has two.
post_meta = getattr(self.client, "post_meta", None)
# #4413: optional client seam for a source that is several feeds walked
# one after another (a Discord server: every channel and thread). The
# tick early-out means "this feed has nothing new", and without the
# seam it ends the WHOLE walk — so the first quiet channel would hide
# every channel after it. With it, the early-out asks the client to
# move on and the walk continues. Absent → the early-out ends the walk,
# exactly as before (Patreon and SubscribeStar are one feed each).
skip_feed = getattr(self.client, "skip_feed", None)
horizon: datetime | None = None
if mode == "tick" and revisit_days > 0 and post_meta is not None:
horizon = datetime.now(UTC) - timedelta(days=revisit_days)
start = time.monotonic()
last_live = start # plan #709: last live-progress write timestamp
log_lines: list[str] = []
@@ -286,9 +184,6 @@ class Ingester:
# source_filehash and (b) link the on-disk image to its Post (#1288) —
# WITHOUT re-downloading or unlinking the file. Empty outside recapture.
relink: list[tuple[str, str, str]] = []
# Media handed to phase 3 for import. Marked seen by phase 3 once the
# import has run (`mark_seen_after_import`), not here — see there.
fetched: list[tuple[str, str]] = []
downloaded = 0
errors = 0
quarantined = 0
@@ -309,18 +204,11 @@ class Ingester:
# absolute across chunks instead of an inflating sum. posts_processed
# stays the gross per-chunk count used for the run summary.
chunk_new_posts = 0
# Posts inside the revisit window that we had already captured, and the
# media those revisits turned up. Reported in the run summary — the
# operator's ask was to SEE the updated posts, not only to end up with
# their files ("so we can update ours to match").
revisited = 0
revisit_downloads = 0
consecutive_seen = 0
emitted_cursor: str | None = None
reached_bottom = False
budget_hit = False
early_out = False
feeds_caught_up = 0 # #4413: feeds a tick left early via skip_feed
stopped = False # plan #708 B4: operator hit Stop mid-walk
cancel_armed = False # latched once we observe a live "running" state
@@ -342,7 +230,6 @@ class Ingester:
written_paths=written,
post_record_paths=list(post_records),
relink_source_paths=list(relink),
mark_seen_after_import=lambda: self._mark_seen(source_id, fetched),
stdout="\n".join(log_lines),
stderr="",
return_code=return_code,
@@ -358,12 +245,6 @@ class Ingester:
per_item_failures=errors,
quarantined_count=quarantined,
dead_lettered_count=dead_lettered,
# #874 follow-up: the native path counted gated posts but
# never reported them, so DownloadDetailModal's "Tier-gated"
# field read 0 on every native walk while gallery-dl's read
# true. A paywalled creator was indistinguishable from a
# silent one.
tier_gated_count=gated_skipped,
),
)
@@ -418,17 +299,7 @@ class Ingester:
# Time-box check at the post boundary (coarse, like a gallery-dl
# chunk). Backfill/recovery resume from emitted_cursor next chunk.
# The second half is phase 3's share of the task — see
# CHUNK_TOTAL_SECONDS. A mid-page stop resumes the same page.
elapsed = time.monotonic() - start
phase3 = (
len(written) * PHASE3_IMPORT_SECONDS
+ len(relink) * PHASE3_RELINK_SECONDS
)
if (
elapsed >= time_budget_seconds
or elapsed + phase3 >= CHUNK_TOTAL_SECONDS
):
if time.monotonic() - start >= time_budget_seconds:
budget_hit = True
break
@@ -439,20 +310,6 @@ class Ingester:
# resume_cursor None, so everything counts.
if not (resume_cursor and page_cursor == resume_cursor):
chunk_new_posts += 1
# Inside the revisit window? Computed per post rather than
# "stop once one post is old" because the feed is only MOSTLY
# date-ordered — a pinned or re-pinned post can sit above older
# ones, and one such post must not end the walk.
in_window = False
if horizon is not None:
published = _parse_published((post_meta(post) or {}).get("date"))
in_window = published is not None and published >= horizon
# Set by the post-record block below when this post was already
# captured on an earlier walk. Stays False when the platform has
# no post-record seam, so the revisit accounting simply reports
# nothing rather than guessing.
post_already_recorded = False
downloaded_before = downloaded
# Tier-gated post (#874): the account can't fully view it, so
# Patreon serves only blurred locked-preview media. Skip it
# ENTIRELY — no media download AND no post-record stub (operator
@@ -484,31 +341,11 @@ class Ingester:
set() if recapture_records
else self._seen_keys(source_id, [pkey])
)
post_already_recorded = pkey in already
# A post inside the revisit window is re-read even
# though the gate has it: that gate's whole job is to
# stop us paying for a post twice, and an EDITED post is
# not the same post. `revisit=True` keeps the cost at
# zero requests — the downloader re-reads the body from
# the feed response already in hand and declines to
# write at all if that body came back empty, so a
# detail-fetched body is never overwritten by a blank.
if not post_already_recorded or in_window:
rec = write_post_record(
post, artist_slug, revisit=post_already_recorded,
)
if not post_already_recorded:
# FIRST captures only feed the #862 body canary.
# A revisit legitimately comes back empty — a
# post whose body only ever arrived from the
# detail endpoint has none in the feed, and the
# downloader declines to write it. Counting
# those into the sample would walk the canary
# toward firing on healthy ticks, which is the
# one thing a drift alarm must never do.
posts_recorded += 1
if rec.body_chars:
posts_with_body += 1
if pkey not in already:
rec = write_post_record(post, artist_slug)
posts_recorded += 1
if rec.body_chars:
posts_with_body += 1
if rec.path is not None:
post_records.append(str(rec.path))
self._mark_seen(source_id, [(pkey, ppid)])
@@ -518,8 +355,7 @@ class Ingester:
# a 0-char body is the "why is this one empty" answer.
log_lines.append(
f" post {ppid} [{rec.post_type or '?'}] "
+ ("re-read, " if post_already_recorded else "")
+ f"body: {rec.body_chars} chars"
f"body: {rec.body_chars} chars"
+ ("" if rec.body_chars else " — EMPTY")
+ (f" — {rec.title}" if rec.title else "")
)
@@ -552,13 +388,6 @@ class Ingester:
recapture=recapture,
)
# An on-disk file is only "done" if something imported it. One
# with no ImageRecord at its path was written by a run that died
# before phase 3 — it goes to import, not to the ledger.
imported_paths = self._recorded_paths([
str(o.path) for o in outcomes
if o.status == "skipped_disk" and o.path is not None
])
to_mark: list[tuple[str, str]] = []
to_clear: list[str] = [] # recovered → drop any dead-letter row
to_fail: list[tuple[str, str, str]] = [] # (key, post_id, error)
@@ -570,29 +399,11 @@ class Ingester:
downloaded += 1
if outcome.path is not None:
written.append(str(outcome.path))
fetched.append((key, media_item.post_id))
to_mark.append((key, media_item.post_id))
to_clear.append(key)
consecutive_seen = 0
elif (
outcome.status == "skipped_disk"
and outcome.path is not None
and str(outcome.path) not in imported_paths
):
# On disk, never imported: a prior run wrote it and died
# before phase 3. Import it now. Safe to feed to
# attach_in_place because no record owns this path —
# the unlink below is about a file that IS the record.
written.append(str(outcome.path))
fetched.append((key, media_item.post_id))
to_clear.append(key)
skipped_count += 1
consecutive_seen += 1
log_lines.append(
f" post {media_item.post_id} — on disk but never "
f"imported: {outcome.path.name}"
)
elif outcome.status == "skipped_disk":
# Already on disk and imported. Reconcile the ledger so a
# Already on disk (a prior run). Reconcile the ledger so a
# later tick skips it at tier-1 without a disk stat, but
# do NOT re-feed it to phase 3 — attach_in_place would see
# the duplicate sha256 and unlink the on-disk copy.
@@ -628,15 +439,7 @@ class Ingester:
to_fail.append((key, media_item.post_id, outcome.error or "error"))
# An error neither advances nor resets the run-of-seen.
# `not in_window` is the revisit window's half of the
# early-out: a run of already-seen items is only permission
# to stop once the walk is BELOW the horizon. Both halves,
# never either alone — see DEFAULT_REVISIT_DAYS.
if (
mode == "tick"
and not in_window
and consecutive_seen >= seen_threshold
):
if mode == "tick" and consecutive_seen >= seen_threshold:
early_out = True
break
@@ -650,21 +453,6 @@ class Ingester:
if to_fail:
self._record_failures(source_id, to_fail)
# An already-captured post that yielded NEW media is an edited
# post — the operator's Floppystack case, and the one thing in
# this walk worth naming individually in the run log. The media
# half needed no new detection: `extract_media` reads the media
# list off the live feed response, so a hotfix build appended
# last night is simply a ledger key we have never seen.
new_here = downloaded - downloaded_before
if post_already_recorded and new_here:
revisited += 1
revisit_downloads += new_here
log_lines.append(
f" post {post.get('id')} — updated: "
f"{new_here} new file(s)"
)
# plan #709: time-throttled live progress to the running event so
# the Downloads view ticks ~every 5s, independent of page size.
now = time.monotonic()
@@ -676,22 +464,12 @@ class Ingester:
"errors": errors,
"quarantined": quarantined,
"posts": posts_processed,
# Ticks during the walk, not only at finalization: a
# deep backfill on a creator we've lost access to is
# otherwise a long run of zeros with no explanation.
"gated": gated_skipped,
})
if early_out:
if skip_feed is None:
break
skip_feed()
feeds_caught_up += 1
early_out = False
consecutive_seen = 0
break
else:
# A walk that left feeds early did not read to their ends.
reached_bottom = not feeds_caught_up
reached_bottom = True
except self._error_base as exc:
# The platform's client-error base — _failure_result (adapter)
# maps it to a typed error.
@@ -733,13 +511,6 @@ class Ingester:
# visible in the Raw stdout (e.g. "bodies 3/180" reads as off).
+ (f", bodies {posts_with_body}/{posts_recorded}" if posts_recorded else "")
+ (f", {gated_skipped} gated-skipped" if gated_skipped else "")
# Only when it happened: on a quiet tick this is 0 and saying so
# every run would bury the times it is not.
+ (
f", {revisited} post(s) updated ({revisit_downloads} new file(s))"
if revisited else ""
)
+ (f", {feeds_caught_up} feed(s) caught up" if feeds_caught_up else "")
+ (", reached end" if reached_bottom else "")
+ (", time-boxed" if budget_hit else "")
)
@@ -753,13 +524,7 @@ class Ingester:
# next chunk resumes from the emitted cursor. No progress → TIMEOUT,
# which feeds download_service's backfill stall-guard. rc<0 mirrors
# subprocess TimeoutExpired so completion detection stays false.
# Work handed to phase 3 is progress too: a recapture chunk that
# stopped for its imports downloaded nothing, and may not have left
# its first page.
made_progress = (
downloaded > 0 or bool(written) or bool(relink)
or emitted_cursor != resume_cursor
)
made_progress = downloaded > 0 or emitted_cursor != resume_cursor
if made_progress:
return _result(
success=False, return_code=-1,
@@ -799,33 +564,17 @@ class Ingester:
error_type=ErrorType.API_DRIFT, error_message=msg,
)
# Normal success: reached the bottom, or a tick that early-outed. A
# zero-download walk still returns success here — a re-confirming walk
# that found nothing new genuinely completed. A tick that early-outed
# also lands here; ticks never set backfill state so the lifecycle is a
# no-op for them.
#
# success=True and return_code=0 are load-bearing, not cosmetic. They
# are what make this a COMPLETE walk for
# download_service._apply_backfill_lifecycle (via walk_completed) and
# what map it to status "ok", so a walk that fetched nothing doesn't
# accrue consecutive_failures or a backoff it hasn't earned.
#
# #874 follow-up: "nothing new" and "everything sat behind a tier you
# don't hold" are different facts, and returning None for both made a
# paywalled creator indistinguishable from a silent one. TIER_LIMITED is
# classified LAST — every real failure has already returned above —
# because tier-gating is the weakest signal and must never mask a
# genuine error. It is informational, so walk_completed still counts
# this walk as finished (see that predicate for why re-walking a
# paywalled creator forever is the bug being avoided).
gated_error = classify_tier_gated(gated_skipped)
# Normal success: reached the bottom, or a tick that early-outed. rc 0 +
# error_type None is REQUIRED for a backfill/recovery walk that reached
# the bottom to be marked COMPLETE by
# download_service._apply_backfill_lifecycle — so we return None even
# when downloaded == 0 (a re-confirming walk that found nothing new still
# completed). success=True maps to status "ok" regardless. A tick that
# early-outed also returns here; ticks never set backfill state so the
# lifecycle is a no-op for them.
return _result(
success=True, return_code=0,
error_type=gated_error,
error_message=(
tier_gated_message(gated_skipped) if gated_error else None
),
error_type=None, error_message=None,
)
# -- failure mapping (adapter overrides) -------------------------------
@@ -965,16 +714,6 @@ class Ingester:
)
session.commit()
def _recorded_paths(self, paths: list[str]) -> set[str]:
"""Which of `paths` an ImageRecord already points at."""
if not paths:
return set()
with self.session_factory() as session:
rows = session.execute(
select(ImageRecord.path).where(ImageRecord.path.in_(paths))
).scalars().all()
return set(rows)
def _mark_seen(self, source_id: int, items: list[tuple[str, str]]) -> None:
"""Idempotent upsert of (filehash, post_id) seen-ledger rows for a page.
@@ -1,359 +0,0 @@
"""Reconciling the learned roster against the sources FC actually tracks.
Milestone 387, step C4. The step the operator asked for; C0-C3 are what make it
trustworthy enough to act on.
## The buckets
1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The
adoption win, and the only bucket carrying an action.
2. `tracked_not_subscribed` — FC follows this and the roster does not show you
paying for it. No longer shown on the card: the operator reversed the
2026-09-11 "report only" call on 2026-09-13. The lapsed half of it now ACTS,
in `apply_membership_lapses` below (#3995). The absent half still only
reports, because absence proves nothing.
3. `matched` — the healthy set. Counted, not listed loudly.
4. `unidentified` — sources this join cannot speak to at all. Reported as
exactly that, because the alternative is filing them under a verdict.
## Why absence is the dangerous direction
Bucket 1 is safe to be wrong about: the cost of offering a source the operator
does not want is one ignored row. Bucket 2 is not. It is computed from an
ABSENCE — no membership matched — and three different things produce that
absence: the subscription genuinely lapsed, the sweep failed, or the creator
renamed and this source has never been walked so no exact id was ever cached.
Two guards follow from that, and they are the substance of this module:
* the whole bucket is gated on `roster_is_fresh`, so a failed or never-run sweep
yields an empty list rather than a confident accusation (C3 built the state
this reads);
* every row carries the BASIS for its claim, so "your membership says former
patron" and "we know this creator's id and it is not in your roster" and "we
only have a URL handle to go on" are three different sentences rather than one
overconfident one.
`has_paid_access` returning None is honoured throughout: unknown is never
rendered as lapsed. That is the whole reason it returns a tri-state.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, MembershipSync, PlatformMembership, Source
from .membership_roster import (
get_sync_state,
identity_keys_for_source,
pair_sources_with_memberships,
roster_is_fresh,
url_tail,
)
from .native_ingest_common import has_paid_access
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
# the UI renders a different sentence per basis, because collapsing them into
# one would make the weakest claim sound like the strongest.
BASIS_LAPSED = "lapsed" # a matched membership says access ended
BASIS_ABSENT_EXACT = "absent_exact" # exact id known, not in a fresh roster
BASIS_ABSENT_HANDLE = "absent_handle" # only a URL handle to go on
def _membership_row(m: PlatformMembership) -> dict:
return {
"id": m.id,
"platform": m.platform,
"external_campaign_id": m.external_campaign_id,
"display_name": m.display_name or m.vanity_or_none(),
"url": m.url,
"vanity": m.vanity_or_none(),
"status": m.status,
"tier_names": m.tier_names,
"amount_cents": m.amount_cents,
"currency": m.currency,
"paid_access": has_paid_access(
m.platform, m.status,
is_free_member=bool((m.details or {}).get("is_free_member")),
),
}
def _source_row(source: Source, artist: Artist) -> dict:
return {
"id": source.id,
"platform": source.platform,
"url": source.url,
"enabled": source.enabled,
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
}
async def reconcile(
session: AsyncSession, *, platform: str, now: datetime | None = None,
) -> dict:
"""Sort one platform's memberships and sources into the four buckets.
Always returns the COMPLETE shape, including when the roster is not fresh —
a caller reading `len(result["tracked_not_subscribed"])` must not have to
check which keys exist first. `fresh` is what says whether the emptiness
means anything.
"""
state = await get_sync_state(session, platform)
fresh = roster_is_fresh(state, now=now)
memberships = (await session.execute(
select(PlatformMembership).where(PlatformMembership.platform == platform)
)).scalars().all()
rows = (await session.execute(
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.where(Source.platform == platform)
)).all()
# The join itself lives in `membership_roster` beside `match_kind`, so C5's
# gated-reason annotation pairs sources with memberships by exactly the same
# rule this card sorts them by. Two copies would let the Subscriptions row
# and this card disagree about which creator a source IS.
pairs = pair_sources_with_memberships([s for s, _a in rows], memberships)
matched_membership_ids = {m.id for m, _kind in pairs.values()}
subscribed_not_tracked = []
for m in memberships:
if m.id in matched_membership_ids:
continue
paid = has_paid_access(
m.platform, m.status,
is_free_member=bool((m.details or {}).get("is_free_member")),
)
# A membership FC knows has ENDED is not an adoption opportunity —
# adding it would start a walk that can only fetch what is already
# public. Unknown (None) is still offered: the operator can judge it,
# and refusing to show it would hide a real subscription behind a word
# this code has not been taught.
if paid is False:
continue
subscribed_not_tracked.append(_membership_row(m))
tracked_not_subscribed = []
matched = []
unidentified = []
for source, artist in rows:
pair = pairs.get(source.id)
if pair is not None:
m, kind = pair
paid = has_paid_access(
m.platform, m.status,
is_free_member=bool((m.details or {}).get("is_free_member")),
)
if paid is False:
if not source.enabled:
# Already off. Reporting a source the operator has already
# stopped following is noise, not a finding.
continue
row = _source_row(source, artist)
row["basis"] = BASIS_LAPSED
row["matched_by"] = kind
row["membership"] = _membership_row(m)
tracked_not_subscribed.append(row)
else:
row = _source_row(source, artist)
row["matched_by"] = kind
row["membership"] = _membership_row(m)
matched.append(row)
continue
# No membership matched. Whether that MEANS anything depends entirely on
# how well this source can be identified at all.
has_exact = bool(identity_keys_for_source(source))
if not has_exact and url_tail(source.url) is None:
# Nothing to match on — a sidecar anchor or a URL with no handle.
# Reported as unidentified rather than silently dropped, so the
# counts add up to the source list the operator can see.
unidentified.append(_source_row(source, artist))
continue
if not source.enabled:
# Already off. Telling the operator to stop following something they
# have stopped following is noise, not a finding.
continue
row = _source_row(source, artist)
row["basis"] = BASIS_ABSENT_EXACT if has_exact else BASIS_ABSENT_HANDLE
row["matched_by"] = None
row["membership"] = None
tracked_not_subscribed.append(row)
# THE GATE. Everything above computed the bucket; this decides whether it may
# be shown. A stale or never-run roster makes every absence meaningless, and
# an absence rendered as a verdict is how this feature would tell the
# operator to cancel something they are still paying for.
if not fresh:
tracked_not_subscribed = []
return {
"platform": platform,
"fresh": fresh,
# How many sources exist on this platform at all. The UI needs it to
# decide whether an untrustworthy roster is worth mentioning: with no
# sources here there is nothing to reconcile, and a stale-roster warning
# would be noise on an install that simply has not started yet (that
# empty-install case is C6's, not this card's).
"tracked_total": len(rows),
"last_success_at": (
state.last_success_at.isoformat()
if state is not None and state.last_success_at else None
),
"subscribed_not_tracked": subscribed_not_tracked,
"tracked_not_subscribed": tracked_not_subscribed,
"matched": matched,
"unidentified": unidentified,
}
async def reconcile_all(session: AsyncSession, now: datetime | None = None) -> dict:
"""Every platform the roster knows about, in one payload for the UI.
The platform list is the UNION of platforms with memberships and platforms
with sync state, not just the former. A sweep that has never succeeded has
recorded zero memberships, and deriving the list from memberships alone
would drop exactly that platform from the payload — making a broken
credential indistinguishable from a platform FC was never asked about. That
distinction is the whole reason C3 records sync state.
"""
with_memberships = (await session.execute(
select(PlatformMembership.platform).distinct()
)).scalars().all()
with_state = (await session.execute(
select(MembershipSync.platform)
)).scalars().all()
platforms = set(with_memberships) | set(with_state)
return {
"platforms": [
await reconcile(session, platform=p, now=now) for p in sorted(platforms)
]
}
# ---------------------------------------------------------------------------
# Stop pulling what the account no longer pays for (#3995)
# ---------------------------------------------------------------------------
#
# Operator decision, 2026-09-13, reversing the 2026-09-11 "report only" call
# for this direction: "if I kill a subscription on patreon I would like the
# pulling to stop on curator as well", with automatic resume on resubscribing.
#
# This is a SOURCE-level action taken by the daily sweep, visible on the source
# row and reversible there. It is not a fetch-path decision. The line C5 draws,
# that the roster never decides a POST is inaccessible, still holds: nothing
# here reads per-post access, and no download path reads the roster
# (`test_no_fetch_path_can_read_the_roster`). The scheduler keeps selecting on
# `enabled` alone.
#
# Acts ONLY on positive evidence. A source whose matched membership says access
# has ended is stopped. A source with NO matched membership is left alone,
# because absence has innocent causes: a creator rename, a source never walked
# so no id is cached, a membership the platform stopped listing. Stopping on
# absence would switch off things the operator still pays for.
#
# Two app-managed config_overrides keys carry the state. The `_` prefix is
# already the "FC writes this, an operator edit preserves it" family.
# _membership_stopped set when the sweep stops a source; the sweep resumes
# ONLY sources carrying it, so a source the operator
# switched off by hand is never switched back on
# _membership_kept set by SourceService.update when the operator turns a
# stopped source back ON: a deliberate choice to keep
# pulling a lapsed creator, which the next sweep must
# not undo. Cleared when the membership is paid again.
STOPPED_KEY = "_membership_stopped"
KEPT_KEY = "_membership_kept"
def _access_expires_at(m: PlatformMembership) -> datetime | None:
"""When paid access actually ends, if the platform says.
Patreon keeps a cancelled membership's access until the end of the billing
period and reports that date (`member.access_expires_at`, note #3992).
SubscribeStar's page gives no such date, so a cancelled SubscribeStar
membership stops at once. Returns None when there is no usable date.
"""
details = m.details or {}
raw = details.get("access_expires_at") or (details.get("member") or {}).get("access_expires_at")
if not isinstance(raw, str) or not raw:
return None
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
async def apply_membership_lapses(
session: AsyncSession, *, platform: str, now: datetime | None = None,
) -> dict:
"""Stop sources whose paid access has ended; resume the ones this stopped.
Refuses to act on a roster that isn't fresh, for the same reason C4 refuses
to draw conclusions from one.
"""
now = now or datetime.now(UTC)
state = await get_sync_state(session, platform)
if not roster_is_fresh(state, now=now):
return {"platform": platform, "skipped": "roster not fresh", "stopped": 0, "resumed": 0}
memberships = (await session.execute(
select(PlatformMembership).where(PlatformMembership.platform == platform)
)).scalars().all()
sources = (await session.execute(
select(Source).where(Source.platform == platform)
)).scalars().all()
pairs = pair_sources_with_memberships(list(sources), list(memberships))
stopped: list[int] = []
resumed: list[int] = []
for source in sources:
pair = pairs.get(source.id)
if pair is None:
continue # absence is never acted on, see above
m, _kind = pair
paid = has_paid_access(
m.platform, m.status,
is_free_member=bool((m.details or {}).get("is_free_member")),
)
co = dict(source.config_overrides or {})
if paid is True:
changed = co.pop(KEPT_KEY, None) is not None
if STOPPED_KEY in co:
co.pop(STOPPED_KEY)
source.enabled = True
resumed.append(source.id)
changed = True
if changed:
source.config_overrides = co
continue
# Unknown status: never a reason to stop something (has_paid_access's
# tri-state exists for exactly this).
if paid is None:
continue
if not source.enabled or co.get(KEPT_KEY):
continue
expires = _access_expires_at(m)
if expires is not None and expires > now:
continue # still inside the paid-through period
co[STOPPED_KEY] = {"at": now.isoformat(), "status": m.status}
source.config_overrides = co
source.enabled = False
# The same clean slate a manual disable gives (SourceService.update,
# #1285), so a stopped source doesn't linger as failing or gated.
source.last_error = None
source.error_type = None
source.consecutive_failures = 0
stopped.append(source.id)
await session.commit()
return {"platform": platform, "stopped": len(stopped), "resumed": len(resumed)}
-453
View File
@@ -1,453 +0,0 @@
"""The learned membership roster: what the account actually subscribes to.
Milestone 387, phase C. Sibling of `service_roster` (milestone 365) and built
on the same insight — an absence is only observable against a record of
presence. There, a stopped worker; here, a subscription that lapsed.
## Nothing calls this yet
`touch_membership` is written before its caller because the caller (the sweep,
C3) needs a client seam (C2) that needs Patreon's real response characterised
from a captured sample (C0), and that capture needs the operator's browser
session. The write side does not depend on any of it: an upsert keyed on
(platform, external_campaign_id) is the same regardless of what the payload
turns out to look like, and `details` carries whatever C0 finds.
## Why the whitelist lives here and not in the column
`platform_membership.status` is an unconstrained String holding the PLATFORM's
own word — `active_patron`, not some normalised FC value. The mapping from
those words to FC's meaning is a read-site concern and belongs in code that can
be corrected without a migration, because the vocabulary comes from whatever
each platform says and will be discovered per platform rather than designed up
front. `native_ingest_common.MEMBERSHIP_STATUS` is where that knowledge
accumulates as platforms are characterised, and it holds no guesses. It lives
there rather than here because platform clients need it, and a client may not
import this module (test_gated_reason.py).
"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import MembershipSync, PlatformMembership, Source
from .native_ingest_common import has_paid_access
log = logging.getLogger(__name__)
async def touch_membership(
session: AsyncSession,
*,
platform: str,
external_campaign_id: str,
display_name: str | None = None,
url: str | None = None,
status: str | None = None,
tier_names: list | None = None,
amount_cents: int | None = None,
currency: str | None = None,
details: dict | None = None,
) -> None:
"""Record that this membership was observed just now.
Upsert rather than read-modify-write, for the same reason as
`service_roster.touch_service`: a sweep may overlap its own previous run,
and the last writer is simply the most recent sighting.
`first_seen_at` is deliberately NOT in the update set. It is the one field
that answers "has this ever been true", which is what makes a membership's
later DISAPPEARANCE readable as a lapse rather than indistinguishable from
a creator FC never knew about. Every other column is last-writer-wins,
including status — a membership that goes from active to former must move.
"""
stmt = pg_insert(PlatformMembership).values(
platform=platform,
external_campaign_id=external_campaign_id,
display_name=display_name,
url=url,
status=status,
tier_names=tier_names,
amount_cents=amount_cents,
currency=currency,
details=details or {},
)
stmt = stmt.on_conflict_do_update(
constraint="uq_platform_membership_platform_campaign",
set_={
"display_name": stmt.excluded.display_name,
"url": stmt.excluded.url,
"status": stmt.excluded.status,
"tier_names": stmt.excluded.tier_names,
"amount_cents": stmt.excluded.amount_cents,
"currency": stmt.excluded.currency,
"details": stmt.excluded.details,
"last_seen_at": func.now(),
},
)
await session.execute(stmt)
# ---------------------------------------------------------------------------
# The sweep, and the state that makes its failures readable (#387 C3)
# ---------------------------------------------------------------------------
#
# How long a successful sync stays trustworthy. Beyond this the roster is
# STALE, and C4 must refuse to draw conclusions from it — "you are tracking 12
# sources you no longer subscribe to", computed from a roster that stopped
# syncing a week ago, is an invitation to cancel things the operator is still
# paying for.
#
# Generous relative to the daily cadence: a few missed runs are a blip, not a
# reason to stop trusting a roster that changes on a billing cycle.
ROSTER_STALE_AFTER = timedelta(days=3)
async def get_sync_state(session: AsyncSession, platform: str) -> MembershipSync | None:
return (await session.execute(
select(MembershipSync).where(MembershipSync.platform == platform)
)).scalar_one_or_none()
def roster_is_fresh(state: MembershipSync | None, *, now: datetime | None = None) -> bool:
"""May a caller draw CONCLUSIONS from this roster?
False for never-synced and for stale, and those are deliberately the same
answer here even though the UI must tell them apart: both mean the roster
is not evidence. The asymmetry that matters is that `False` never means
"you subscribe to nothing" — it means "we do not know", and a caller that
cannot represent "we do not know" must not be asking this question.
"""
if state is None or state.last_success_at is None:
return False
now = now or datetime.now(UTC)
return (now - state.last_success_at) <= ROSTER_STALE_AFTER
async def _record_sync(session: AsyncSession, platform: str, **values) -> None:
stmt = pg_insert(MembershipSync).values(platform=platform, **values)
await session.execute(stmt.on_conflict_do_update(
constraint="uq_membership_sync_platform",
set_={**values, "updated_at": func.now()},
))
def roster_user_id(client) -> str | None:
"""The account id a client's roster walk needs, if that client needs one.
Patreon's members endpoint filters on the account's own user id, so the
sweep has to resolve it first. SubscribeStar's /subscriptions page is simply
the logged-in account's, with nothing to resolve. Probed with `getattr`,
the same way the sweep probes `iter_memberships` itself (rule #169), rather
than called unconditionally.
Calling `current_user_id()` unconditionally was the one place the membership
seam was still Patreon-shaped: note #3970 promised a second platform would be
one `builders` line plus the client method, and D1 found the sweep would
instead have crashed on the first client without that method.
"""
resolve = getattr(client, "current_user_id", None)
return resolve() if resolve is not None else None
async def sync_platform(
session: AsyncSession,
*,
platform: str,
fetch: Callable[[], Awaitable[list]],
now: datetime | None = None,
) -> dict:
"""Walk one platform's roster and record what happened.
`fetch` is injected rather than built here so the error-to-state mapping —
the part with the consequences — is testable without a credential, and so
this service needs to know nothing about how any particular client is
constructed.
THE FETCH COMPLETES BEFORE ANYTHING IS WRITTEN. That ordering is the whole
safety property: a walk that dies half way through pagination writes
nothing, so a failure can never leave a roster that is partly this week's
and partly last week's. (`touch_membership` never deletes, so a failure
cannot empty the roster either — but "intact" should mean intact, not
merely non-empty.)
Returns a summary dict; never raises for a platform failure, because one
platform failing must not abort the others.
"""
now = now or datetime.now(UTC)
await _record_sync(session, platform, last_attempt_at=now)
await session.commit()
try:
memberships = await fetch()
except Exception as exc: # noqa: BLE001 - deliberately broad, see below
# Broad on purpose: a sweep is a background job, and ANY escape here
# kills the run for every other platform too. The exception's class
# name is recorded so the distinction the client drew (auth vs drift
# vs transport) survives into the UI, which is where it is actionable.
#
# EXCEPT the worker asking us to stop. Celery raises its soft time
# limit as an ordinary Exception subclass, so a broad catch swallows
# the shutdown request and lets the sweep run on into the HARD limit,
# where it is SIGKILLed mid-transaction. A sweep that cannot be stopped
# is worse than one that fails. (KeyboardInterrupt and SystemExit are
# BaseException and pass through this clause already.)
from celery.exceptions import SoftTimeLimitExceeded
if isinstance(exc, SoftTimeLimitExceeded):
raise
await session.rollback()
await _record_sync(
session, platform,
last_error_type=type(exc).__name__,
last_error_message=str(exc)[:2000],
)
await session.commit()
log.warning("membership sync failed for %s: %s", platform, exc)
return {"platform": platform, "ok": False, "error": type(exc).__name__}
for m in memberships:
await touch_membership(
session,
platform=platform,
external_campaign_id=m.campaign_id,
display_name=m.display_name,
url=m.url,
status=m.status,
tier_names=m.tier_names or None,
amount_cents=m.amount_cents,
currency=m.currency,
details={**(m.details or {}), "is_free_member": m.is_free_member},
)
await _record_sync(
session, platform,
last_success_at=now,
last_count=len(memberships),
# Cleared on success — a stale error beside a fresh success would read
# as "still broken" forever.
last_error_type=None,
last_error_message=None,
)
await session.commit()
log.info("membership sync ok for %s: %d membership(s)", platform, len(memberships))
return {"platform": platform, "ok": True, "count": len(memberships)}
# ---------------------------------------------------------------------------
# Membership <-> Source identity (#387 C4)
# ---------------------------------------------------------------------------
#
# "Is this membership already tracked?" is asked by TWO features — C4's
# reconciliation buckets and E4's creator suggestions — and it lives here, once,
# on purpose. Built inline in C4 it would have looked finished while leaving E4
# matching on name similarity alone, so the two would answer the same question
# differently and only one of them would be right.
#
# E4 and C4 use it from opposite sides: E4 as the NEGATIVE check (propose only
# where nothing matches) and C4 as the join itself.
# Any platform that caches its creator id does so under this suffix; see
# `download_service._phase3_persist`, which writes `patreon_campaign_id`.
_CAMPAIGN_KEY_SUFFIX = "_campaign_id"
def identity_keys_for_source(source: Source) -> set[str]:
"""Every platform-side creator id cached on this source.
Reads ANY `<platform>_campaign_id` override rather than naming Patreon's,
so a second platform participates by caching its id under the same suffix —
no registry, no `if platform ==` branch (rule 169). A source that has never
been walked has cached nothing and simply contributes no exact key, which is
what makes the handle fallback below necessary rather than merely tolerated.
"""
keys = set()
for name, value in (source.config_overrides or {}).items():
if name.endswith(_CAMPAIGN_KEY_SUFFIX) and isinstance(value, str) and value:
keys.add(value)
return keys
def url_tail(url: str | None) -> str | None:
"""The creator handle at the end of a source URL, lowercased.
Deliberately the same derivation as `PlatformMembership.vanity_or_none`'s
own fallback, so both sides of the comparison reduce a URL to a handle the
same way. Query strings and fragments are stripped first; Patreon's `/c/`
and `/cw/` forms both end in the vanity, so they need no special case (the
missing-`/c/` regex is what broke creator detection in #1485).
Returns None for the pre-0030 `sidecar:` synthetic anchors, which are not
feeds and must never match anything.
"""
if not url or url.startswith("sidecar:"):
return None
cleaned = url.split("?", 1)[0].split("#", 1)[0]
tail = cleaned.rstrip("/").rsplit("/", 1)[-1]
return tail.lower() or None
def match_kind(source: Source, membership: PlatformMembership) -> str | None:
"""How this source and this membership are known to be the same creator.
Returns "campaign" for an exact platform-id match, "vanity" for agreeing URL
handles, or None for no evidence.
THE ORDER MUST NOT BE INVERTED. The campaign id is exact and the handle is
not, but the id is only written AFTER a source has been walked at least once
— so checking the handle first would let a stale or renamed URL outvote the
authoritative id on every source FC has actually polled.
"""
if source.platform != membership.platform:
return None
if membership.external_campaign_id in identity_keys_for_source(source):
return "campaign"
vanity = membership.vanity_or_none()
tail = url_tail(source.url)
if vanity and tail and vanity.strip().lower() == tail:
return "vanity"
return None
def pair_sources_with_memberships(
sources: list[Source], memberships: list[PlatformMembership],
) -> dict[int, tuple[PlatformMembership, str]]:
"""Source id -> the membership it is the same creator as, and how we know.
Extracted from C4's reconcile loop when C5 became its second caller. It is
a nested loop rather than a SQL join because the match is a predicate over
a JSON blob and a derived URL handle, neither of which is indexable, and
both sides are tens of rows on any real library. Keeping it in Python means
ONE definition of identity (`match_kind`) instead of a second one in SQL
that could drift from it.
First match wins, which is `match_kind`'s ordering doing its job: a source
with a cached campaign id can only pair with the membership holding that
id, so an ambiguous handle never outvotes it.
"""
pairs: dict[int, tuple[PlatformMembership, str]] = {}
for source in sources:
for m in memberships:
kind = match_kind(source, m)
if kind:
pairs[source.id] = (m, kind)
break
return pairs
# ---------------------------------------------------------------------------
# Why the posts are invisible (#387 C5)
# ---------------------------------------------------------------------------
#
# A3 made a tier-gated source say "47 posts you can't see". These are the words
# the roster is allowed to add to that count — and ONLY to that count.
#
# THE LINE: the roster ANNOTATES the gated flag, it never produces it.
# `current_user_can_view` (read per post by `patreon_client.post_is_gated`) is
# the authoritative per-post signal, and entitled-tier data cannot stand in for
# it — a creator can gate a post behind an access rule that maps onto no tier
# name at all. So nothing here may suppress a download, skip a walk, or decide
# a post is inaccessible. It explains a skip that ALREADY happened. Getting
# that backwards would make FC silently stop fetching content the operator is
# paying for, which is the worst failure available in this milestone.
# `test_no_fetch_path_can_read_the_roster` pins that structurally.
GATED_LAPSED = "lapsed" # the membership ended — resubscribe, or disable
GATED_TIER = "tier" # paying, but this tier doesn't reach these posts
GATED_FREE = "free" # a current FREE follow — nobody is paying for access
def gated_reason(
platform: str, status: str | None, *, is_free_member: bool = False,
) -> str | None:
"""Why a tier-gated source's posts are out of reach, if the roster knows.
None means "no words beyond the count" and is the answer for every case
where the roster is not evidence: a status this code has not been taught,
and (at the call site) a campaign absent from the roster or a roster too
stale to trust. Absence is not evidence — the same discipline as
`test_post_is_gated_only_on_explicit_false`.
`is_free_member` is read AFTER the status axis, not folded into it, which
is why `has_paid_access` is called here with it forced off. The two axes
are independent in Patreon's payload, and collapsing them loses a real
distinction: a current free follower has not lost anything, so telling them
"you're not a patron any more" would be a false sentence about a state they
were never in.
"""
by_status = has_paid_access(platform, status, is_free_member=False)
if by_status is None:
return None
if not by_status:
return GATED_LAPSED
return GATED_FREE if is_free_member else GATED_TIER
async def gated_reasons_for_sources(
session: AsyncSession, sources: list[Source], *, now: datetime | None = None,
) -> dict[int, str]:
"""The reason word for each of these sources, where the roster has one.
Callers pass ONLY the sources already known to be tier-gated: the question
"why can't I see these posts" is meaningless for a source whose posts are
all visible, and asking it anyway would put roster data on rows that have
no gated state for it to annotate.
Sources with no entry in the result get A3's bare count, which is the
correct degraded rendering for all three of: platform never swept, roster
stale, campaign not in the roster.
"""
if not sources:
return {}
platforms = {s.platform for s in sources}
# Per platform, because freshness is per platform: a working Patreon sweep
# must not lend its credibility to a SubscribeStar roster that has never
# run. Same gate as C4's `tracked_not_subscribed`, for the same reason.
fresh = {
p for p in platforms
if roster_is_fresh(await get_sync_state(session, p), now=now)
}
if not fresh:
return {}
memberships = (await session.execute(
select(PlatformMembership).where(
PlatformMembership.platform.in_(sorted(fresh))
)
)).scalars().all()
pairs = pair_sources_with_memberships(
[s for s in sources if s.platform in fresh], memberships,
)
reasons: dict[int, str] = {}
for source_id, (m, _kind) in pairs.items():
reason = gated_reason(
m.platform, m.status,
is_free_member=bool((m.details or {}).get("is_free_member")),
)
if reason is not None:
reasons[source_id] = reason
return reasons
async def source_for_membership(
session: AsyncSession, membership: PlatformMembership,
) -> Source | None:
"""The source FC already tracks for this membership, if there is one.
Scoped to the membership's own platform, so a creator tracked on Discord and
subscribed to on Patreon does not read as already-tracked — that pairing is
E4's suggestion to make, not an identity.
"""
rows = (await session.execute(
select(Source).where(Source.platform == membership.platform)
)).scalars().all()
for source in rows:
if match_kind(source, membership):
return source
return None
-52
View File
@@ -35,58 +35,6 @@ DEFAULT_SIM_THRESHOLD = 0.85
_FIGURE_KINDS = ("face", "figure")
# How many cosine scores to hold in memory at once, per matmul block.
# 4M float32 is 16 MB — small enough to stay in cache-friendly territory on the
# shared ml lane, large enough that the per-call overhead stops mattering.
_MAX_SCORE_ELEMS = 4_000_000
def char_maxima(q_by_image, allref, seg, np, *, max_elems=_MAX_SCORE_ELEMS):
"""(n_images, n_chars) — each image's best cosine to each character.
`q_by_image` is one L2-normalised `(n_figures, dim)` array per image, in
the order the answer comes back in. `allref` is every character's
prototypes stacked, and `seg` their per-character start offsets into it.
## Why this is batched, and why that is safe
`scheduled_ccip_auto_apply` did this one image at a time — a `(nq, dim) @
(dim, total)` product per image, over every image in the library on every
run. At ~119k images that is 119k separate matmuls, each too small to pay
for its own BLAS setup, and on 2026-09-23 the daily sweep hit its 1800s
soft limit on the operator's instance.
Batching changes no arithmetic. The score a character gets for an image is
a max over that image's figures AND over that character's prototypes, and
max does not care in what order or grouping it is taken — so reducing the
prototype axis first (per row, inside a block) and the figure axis after
(per image, across blocks) gives exactly what the per-image loop gave.
That equivalence is what `test_char_maxima_matches_the_per_image_loop`
pins, against the naive form written out longhand.
Blocked by ROWS rather than done in one product, because the full score
matrix is (all figures in the chunk x every prototype) and that grows with
the library on both axes. The block bound is on elements, so the memory
this uses stays flat as either axis grows.
"""
counts = [len(q) for q in q_by_image]
rows = np.vstack(q_by_image)
total = max(int(allref.shape[0]), 1)
block = max(1, max_elems // total)
per_row = np.empty((rows.shape[0], len(seg)), dtype=np.float32)
for a in range(0, rows.shape[0], block):
scores = rows[a:a + block] @ allref.T
per_row[a:a + block] = np.maximum.reduceat(scores, seg, axis=1)
# Start offset of each image's rows. Every image has at least one figure —
# it is in `q_by_image` because a region produced it — so these strictly
# increase, which is what `reduceat` needs to reduce rather than pass a row
# through untouched.
starts = np.cumsum([0] + counts[:-1])
return np.maximum.reduceat(per_row, starts, axis=0)
async def _settings_threshold(session: AsyncSession) -> float:
val = (
await session.execute(
+3 -12
View File
@@ -11,21 +11,12 @@ from pathlib import Path
import numpy as np
from PIL import Image, ImageFile
from ..worker_lanes import LANES_BY_NAME
ImageFile.LOAD_TRUNCATED_IMAGES = True
# Cap torch's intra-op threads so each ml-worker replica is a bounded core
# consumer on a shared node (torch otherwise uses all cores).
#
# Read from the lane rather than restated here. This was a literal 4 beside a
# comment reading "keep N_replicas x this within the cores allotted to ML" —
# a constraint written where nothing could act on it, and nothing did: the ML
# ceiling came from memory alone, offered the operator ~49 slots on a
# large-memory host, and the lane spent 2026-09-23 with ~200 torch threads on
# it. `derived_ceiling` now divides the cores by this number, which only means
# anything while the two are the same number.
_INTRA_OP_THREADS = LANES_BY_NAME["ml"].threads_per_slot
# consumer on a shared node (torch otherwise uses all cores). Keep
# N_replicas × this within the cores allotted to ML to avoid oversubscription.
_INTRA_OP_THREADS = 4
DEFAULT_MODEL_NAME = os.environ.get(
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
@@ -211,59 +211,6 @@ class PostRecordOutcome:
body_chars: int
# -- membership roster seam (shared dataclass, #387 C2/C7) -----------------
@dataclass
class Membership:
"""One membership the ACCOUNT holds, as the roster needs it (#387 C2).
Lives HERE rather than in the platform module that first produced it, for
the same reason `PostRecordOutcome` does: it is the seam's contract, not
Patreon's. C7 moved it — while it sat in `patreon_client` a second platform
would have had to import its contract from the first platform's module,
which inverts the dependency and is how a "portable" seam quietly becomes
Patreon-shaped.
Deliberately not a raw upstream row: the sweep should not have to know that
a tier lives behind a JSON:API `reward` relationship, and
`platform_membership` should not gain columns because one platform shapes
things a certain way.
`status` carries the PLATFORM's own word, verbatim and unmapped
(`active_patron`, `former_patron`, ...). Deciding what it means is the read
site's job — `has_paid_access`, below — precisely so an
unrecognised word records as evidence rather than as a decision.
`is_free_member` is SEPARATE from status and must stay that way. Patreon
expresses a free follow as this boolean rather than as a status value, so
"does the account pay for this" is `status == "active_patron" and not
is_free_member` — a question the status string alone cannot answer. NOTE:
the C0 capture contains no ACTIVE free member, so the two fields are
perfectly correlated in that sample; the separation is what the schema
says, not something the sample proves.
A platform that lacks a field supplies the empty answer, never a guess:
no tiers -> `[]`, no pledge -> `amount_cents=None` (absent stays
distinguishable from zero — "free" and "we don't know" are different
answers), no vanity -> None and identity falls back to the URL tail.
"""
campaign_id: str
display_name: str | None
url: str | None
vanity: str | None
status: str | None
is_free_member: bool
tier_names: list[str]
amount_cents: int | None
currency: str | None
# Everything the roster did not model, kept so a later question can be
# answered without another authenticated round-trip. Scoped to the
# membership's own attributes plus the creator's — never the raw page,
# which is where the card/address resources live.
details: dict
# -- base downloader (shared fetch/validate plumbing) ----------------------
class BaseNativeDownloader:
@@ -396,81 +343,3 @@ class BaseNativeDownloader:
sidecar_path = media_path.with_suffix(".json")
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
# --- membership status vocabulary (#387) ------------------------------------
#
# Lives here, beside `Membership`, rather than in `membership_roster`. It is
# pure platform knowledge with no database behind it, and the platform clients
# need it too. Patreon's must tell a lapsed membership to a deleted creator
# (skippable) from a paid one it cannot attribute (drift), and a client may not
# import `membership_roster`: test_gated_reason.py forbids any fetch path from
# reaching the roster, so the roster can explain a skip but never cause one.
#
# Platform word -> whether the account currently has paid access.
#
# Every entry here must come from a CHARACTERISED response, never from API docs
# or a plausible guess — project rule 130, and inventing a status before seeing
# it in a real payload is exactly the failure it names.
#
# patreon: from a live capture of the operator's own session, 2026-09-10
# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and
# only those two are here.
#
# `declined_patron` is deliberately ABSENT even though it looks obviously
# right. It appears in the request's `filter[membership_type]`, and the capture
# proved that filter is NOT the same vocabulary as the attribute — a row
# selected by the filter as `free_member` came back with
# `patron_status: former_patron`, a word the filter does not contain. Reading
# the filter as an enum is the specific mistake the capture caught; adding
# `declined_patron` on the strength of it would be repeating that mistake one
# step later.
#
# Unknown words are NOT an error: an unrecognised status means the roster
# records evidence it cannot yet interpret, which is a better state than
# dropping the row or asserting a meaning for it.
#
# subscribestar: from a live capture of the account's /subscriptions page,
# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word —
# a membership's state is which of two tables it sits in — so the "word" stored
# is the table card's own `data-identifier`, verbatim. Those two identifiers are
# the whole vocabulary; there is nothing further to characterise later.
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {
"patreon": {
"active_patron": True,
"former_patron": False,
},
"subscribestar": {
"active_subscriptions": True,
"cancelled_subscriptions": False,
},
}
def has_paid_access(
platform: str, status: str | None, *, is_free_member: bool = False,
) -> bool | None:
"""Does this membership mean the account currently PAYS for access?
Returns None for a status this code has not been taught, which callers must
treat as "unknown" rather than as False. The difference matters: False says
the operator has lost access, and asserting that from an unrecognised word
would tell them to cancel a source they are still paying for.
`is_free_member` is a second axis, not a status, and that is Patreon's
design rather than ours: the capture shows a free follow expressed as a
boolean alongside `patron_status`, so a "current" membership can still be
one nobody is paying for. Taking status alone would report a free follower
as a paying patron, and C4 would then never offer to clean it up.
(Honest limit: the capture contains no ACTIVE free member, so it cannot
demonstrate the two axes coming apart. The separation is what the payload's
shape says; the sample only shows it is possible, not that it happens.)
"""
if status is None:
return None
known = MEMBERSHIP_STATUS.get(platform, {}).get(status)
if known is None:
return None
if not known:
return False
return not is_free_member
+21 -260
View File
@@ -14,18 +14,6 @@ the later step can drive it:
- extract_media(post, included_index) → list[MediaItem]
- parse_cursor_from_url(url) → cursor
Milestone 387 added a SECOND read path on the same session: the membership
roster — what the ACCOUNT subscribes to, as opposed to what one creator has
posted.
- iter_memberships(user_id) → Iterator[Membership]
- current_user_id() → str
It is an OPTIONAL seam by construction, probed with
`getattr(client, "iter_memberships", None)` exactly as `post_is_gated` already
is. A client that does not implement it (Discord, HentaiFoundry) makes the
whole feature invisible for that platform — no flag, no config row, no
"unsupported" branch to keep alive.
Drift detection is loud on purpose: Patreon ships JSON:API and the shapes we
depend on (top-level `data`, media resources carrying `file_name`/`url`) are
the contract. If a response comes back as an HTML login page or a media
@@ -53,12 +41,10 @@ from ..utils.paths import filehash_from_url
from ..utils.prosemirror import post_body_html
from .native_ingest_common import (
_MAX_429_RETRIES,
Membership,
NativeAuthError,
NativeDriftError,
NativeIngestError,
basename_from_url,
has_paid_access,
make_session,
retry_after_seconds,
)
@@ -66,34 +52,8 @@ from .native_ingest_common import (
log = logging.getLogger(__name__)
_POSTS_URL = "https://www.patreon.com/api/posts"
_MEMBERS_URL = "https://www.patreon.com/api/members"
_CURRENT_USER_URL = "https://www.patreon.com/api/current_user"
_TIMEOUT_SECONDS = 30.0
# --- membership roster contract (#387 C2) ---------------------------------
# Characterized from a real capture of the operator's own session — Scribe note
# #3886. NOT from Patreon's public v2 API, which is the CREATOR api behind
# OAuth scopes and a different surface entirely (project rule 130).
#
# DELIBERATELY MINIMAL, and that is a privacy decision rather than a
# performance one. The web app's own include set pulls `latest_pledge.card`
# and `address`; the card resources come back carrying the ACCOUNT HOLDER'S
# EMAIL in `merchant_name`. Copying the browser's query string wholesale — the
# obvious move — would have FC fetching payment PII it has no use for and can
# only mishandle. We ask for the creator and the tier, and nothing else.
_MEMBERS_INCLUDE = "campaign,reward"
_FIELDS_MEMBER = (
"patron_status,is_free_member,is_gifted,pledge_amount_cents,currency,"
"pledge_cadence,next_charge_date,access_expires_at"
)
_FIELDS_MEMBERS_CAMPAIGN = "name,url,vanity,is_active"
_FIELDS_REWARD = "title"
# The browser sends 1000. Whether a server-side ceiling applies below that is
# untested (note #3886, open question 4), so page conservatively: a wrong guess
# costs one extra request, and the paging loop is driven by meta.pagination
# rather than by this number.
_MEMBERS_PAGE_COUNT = 200
# JSON:API request contract (observed from real traffic — see module plan).
_INCLUDE = (
"campaign,access_rules,attachments,attachments_media,audio,images,media,"
@@ -222,27 +182,20 @@ class PatreonClient:
params["page[cursor]"] = cursor
return params
def _request(self, url: str, params: dict[str, str], *, what: str, scope: str) -> dict:
"""One paced, retried, error-classified GET returning parsed JSON.
Extracted from `_fetch` so the membership endpoint (#387 C2) rides the
SAME request path rather than growing a second copy of the 429 backoff,
the auth-vs-drift classification and the Retry-After plumbing. Two
copies of this would drift, and the half that drifted would be the one
that only runs once a day.
`what` / `scope` only shape the messages ("posts"/"campaign_id=123"),
so a failure still says which call failed and against what.
"""
def _fetch(self, campaign_id: str, cursor: str | None) -> dict:
if self._request_sleep > 0:
time.sleep(self._request_sleep) # pace the API endpoint
attempt = 0
while True:
try:
resp = self._session.get(url, params=params, timeout=_TIMEOUT_SECONDS)
resp = self._session.get(
_POSTS_URL,
params=self._params(campaign_id, cursor),
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise PatreonAPIError(
f"Patreon {what} request failed ({scope}): {exc}"
f"Patreon posts request failed (campaign_id={campaign_id}): {exc}"
) from exc
# Transient rate-limit: back off and retry rather than failing the
@@ -252,8 +205,8 @@ class PatreonClient:
attempt += 1
delay = retry_after_seconds(resp, attempt)
log.warning(
"Patreon 429 (%s) — backing off %.1fs (retry %d/%d)",
scope, delay, attempt, self._max_retries,
"Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)",
campaign_id, delay, attempt, self._max_retries,
)
time.sleep(delay)
continue
@@ -263,8 +216,9 @@ class PatreonClient:
# Auth rejected — expired/missing cookies or an insufficient tier.
# Actionable as "rotate credentials", so it's auth, not drift/http.
raise PatreonAuthError(
f"Patreon {what} API returned HTTP {resp.status_code} — auth "
f"rejected (cookies expired or tier insufficient; {scope})",
f"Patreon posts API returned HTTP {resp.status_code} — auth "
f"rejected (cookies expired or tier insufficient; "
f"campaign_id={campaign_id})",
status_code=resp.status_code,
)
if resp.status_code != 200:
@@ -280,27 +234,24 @@ class PatreonClient:
except (TypeError, ValueError):
retry_after = None
raise PatreonAPIError(
f"Patreon {what} API returned HTTP {resp.status_code} ({scope})",
f"Patreon posts API returned HTTP {resp.status_code} "
f"(campaign_id={campaign_id})",
status_code=resp.status_code,
retry_after=retry_after,
)
try:
return resp.json()
payload = resp.json()
except ValueError as exc:
# A non-JSON body here is almost always the HTML login/challenge
# page served when cookies are missing/expired — that is an AUTH
# failure (rotate cookies), not API drift (update the ingester) and
# not a transient network error.
raise PatreonAuthError(
f"Patreon {what} API returned a non-JSON response (likely an "
f"HTML login/challenge page — session expired; {scope}): {exc}"
"Patreon posts API returned a non-JSON response (likely an "
f"HTML login/challenge page — session expired; "
f"campaign_id={campaign_id}): {exc}"
) from exc
def _fetch(self, campaign_id: str, cursor: str | None) -> dict:
return self._request(
_POSTS_URL, self._params(campaign_id, cursor),
what="posts", scope=f"campaign_id={campaign_id}",
)
return payload
# -- parsing -----------------------------------------------------------
@@ -483,14 +434,8 @@ class PatreonClient:
@staticmethod
def post_meta(post: dict) -> dict:
"""Title + published date for a post. Part of the client contract.
Written for a preview sample (plan #708 B4) whose caller has since gone;
as of 2026-09-23 its consumer is the core's REVISIT WINDOW, which needs
a post's date to know whether a tick is still inside it. Both native
clients answer in the same shape — an ISO-8601 string under `date`, or
None — so the core reads a date without knowing the platform.
"""
"""Title + published date for a post — for the preview sample (plan #708
B4). Part of the client contract `ingest_core.Ingester.preview` calls."""
attrs = post.get("attributes") or {}
title = attrs.get("title")
published = attrs.get("published_at")
@@ -565,190 +510,6 @@ class PatreonClient:
return
current_cursor = next_cursor
# -- membership roster (#387 C2) ---------------------------------------
def current_user_id(self) -> str:
"""The signed-in account's own numeric user id.
Needed because `/api/members` is filtered by `filter[user_id]` — the
endpoint answers "who are the members of X", and the account asking
about ITSELF still has to say so.
INFERRED, NOT CHARACTERIZED. C0 captured `/api/members`, not this; what
is relied on here is only the JSON:API envelope (`data.id`), which this
same API demonstrably uses everywhere else. If that inference is wrong
it raises drift rather than returning something plausible — which is
the right failure, because the alternative is a confidently empty
roster and an empty roster means "cancel everything" to C4.
"""
payload = self._request(
_CURRENT_USER_URL, {"json-api-version": "1.0"},
what="current_user", scope="self",
)
data = (payload or {}).get("data")
if not isinstance(data, dict) or not data.get("id"):
raise PatreonDriftError(
"Patreon current_user response had no data.id — cannot scope "
"the membership roster to this account"
)
return str(data["id"])
def _members_params(self, user_id: str | None, offset: int) -> dict[str, str]:
params = {
"include": _MEMBERS_INCLUDE,
"fields[member]": _FIELDS_MEMBER,
"fields[campaign]": _FIELDS_MEMBERS_CAMPAIGN,
"fields[reward]": _FIELDS_REWARD,
"page[offset]": str(offset),
"page[count]": str(_MEMBERS_PAGE_COUNT),
"json-api-version": "1.0",
"json-api-use-default-includes": "false",
}
if user_id:
params["filter[user_id]"] = user_id
# NOTE: `filter[membership_type]` is deliberately NOT sent. The browser
# sends the six values its settings page wants to show, and the capture
# proves that list is NOT the same vocabulary as the `patron_status`
# attribute — a row selected as `free_member` came back with
# `patron_status: former_patron`, a word absent from the filter. Sending
# no filter asks for everything the endpoint will give, which is what a
# roster wants: a membership that DISAPPEARS is the signal C4 reads, and
# a filter tuned for a UI that hides lapses would manufacture exactly
# that disappearance. (Note #3886, open question 1.)
return params
@staticmethod
def _validate_members_response(response: dict) -> None:
"""Drift checks specific to the roster.
Stricter than the posts path about pagination on purpose: `iter_posts`
can treat a missing `links.next` as "that was the last page", but here
a missing total is indistinguishable from a truncated page — and a
roster that silently stops half way reads downstream as "you cancelled
those", which is the worst wrong answer this feature can give.
"""
PatreonClient._validate_response(response)
meta = response.get("meta")
if not isinstance(meta, dict):
raise PatreonDriftError("Patreon members response missing 'meta'")
pagination = meta.get("pagination")
if not isinstance(pagination, dict) or "total" not in pagination:
raise PatreonDriftError(
"Patreon members response missing meta.pagination.total — "
"cannot tell a complete roster from a truncated one"
)
def _membership(self, member: dict, index: dict) -> Membership | None:
"""One member row as a Membership, or None for a row the roster can skip.
The one skippable row is a LAPSED membership whose creator no longer
exists. The live roster (note #3886, CORRECTION 3) returned 104 rows,
because FC sends no membership-type filter and so gets lapses going back
years. One of them, a membership that ended in 2017, carried no
`campaign` relationship at all: the key is absent, not null, and its
reward names no campaign either. The creator's page is gone.
Raising on that row made the whole roster unusable over one membership
nobody can act on. Skipping it changes no conclusion. A lapsed
membership already means "not paying", absence means the same, and no
Source can be matched to a campaign that no longer has an id.
The refusal stays for every other row. An active or unrecognised
membership without a creator is something FC cannot vouch for, and
dropping it would read downstream as a cancellation.
"""
attrs = member.get("attributes") or {}
if "patron_status" not in attrs:
raise PatreonDriftError(
"Patreon member resource has no patron_status attribute"
)
campaign_ids = self._related_ids(member, "campaign")
if not campaign_ids:
paid = has_paid_access(
"patreon", attrs.get("patron_status"),
is_free_member=bool(attrs.get("is_free_member")),
)
if paid is False:
log.info(
"Patreon roster: skipping a lapsed membership with no campaign "
"(creator deleted); status=%s access_expires_at=%s",
attrs.get("patron_status"), attrs.get("access_expires_at"),
)
return None
raise PatreonDriftError(
"Patreon member resource has no campaign relationship — a "
"membership we cannot attribute to a creator is not usable"
)
campaign_id = campaign_ids[0]
campaign = index.get(("campaign", campaign_id)) or {}
# A member has at most one reward, and `reward.data` is legitimately
# null — an active patron with no tier. Absence is a fact about the
# membership, not a parse failure.
tier_names: list[str] = []
for reward_id in self._related_ids(member, "reward"):
title = (index.get(("reward", reward_id)) or {}).get("title")
if title:
tier_names.append(str(title))
return Membership(
campaign_id=campaign_id,
display_name=campaign.get("name"),
url=campaign.get("url"),
vanity=campaign.get("vanity"),
status=attrs.get("patron_status"),
# Default False, not None: the attribute is always present in the
# capture, and treating a missing one as "free" would understate
# access rather than overstate it.
is_free_member=bool(attrs.get("is_free_member")),
tier_names=tier_names,
# The MEMBER's amount, never the reward's. `reward.amount_cents` is
# the creator's list price in the CREATOR's currency (the capture
# has CAD, DKK and EUR rewards sitting on USD pledges), so reading
# it would report a number the operator has never been charged.
amount_cents=attrs.get("pledge_amount_cents"),
currency=attrs.get("currency"),
details={"member": attrs, "campaign": campaign},
)
def iter_memberships(self, user_id: str | None = None) -> Iterator[Membership]:
"""Yield every membership the account holds.
Pages on `page[offset]`/`page[count]` against `meta.pagination.total` —
NOT on `links`. The response's own `links.first` is built without the
`/api/` prefix the request uses, so following it verbatim would hit the
web page instead of the API (note #3886).
`user_id` omitted means the `filter[user_id]` parameter is omitted.
Whether the endpoint then defaults to self is UNTESTED — pass
`current_user_id()` unless you are deliberately probing that.
"""
user_id = user_id or None
offset = 0
seen = 0
while True:
response = self._request(
_MEMBERS_URL, self._members_params(user_id, offset),
what="members", scope="membership roster",
)
self._validate_members_response(response)
index = self._transform(response)
rows = [m for m in (response.get("data") or []) if isinstance(m, dict)]
for member in rows:
membership = self._membership(member, index)
if membership is not None:
yield membership
seen += len(rows)
total = int(response["meta"]["pagination"]["total"] or 0)
# An empty page terminates regardless of what `total` claims. Trusting
# the total alone would spin forever against a server that reports
# more rows than it will hand over.
if not rows or seen >= total:
return
offset += len(rows)
# -- detail (full body enrichment) -------------------------------------
def fetch_post_detail_content(self, post_id: str) -> str | None:
+4 -32
View File
@@ -340,7 +340,7 @@ class PatreonDownloader(BaseNativeDownloader):
def _write_sidecar_data(
self, post: dict, sidecar_path: Path, *, source_url: str | None = None,
minimal: bool = False, detail_fetch: bool = True,
minimal: bool = False,
) -> Path:
"""Serialize the post's metadata to `sidecar_path`. The post-only record
(`write_post_record`) writes the FULL post (body/title/date/url); the
@@ -364,13 +364,7 @@ class PatreonDownloader(BaseNativeDownloader):
# dict — so a multi-image post fetches detail at most once, the post-record
# body-length read reuses it, and a fully-seen post (no fresh download → no
# sidecar write) never pays the extra GET.
# `detail_fetch=False` on a REVISIT (a post inside the tick's revisit
# window that we already captured): re-read the body from the feed
# response we are holding and pay nothing. Without this a 30-day window
# would buy one detail GET per body-less post per tick, forever — a
# per-creator cost that grows with how prolific they are, to re-fetch a
# body we already stored.
if (not content or not content.strip()) and self._content_fetcher and detail_fetch:
if (not content or not content.strip()) and self._content_fetcher:
fetched = self._content_fetcher(str(post.get("id") or ""))
if fetched:
content = fetched
@@ -392,9 +386,7 @@ class PatreonDownloader(BaseNativeDownloader):
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
def write_post_record(
self, post: dict, artist_slug: str, *, revisit: bool = False,
) -> PostRecordOutcome:
def write_post_record(self, post: dict, artist_slug: str) -> PostRecordOutcome:
"""Write a post-ONLY sidecar (no media file) for a media-less post, so
the importer can still upsert the Post + its body — text posts often hold
the only copy of an external <a href> link. Named `_post.json`: the
@@ -405,18 +397,6 @@ class PatreonDownloader(BaseNativeDownloader):
Returns a PostRecordOutcome (path None when the post has no id) carrying
the captured body's shape — post_type + final char count — so the engine
can log per-post handling without re-reading the post itself.
`revisit=True` is the tick re-reading a post it already captured
(ingest_core's revisit window, #...). Two differences, both about not
making an update cost more than it is worth:
* no detail-fetch — the body comes from the feed response already in
hand, so a revisit costs zero requests;
* a body that comes back empty writes NOTHING and returns `path=None`.
On a first capture an empty body is the truth about the post; on a
revisit it usually just means this post's body only ever came from
the detail endpoint we just declined to call, and writing it would
blank a stored body to say something we never learned.
"""
attrs = post.get("attributes") or {}
title = attrs.get("title") if isinstance(attrs.get("title"), str) else None
@@ -426,17 +406,9 @@ class PatreonDownloader(BaseNativeDownloader):
return PostRecordOutcome(
path=None, post_type=post_type, title=title, body_chars=0,
)
if revisit:
feed_body = post_body_html(attrs)
if not (isinstance(feed_body, str) and feed_body.strip()):
return PostRecordOutcome(
path=None, post_type=post_type, title=title, body_chars=0,
)
post_dir = self.images_root / artist_slug / "patreon" / post_dir_name(post)
post_dir.mkdir(parents=True, exist_ok=True)
path = self._write_sidecar_data(
post, post_dir / "_post.json", detail_fetch=not revisit,
)
path = self._write_sidecar_data(post, post_dir / "_post.json")
# _write_sidecar_data has by now memoized any detail-fetched body onto
# post["attributes"]["content"], so re-read it for the FINAL char count.
body = (post.get("attributes") or {}).get("content")
+579
View File
@@ -0,0 +1,579 @@
"""Native Pixiv client — the Pixiv adapter's read path.
Pixiv has a real (if unofficial) API: the mobile app API gallery-dl drives
(`PixivAppAPI`). Per the downloader ground rule — gallery-dl is the
known-working base — this client mirrors gallery-dl 1.32.5's request profile
EXACTLY: the same iOS app headers on every request, the same OAuth
refresh-token dance against oauth.secure.pixiv.net (X-Client-Time +
X-Client-Hash), and the same `/v1/user/illusts` walk paginated by `next_url`.
Deviating from that profile is how the SubscribeStar/Patreon spikes broke, so
any change here should be diffed against gallery-dl's extractor first.
Feed shape (characterized from gallery-dl 1.32.5, extractor/pixiv.py):
- `GET /v1/user/illusts?user_id=<id>` returns `{"illusts": [work...],
"next_url": "https://app-api...?user_id=..&offset=30" | null}`.
- Pagination: re-issue the SAME endpoint with `next_url`'s query params. The
query string doubles as our resumable page cursor (re-fetching it re-serves
the same page — the ingest-core resume contract).
- A work carries id/title/type(illust|manga|ugoira)/caption(HTML)/
create_date(ISO+09:00)/tags[{name,translated_name}]/user/page_count/
x_restrict/series/total_view/total_bookmarks/meta_single_page/meta_pages.
- Files: multi-page → meta_pages[].image_urls.original; single page →
meta_single_page.original_image_url; ugoira → `/v1/ugoira/metadata` zip
(600x600 → 1920x1080 URL swap, gallery-dl's default non-original mode).
`campaign_id` for Pixiv is the numeric user id (extracted from the source URL
by `user_id_from_url` — no network resolver needed).
Gated works: pixiv serves a `https://s.pximg.net/common/images/limit_*.png`
placeholder as the "original" when a work is blocked for this account
(sanity-level filter, my-pixiv lock, deleted). gallery-dl's fallback for those
is a web-AJAX scrape that needs PHPSESSID browser cookies — FC stores only the
OAuth refresh token, so (exactly like our previous gallery-dl configuration,
which warned "No PHPSESSID cookie set") those works are skipped, via the
post_is_gated seam. Auth failures are loud (rotate the refresh token); a
response missing the fields we depend on is DRIFT (update this client).
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
"""
from __future__ import annotations
import hashlib
import logging
import time
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from urllib.parse import parse_qsl, urlsplit
import requests
from ..utils.paths import safe_ext
from .native_ingest_common import (
_MAX_429_RETRIES,
NativeAuthError,
NativeDriftError,
NativeIngestError,
make_session,
retry_after_seconds,
)
log = logging.getLogger(__name__)
_TIMEOUT_SECONDS = 30.0
_API_ROOT = "https://app-api.pixiv.net"
_OAUTH_URL = "https://oauth.secure.pixiv.net/auth/token"
# gallery-dl's public Pixiv-app credentials (PixivAppAPI, also pixivpy's) —
# these identify the official iOS app to the API, NOT the operator; the
# operator's identity is the OAuth refresh token.
_CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT"
_CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj"
_HASH_SECRET = (
"28c1fdd170a5204386cb1313c7077b34"
"f83e4aaf4aa829ce78c231e05b0bae2c"
)
# The exact header set gallery-dl 1.32.5 installs on its session — the proven
# app-API request profile. The Referer also unlocks i.pximg.net media GETs
# (403 without it), so the downloader reuses this constant.
PIXIV_APP_HEADERS = {
"App-OS": "ios",
"App-OS-Version": "16.7.2",
"App-Version": "7.19.1",
"User-Agent": "PixivIOSApp/7.19.1 (iOS 16.7.2; iPhone12,8)",
"Referer": "https://app-api.pixiv.net/",
}
# Placeholder image prefix pixiv serves instead of a blocked work's original
# (limit_sanity_level / limit_mypixiv / limit_unknown variants).
_LIMIT_URL = "https://s.pximg.net/common/images/limit_"
# The app API reports rate-limiting as an error MESSAGE (often on HTTP 403),
# not only as HTTP 429. gallery-dl sleeps 300s in-walk; sleeping that long
# inside our time-boxed chunk would eat the whole budget, so we surface it as
# a typed 429 and let download_service's cooldown machinery honor the wait.
_RATE_LIMIT_RETRY_AFTER = 300.0
_TITLE_MAX = 50 # gallery-dl pixiv filename template: {title[:50]}
_RATINGS = {0: "General", 1: "R-18", 2: "R-18G"}
class PixivAPIError(NativeIngestError):
"""Base for native Pixiv client failures. status_code / retry_after are
inherited from NativeIngestError."""
class PixivAuthError(PixivAPIError, NativeAuthError):
"""Auth failure — missing/expired/revoked OAuth refresh token. Fix =
rotate the credential (Settings → Credentials → Pixiv), not update the
client. Maps to error_type 'auth_error'."""
class PixivDriftError(PixivAPIError, NativeDriftError):
"""A response did not match the shape this client depends on (missing
`illusts`, un-parseable JSON where JSON was promised). Fail loud so the
run flags 'the Pixiv app API changed' instead of silently importing
nothing. Maps to API_DRIFT."""
@dataclass
class MediaItem:
"""One resolved downloadable file belonging to a Pixiv work.
Fields mirror the other native clients' MediaItem so the downloader and
ledger are structurally the same. Pixiv original URLs carry no content
hash, so `filehash` is always None and the ledger keys on
`<post_id>:<media_id>` where media_id is `p<num>` (page) or `ugoira`
(the frame zip) — stable across URL-shape drift.
"""
url: str
filename: str
kind: str
filehash: str | None
post_id: str
media_id: str
def user_id_from_url(url: str) -> str | None:
"""The numeric pixiv user id from a source URL, or None.
Handles the modern forms FC accepts as sources
(https://www.pixiv.net/users/<id>, /en/users/<id>) plus the legacy
member.php?id=<id>. This IS the campaign id — no network resolver.
"""
parts = urlsplit(url or "")
if "pixiv.net" not in parts.netloc:
return None
segs = [s for s in parts.path.split("/") if s]
if segs and segs[0] == "en":
segs = segs[1:]
if len(segs) >= 2 and segs[0] == "users" and segs[1].isdigit():
return segs[1]
if segs and segs[0] == "member.php":
qid = dict(parse_qsl(parts.query)).get("id", "")
if qid.isdigit():
return qid
return None
def _work_filename(work: dict, num: int, url: str) -> str:
"""gallery-dl layout parity: `{id}_{title[:50]}_{num:>02}.{extension}`
(the downloader sanitizes the final segment)."""
title = work.get("title")
title50 = (title if isinstance(title, str) else "")[:_TITLE_MAX]
ext = safe_ext(urlsplit(url).path.rsplit("/", 1)[-1])
return f"{work.get('id')}_{title50}_{num:02d}{ext}"
class PixivClient:
"""Synchronous Pixiv app-API read client. Construct with the operator's
OAuth refresh token (the same token-type Credential the gallery-dl path
consumed as `extractor.pixiv.refresh-token`)."""
def __init__(
self,
refresh_token: str | None,
*,
request_sleep: float = 0.0,
max_retries: int = _MAX_429_RETRIES,
session: requests.Session | None = None,
):
self.refresh_token = refresh_token
self._request_sleep = request_sleep or 0.0
self._max_retries = max_retries
# No cookies — the app API authenticates via the Bearer token _login
# installs. make_session still supplies the retry/UA plumbing; the
# extra_headers overwrite its browser UA with the app profile.
self._session = (
session if session is not None
else make_session(None, extra_headers=PIXIV_APP_HEADERS)
)
self._authed_user: dict = {}
# Monotonic deadline after which the access token must be refreshed;
# 0 forces a refresh on first use.
self._token_deadline = 0.0
# -- auth ----------------------------------------------------------------
def _login(self) -> None:
"""Exchange the refresh token for a Bearer access token (gallery-dl's
`_login_impl`, including the X-Client-Time/X-Client-Hash pair the
endpoint validates). No-op while the current token is still fresh."""
if time.monotonic() < self._token_deadline:
return
if not self.refresh_token:
raise PixivAuthError(
"No Pixiv refresh token configured — add the OAuth refresh "
"token as the Pixiv credential (token type)."
)
# gallery-dl stamps naive-UTC with a literal +00:00 suffix.
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S+00:00")
headers = {
"X-Client-Time": now,
"X-Client-Hash": hashlib.md5(
(now + _HASH_SECRET).encode()
).hexdigest(),
}
data = {
"client_id": _CLIENT_ID,
"client_secret": _CLIENT_SECRET,
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
"get_secure_url": "1",
}
try:
resp = self._session.post(
_OAUTH_URL, data=data, headers=headers,
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise PixivAPIError(f"Pixiv OAuth request failed: {exc}") from exc
if resp.status_code >= 400:
raise PixivAuthError(
"Pixiv rejected the refresh token (HTTP "
f"{resp.status_code}) — rotate the Pixiv credential.",
status_code=resp.status_code,
)
try:
payload = resp.json()["response"]
access = payload["access_token"]
except (ValueError, KeyError, TypeError) as exc:
raise PixivDriftError(
f"Pixiv OAuth response shape changed: {exc}"
) from exc
self._authed_user = payload.get("user") or {}
self._session.headers["Authorization"] = f"Bearer {access}"
# expires_in is 3600 today; refresh 60s early so a long walk never
# rides an expiring token into a spurious 400.
expires_in = payload.get("expires_in")
lifetime = float(expires_in) if isinstance(expires_in, (int, float)) else 3600.0
self._token_deadline = time.monotonic() + max(60.0, lifetime - 60.0)
# -- request -------------------------------------------------------------
def _call(self, endpoint: str, params: dict) -> dict:
"""Authenticated app-API GET → parsed JSON body, with the shared 429
backoff and the loud auth/drift/rate-limit mapping."""
self._login()
if self._request_sleep > 0:
time.sleep(self._request_sleep)
url = _API_ROOT + endpoint
attempt = 0
while True:
try:
resp = self._session.get(
url, params=params, timeout=_TIMEOUT_SECONDS
)
except requests.RequestException as exc:
raise PixivAPIError(
f"Pixiv request failed ({endpoint}): {exc}"
) from exc
if resp.status_code == 429 and attempt < self._max_retries:
attempt += 1
delay = retry_after_seconds(resp, attempt)
log.warning(
"Pixiv 429 (%s) — backing off %.1fs (retry %d/%d)",
endpoint, delay, attempt, self._max_retries,
)
time.sleep(delay)
continue
break
try:
body = resp.json()
except ValueError as exc:
raise PixivDriftError(
f"Pixiv returned non-JSON for {endpoint} "
f"(HTTP {resp.status_code})"
) from exc
error = body.get("error") if isinstance(body, dict) else None
message = ""
if isinstance(error, dict):
message = str(
error.get("user_message") or error.get("message") or ""
)
# Rate limiting first: the app API reports it as an error MESSAGE
# (often on HTTP 403), which must not be mistaken for an auth failure.
if resp.status_code == 429 or "rate limit" in message.lower():
raise PixivAPIError(
f"Pixiv rate limit hit ({endpoint}): {message or 'HTTP 429'}",
status_code=429,
retry_after=_RATE_LIMIT_RETRY_AFTER,
)
if resp.status_code in (400, 401, 403):
# Invalid/expired access token surfaces as 400 invalid_grant-style
# errors on the app API; 401/403 are straight auth rejections.
raise PixivAuthError(
f"Pixiv rejected the request ({endpoint}, HTTP "
f"{resp.status_code}): {message or 'auth rejected'} — "
"rotate the Pixiv refresh token.",
status_code=resp.status_code,
)
if resp.status_code >= 400:
raise PixivAPIError(
f"Pixiv API error ({endpoint}, HTTP {resp.status_code}): "
f"{message or 'unknown error'}",
status_code=resp.status_code,
)
if error:
# HTTP 200 carrying an error object — unexpected, but never
# silently treat it as data.
raise PixivAPIError(
f"Pixiv API error ({endpoint}): {message or error}",
status_code=resp.status_code,
)
return body
# -- normalization -------------------------------------------------------
@staticmethod
def _normalize(work: dict) -> dict:
"""Wrap an app-API work in the `{"id", "attributes", ...}` post shape
the platform-agnostic core and shared helpers read. The raw work rides
along under `_work` for extract_media / the post record."""
title = work.get("title")
caption = work.get("caption")
wtype = work.get("type")
return {
"id": work.get("id"),
"attributes": {
"title": title if isinstance(title, str) else "",
"content": caption if isinstance(caption, str) else "",
"published_at": work.get("create_date"),
"post_type": wtype if isinstance(wtype, str) else "illust",
},
"_work": work,
}
# -- post-first seams ----------------------------------------------------
@staticmethod
def post_record_key(post: dict) -> tuple[str, str] | None:
"""`(ledger_key, post_id)` gating post-record capture through the seen
ledger (`post:<id>` synthetic key), or None when the work has no id."""
pid = post.get("id")
pid = str(pid) if pid is not None else ""
if not pid:
return None
return (f"post:{pid}", pid)
@staticmethod
def post_meta(post: dict) -> dict:
attrs = post.get("attributes") or {}
return {"title": attrs.get("title") or None, "date": attrs.get("published_at")}
@staticmethod
def post_is_gated(post: dict) -> bool:
"""True when this account cannot fetch the work's real files: pixiv
substitutes a `limit_*` placeholder for the original (sanity-level
filter / my-pixiv lock / deleted), or zeroes the author (deleted
account). Mirrors #874 semantics: gated content leaves NO trace — a
placeholder thumbnail and an empty stub would only pollute the
archive. (gallery-dl's PHPSESSID web-scrape fallback for these is out
of scope: FC holds no pixiv browser cookies — module docstring.)"""
work = post.get("_work") or {}
user = work.get("user") or {}
if not user.get("id"):
return True
if work.get("meta_pages"):
return False
single = work.get("meta_single_page") or {}
original = single.get("original_image_url")
return isinstance(original, str) and original.startswith(_LIMIT_URL)
# -- media ---------------------------------------------------------------
def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]:
"""Resolve a work's downloadable files (gallery-dl's `_extract_files`):
multi-page originals, the single-page original, or the ugoira frame
zip. `included_index` is unused (pixiv works are self-contained)."""
work = post.get("_work") or {}
pid = str(post.get("id") or "")
if not pid or self.post_is_gated(post):
return []
if work.get("type") == "ugoira":
return self._ugoira_media(work, pid)
meta_pages = work.get("meta_pages") or []
if meta_pages:
items = []
for num, page in enumerate(meta_pages):
urls = page.get("image_urls") or {}
url = urls.get("original")
if not isinstance(url, str) or not url:
continue
items.append(
MediaItem(
url=url,
filename=_work_filename(work, num, url),
kind="image",
filehash=None,
post_id=pid,
media_id=f"p{num}",
)
)
return items
single = work.get("meta_single_page") or {}
url = single.get("original_image_url")
if not isinstance(url, str) or not url or url.startswith(_LIMIT_URL):
return []
return [
MediaItem(
url=url,
filename=_work_filename(work, 0, url),
kind="image",
filehash=None,
post_id=pid,
media_id="p0",
)
]
def _ugoira_meta(self, work: dict, pid: str) -> dict | None:
"""Fetch + memoize the ugoira metadata (frames + zip urls) for a work.
Idempotent and cached on the work dict, so the post record and the
media extraction share ONE `/v1/ugoira/metadata` call regardless of
which runs first (the core writes the post record BEFORE it extracts
media). Returns None — and caches the miss — on a non-auth failure
(matching gallery-dl's downgrade); auth failures stay loud."""
if "_ugoira_meta" in work:
return work["_ugoira_meta"]
try:
body = self._call("/v1/ugoira/metadata", {"illust_id": pid})
meta = body["ugoira_metadata"]
except PixivAuthError:
raise
except (PixivAPIError, KeyError, TypeError) as exc:
log.warning("Pixiv ugoira metadata failed for %s: %s", pid, exc)
work["_ugoira_meta"] = None
return None
work["_ugoira_meta"] = meta
# Frame delays: a future ugoira→video conversion needs the timings (the
# zip alone has none), so the post record captures them.
work["_ugoira_frames"] = meta.get("frames") or []
return meta
def fetch_ugoira_frames(self, post: dict) -> None:
"""Populate `post['_work']['_ugoira_frames']` for an ugoira post (no-op
otherwise). The core writes the post record BEFORE extract_media, so
without this the frame timings would never reach the record; this
fetches (and memoizes, so extract_media reuses it) the metadata. Injected
into the downloader by the ingester, mirroring Patreon's content_fetcher.
Auth errors propagate; other failures leave frames unset."""
work = post.get("_work") or {}
if work.get("type") != "ugoira":
return
pid = str(post.get("id") or "")
if pid:
self._ugoira_meta(work, pid)
def _ugoira_media(self, work: dict, pid: str) -> list[MediaItem]:
"""The ugoira frame zip (gallery-dl's default non-original mode):
`/v1/ugoira/metadata` → zip_urls.medium with the 600x600→1920x1080
swap. A metadata failure downgrades to 'no media' with a warning
(matching gallery-dl) instead of failing the walk — except auth
failures, which stay loud."""
meta = self._ugoira_meta(work, pid)
if meta is None:
return []
try:
zip_url = meta["zip_urls"]["medium"]
except (KeyError, TypeError) as exc:
log.warning("Pixiv ugoira zip url missing for %s: %s", pid, exc)
return []
url = zip_url.replace("_ugoira600x600", "_ugoira1920x1080", 1)
return [
MediaItem(
url=url,
filename=_work_filename(work, 0, url),
kind="ugoira",
filehash=None,
post_id=pid,
media_id="ugoira",
)
]
# -- iteration -----------------------------------------------------------
def iter_posts(
self, campaign_id: str, cursor: str | None = None
) -> Iterator[tuple[dict, dict, str | None]]:
"""Yield (post, {}, page_cursor) for every work in the user's feed.
`campaign_id` is the numeric pixiv user id. `cursor` is the query
string of the app API's `next_url` (offset pagination); None fetches
page 1. The yielded `page_cursor` is the cursor that FETCHED this
work's page, so the core checkpoints a value that re-serves the same
page on resume (the shared cursor contract)."""
if not str(campaign_id or "").isdigit():
raise PixivDriftError(
f"Pixiv campaign id must be a numeric user id, got "
f"{campaign_id!r}"
)
current = cursor
while True:
page_cursor = current
if current is None:
params: dict = {"user_id": campaign_id}
else:
params = dict(parse_qsl(current))
data = self._call("/v1/user/illusts", params)
works = data.get("illusts")
if not isinstance(works, list):
raise PixivDriftError(
"Pixiv user-illusts response had no 'illusts' list "
f"(keys: {sorted(data)[:8]})"
)
for work in works:
if not isinstance(work, dict):
continue
yield self._normalize(work), {}, page_cursor
next_url = data.get("next_url")
if not next_url:
return
current = str(next_url).rpartition("?")[2]
# -- user detail ---------------------------------------------------------
def resolve_display_name(self, user_id: str) -> str | None:
"""The pixiv user's display name via `/v1/user/detail` (gallery-dl's
user_detail) — used to name the Artist when a source is added by numeric
id. None on any failure (the caller falls back to the id)."""
try:
body = self._call("/v1/user/detail", {"user_id": str(user_id)})
except PixivAPIError:
return None
name = (body.get("user") or {}).get("name") if isinstance(body, dict) else None
return name if isinstance(name, str) and name.strip() else None
# -- verify --------------------------------------------------------------
def verify_auth(self) -> tuple[bool | None, str]:
"""Cheap credential probe: run the OAuth refresh (the thing that fails
when the token is bad) without walking any feed."""
try:
self._token_deadline = 0.0 # force a real refresh
self._login()
except PixivAuthError as exc:
return False, f"Pixiv rejected the credential — {exc}"
except PixivAPIError as exc:
return None, f"Couldn't verify (network/HTTP issue): {exc}"
account = self._authed_user.get("account") or self._authed_user.get("name")
suffix = f" as {account}" if account else ""
return True, f"Credentials valid — Pixiv OAuth refresh succeeded{suffix}."
def rating_label(x_restrict) -> str | None:
"""Human rating from pixiv's x_restrict (0/1/2) — written into the post
record so the archive keeps the R-18 flag without the reader needing to
know pixiv's numeric scheme."""
if isinstance(x_restrict, bool) or not isinstance(x_restrict, int):
return None
return _RATINGS.get(x_restrict)
+276
View File
@@ -0,0 +1,276 @@
"""Native Pixiv media downloader — the Pixiv counterpart to
patreon_downloader / subscribestar_downloader.
Given a normalized Pixiv work and its resolved `MediaItem`s
(pixiv_client.extract_media), download the originals to gallery-dl's on-disk
layout (so pre-cutover gallery-dl downloads are recognized on disk and not
re-fetched), write the post-first sidecars the importer consumes, and report
per-media outcomes.
On-disk layout (matches FC's gallery-dl pixiv config, PLATFORM_DEFAULTS:
base-directory `<images_root>/<artist_slug>/pixiv` + `directory:
["{category}"]` + filename `{id}_{title[:50]}_{num:>02}.{extension}`):
<images_root>/<artist_slug>/pixiv/pixiv/<id>_<title50>_<NN>.<ext>
— note the intentional DOUBLE `pixiv` segment: gallery-dl appended
`{category}` under a base-directory that already ended in the platform name,
and tier-2 disk-skip parity requires reproducing that exactly. The layout is
FLAT (no per-post directory), so the post-first record is `_post_<id>.json`
in the same directory (the id suffix prevents the collisions a bare
`_post.json` would have here; phase 3 receives explicit post_record_paths, so
the name is a convention, not a discovery key).
Simpler than Patreon (no Mux/yt-dlp video branch) — the one special file is
the ugoira frame zip, downloaded as-is; FC's archive-containment import
extracts the frames, and the frame DELAYS ride the post record (the zip
carries none — a future ugoira→video conversion needs them).
PURE: no DB; the seen-skip is an injected predicate. FC runs on a plain-HTTP
homelab; nothing here uses a secure-context Web API.
"""
from __future__ import annotations
import json
import logging
import re
import time
from collections.abc import Callable
from pathlib import Path
import requests
from .native_ingest_common import (
BaseNativeDownloader,
MediaOutcome,
PostRecordOutcome,
)
from .pixiv_client import PIXIV_APP_HEADERS, rating_label
log = logging.getLogger(__name__)
# Control chars (0x00–0x1f + 0x7f DEL) — gallery-dl's default `path-remove`.
_GDL_PATH_REMOVE_RE = re.compile(r"[\x00-\x1f\x7f]")
def gdl_clean_filename(name: str) -> str:
"""Reproduce gallery-dl's on-disk filename EXACTLY as it wrote it on this
Linux host, so the tier-2 disk-skip recognizes pre-cutover files instead of
re-downloading them.
gallery-dl's PathFormat.build_filename is `clean_path(clean_segment(name))`.
On Linux (verified against gallery-dl 1.32.5 path.py) the defaults resolve to:
- path-restrict "auto" → "/" → clean_segment replaces ONLY "/" → "_"
- path-remove "\\x00-\\x1f\\x7f" → clean_path DELETES control chars
- path-strip "auto" → "" → NO trailing dot/space stripping
Crucially it does NOT touch the Windows-forbidden set (<>:"|?*) — those stay
raw in titles on disk. A stricter sanitizer here would rename any such title,
miss the on-disk match, and re-pull the whole work. Order mirrors gallery-dl
(segment inner, path outer); for these disjoint char sets it's commutative.
"""
return _GDL_PATH_REMOVE_RE.sub("", name.replace("/", "_"))
# Enrichment keys copied verbatim from the app-API work dict into the post
# record (they're already JSON scalars/objects). Everything lands in
# Post.raw_metadata via the importer, so the archive keeps pixiv's stats and
# structure without a schema change.
_WORK_PASSTHROUGH_KEYS = (
"type",
"page_count",
"width",
"height",
"total_view",
"total_bookmarks",
"total_comments",
"is_bookmarked",
"illust_ai_type",
"series",
)
class PixivDownloader(BaseNativeDownloader):
"""Download resolved Pixiv media to gallery-dl's on-disk layout.
Subclasses BaseNativeDownloader for the shared streaming GET
(transient-retry + Range-resume) and validation/quarantine. PURE: no DB."""
def __init__(
self,
images_root: Path,
cookies_path: str | None = None,
*,
validate: bool = True,
rate_limit: float = 0.0,
session: requests.Session | None = None,
ugoira_frames_fetcher: Callable[[dict], None] | None = None,
):
super().__init__(
images_root, cookies_path, platform="pixiv",
validate=validate, rate_limit=rate_limit, session=session,
)
# Injected by the ingester (client.fetch_ugoira_frames) so write_post_record
# can populate frame timings — which extract_media memoizes, but the core
# writes the post record FIRST. Mirrors Patreon's content_fetcher.
self._ugoira_frames_fetcher = ugoira_frames_fetcher
if session is None:
# i.pximg.net 403s any GET without the app Referer; mirror the
# client's full app-header profile (gallery-dl serves media off
# the same session it drives the API with). An injected session
# (tests) owns its own headers.
self.session.headers.update(PIXIV_APP_HEADERS)
# -- public ------------------------------------------------------------
def download_post(
self,
post: dict,
media_items: list,
artist_slug: str,
*,
is_seen: Callable[[object], bool] = lambda m: False,
should_stop: Callable[[], bool] = lambda: False,
recapture: bool = False,
) -> list[MediaOutcome]:
"""Download every media item of one work; return per-item outcomes.
Mirrors SubscribeStarDownloader.download_post (two-tier skip, mid-post
time-box, recapture surfacing)."""
flat_dir = self._flat_dir(artist_slug)
outcomes: list[MediaOutcome] = []
for media in media_items:
if should_stop():
break
try:
outcomes.append(
self._download_one(
post, media, flat_dir, artist_slug, is_seen,
recapture=recapture,
)
)
except Exception as exc: # resilient: isolate one item's failure
log.warning(
"Pixiv media failed (work %s, %s): %s",
post.get("id"), getattr(media, "media_id", "?"), exc,
)
outcomes.append(
MediaOutcome(media=media, status="error", path=None, error=str(exc))
)
return outcomes
def _flat_dir(self, artist_slug: str) -> Path:
# Double platform segment — gallery-dl layout parity (module docstring).
return self.images_root / artist_slug / "pixiv" / "pixiv"
# -- per-item ----------------------------------------------------------
def _download_one(
self,
post: dict,
media,
flat_dir: Path,
artist_slug: str,
is_seen: Callable[[object], bool],
*,
recapture: bool = False,
) -> MediaOutcome:
seen = is_seen(media)
if seen and not recapture:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
# The client's filename already carries the {id}_{title50}_{NN} shape
# (raw title, gallery-dl-template order); clean it to the byte-exact
# name gallery-dl wrote on disk so tier-2 disk-skip matches (else a
# re-download of the whole work). See gdl_clean_filename.
media_path = flat_dir / gdl_clean_filename(media.filename)
if media_path.exists(): # tier-2: already on disk
return MediaOutcome(
media=media, status="skipped_disk", path=media_path, error=None
)
# recapture: a seen item not on disk is NOT re-downloaded (recovery's job).
if seen:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
flat_dir.mkdir(parents=True, exist_ok=True)
if self._rate_limit > 0:
time.sleep(self._rate_limit)
out_path = self._fetch_get(media.url, media_path)
reason, quarantine_dest = self._validate_path(out_path, artist_slug, media.url)
if reason is not None:
return MediaOutcome(
media=media, status="quarantined", path=quarantine_dest, error=reason,
)
self._write_minimal_sidecar(post, out_path, source_url=media.url)
return MediaOutcome(media=media, status="downloaded", path=out_path, error=None)
# -- post record ---------------------------------------------------------
def write_post_record(self, post: dict, artist_slug: str) -> PostRecordOutcome:
"""Write the post-first `_post_<id>.json` — the sole writer of the post
body/metadata on the native path. Beyond the standard body fields, the
record carries pixiv's own structure (tags + EN translations, rating,
series, view/bookmark counts, AI flag, dimensions, author, ugoira frame
delays) so the archive keeps what the platform knows about the work."""
attrs = post.get("attributes") or {}
work = post.get("_work") or {}
title = attrs.get("title") if isinstance(attrs.get("title"), str) else None
post_type = attrs.get("post_type") if isinstance(attrs.get("post_type"), str) else None
pid = str(post.get("id") or "")
if not pid:
return PostRecordOutcome(
path=None, post_type=post_type, title=title, body_chars=0,
)
content = attrs.get("content")
content = content if isinstance(content, str) else ""
data: dict = {
"category": "pixiv",
"id": pid,
"title": title or "",
"content": content,
"published_at": attrs.get("published_at"),
# The post permalink is synthesized by platforms/pixiv.py
# derive_post_url from `id` at parse time — no url key here.
"rating": rating_label(work.get("x_restrict")),
}
for key in _WORK_PASSTHROUGH_KEYS:
if key in work:
data[key] = work[key]
tags = work.get("tags")
if isinstance(tags, list):
data["tags"] = [
{
"name": t.get("name"),
"translated_name": t.get("translated_name"),
}
for t in tags
if isinstance(t, dict)
]
user = work.get("user")
if isinstance(user, dict):
data["user"] = {
"id": user.get("id"),
"account": user.get("account"),
"name": user.get("name"),
}
# Ugoira frame timings. extract_media memoizes these, but the core writes
# the post record BEFORE extracting media, so fetch them here (shared +
# idempotent via the client's memoization) so the record actually keeps
# them — the zip carries no timings.
if (
work.get("type") == "ugoira"
and not work.get("_ugoira_frames")
and self._ugoira_frames_fetcher is not None
):
self._ugoira_frames_fetcher(post)
frames = work.get("_ugoira_frames")
if frames:
data["ugoira_frames"] = frames
flat_dir = self._flat_dir(artist_slug)
flat_dir.mkdir(parents=True, exist_ok=True)
path = flat_dir / f"_post_{pid}.json"
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
return PostRecordOutcome(
path=path, post_type=post_type, title=title, body_chars=len(content),
)
+121
View File
@@ -0,0 +1,121 @@
"""Native Pixiv ingester — the Pixiv ADAPTER over the platform-agnostic core
(`ingest_core.Ingester`).
Thin counterpart to patreon_ingester / subscribestar_ingester: wires the Pixiv
client/downloader/ledger models/constraints/key into the core and supplies the
Pixiv failure mapping. The modes (tick / backfill / recovery / recapture), the
seen + dead-letter ledgers, cursor checkpointing, and the post-first capture
all live in the core. `download_service.download_source` drives
`PixivIngester.run` exactly as it drives the other two.
`campaign_id` is the numeric pixiv user id (download_backends extracts it from
the source URL — no network resolver). Auth is the operator's OAuth refresh
token (the token-type Credential), passed as `auth_token` — pixiv is the first
native platform authenticating by token rather than cookies, so the uniform
constructor accepts both and ignores what it doesn't need.
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from pathlib import Path
from ..models import PixivFailedMedia, PixivSeenMedia
from .ingest_core import DEAD_LETTER_THRESHOLD, Ingester
from .pixiv_client import MediaItem, PixivAPIError, PixivClient
from .pixiv_downloader import PixivDownloader
__all__ = [
"DEAD_LETTER_THRESHOLD",
"PixivIngester",
"_ledger_key",
"verify_pixiv_credential",
]
log = logging.getLogger(__name__)
_LEDGER_KEY_MAX = 128
def _ledger_key(media: MediaItem) -> str:
"""Stable per-media identity for the cross-run seen-ledger. Pixiv original
URLs carry no content hash, so the key is the page/zip identity scoped to
its work: `<illust_id>:p<num>` / `<illust_id>:ugoira`. Bounded to the
column width."""
if media.filehash:
return media.filehash
return f"{media.post_id}:{media.media_id}"[:_LEDGER_KEY_MAX]
class PixivIngester(Ingester):
"""Walk a pixiv user's works, download unseen originals, return a
`DownloadResult`. A thin adapter over `ingest_core.Ingester`; `client` /
`downloader` are injectable seams so unit tests run without network."""
def __init__(
self,
images_root: Path,
cookies_path: str | None,
session_factory: Callable[[], object],
*,
validate: bool = True,
rate_limit: float = 0.0,
request_sleep: float = 0.0,
auth_token: str | None = None,
client: PixivClient | None = None,
downloader: PixivDownloader | None = None,
):
self.images_root = Path(images_root)
self.cookies_path = str(cookies_path) if cookies_path else None
resolved_client = (
client
if client is not None
else PixivClient(auth_token, request_sleep=request_sleep)
)
resolved_downloader = (
downloader
if downloader is not None
else PixivDownloader(
self.images_root, cookies_path, validate=validate, rate_limit=rate_limit,
# write_post_record runs before extract_media in the core, so it
# fetches ugoira frame timings via the SAME client (shared,
# memoized) — else the record's ugoira_frames stays empty.
ugoira_frames_fetcher=resolved_client.fetch_ugoira_frames,
)
)
super().__init__(
client=resolved_client,
downloader=resolved_downloader,
session_factory=session_factory,
seen_model=PixivSeenMedia,
failed_model=PixivFailedMedia,
seen_constraint="uq_pixiv_seen_media_source_id",
failed_constraint="uq_pixiv_failed_media_source_id",
ledger_key=_ledger_key,
platform="pixiv",
error_base=PixivAPIError,
# API_DRIFT message phrasing; the base Ingester._failure_result owns
# the auth/drift/HTTP→error_type mapping (shared across platforms).
drift_label="Pixiv app API",
# Captions are legitimately empty for many pixiv artists, so the
# zero-bodies #862 canary would false-positive here; the client's
# response-shape checks (missing `illusts` → drift) cover the same
# failure class structurally.
body_canary=False,
)
async def verify_pixiv_credential(
auth_token: str | None,
) -> tuple[bool | None, str]:
"""Native Pixiv credential probe — one OAuth refresh via
PixivClient.verify_auth (the exact call that fails when the token is
bad; no feed walk). Returns the uniform `(ok, message)` contract so
download_backends.verify_source_credential treats it like the others."""
client = PixivClient(auth_token)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, client.verify_auth)
+2 -4
View File
@@ -23,10 +23,8 @@ log = logging.getLogger(__name__)
# Platforms walked one-at-a-time. gallery-dl platforms are intentionally NOT
# here: each runs as a self-pacing subprocess and they're lower-volume. The
# native-ingester platforms are serialized (one paced scrape/API walk at a time).
# Add a platform here to cap it to a single concurrent walk. Discord most of
# all: every source walks on the operator's ONE user token, and parallel walks
# on a user account are both how its rate limit trips and what gets it flagged.
SERIALIZED_PLATFORMS = frozenset({"patreon", "subscribestar", "discord"})
# Add a platform here to cap it to a single concurrent walk.
SERIALIZED_PLATFORMS = frozenset({"patreon", "subscribestar"})
_LOCK_PREFIX = "fc:download_lock:"

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