Compare commits

..
245 Commits
Author SHA1 Message Date
bvandeusen 6bf205407b Merge pull request 'pixiv retired, placement reconciler removed, build race fixed, mirror pinned' (#257) from dev into main
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 14s
extension / lint (push) Successful in 16s
Build images / build-web (push) Successful in 12s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m14s
2026-09-22 07:43:12 -04:00
bvandeusen 32d37cd453 Merge pull request 'Placement reconciler: put every image in its own artist's folder, one artist at a time' (#256) from dev into main
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 18s
Build images / build-web (push) Successful in 14s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 43s
CI / integration (push) Successful in 2m24s
2026-09-21 15:28:38 -04:00
bvandeusen e2246e861e Merge pull request 'Near-duplicate dedup rebuilt on three gates, backup credential leak closed, library consolidation started' (#255) from dev into main
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 13s
Build images / build-web (push) Successful in 12s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 2m22s
2026-09-21 11:11:39 -04:00
bvandeusen b4e7b98390 Lapsed subscriptions stop pulling, a quieter Subscriptions card, and working feed filters (#254)
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 12s
Build images / build-web (push) Successful in 12s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 2m30s
2026-09-13 22:45:03 -04:00
bvandeusen c3dac235a9 Wide-window feed, Discord downloads named and dated correctly, and a repair for the broken ones (#253)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 12s
Build images / build-web (push) Successful in 11s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m8s
2026-09-13 20:57:06 -04:00
bvandeusen 69d9c14e35 The Patreon roster syncs, the favicon shows, and the logo sits behind every page (#252)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 13s
Build images / build-web (push) Successful in 12s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 2m20s
2026-09-13 18:22:00 -04:00
bvandeusen 057bab1011 What you pay for, what Discord drops, and pixiv switched off (#251)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 25s
Build images / build-ml (push) Successful in 26s
Build images / build-web (push) Successful in 15s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 42s
extension / lint (push) Successful in 38s
CI / backend-lint-and-test (push) Successful in 2m16s
CI / integration (push) Successful in 3m46s
2026-09-13 12:09:28 -04:00
bvandeusen a8fdbd86bc Merge pull request 'FabledCurator can now tell you one of its own parts has stopped' (#250) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 13s
Build images / build-web (push) Successful in 13s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 1m53s
2026-09-02 18:01:38 -04:00
bvandeusen 0421fd3109 Merge pull request 'The weekly refresh now publishes only what a gate has proven — and a new install can start' (#249) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / extension-version (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
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 22s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 1m49s
2026-09-02 16:31:55 -04:00
bvandeusen d01f33dea6 Merge pull request 'A gate for the weekly refresh, and the lever that makes it testable' (#248) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (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
Build images / smoke-web (push) Skipped
CI / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 1m42s
2026-09-02 15:51:50 -04:00
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
201 changed files with 2136 additions and 17710 deletions
+2 -2
View File
@@ -79,7 +79,7 @@ jobs:
- name: Resolve the Postgres service and install deps - name: Resolve the Postgres service and install deps
run: | run: |
set -eux 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. # 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) PG=$(docker ps --filter "name=compare" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
test -n "$PG" test -n "$PG"
@@ -89,7 +89,7 @@ jobs:
echo "DB_HOST=$PG_IP" >> "$GITHUB_ENV" echo "DB_HOST=$PG_IP" >> "$GITHUB_ENV"
# Socket probe in python, not bash's /dev/tcp — these steps run under # 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 # `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="" pg_ready=""
for i in $(seq 1 60); do 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 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
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."
+3 -103
View File
@@ -28,10 +28,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \ postgresql-client \
zstd \ zstd \
megatools \ 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 \ libjpeg62-turbo \
libwebp7 \ libwebp7 \
libpng16-16 \ libpng16-16 \
@@ -40,59 +36,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
COPY requirements.txt requirements-ml.txt ./ COPY requirements.txt ./
RUN pip install -r 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 backend/ ./backend/
COPY alembic/ ./alembic/ COPY alembic/ ./alembic/
COPY alembic.ini ./ COPY alembic.ini ./
@@ -126,51 +72,5 @@ ENV FC_VERSION=${FC_VERSION}
EXPOSE 8080 EXPOSE 8080
# ONE healthcheck for every role, because the image knows which role it is ENTRYPOINT ["./entrypoint.sh"]
# running and a deployment should not have to repeat it. `healthcheck` reads CMD ["web"]
# 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"]
+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"]
+10 -20
View File
@@ -245,9 +245,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 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 of the newest change to that artifact's *own* shipped files, as
`YYYY.MM.DD.HHMM` UTC (rule 148). Three artifacts, three independent versions `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 the a push touching only `agent/` re-versions the agent and leaves web and ml
extension alone, and CI skips the builds whose content did not move. 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 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 only answer to "which build is this?". The foot of Settings shows
@@ -260,32 +260,22 @@ commits since the previous tag; it builds no image.
## What's in here ## 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 | | 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`. | | **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`. | | **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. | | **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
## CI / Forgejo setup ## CI / Forgejo setup
Two workflows that matter here: `build.yml` (the six verification lanes — lint, Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
extension-version check, backend unit tests, frontend build, extension lint + frontend build, integration), `extension.yml` (extension lint, vitest, XPI
vitest + XPI content check, integration — and then sign + publish), and content verification), `build.yml` (sign + publish), and `release.yml`, which
`release.yml`, which runs only on a `v*` tag and publishes a changelog without runs only on a `v*` tag and publishes a changelog without building anything.
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.
**The toolchain each job runs in is its `container.image`, not its `runs-on` **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 label.** `runs-on: python-ci` only schedules the job onto a runner; every job
+10 -40
View File
@@ -1,21 +1,10 @@
# FabledCurator GPU agent — runs on the desktop with the GPU. # FabledCurator GPU agent — runs on the desktop with the GPU.
# # CUDA 12.9 + cuDNN 9 runtime so onnxruntime-gpu can use the card (it needs
# The `base` flavour, not `cudnn-runtime`: CUDA and cuDNN arrive as the # cuDNN 9 — the plain -runtime image lacks it: "libcudnn.so.9: cannot open
# `nvidia-*` pip packages torch and onnxruntime-gpu depend on, so the base only # shared object file"); ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
# has to hand the container the driver (it sets NVIDIA_VISIBLE_DEVICES / # Stays on the CUDA-12 / cuDNN-9 line the default onnxruntime-gpu + torch are
# NVIDIA_DRIVER_CAPABILITIES for the Container Toolkit). Until #1451 this was # built against (CUDA 13 has only nascent ONNX Runtime support).
# `12.9.2-cudnn-runtime` under a `torch==2.6.0+cu124` — and requirements.txt then FROM nvidia/cuda:12.9.2-cudnn-runtime-ubuntu24.04
# 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
# PIP_BREAK_SYSTEM_PACKAGES: Ubuntu 24.04 marks its system Python as externally # 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 # 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/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# torch AND torchvision from the cu130 index, together and first. Installing # torch from the CUDA-12.4 wheel index; its wheels bundle their own CUDA + cuDNN
# torch alone is what let the next step swap it out: ultralytics needs # so they run on the 12.9 base and coexist with onnxruntime-gpu. Installed first
# torchvision, PyPI's torchvision pins its own torch, and pip replaced ours to # + separately so the GPU build of torch is deterministic and layer-cached.
# match. With both present, requirements.txt finds them satisfied. RUN pip3 install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
RUN pip3 install --no-cache-dir --index-url https://download.pytorch.org/whl/cu130 \
torch torchvision
COPY requirements.txt . COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt RUN pip3 install --no-cache-dir -r requirements.txt
COPY fc_agent ./fc_agent 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 # 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). # a volume to persist them across restarts (the SigLIP download is ~3.5 GB once).
ENV HF_HOME=/models 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 EXPOSE 8770
# The control UI; the worker is started from it (or POST /start). # 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 nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker sudo systemctl restart docker
# verify: # verify:
docker run --rm --gpus all nvidia/cuda:13.0.3-base-ubuntu24.04 nvidia-smi docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.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
``` ```
## 1. Get a token ## 1. Get a token
In FC: **Settings → Tagging → GPU agent → Generate token** (or Rotate). Copy it. 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 ```sh
docker pull git.fabledsword.com/bvandeusen/fabledcurator-agent:latest 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 import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from . import accel, logbuf from . import logbuf
from .build_info import FC_CHANNEL, FC_REVISION, FC_VERSION, build_id, display_version
from .config import Config from .config import Config
from .gpu import read_gpu from .gpu import read_gpu
from .worker import Worker from .worker import Worker
log = logging.getLogger("fc_agent.app") log = logging.getLogger("fc_agent.app")
# DERIVED at image build time, not hand-maintained — see build_info. This was a # Bump on every agent change. The page embeds this and /status reports it; the UI
# literal an author was asked to bump on every agent change, and the September # warns to reload when they differ — so a stale browser-cached page can't be
# image printed the same "2026-07-17.1" as the July one, so the surface meant to # mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
# answer "did my pull work?" answered the same either way. 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"
#
# 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.
logbuf.install() logbuf.install()
cfg = Config.from_env() cfg = Config.from_env()
@@ -47,9 +42,6 @@ async def _no_store(request, call_next):
@app.on_event("startup") @app.on_event("startup")
def _maybe_autostart() -> None: 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: # With AUTO_START set, a container restart (host reboot, or `restart:
# unless-stopped` after a crash) resumes the worker on its own — the slots # 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 # 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) @app.get("/", response_class=HTMLResponse)
def index() -> str: def index() -> str:
# Two substitutions, not one: `__VERSION__` is what a person reads in the return _PAGE.replace("__BUILD__", VERSION)
# 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())
)
@app.post("/start") @app.post("/start")
@@ -132,15 +117,7 @@ def status():
s["fc_url"] = cfg.fc_url s["fc_url"] = cfg.fc_url
s["configured"] = bool(cfg.token) s["configured"] = bool(cfg.token)
s["queue"] = worker.latest_queue() s["queue"] = worker.latest_queue()
# `build` is the comparison token the page checks — see build_info. s["build"] = VERSION
# `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
return JSONResponse(s) 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} width:30px;height:32px;font:700 16px system-ui;cursor:pointer}
.step:hover{border-color:var(--acc)} .step:hover{border-color:var(--acc)}
#conc,#bw{width:3.4rem;height:32px;text-align:center;font:700 16px system-ui;background:#11151a; #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} color:var(--fg);border:1px solid var(--bd);border-radius:8px}
/* 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}
.unit{color:var(--mut);font-size:12px;font-weight:600} .unit{color:var(--mut);font-size:12px;font-weight:600}
.hint{color:var(--mut);font-size:12px;margin-top:12px} .hint{color:var(--mut);font-size:12px;margin-top:12px}
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px} .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=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> <div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div>
</header> </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"> <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 a newer agent version is running — reload this page (Ctrl+Shift+R) to update the controls
</div> </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> <div id=banner class=banner style=display:none>
curator unreachable — holding work + retrying, resumes on its own (no restart needed) curator unreachable — holding work + retrying, resumes on its own (no restart needed)
</div> </div>
@@ -253,9 +225,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
<button class=step onclick=setc(1)>+</button> <button class=step onclick=setc(1)>+</button>
</div> </div>
<div class=stepper title="aggregate download cap, downloads + video streams combined — 0 = unlimited"> <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)"> <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> <span class=unit>MB/s</span>
</div> </div>
</div> </div>
@@ -292,7 +262,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
</section> </section>
</div> </div>
<script> <script>
const PAGE_BUILD="__BUILD_ID__" const PAGE_BUILD="__BUILD__"
let CAP=8 let CAP=8
// Optimistic transitional state on click, then apply the POST's own status // 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 // 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'}, await fetch('/auto',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:on})});refresh() 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){ async function setbw(v){
v=Math.max(0,parseFloat(v)||0); bw.value=v v=Math.max(0,parseFloat(v)||0); bw.value=v
await fetch('/bandwidth',{method:'POST',headers:{'Content-Type':'application/json'}, 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. // unreachable curator; grey when stopped; red with no token.
let dc='dot', lbl='stopped' let dc='dot', lbl='stopped'
if(!ok){ dc='dot red'; lbl='no token' } 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' 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==='starting'){ dc='dot amber'; lbl='starting…' } else if(st==='starting'){ dc='dot amber'; lbl='starting…' }
else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' } else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' }
dot.className=dc; connlbl.textContent=lbl dot.className=dc; connlbl.textContent=lbl
banner.style.display=(st==='running' && !s.queue)?'block':'none' 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' 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 requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
from . import accel
class FcClient: class FcClient:
def __init__(self, base_url: str, token: str, agent_id: str): 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]: def lease(self, batch_size: int) -> list[dict]:
r = self.s.post( r = self.s.post(
f"{self.base}/api/gpu/jobs/lease", f"{self.base}/api/gpu/jobs/lease",
json={ json={"agent_id": self.agent_id, "batch_size": batch_size},
"agent_id": self.agent_id, "batch_size": batch_size,
"accel": accel.summary(),
},
timeout=30, timeout=30,
) )
r.raise_for_status() r.raise_for_status()
@@ -95,9 +90,7 @@ class FcClient:
}) })
def heartbeat(self, job_ids: list[int]) -> None: def heartbeat(self, job_ids: list[int]) -> None:
self._post_quiet( self._post_quiet("/api/gpu/jobs/heartbeat", {"job_ids": job_ids})
"/api/gpu/jobs/heartbeat", {"job_ids": job_ids, "accel": accel.summary()},
)
def fail(self, job_id: int, error: str) -> None: def fail(self, job_id: int, error: str) -> None:
self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error}) self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error})
+4 -33
View File
@@ -342,43 +342,14 @@ class Worker:
# --- background loops --------------------------------------------------- # --- background loops ---------------------------------------------------
def _heartbeat_loop(self) -> None: def _heartbeat_loop(self) -> None:
"""Keep every held lease alive, and say we are here even when holding none. """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;
Leases: buffered jobs waiting on the GPU would otherwise be reclaimed by a reclaimed lease just re-leases elsewhere — never fatal."""
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.
"""
while True: while True:
if self._running: if self._running:
with self._held_lock: with self._held_lock:
ids = list(self._held) ids = list(self._held)
if ids:
self.client.heartbeat(ids) self.client.heartbeat(ids)
time.sleep(HEARTBEAT_INTERVAL) time.sleep(HEARTBEAT_INTERVAL)
+4 -6
View File
@@ -1,12 +1,10 @@
# CCIP + figure detection (ONNX models, auto-downloaded from HuggingFace). # CCIP + figure detection (ONNX models, auto-downloaded from HuggingFace).
dghs-imgutils>=0.4 dghs-imgutils>=0.4
# GPU inference for the ONNX models. Swap to onnxruntime (CPU) for a slow # 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 # server-side fallback run.
# CUDA provider loads (fc_agent/accel.py preloads them) rather than relying on onnxruntime-gpu
# torch happening to install the same ones. # The crop EMBEDDER (concept bag). torch is installed separately in the
onnxruntime-gpu[cuda,cudnn] # Dockerfile from the CUDA-12.4 wheel index so the GPU build is deterministic;
# The crop EMBEDDER (concept bag). torch + torchvision are installed separately
# in the Dockerfile from the cu130 wheel index, so pip never swaps them out;
# transformers loads whatever SigLIP-family model the server announces. # transformers loads whatever SigLIP-family model the server announces.
transformers>=4.45 transformers>=4.45
# Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic # Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic
@@ -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")
-2
View File
@@ -41,7 +41,6 @@ def all_blueprints() -> list[Blueprint]:
from .system_health import system_health_bp from .system_health import system_health_bp
from .tags import tags_bp from .tags import tags_bp
from .thumbnails import thumbnails_bp from .thumbnails import thumbnails_bp
from .workers import workers_bp
return [ return [
api_bp, api_bp,
attachments_bp, attachments_bp,
@@ -53,7 +52,6 @@ def all_blueprints() -> list[Blueprint]:
showcase_bp, showcase_bp,
settings_bp, settings_bp,
system_activity_bp, system_activity_bp,
workers_bp,
system_health_bp, system_health_bp,
system_backup_bp, system_backup_bp,
admin_bp, admin_bp,
+2 -20
View File
@@ -19,7 +19,6 @@ from ..models import AppSetting
from ..services.extension_service import ( from ..services.extension_service import (
ExtensionService, ExtensionService,
InvalidUrlError, InvalidUrlError,
UnknownArtistError,
UnknownPlatformError, UnknownPlatformError,
) )
from ..services.source_service import KNOWN_PLATFORMS from ..services.source_service import KNOWN_PLATFORMS
@@ -88,14 +87,10 @@ async def probe_source():
url = (request.args.get("url") or "").strip() url = (request.args.get("url") or "").strip()
if not url: if not url:
return _bad("invalid_body", detail="url query parameter is required") return _bad("invalid_body", detail="url query parameter is required")
from .credentials import _get_crypto
async with get_session() as session: async with get_session() as session:
if not await _ext_key_required(session): if not await _ext_key_required(session):
return _bad("unauthorized", status=401) return _bad("unauthorized", status=401)
# crypto lets a Discord probe name the server and channel with the result = await ExtensionService(session).probe(url)
# stored token; every other platform ignores it.
result = await ExtensionService(session, _get_crypto()).probe(url)
return jsonify(result) return jsonify(result)
@@ -107,15 +102,6 @@ async def quick_add_source():
url = body.get("url") url = body.get("url")
if not isinstance(url, str) or not url.strip(): if not isinstance(url, str) or not url.strip():
return _bad("invalid_body", detail="url is required") 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")
from .credentials import _get_crypto from .credentials import _get_crypto
@@ -125,11 +111,7 @@ async def quick_add_source():
try: try:
# crypto lets an add resolve the artist's display name via the # crypto lets an add resolve the artist's display name via the
# stored credential (else it falls back to the URL handle). #130. # stored credential (else it falls back to the URL handle). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source( result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
url, artist_id=artist_id, artist_name=artist_name,
)
except UnknownArtistError as exc:
return _bad("not_found", detail=str(exc), status=404)
except UnknownPlatformError as exc: except UnknownPlatformError as exc:
return _bad( return _bad(
"unknown_platform", "unknown_platform",
+2 -31
View File
@@ -245,29 +245,6 @@ async def errors_recover(image_id: int):
# --- Agent (bearer token): lease / submit / heartbeat / fail ------------ # --- 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"]) @gpu_bp.route("/jobs/lease", methods=["POST"])
async def lease(): async def lease():
body = await request.get_json(silent=True) or {} body = await request.get_json(silent=True) or {}
@@ -290,10 +267,7 @@ async def lease():
key=f"agent:{agent_id}", key=f"agent:{agent_id}",
kind="agent", kind="agent",
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})", display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
details={ details={"agent_id": agent_id, "last_call": "lease", "leased": len(jobs)},
"agent_id": agent_id, "last_call": "lease", "leased": len(jobs),
**_accel_detail(body),
},
) )
ml = await MLSettings.load(session) ml = await MLSettings.load(session)
# image rows for url/mime in one shot # image rows for url/mime in one shot
@@ -373,10 +347,7 @@ async def heartbeat():
key=f"agent:{agent_id}", key=f"agent:{agent_id}",
kind="agent", kind="agent",
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})", display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
details={ details={"agent_id": agent_id, "last_call": "heartbeat", "extended": n},
"agent_id": agent_id, "last_call": "heartbeat", "extended": n,
**_accel_detail(body),
},
) )
await session.commit() await session.commit()
return jsonify({"extended": n}) return jsonify({"extended": n})
+1 -3
View File
@@ -207,8 +207,6 @@ async def rescan_associations():
be within the window to exist at all); this is the button for a first run be within the window to exist at all); this is the button for a first run
over a library that predates the feature.""" over a library that predates the feature."""
async with get_session() as session: async with get_session() as session:
# full=True: the button reaches the whole history, which the hourly result = await association_rescan(session)
# sweep's 48-hour horizon never does.
result = await association_rescan(session, full=True)
await session.commit() await session.commit()
return jsonify(result) return jsonify(result)
-20
View File
@@ -36,7 +36,6 @@ _EDITABLE_FIELDS = (
"download_validate_files", "download_validate_files",
"download_schedule_default_seconds", "download_schedule_default_seconds",
"download_event_retention_days", "download_event_retention_days",
"download_revisit_days",
"download_failure_warning_threshold", "download_failure_warning_threshold",
"series_suggest_enabled", "series_suggest_enabled",
"series_suggest_threshold", "series_suggest_threshold",
@@ -44,9 +43,6 @@ _EDITABLE_FIELDS = (
"discord_link_enabled", "discord_link_enabled",
"discord_link_threshold", "discord_link_threshold",
"discord_link_window_hours", "discord_link_window_hours",
"discord_link_auto",
"discord_link_fold_hours",
"discord_family_window_days",
"extdl_mega_enabled", "extdl_mega_enabled",
"extdl_gdrive_enabled", "extdl_gdrive_enabled",
"extdl_mediafire_enabled", "extdl_mediafire_enabled",
@@ -117,12 +113,6 @@ async def update_import_settings():
v = body["download_schedule_default_seconds"] v = body["download_schedule_default_seconds"]
if not isinstance(v, int) or isinstance(v, bool) or v < 60 or v > 86400: if not isinstance(v, int) or isinstance(v, bool) or v < 60 or v > 86400:
return _bad_int("download_schedule_default_seconds", 60, 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: if "download_event_retention_days" in body:
v = body["download_event_retention_days"] v = body["download_event_retention_days"]
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 3650: if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 3650:
@@ -168,10 +158,6 @@ async def update_import_settings():
body["discord_link_enabled"], bool body["discord_link_enabled"], bool
): ):
return jsonify({"error": "discord_link_enabled must be a boolean"}), 400 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: if "discord_link_threshold" in body:
v = body["discord_link_threshold"] v = body["discord_link_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1: if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
@@ -184,12 +170,6 @@ async def update_import_settings():
return jsonify( return jsonify(
{"error": "discord_link_window_hours must be a positive number"} {"error": "discord_link_window_hours must be a positive number"}
), 400 ), 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( if "wip_title_tagging_enabled" in body and not isinstance(
body["wip_title_tagging_enabled"], bool body["wip_title_tagging_enabled"], bool
): ):
+7 -11
View File
@@ -21,22 +21,18 @@ from ..config import get_config
from ..extensions import get_session from ..extensions import get_session
from ..models import TaskRun from ..models import TaskRun
from ..services.scheduler_service import scheduler_status from ..services.scheduler_service import scheduler_status
from ..services.worker_lanes import LANES
system_activity_bp = Blueprint( system_activity_bp = Blueprint(
"system_activity", __name__, url_prefix="/api/system/activity", "system_activity", __name__, url_prefix="/api/system/activity",
) )
# Every queue, grouped by the lane that consumes it. DERIVED from # Canonical queue order — must match celery_app.task_routes. UI renders
# `worker_lanes.LANES` (milestone 422 step 1) rather than written out: # in this order; queues with no LLEN response show as null rather than
# this was a hand-kept third copy of "which queues exist", alongside # absent.
# celery_app.task_routes and service_roster.ROLE_NAMES, and its own comment _QUEUE_NAMES = (
# admitted the coupling — "must match celery_app.task_routes". "default", "import", "thumbnail", "ml",
# "download", "scan", "maintenance", "maintenance_long",
# 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)
# Cache module-level so all requests share the cache between polls. # Cache module-level so all requests share the cache between polls.
# Tests can reset via direct dict mutation if needed. # Tests can reset via direct dict mutation if needed.
+15 -66
View File
@@ -26,6 +26,7 @@ a true statement.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -35,7 +36,9 @@ from sqlalchemy import select, text
from ..config import get_config from ..config import get_config
from ..extensions import get_session from ..extensions import get_session
from ..models import ServiceSeen from ..models import ServiceSeen
from ..services.worker_lanes import SWEEP_PERIOD_SECONDS from ..services.service_roster import refresh_if_stale
log = logging.getLogger(__name__)
system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system") system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system")
@@ -50,36 +53,14 @@ system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system"
STALE_AFTER_SECONDS = 90 STALE_AFTER_SECONDS = 90
DOWN_AFTER_SECONDS = 300 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 # Probes cross a process boundary, so they carry deadlines. A hung Postgres
# must make this endpoint say "postgres: down", not hang alongside it. # must make this endpoint say "postgres: down", not hang alongside it.
PROBE_TIMEOUT_SECONDS = 2.0 PROBE_TIMEOUT_SECONDS = 2.0
_OK, _STALE, _DOWN, _UNKNOWN = "ok", "stale", "down", "unknown" _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. # Worst-first, so an overall verdict is just the max.
_SEVERITY = {_OK: 0, _UNKNOWN: 1, _DEGRADED: 2, _STALE: 3, _DOWN: 4} _SEVERITY = {_OK: 0, _UNKNOWN: 1, _STALE: 2, _DOWN: 3}
def _age_state(age_seconds: float) -> str: def _age_state(age_seconds: float) -> str:
@@ -105,39 +86,6 @@ def _describe_learned(name: str, state: str, age: float, details: dict) -> str:
return f"{name} has not checked in for {ago} — treat it as stopped" 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: async def _probe_postgres(session) -> dict:
started = time.monotonic() started = time.monotonic()
try: try:
@@ -203,25 +151,26 @@ async def system_health():
parts.append(pg) parts.append(pg)
if pg["state"] == _OK: if pg["state"] == _OK:
# A PURE READ since 2026-09-23. This used to refresh the celery # Rate-limited inside; see service_roster on why the web process
# roster here, rate-limited to once per 20s — so the roster only # is the right observer.
# advanced while somebody had a browser open, and a broadcast rode try:
# on a request. `size_worker_lanes` writes it now, on a timer, and await refresh_if_stale(session)
# the assertion below is what keeps that cadence honest. await session.commit()
except Exception: # noqa: BLE001
log.warning("system health: roster refresh failed", exc_info=True)
rows = ( rows = (
await session.execute(select(ServiceSeen).order_by(ServiceSeen.display_name)) await session.execute(select(ServiceSeen).order_by(ServiceSeen.display_name))
).scalars().all() ).scalars().all()
for row in rows: for row in rows:
age = (now - row.last_seen_at).total_seconds() age = (now - row.last_seen_at).total_seconds()
state, detail = _learned_state( state = _age_state(age)
row.display_name, _age_state(age), age, row.details or {},
)
parts.append({ parts.append({
"key": row.key, "key": row.key,
"kind": row.kind, "kind": row.kind,
"name": row.display_name, "name": row.display_name,
"state": state, "state": state,
"detail": detail, "detail": _describe_learned(row.display_name, state, age, row.details or {}),
"last_seen_at": row.last_seen_at.isoformat(), "last_seen_at": row.last_seen_at.isoformat(),
"first_seen_at": row.first_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"}, **{k: v for k, v in (row.details or {}).items() if k != "agent_id"},
-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,
)
-39
View File
@@ -14,7 +14,6 @@ Queues:
from celery import Celery from celery import Celery
from .config import get_config from .config import get_config
from .services.worker_lanes import SWEEP_PERIOD_SECONDS
def make_celery() -> Celery: def make_celery() -> Celery:
@@ -62,13 +61,6 @@ def make_celery() -> Celery:
# can never starve the quick self-healing sweeps (operator-flagged # can never starve the quick self-healing sweeps (operator-flagged
# 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours). # 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours).
"backend.app.tasks.maintenance.*": {"queue": "maintenance"}, "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.backup.*": {"queue": "maintenance_long"},
"backend.app.tasks.admin.*": {"queue": "maintenance_long"}, "backend.app.tasks.admin.*": {"queue": "maintenance_long"},
"backend.app.tasks.library_audit.*": {"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", "task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
"schedule": 300.0, # every 5 minutes "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": { "cleanup-old-tasks": {
"task": "backend.app.tasks.maintenance.cleanup_old_tasks", "task": "backend.app.tasks.maintenance.cleanup_old_tasks",
"schedule": 86400.0, # daily "schedule": 86400.0, # daily
@@ -360,9 +324,6 @@ def make_celery() -> Celery:
}, },
}, },
timezone="UTC", 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). # FC-3i: register task_run signal handlers (side-effect import).
from . import celery_signals # noqa: F401 from . import celery_signals # noqa: F401
-8
View File
@@ -8,8 +8,6 @@ from .backup_run import BackupRun
from .base import Base from .base import Base
from .character_prototype import CcipPrototypeState, CharacterPrototype from .character_prototype import CcipPrototypeState, CharacterPrototype
from .credential import Credential from .credential import Credential
from .discord_failed_media import DiscordFailedMedia
from .discord_seen_media import DiscordSeenMedia
from .download_event import DownloadEvent from .download_event import DownloadEvent
from .external_link import ExternalLink from .external_link import ExternalLink
from .gpu_job import GpuJob from .gpu_job import GpuJob
@@ -46,8 +44,6 @@ from .tag_head import TagHead
from .tag_positive_confirmation import TagPositiveConfirmation from .tag_positive_confirmation import TagPositiveConfirmation
from .tag_suggestion_rejection import TagSuggestionRejection from .tag_suggestion_rejection import TagSuggestionRejection
from .task_run import TaskRun from .task_run import TaskRun
from .worker_lane import WorkerLane
from .worker_lane_sample import WorkerLaneSample
__all__ = [ __all__ = [
"Base", "Base",
@@ -58,8 +54,6 @@ __all__ = [
"BackupRun", "BackupRun",
"Source", "Source",
"Credential", "Credential",
"DiscordFailedMedia",
"DiscordSeenMedia",
"PatreonFailedMedia", "PatreonFailedMedia",
"PatreonSeenMedia", "PatreonSeenMedia",
"SubscribeStarFailedMedia", "SubscribeStarFailedMedia",
@@ -100,6 +94,4 @@ __all__ = [
"TagPositiveConfirmation", "TagPositiveConfirmation",
"TagSuggestionRejection", "TagSuggestionRejection",
"TaskRun", "TaskRun",
"WorkerLane",
"WorkerLaneSample",
] ]
@@ -1,36 +0,0 @@
"""DiscordFailedMedia — per-source dead-letter ledger of Discord files that
keep failing to download or 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.
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import DateTime
from .base import Base
class DiscordFailedMedia(Base):
__tablename__ = "discord_failed_media"
__table_args__ = (
UniqueConstraint("source_id", "filehash", name="uq_discord_failed_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)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
first_failed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_failed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=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()
)
-54
View File
@@ -68,16 +68,6 @@ class ImportSettings(Base):
Integer, nullable=False, default=90, Integer, nullable=False, default=90,
server_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( download_failure_warning_threshold: Mapped[int] = mapped_column(
Integer, nullable=False, default=5, Integer, nullable=False, default=5,
server_default="5", server_default="5",
@@ -139,50 +129,6 @@ class ImportSettings(Base):
server_default="24", 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, # #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 # rule #26). Column names are extdl_<host>_enabled so the worker reads them
# via getattr(settings, f"extdl_{host}_enabled", True). # via getattr(settings, f"extdl_{host}_enabled", True).
-7
View File
@@ -91,13 +91,6 @@ class PostAssociation(Base):
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="pending", index=True 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
-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())
+3 -53
View File
@@ -24,7 +24,6 @@ rows undecryptable (recovery = delete the rows and re-upload).
import logging import logging
import os import os
import tempfile
from pathlib import Path from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
@@ -81,59 +80,10 @@ class CredentialCrypto:
parent = self._key_path.parent parent = self._key_path.parent
parent.mkdir(parents=True, exist_ok=True) parent.mkdir(parents=True, exist_ok=True)
os.chmod(parent, 0o700) os.chmod(parent, 0o700)
# 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() key = Fernet.generate_key()
fd, tmp_name = tempfile.mkstemp( self._key_path.write_bytes(key)
dir=parent, prefix=f".{self._key_path.name}.", suffix=".tmp", os.chmod(self._key_path, 0o600)
) return key
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()
def encrypt(self, plaintext: str) -> bytes: def encrypt(self, plaintext: str) -> bytes:
return self._fernet.encrypt(plaintext.encode("utf-8")) return self._fernet.encrypt(plaintext.encode("utf-8"))
-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),
)
+10 -405
View File
@@ -59,23 +59,14 @@ from __future__ import annotations
import logging import logging
import math import math
from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from sqlalchemy import Select, delete, func, select, union, update from sqlalchemy import Select, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ( from ..models import ImageProvenance, ImageRecord, MLSettings, Post, Source
ImageProvenance,
ImageRecord,
MLSettings,
Post,
PostAssociation,
Source,
)
from .post_naming import FAMILY_MAX_POSTS, leading_name, rarity, token_frequencies
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -126,31 +117,6 @@ def cosine_distance(a, b) -> float:
return 1.0 - (dot / (na * nb)) return 1.0 - (dot / (na * nb))
def _message_images(posts):
"""(post_id, image_id) for every image a message carries — owned AND re-posted.
A message owns an image through `primary_post_id`, but only the FIRST
message imported with a given file does. The same file posted again is a
provenance link, and the backfill runs newest-first, so it is usually the
ORIGINAL message that ends up owning nothing. Reading ownership alone left
101 of Yellowroom's messages (2018–2020 mostly) ungroupable: every image
they carried also sat in another message.
`posts` is a list of ids or a select of them; filtering both branches by it
keeps the union to the messages in hand rather than the whole library.
Callers pass MESSAGE posts only — a drop's own provenance rows would read
as images it carries.
"""
owned = select(
ImageRecord.primary_post_id.label("post_id"), ImageRecord.id.label("image_id"),
).where(ImageRecord.primary_post_id.in_(posts))
reposted = select(
ImageProvenance.post_id.label("post_id"),
ImageProvenance.image_record_id.label("image_id"),
).where(ImageProvenance.post_id.in_(posts))
return union(owned, reposted).subquery()
def _candidate_stmt(source_id: int, *, not_after: datetime) -> Select: def _candidate_stmt(source_id: int, *, not_after: datetime) -> Select:
"""Ungrouped Discord message-posts, one representative image each, OLDEST """Ungrouped Discord message-posts, one representative image each, OLDEST
FIRST — which is the order `build_groups` requires. FIRST — which is the order `build_groups` requires.
@@ -168,15 +134,13 @@ def _candidate_stmt(source_id: int, *, not_after: datetime) -> Select:
take the OLDEST candidates instead of the lowest-numbered ones. take the OLDEST candidates instead of the lowest-numbered ones.
""" """
sort_key = func.coalesce(Post.post_date, Post.downloaded_at) sort_key = func.coalesce(Post.post_date, Post.downloaded_at)
carried = _message_images(select(Post.id).where(Post.source_id == source_id))
inner = ( inner = (
select( select(
Post.id.label("post_id"), Post.id.label("post_id"),
sort_key.label("occurred_at"), sort_key.label("occurred_at"),
ImageRecord.siglip_embedding.label("embedding"), ImageRecord.siglip_embedding.label("embedding"),
) )
.join(carried, carried.c.post_id == Post.id) .join(ImageRecord, ImageRecord.primary_post_id == Post.id)
.join(ImageRecord, ImageRecord.id == carried.c.image_id)
.where( .where(
Post.source_id == source_id, Post.source_id == source_id,
# Never absorb a post FC wrote, and never re-absorb one already # Never absorb a post FC wrote, and never re-absorb one already
@@ -322,10 +286,9 @@ async def _link_member_images(
""" """
if not member_ids: if not member_ids:
return 0 return 0
carried = _message_images(member_ids) image_rows = (await session.execute(
image_rows = sorted(set((await session.execute( select(ImageRecord.id).where(ImageRecord.primary_post_id.in_(member_ids))
select(carried.c.image_id) )).scalars().all()
)).scalars().all()))
if not image_rows: if not image_rows:
return 0 return 0
await session.execute( await session.execute(
@@ -449,11 +412,9 @@ async def _group_seed(session: AsyncSession, post_id: int) -> list[float] | None
free to disagree; this way there is one. free to disagree; this way there is one.
""" """
sort_key = func.coalesce(Post.post_date, Post.downloaded_at) sort_key = func.coalesce(Post.post_date, Post.downloaded_at)
carried = _message_images(select(Post.id).where(Post.absorbed_by_post_id == post_id))
return (await session.execute( return (await session.execute(
select(ImageRecord.siglip_embedding) select(ImageRecord.siglip_embedding)
.join(carried, carried.c.image_id == ImageRecord.id) .join(Post, ImageRecord.primary_post_id == Post.id)
.join(Post, Post.id == carried.c.post_id)
.where( .where(
Post.absorbed_by_post_id == post_id, Post.absorbed_by_post_id == post_id,
ImageRecord.siglip_embedding.is_not(None), ImageRecord.siglip_embedding.is_not(None),
@@ -639,352 +600,6 @@ async def join_open_groups(
return joined return joined
# ---------------------------------------------------------------------------
# #4390: a trickle is one drop — later drops of the same piece merge in.
# ---------------------------------------------------------------------------
#
# Operator, 2026-09-24, on a feed of "Grouped from 1 Discord message" cards:
# *"the groups are still single image even when they can clearly be seen as
# group"*. 665 of Yellowroom's 714 drops were one message.
#
# Both paths above join on cosine distance to the group's SEED, and the stages
# of one piece fail it: `svtt_wip4` did not join `svtt_wip3` from the day
# before. A creator trickles a piece out as sketch -> wip -> wip -> release, and
# each stage is nearest to the one before it, not to the first.
#
# Measured on artist 8 before any of this was written (#4390 log):
#
# * phash cannot see it. Stages of one piece sit 68-134 bits apart; unrelated
# pieces by the same artist sit at a median of 126, p5 110. Lesson #4400.
# * The embedding's NEAREST neighbour can. Every stage of three real trickles
# had a sibling stage as its single nearest image in the artist's whole
# library, while siblings further along ranked 40-100 — which is exactly why
# seed distance fails. Negative control over all 137 recent Discord images:
# where the nearest neighbour was another message within 7 days, the two
# carried the same working name 53 times out of 53. Disagreements start past
# 7 days.
# * The working name sees it directly, when there is one.
#
# So a later drop merges into an earlier one when the two are within
# `discord_group_close_after_hours` of each other (168h — the measured 7 days)
# AND either they share a gated LEADING working name, or one's image has the
# other's image as its nearest neighbour. A drop reaching SEVERAL earlier drops
# pulls them all together — unless two of them are named as different pieces,
# in which case nothing moves (see `_compatible`): leaving a drop alone is
# recoverable, a wrong merge asserts that unrelated art belongs together.
#
# Chaining is permitted here and was forbidden above, deliberately. The seed
# rule exists because tiny steps can drift from one piece to another; the
# measured precision of nearest-neighbour inside 7 days is what bounds drift
# for this route, and each link is between neighbours in time, never across a
# quiet week.
# How many unchecked drops one sweep examines per source. A first run over an
# established library drains over successive sweeps, oldest first, rather than
# issuing one nearest-neighbour query per image of the whole history at once.
TRICKLE_BATCH = 300
@dataclass
class _Drop:
post: Post
members: set[int]
first_at: datetime
last_at: datetime
names: set[str]
images: list[int]
nearest: set[int] | None
async def _nearest_message(
session: AsyncSession, *, artist_id: int, image_id: int, exclude: set[int],
) -> int | None:
"""The post that owns the nearest image in the artist's whole library.
The whole LIBRARY, not this source, because that is what was measured: a
neighbour that turns out to be a Patreon re-post simply yields no Discord
drop to merge into, which errs toward leaving things alone. `exclude` is
the drop's own messages — an image is always nearest to its own siblings
in the same drop, which says nothing.
"""
embedding = (await session.execute(
select(ImageRecord.siglip_embedding).where(ImageRecord.id == image_id)
)).scalar_one_or_none()
if embedding is None:
return None
stmt = (
select(ImageRecord.primary_post_id)
.where(
ImageRecord.artist_id == artist_id,
ImageRecord.id != image_id,
ImageRecord.siglip_embedding.is_not(None),
ImageRecord.primary_post_id.is_not(None),
)
.order_by(ImageRecord.siglip_embedding.cosine_distance(embedding))
.limit(1)
)
if exclude:
stmt = stmt.where(ImageRecord.primary_post_id.not_in(exclude))
return (await session.execute(stmt)).scalar_one_or_none()
async def _load_drops(session: AsyncSession, source: Source) -> list[_Drop]:
"""Every live drop of this source, with what the merge rule reads, oldest first."""
posts = (await session.execute(
select(Post).where(
Post.source_id == source.id,
Post.synthesized_by == DROP_GROUPER,
Post.absorbed_by_post_id.is_(None),
)
)).scalars().all()
if not posts:
return []
by_id = {p.id: p for p in posts}
msg_at = func.coalesce(Post.post_date, Post.downloaded_at)
members: dict[int, set[int]] = {pid: set() for pid in by_id}
times: dict[int, list[datetime]] = {pid: [] for pid in by_id}
for mid, owner, at in (await session.execute(
select(Post.id, Post.absorbed_by_post_id, msg_at)
.where(Post.absorbed_by_post_id.in_(list(by_id)))
)).all():
members[owner].add(mid)
times[owner].append(at)
owner_of = {m: d for d, ms in members.items() for m in ms}
names: dict[int, set[str]] = {pid: set() for pid in by_id}
images: dict[int, list[int]] = {pid: [] for pid in by_id}
if owner_of:
carried = _message_images(list(owner_of))
for iid, message, path in (await session.execute(
select(ImageRecord.id, carried.c.post_id, ImageRecord.path)
.join(carried, carried.c.image_id == ImageRecord.id)
.order_by(ImageRecord.id)
)).all():
drop = owner_of[message]
if iid in images[drop]:
continue # one file carried by two of the drop's messages
images[drop].append(iid)
if (name := leading_name(path)) is not None:
names[drop].add(name)
out = []
for pid, post in by_id.items():
if not times[pid]:
continue
stored = (post.synthesis_details or {}).get("nearest_message_ids")
out.append(_Drop(
post=post, members=members[pid],
first_at=min(times[pid]), last_at=max(times[pid]),
names=names[pid], images=images[pid],
nearest=set(stored) if stored is not None else None,
))
return sorted(out, key=lambda d: (d.first_at, d.post.id))
async def _name_posts(session: AsyncSession, artist_id: int) -> Counter[str]:
"""Post-span counts of the artist's working names — the same corpus the
teaser card and the announcement matcher count against."""
by_post: dict[int, list[str]] = {}
for pid, path in (await session.execute(
select(ImageRecord.primary_post_id, ImageRecord.path).where(
ImageRecord.artist_id == artist_id,
ImageRecord.primary_post_id.is_not(None),
)
)).all():
by_post.setdefault(pid, []).append(path)
return token_frequencies(by_post.values())
async def _repoint_associations(
session: AsyncSession, *, from_id: int, to_id: int,
) -> None:
"""Move announcement links from a drop about to merge onto the one it joins.
Without this the merge would silently undo a teaser link: the association's
payload FK cascades on delete. Where the teaser already points at the
surviving drop, the stronger claim is kept — a link over a proposal over a
dismissal — and the duplicate goes.
"""
rank = {"linked": 2, "pending": 1, "dismissed": 0}
moving = (await session.execute(
select(PostAssociation).where(PostAssociation.payload_post_id == from_id)
)).scalars().all()
for a in moving:
existing = (await session.execute(
select(PostAssociation).where(
PostAssociation.announcement_post_id == a.announcement_post_id,
PostAssociation.payload_post_id == to_id,
)
)).scalar_one_or_none()
if existing is None:
a.payload_post_id = to_id
continue
if rank.get(a.status, 0) > rank.get(existing.status, 0):
existing.status = a.status
existing.linked_by = a.linked_by
await session.delete(a)
await session.flush()
async def merge_trickles(
session: AsyncSession,
source: Source,
*,
gap: timedelta,
min_images: int,
cooldown: timedelta,
batch: int = TRICKLE_BATCH,
) -> int:
"""Fold later drops of the same piece into the earlier one. Returns merges."""
drops = await _load_drops(session, source)
if len(drops) < 2:
return 0
name_posts = await _name_posts(session, source.artist_id)
def gated(names: set[str]) -> set[str]:
return {n for n in names if rarity(name_posts.get(n, 0), FAMILY_MAX_POSTS) > 0}
alive: list[_Drop] = []
merged = 0
checked = 0
for drop in drops:
details = drop.post.synthesis_details or {}
if details.get("trickle_checked"):
alive.append(drop)
continue
if checked >= batch:
# Unchecked and out of budget: still a candidate for LATER drops'
# reverse edges, just not examined itself this run.
alive.append(drop)
continue
checked += 1
if drop.nearest is None:
found: set[int] = set()
for iid in drop.images:
pid = await _nearest_message(
session, artist_id=source.artist_id, image_id=iid,
exclude=drop.members,
)
if pid is not None:
found.add(pid)
drop.nearest = found
mine = gated(drop.names)
targets: dict[int, tuple[_Drop, str]] = {}
for earlier in alive:
if drop.first_at - earlier.last_at > gap:
continue
shared = mine & gated(earlier.names)
if set(drop.images) & set(earlier.images):
# The creator posted the very same file again — the strongest
# evidence there is, and one nearest-neighbour cannot see: it
# skips the image itself, which is the one they share.
targets[earlier.post.id] = (earlier, "same_image")
elif shared:
targets[earlier.post.id] = (earlier, f"name:{min(shared)}")
elif drop.nearest & earlier.members or (earlier.nearest or set()) & drop.members:
targets[earlier.post.id] = (earlier, "nearest")
record = dict(details)
record["nearest_message_ids"] = sorted(drop.nearest)
record["trickle_checked"] = True
drop.post.synthesis_details = record
if not targets or not _compatible(
[gated(t.names) for t, _route in targets.values()] + [mine]
):
alive.append(drop)
continue
# Every target is the same piece as this drop, so they are the same
# piece as each other: fold them all into the earliest, then this drop.
ordered = sorted(targets.values(), key=lambda tr: (tr[0].first_at, tr[0].post.id))
into = ordered[0][0]
for other, route in ordered[1:]:
await _merge_drop(
session, into=into, drop=other, route=route,
min_images=min_images, cooldown=cooldown,
)
alive.remove(other)
merged += 1
await _merge_drop(
session, into=into, drop=drop, route=ordered[0][1],
min_images=min_images, cooldown=cooldown,
)
merged += 1
return merged
def _compatible(name_sets: list[set[str]]) -> bool:
"""May drops carrying these working names become one post?
Refused only when two of them are NAMED AS DIFFERENT PIECES — both carry a
gated name, and they share none. An unnamed drop (a canvas screenshot)
fits anywhere, which is the whole of the Marin case: two early stages both
nearest to the same later one are one trickle, not an ambiguity.
What it does NOT refuse is a drop the creator made two pieces in
themselves. Measured on artist 8: one November message carries both
`AdL01_wip4` and `Year_20k_wip_z4`, so its drop holds both names, and a
later drop of either piece joins it on its own name. That is the creator's
co-posting carried forward — Discord shows those two together too — not a
bridge FC built.
"""
named = [n for n in name_sets if n]
return all(a & b for i, a in enumerate(named) for b in named[i + 1:])
async def _merge_drop(
session: AsyncSession,
*,
into: _Drop,
drop: _Drop,
route: str,
min_images: int,
cooldown: timedelta,
) -> None:
"""Absorb `drop`'s messages into `into`, carry its links over, delete it.
Growth is stamped at the merged messages' OWN time, not the wall clock.
Merging history must not drag a two-year-old drop to the top of the feed,
and the time the group actually grew is when those messages arrived.
"""
await _repoint_associations(session, from_id=drop.post.id, to_id=into.post.id)
grew_before = into.post.last_grew_at
await _absorb_into(
session, group=into.post, member_ids=sorted(drop.members),
source_id=into.post.source_id, now=drop.last_at,
min_images=min_images, cooldown=cooldown,
)
# Never backwards: a group that already grew later than these messages
# keeps that later date.
if grew_before is not None and grew_before > drop.last_at:
into.post.last_grew_at = grew_before
details = dict(into.post.synthesis_details or {})
if grew_before is not None and grew_before > drop.last_at:
details["last_grew_at"] = grew_before.isoformat()
# The honesty rule, extended: a grouping FC invented says what it was
# built from, and a merge says WHY — "name:svtt" or "nearest".
details["merged"] = [
*details.get("merged", []),
{"post_id": drop.post.id, "route": route, "message_ids": sorted(drop.members)},
]
details["nearest_message_ids"] = sorted((into.nearest or set()) | (drop.nearest or set()))
into.post.synthesis_details = details
into.members |= drop.members
into.names |= drop.names
into.images += drop.images
into.nearest = (into.nearest or set()) | (drop.nearest or set())
into.last_at = max(into.last_at, drop.last_at)
await session.execute(delete(ImageProvenance).where(ImageProvenance.post_id == drop.post.id))
await session.delete(drop.post)
await session.flush()
async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict: async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict:
"""Group every enabled Discord source. No-op when the switch is off. """Group every enabled Discord source. No-op when the switch is off.
@@ -997,7 +612,6 @@ async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict:
if not settings.discord_grouping_enabled: if not settings.discord_grouping_enabled:
return { return {
"enabled": False, "sources": 0, "posts_created": 0, "images_joined": 0, "enabled": False, "sources": 0, "posts_created": 0, "images_joined": 0,
"drops_merged": 0,
} }
sources = (await session.execute( sources = (await session.execute(
@@ -1012,7 +626,6 @@ async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict:
created = 0 created = 0
joined = 0 joined = 0
merged = 0
for source in sources: for source in sources:
joined += await join_open_groups( joined += await join_open_groups(
session, source, session, source,
@@ -1031,20 +644,12 @@ async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict:
window_minutes=window_minutes, window_minutes=window_minutes,
now=now, now=now,
) )
# Last, so the drops the two passes above just wrote are merged in
# the same sweep rather than showing as singletons for an hour.
merged += await merge_trickles(
session, source,
gap=timedelta(hours=float(settings.discord_group_close_after_hours)),
min_images=int(settings.discord_group_resurface_min_images),
cooldown=timedelta(hours=float(settings.discord_group_resurface_cooldown_hours)),
)
log.info( log.info(
"discord drop grouping: %d source(s), %d synthetic post(s) created, " "discord drop grouping: %d source(s), %d synthetic post(s) created, "
"%d image(s) joined to open groups, %d trickle drop(s) merged", "%d image(s) joined to open groups",
len(sources), created, joined, merged, len(sources), created, joined,
) )
return { return {
"enabled": True, "sources": len(sources), "enabled": True, "sources": len(sources),
"posts_created": created, "images_joined": joined, "drops_merged": merged, "posts_created": created, "images_joined": joined,
} }
-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)
+8 -24
View File
@@ -23,22 +23,16 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from .discord_ingester import DiscordIngester
from .gallery_dl import DownloadResult, ErrorType from .gallery_dl import DownloadResult, ErrorType
from .ingest_core import DEFAULT_REVISIT_DAYS
from .patreon_ingester import PatreonIngester from .patreon_ingester import PatreonIngester
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
from .platforms import known_platform_keys from .platforms import known_platform_keys
from .subscribestar_ingester import SubscribeStarIngester from .subscribestar_ingester import SubscribeStarIngester
# Platforms whose download + verify go through the native ingester rather than # Platforms whose download + verify go through the native ingester rather than
# gallery-dl. gallery-dl still serves the rest (hentaifoundry) until it # gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord) until
# migrates too. Discord joined in milestone 428. # they migrate too.
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "discord"}) NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar"})
# 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: def _unsupported_platform_message(platform: str) -> str | None:
@@ -72,7 +66,6 @@ def _native_ingester_cls(platform: str):
return { return {
"patreon": PatreonIngester, "patreon": PatreonIngester,
"subscribestar": SubscribeStarIngester, "subscribestar": SubscribeStarIngester,
"discord": DiscordIngester,
}[platform] }[platform]
@@ -90,7 +83,6 @@ async def run_download(
mode: str | None, mode: str | None,
gdl, gdl,
sync_session_factory, sync_session_factory,
revisit_days: int = DEFAULT_REVISIT_DAYS,
) -> tuple[DownloadResult, str | None]: ) -> tuple[DownloadResult, str | None]:
"""Uniform download across backends — the download counterpart to """Uniform download across backends — the download counterpart to
`verify_source_credential`, so this module is the ONE place that knows how `verify_source_credential`, so this module is the ONE place that knows how
@@ -114,7 +106,7 @@ async def run_download(
), None ), None
if uses_native_ingester(platform): if uses_native_ingester(platform):
return await _run_native_ingester( 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( result = await gdl.download(
url=ctx["url"], url=ctx["url"],
@@ -132,10 +124,10 @@ async def _resolve_native_campaign_id(
platform: str, url: str, cookies_path: str | None, overrides: dict, platform: str, url: str, cookies_path: str | None, overrides: dict,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
"""`(campaign_id, resolved_campaign_id)` for a native source. SubscribeStar's """`(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 feed id IS the creator URL (no lookup → resolved None). Patreon resolves the
resolves the campaign id from the vanity URL (resolved non-None when a lookup campaign id from the vanity URL (resolved non-None when a lookup actually ran,
actually ran, so phase 3 caches it).""" so phase 3 caches it)."""
if platform in _URL_IS_FEED: if platform == "subscribestar":
return url, None return url, None
return await resolve_campaign_id_for_source(url, cookies_path, overrides) return await resolve_campaign_id_for_source(url, cookies_path, overrides)
@@ -154,7 +146,6 @@ def _campaign_resolution_error(platform: str, url: str) -> str:
async def _run_native_ingester( async def _run_native_ingester(
ctx: dict, source_config, mode: str | None, gdl, sync_session_factory, ctx: dict, source_config, mode: str | None, gdl, sync_session_factory,
revisit_days: int = DEFAULT_REVISIT_DAYS,
) -> tuple[DownloadResult, str | None]: ) -> tuple[DownloadResult, str | None]:
"""Run the native ingester for a native platform in a worker thread (sync """Run the native ingester for a native platform in a worker thread (sync
requests/subprocess). Patreon resolves a campaign id from the vanity URL; requests/subprocess). Patreon resolves a campaign id from the vanity URL;
@@ -219,9 +210,6 @@ async def _run_native_ingester(
mode=mode, mode=mode,
resume_cursor=source_config.resume_cursor, resume_cursor=source_config.resume_cursor,
time_budget_seconds=source_config.timeout, 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)), posts_base=int(overrides.get("_backfill_posts", 0)),
# plan #709: live progress writes to this running event mid-walk. # plan #709: live progress writes to this running event mid-walk.
event_id=ctx.get("event_id"), event_id=ctx.get("event_id"),
@@ -258,10 +246,6 @@ async def verify_source_credential(
from .subscribestar_ingester import verify_subscribestar_credential from .subscribestar_ingester import verify_subscribestar_credential
return await verify_subscribestar_credential(url, cookies_path, config_overrides) return await verify_subscribestar_credential(url, cookies_path, config_overrides)
if platform == "discord":
from .discord_ingester import verify_discord_credential
return await verify_discord_credential(url, auth_token)
from .patreon_ingester import verify_patreon_credential from .patreon_ingester import verify_patreon_credential
return await verify_patreon_credential(url, cookies_path, config_overrides) return await verify_patreon_credential(url, cookies_path, config_overrides)
-16
View File
@@ -39,7 +39,6 @@ from .gallery_dl import (
walk_completed, walk_completed,
) )
from .importer import Importer from .importer import Importer
from .ingest_core import DEFAULT_REVISIT_DAYS
from .platforms import auth_type_for from .platforms import auth_type_for
from .scheduler_service import set_platform_cooldown from .scheduler_service import set_platform_cooldown
@@ -61,7 +60,6 @@ class DownloadService:
importer: Importer, importer: Importer,
cred_service: CredentialService, cred_service: CredentialService,
sync_session_factory=None, sync_session_factory=None,
revisit_days: int = DEFAULT_REVISIT_DAYS,
): ):
self.async_session = async_session self.async_session = async_session
self.sync_session = sync_session self.sync_session = sync_session
@@ -73,12 +71,6 @@ class DownloadService:
# the multi-minute walk — see PatreonIngester). Only the patreon branch # the multi-minute walk — see PatreonIngester). Only the patreon branch
# of phase 2 uses it; gallery-dl sources leave it None. # of phase 2 uses it; gallery-dl sources leave it None.
self.sync_session_factory = sync_session_factory 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: async def download_source(self, source_id: int) -> int:
"""Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is.""" """Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is."""
@@ -186,7 +178,6 @@ class DownloadService:
return await run_download( return await run_download(
ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode, ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode,
gdl=self.gdl, sync_session_factory=self.sync_session_factory, 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]: async def _phase1_setup(self, source_id: int) -> dict[str, Any]:
@@ -423,13 +414,6 @@ class DownloadService:
await loop.run_in_executor(None, _upsert) 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 # #830 recapture: backfill source_filehash on EXISTING on-disk images so
# their post-body inline <img src=CDN> remaps to the local copy. A # their post-body inline <img src=CDN> remaps to the local copy. A
# SEPARATE non-deleting channel (NOT the import list — that would unlink # SEPARATE non-deleting channel (NOT the import list — that would unlink
+8 -186
View File
@@ -21,9 +21,6 @@ from .source_service import BACKFILL_MAX_CHUNKS
log = logging.getLogger(__name__) 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): class UnknownPlatformError(Exception):
"""URL didn't match any platform pattern.""" """URL didn't match any platform pattern."""
@@ -33,10 +30,6 @@ class InvalidUrlError(Exception):
"""URL was empty or missing a scheme.""" """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 # Mirrored byte-for-byte from extension/lib/platforms.js
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand — # PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
# reviewers catch drift. # reviewers catch drift.
@@ -62,39 +55,8 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)", r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
re.IGNORECASE, 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+)?/?(?:[?#].*)?$",
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: class ExtensionService:
def __init__(self, session: AsyncSession, crypto=None) -> None: def __init__(self, session: AsyncSession, crypto=None) -> None:
@@ -103,42 +65,23 @@ class ExtensionService:
# add-time. None → skip resolution, fall back to the handle. # add-time. None → skip resolution, fall back to the handle.
self._crypto = crypto self._crypto = crypto
async def quick_add_source( async def quick_add_source(self, url: str) -> dict:
self,
url: str,
*,
artist_id: int | None = None,
artist_name: str | None = None,
) -> 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."""
platform, raw_slug = self._derive(url) platform, raw_slug = self._derive(url)
url = canonical_source_url(platform, url, raw_slug)
# Identity by SOURCE handle (#130): an existing (platform, url) source # Identity by SOURCE handle (#130): an existing (platform, url) source
# keeps its artist on re-add — even if that artist was since renamed (its # 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 # frozen slug no longer matches the current name). Only a genuinely new
# add named a different artist. Only a genuinely new source # source resolves/creates an artist.
# resolves/creates an artist. existing = (await self.session.execute(
existing = await self._existing_source(platform, url) select(Source).where(Source.platform == platform, Source.url == url)
)).scalar_one_or_none()
if existing is not None: if existing is not None:
artist = (await self.session.execute( artist = (await self.session.execute(
select(Artist).where(Artist.id == existing.artist_id) select(Artist).where(Artist.id == existing.artist_id)
)).scalar_one() )).scalar_one()
return self._shape(existing, artist, created_source=False, created_artist=False) return self._shape(existing, artist, created_source=False, created_artist=False)
if artist_id is not None: # New source → name the artist properly by resolving the real display
artist = (await self.session.execute( # name from the platform (falls back to the URL handle).
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
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) name = await self._resolve_artist_name(platform, raw_slug, url)
artist, created_artist = await self._find_or_create_artist(name) artist, created_artist = await self._find_or_create_artist(name)
source, created_source = await self._find_or_create_source( source, created_source = await self._find_or_create_source(
@@ -146,20 +89,6 @@ class ExtensionService:
) )
return self._shape(source, artist, created_source, created_artist) return self._shape(source, artist, created_source, created_artist)
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)
@staticmethod @staticmethod
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict: def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
return { return {
@@ -188,11 +117,6 @@ class ExtensionService:
platforms (and any failure — no credential, network error) fall back to platforms (and any failure — no credential, network error) fall back to
the URL handle, which is already readable. the URL handle, which is already readable.
The resolvers are sync, so they run in an executor.""" 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}"
if self._crypto is None or platform not in ("patreon", "subscribestar"): if self._crypto is None or platform not in ("patreon", "subscribestar"):
return raw_slug return raw_slug
import asyncio import asyncio
@@ -242,8 +166,6 @@ class ExtensionService:
platform, raw_slug = self._derive(url) platform, raw_slug = self._derive(url)
except (UnknownPlatformError, InvalidUrlError): except (UnknownPlatformError, InvalidUrlError):
return {"state": "unknown_platform"} return {"state": "unknown_platform"}
if platform == DISCORD:
return await self._probe_discord(raw_slug)
slug = slugify(raw_slug) slug = slugify(raw_slug)
artist = (await self.session.execute( artist = (await self.session.execute(
@@ -283,106 +205,6 @@ class ExtensionService:
}, },
} }
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,
},
}
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]: def _derive(self, url: str) -> tuple[str, str]:
if not isinstance(url, str) or not url.strip(): if not isinstance(url, str) or not url.strip():
raise InvalidUrlError("url is empty") raise InvalidUrlError("url is empty")
+73 -12
View File
@@ -17,7 +17,6 @@ import subprocess
import sys import sys
import tempfile import tempfile
import time import time
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import StrEnum from enum import StrEnum
@@ -95,6 +94,48 @@ BACKFILL_CHUNK_SECONDS = 600
_DEFAULT_GDL_TIMEOUT_SECONDS = 870 _DEFAULT_GDL_TIMEOUT_SECONDS = 870
# --- Discord naming ---------------------------------------------------------
#
# Derived from a REAL sidecar (operator's instance, 2026-09-13), not from memory
# of gallery-dl's extractor. What gallery-dl's discord extractor actually emits
# for an attachment: `channel` is a plain STRING (the channel's name), the
# message is `message_id`, the attachment's position in it is `num`, and there
# is NO `id` key at all.
#
# The previous patterns asked for `{channel[name]}` and `{id}`. Both render as
# "None", so every Discord download since the platform was added landed in a
# directory called `None` as `<date>_None_<original name>`. Worse, the sidecar was
# named `{filename}.json` — the attachment's ORIGINAL name — which (a) `find_
# sidecar` can never pair with `<date>_None_<name>.png`, so no Discord file ever
# got a Post or a post date, and (b) collides: every `image.png` in a channel
# overwrote the same `image.json`, so the one sidecar that survived described
# whichever message happened to be written last.
#
# The fix names the sidecar EXACTLY like the media minus its extension, so
# `find_sidecar`'s first candidate (`media.with_suffix(".json")`) is the match
# and the name is unique per attachment. tests/test_gallery_dl_naming.py renders
# these patterns against a sanitized copy of the real sidecar, so a key that
# does not exist fails CI instead of silently becoming "None".
DISCORD_FILENAME = "{date:%Y%m%d}_{message_id}_{num:>02}_{filename}.{extension}"
DISCORD_DIRECTORY = ["{channel}"]
def sidecar_name_for(media_pattern: str) -> str | None:
"""The metadata filename pattern that names a sidecar exactly like its media.
Returns None for a pattern that does not end in `.{extension}`, since then
there is no media stem to mirror and the caller must fall back.
"""
suffix = ".{extension}"
if not media_pattern.endswith(suffix):
return None
return media_pattern[: -len(suffix)] + ".json"
def metadata_postprocessor(filename: str) -> dict:
return {"name": "metadata", "mode": "json", "directory": ".", "filename": filename}
def archive_path(images_root: Path) -> Path: def archive_path(images_root: Path) -> Path:
"""gallery-dl's download archive: the record of what it has already fetched. """gallery-dl's download archive: the record of what it has already fetched.
@@ -189,13 +230,6 @@ class DownloadResult:
# the platform cooldown matches the hint instead of a flat default. None when # the platform cooldown matches the hint instead of a flat default. None when
# unknown (no header, or not a rate-limit failure). # unknown (no header, or not a rate-limit failure).
retry_after_seconds: float | None = None 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: def extract_errors_warnings(stderr: str) -> str:
@@ -383,16 +417,27 @@ class GalleryDLService:
# (services/patreon_ingester.py), not gallery-dl. # (services/patreon_ingester.py), not gallery-dl.
PLATFORM_DEFAULTS = { PLATFORM_DEFAULTS = {
# subscribestar removed — native-ingester platform now (#71); pixiv # subscribestar removed — native-ingester platform now (#71); pixiv
# removed likewise (#129); discord likewise (milestone 428, whose # removed likewise (#129); deviantart removed at #3069 as a dropped
# downloader keeps this config's on-disk naming); deviantart removed at # platform, not a migrated one. The remaining entries are the
# #3069 as a dropped platform, not a migrated one. HentaiFoundry is the # gallery-dl platforms not yet migrated.
# one platform left here, by the operator's choice not to migrate it.
"hentaifoundry": { "hentaifoundry": {
"content_types": ["all"], "content_types": ["all"],
"directory": [], "directory": [],
"filename": "{category}_{index:>03}_{title[:50]}.{extension}", "filename": "{category}_{index:>03}_{title[:50]}.{extension}",
"include": "all", "include": "all",
}, },
"discord": {
"content_types": ["all"],
"directory": DISCORD_DIRECTORY,
"filename": DISCORD_FILENAME,
# Overrides the global `{filename}.json` sidecar for this extractor
# only — see the Discord naming note above.
"postprocessors": [metadata_postprocessor(sidecar_name_for(DISCORD_FILENAME))],
"embeds": "all",
"stickers": True,
"reactions": False,
"threads": True,
},
} }
def __init__( def __init__(
@@ -507,6 +552,17 @@ class GalleryDLService:
if source_config.filename_pattern: if source_config.filename_pattern:
platform_section["filename"] = source_config.filename_pattern platform_section["filename"] = source_config.filename_pattern
# A platform that names its sidecar after its media must keep doing so
# under a per-source filename override, or the pairing breaks exactly the
# way Discord's did. No metadata wanted means no platform postprocessor
# either — the global list was already dropped above.
if "postprocessors" in platform_section:
mirrored = sidecar_name_for(platform_section.get("filename") or "")
if not source_config.save_metadata or mirrored is None:
platform_section.pop("postprocessors")
else:
platform_section["postprocessors"] = [metadata_postprocessor(mirrored)]
platform_section["metadata"] = source_config.save_metadata platform_section["metadata"] = source_config.save_metadata
return config return config
@@ -754,6 +810,9 @@ class GalleryDLService:
if cookies_path: if cookies_path:
config["extractor"]["cookies"] = 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( with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, dir=str(self._config_dir), mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
@@ -937,6 +996,8 @@ class GalleryDLService:
config = self._build_config_for_source(platform, source_config, artist_slug) config = self._build_config_for_source(platform, source_config, artist_slug)
if cookies_path: if cookies_path:
config["extractor"]["cookies"] = cookies_path config["extractor"]["cookies"] = cookies_path
if auth_token and platform == "discord":
config["extractor"].setdefault("discord", {})["token"] = auth_token
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, dir=str(self._config_dir), mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
+9 -238
View File
@@ -31,12 +31,10 @@ import json
import logging import logging
import time import time
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, func, select, text from sqlalchemy import delete, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import ImageRecord
from .gallery_dl import ( from .gallery_dl import (
DownloadResult, DownloadResult,
ErrorType, ErrorType,
@@ -53,33 +51,6 @@ log = logging.getLogger(__name__)
# per-file HEADs. Headroom against paywalled/undownloadable items interleaving. # per-file HEADs. Headroom against paywalled/undownloadable items interleaving.
_TICK_SEEN_THRESHOLD = 20 _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 # plan #705 #7: after this many failed download/validate attempts a media is
# "dead-lettered" and skipped on routine tick/backfill walks (recovery still # "dead-lettered" and skipped on routine tick/backfill walks (recovery still
# re-attempts it). Stops a permanently-broken media re-erroring forever. # re-attempts it). Stops a permanently-broken media re-erroring forever.
@@ -106,44 +77,6 @@ _LIVE_PROGRESS_INTERVAL = 5.0
# recapture (the operator's schema-test flow) reaches the sample. # recapture (the operator's schema-test flow) reaches the sample.
_CANARY_MIN_SAMPLE = 30 _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: class Ingester:
"""Generic native-ingest orchestration. Subclass with a platform adapter """Generic native-ingest orchestration. Subclass with a platform adapter
@@ -199,17 +132,11 @@ class Ingester:
resume_cursor: str | None = None, resume_cursor: str | None = None,
time_budget_seconds: float = 870.0, time_budget_seconds: float = 870.0,
seen_threshold: int = _TICK_SEEN_THRESHOLD, seen_threshold: int = _TICK_SEEN_THRESHOLD,
revisit_days: int = DEFAULT_REVISIT_DAYS,
posts_base: int = 0, posts_base: int = 0,
event_id: int | None = None, event_id: int | None = None,
) -> DownloadResult: ) -> DownloadResult:
"""Walk + download for one source, returning a gallery-dl-shaped result. """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 `mode` is "tick" | "backfill" | "recovery" | "recapture". Recovery
bypasses the tier-1 seen-ledger AND the dead-letter ledger (tier-2 disk 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 still skips kept files). Recapture (#830) is the cheap "re-grab post
@@ -252,29 +179,6 @@ class Ingester:
# no media download, no post-record stub. Absent on stub/not-yet-migrated # no media download, no post-record stub. Absent on stub/not-yet-migrated
# clients → nothing is ever treated as gated. # clients → nothing is ever treated as gated.
post_is_gated = getattr(self.client, "post_is_gated", None) 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() start = time.monotonic()
last_live = start # plan #709: last live-progress write timestamp last_live = start # plan #709: last live-progress write timestamp
log_lines: list[str] = [] log_lines: list[str] = []
@@ -286,9 +190,6 @@ class Ingester:
# source_filehash and (b) link the on-disk image to its Post (#1288) — # source_filehash and (b) link the on-disk image to its Post (#1288) —
# WITHOUT re-downloading or unlinking the file. Empty outside recapture. # WITHOUT re-downloading or unlinking the file. Empty outside recapture.
relink: list[tuple[str, str, str]] = [] 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 downloaded = 0
errors = 0 errors = 0
quarantined = 0 quarantined = 0
@@ -309,18 +210,11 @@ class Ingester:
# absolute across chunks instead of an inflating sum. posts_processed # absolute across chunks instead of an inflating sum. posts_processed
# stays the gross per-chunk count used for the run summary. # stays the gross per-chunk count used for the run summary.
chunk_new_posts = 0 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 consecutive_seen = 0
emitted_cursor: str | None = None emitted_cursor: str | None = None
reached_bottom = False reached_bottom = False
budget_hit = False budget_hit = False
early_out = 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 stopped = False # plan #708 B4: operator hit Stop mid-walk
cancel_armed = False # latched once we observe a live "running" state cancel_armed = False # latched once we observe a live "running" state
@@ -342,7 +236,6 @@ class Ingester:
written_paths=written, written_paths=written,
post_record_paths=list(post_records), post_record_paths=list(post_records),
relink_source_paths=list(relink), relink_source_paths=list(relink),
mark_seen_after_import=lambda: self._mark_seen(source_id, fetched),
stdout="\n".join(log_lines), stdout="\n".join(log_lines),
stderr="", stderr="",
return_code=return_code, return_code=return_code,
@@ -418,17 +311,7 @@ class Ingester:
# Time-box check at the post boundary (coarse, like a gallery-dl # Time-box check at the post boundary (coarse, like a gallery-dl
# chunk). Backfill/recovery resume from emitted_cursor next chunk. # chunk). Backfill/recovery resume from emitted_cursor next chunk.
# The second half is phase 3's share of the task — see if time.monotonic() - start >= time_budget_seconds:
# 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
):
budget_hit = True budget_hit = True
break break
@@ -439,20 +322,6 @@ class Ingester:
# resume_cursor None, so everything counts. # resume_cursor None, so everything counts.
if not (resume_cursor and page_cursor == resume_cursor): if not (resume_cursor and page_cursor == resume_cursor):
chunk_new_posts += 1 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 # Tier-gated post (#874): the account can't fully view it, so
# Patreon serves only blurred locked-preview media. Skip it # Patreon serves only blurred locked-preview media. Skip it
# ENTIRELY — no media download AND no post-record stub (operator # ENTIRELY — no media download AND no post-record stub (operator
@@ -484,28 +353,8 @@ class Ingester:
set() if recapture_records set() if recapture_records
else self._seen_keys(source_id, [pkey]) else self._seen_keys(source_id, [pkey])
) )
post_already_recorded = pkey in already if pkey not in already:
# A post inside the revisit window is re-read even rec = write_post_record(post, artist_slug)
# 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 posts_recorded += 1
if rec.body_chars: if rec.body_chars:
posts_with_body += 1 posts_with_body += 1
@@ -518,8 +367,7 @@ class Ingester:
# a 0-char body is the "why is this one empty" answer. # a 0-char body is the "why is this one empty" answer.
log_lines.append( log_lines.append(
f" post {ppid} [{rec.post_type or '?'}] " 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") + ("" if rec.body_chars else " — EMPTY")
+ (f" — {rec.title}" if rec.title else "") + (f" — {rec.title}" if rec.title else "")
) )
@@ -552,13 +400,6 @@ class Ingester:
recapture=recapture, 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_mark: list[tuple[str, str]] = []
to_clear: list[str] = [] # recovered → drop any dead-letter row to_clear: list[str] = [] # recovered → drop any dead-letter row
to_fail: list[tuple[str, str, str]] = [] # (key, post_id, error) to_fail: list[tuple[str, str, str]] = [] # (key, post_id, error)
@@ -570,29 +411,11 @@ class Ingester:
downloaded += 1 downloaded += 1
if outcome.path is not None: if outcome.path is not None:
written.append(str(outcome.path)) written.append(str(outcome.path))
fetched.append((key, media_item.post_id)) to_mark.append((key, media_item.post_id))
to_clear.append(key) to_clear.append(key)
consecutive_seen = 0 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": 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 # 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 # do NOT re-feed it to phase 3 — attach_in_place would see
# the duplicate sha256 and unlink the on-disk copy. # the duplicate sha256 and unlink the on-disk copy.
@@ -628,15 +451,7 @@ class Ingester:
to_fail.append((key, media_item.post_id, outcome.error or "error")) to_fail.append((key, media_item.post_id, outcome.error or "error"))
# An error neither advances nor resets the run-of-seen. # An error neither advances nor resets the run-of-seen.
# `not in_window` is the revisit window's half of the if mode == "tick" and consecutive_seen >= seen_threshold:
# 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
):
early_out = True early_out = True
break break
@@ -650,21 +465,6 @@ class Ingester:
if to_fail: if to_fail:
self._record_failures(source_id, 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 # plan #709: time-throttled live progress to the running event so
# the Downloads view ticks ~every 5s, independent of page size. # the Downloads view ticks ~every 5s, independent of page size.
now = time.monotonic() now = time.monotonic()
@@ -683,15 +483,9 @@ class Ingester:
}) })
if early_out: if early_out:
if skip_feed is None:
break break
skip_feed()
feeds_caught_up += 1
early_out = False
consecutive_seen = 0
else: else:
# A walk that left feeds early did not read to their ends. reached_bottom = True
reached_bottom = not feeds_caught_up
except self._error_base as exc: except self._error_base as exc:
# The platform's client-error base — _failure_result (adapter) # The platform's client-error base — _failure_result (adapter)
# maps it to a typed error. # maps it to a typed error.
@@ -733,13 +527,6 @@ class Ingester:
# visible in the Raw stdout (e.g. "bodies 3/180" reads as off). # 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", bodies {posts_with_body}/{posts_recorded}" if posts_recorded else "")
+ (f", {gated_skipped} gated-skipped" if gated_skipped 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 "") + (", reached end" if reached_bottom else "")
+ (", time-boxed" if budget_hit else "") + (", time-boxed" if budget_hit else "")
) )
@@ -753,13 +540,7 @@ class Ingester:
# next chunk resumes from the emitted cursor. No progress → TIMEOUT, # next chunk resumes from the emitted cursor. No progress → TIMEOUT,
# which feeds download_service's backfill stall-guard. rc<0 mirrors # which feeds download_service's backfill stall-guard. rc<0 mirrors
# subprocess TimeoutExpired so completion detection stays false. # subprocess TimeoutExpired so completion detection stays false.
# Work handed to phase 3 is progress too: a recapture chunk that made_progress = downloaded > 0 or emitted_cursor != resume_cursor
# 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
)
if made_progress: if made_progress:
return _result( return _result(
success=False, return_code=-1, success=False, return_code=-1,
@@ -965,16 +746,6 @@ class Ingester:
) )
session.commit() 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: 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. """Idempotent upsert of (filehash, post_id) seen-ledger rows for a page.
-52
View File
@@ -35,58 +35,6 @@ DEFAULT_SIM_THRESHOLD = 0.85
_FIGURE_KINDS = ("face", "figure") _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: async def _settings_threshold(session: AsyncSession) -> float:
val = ( val = (
await session.execute( await session.execute(
+3 -12
View File
@@ -11,21 +11,12 @@ from pathlib import Path
import numpy as np import numpy as np
from PIL import Image, ImageFile from PIL import Image, ImageFile
from ..worker_lanes import LANES_BY_NAME
ImageFile.LOAD_TRUNCATED_IMAGES = True ImageFile.LOAD_TRUNCATED_IMAGES = True
# Cap torch's intra-op threads so each ml-worker replica is a bounded core # 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). # consumer on a shared node (torch otherwise uses all cores). Keep
# # N_replicas × this within the cores allotted to ML to avoid oversubscription.
# Read from the lane rather than restated here. This was a literal 4 beside a _INTRA_OP_THREADS = 4
# 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
DEFAULT_MODEL_NAME = os.environ.get( DEFAULT_MODEL_NAME = os.environ.get(
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384" "SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
+2 -8
View File
@@ -483,14 +483,8 @@ class PatreonClient:
@staticmethod @staticmethod
def post_meta(post: dict) -> dict: def post_meta(post: dict) -> dict:
"""Title + published date for a post. Part of the client contract. """Title + published date for a post — for the preview sample (plan #708
B4). Part of the client contract `ingest_core.Ingester.preview` calls."""
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.
"""
attrs = post.get("attributes") or {} attrs = post.get("attributes") or {}
title = attrs.get("title") title = attrs.get("title")
published = attrs.get("published_at") published = attrs.get("published_at")
+4 -32
View File
@@ -340,7 +340,7 @@ class PatreonDownloader(BaseNativeDownloader):
def _write_sidecar_data( def _write_sidecar_data(
self, post: dict, sidecar_path: Path, *, source_url: str | None = None, self, post: dict, sidecar_path: Path, *, source_url: str | None = None,
minimal: bool = False, detail_fetch: bool = True, minimal: bool = False,
) -> Path: ) -> Path:
"""Serialize the post's metadata to `sidecar_path`. The post-only record """Serialize the post's metadata to `sidecar_path`. The post-only record
(`write_post_record`) writes the FULL post (body/title/date/url); the (`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 # 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 # body-length read reuses it, and a fully-seen post (no fresh download → no
# sidecar write) never pays the extra GET. # sidecar write) never pays the extra GET.
# `detail_fetch=False` on a REVISIT (a post inside the tick's revisit if (not content or not content.strip()) and self._content_fetcher:
# 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:
fetched = self._content_fetcher(str(post.get("id") or "")) fetched = self._content_fetcher(str(post.get("id") or ""))
if fetched: if fetched:
content = fetched content = fetched
@@ -392,9 +386,7 @@ class PatreonDownloader(BaseNativeDownloader):
sidecar_path.write_text(json.dumps(data, indent=2)) sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path return sidecar_path
def write_post_record( def write_post_record(self, post: dict, artist_slug: str) -> PostRecordOutcome:
self, post: dict, artist_slug: str, *, revisit: bool = False,
) -> PostRecordOutcome:
"""Write a post-ONLY sidecar (no media file) for a media-less post, so """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 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 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 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 the captured body's shape — post_type + final char count — so the engine
can log per-post handling without re-reading the post itself. 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 {} attrs = post.get("attributes") or {}
title = attrs.get("title") if isinstance(attrs.get("title"), str) else None title = attrs.get("title") if isinstance(attrs.get("title"), str) else None
@@ -426,17 +406,9 @@ class PatreonDownloader(BaseNativeDownloader):
return PostRecordOutcome( return PostRecordOutcome(
path=None, post_type=post_type, title=title, body_chars=0, 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 = self.images_root / artist_slug / "patreon" / post_dir_name(post)
post_dir.mkdir(parents=True, exist_ok=True) post_dir.mkdir(parents=True, exist_ok=True)
path = self._write_sidecar_data( path = self._write_sidecar_data(post, post_dir / "_post.json")
post, post_dir / "_post.json", detail_fetch=not revisit,
)
# _write_sidecar_data has by now memoized any detail-fetched body onto # _write_sidecar_data has by now memoized any detail-fetched body onto
# post["attributes"]["content"], so re-read it for the FINAL char count. # post["attributes"]["content"], so re-read it for the FINAL char count.
body = (post.get("attributes") or {}).get("content") body = (post.get("attributes") or {}).get("content")
+2 -4
View File
@@ -23,10 +23,8 @@ log = logging.getLogger(__name__)
# Platforms walked one-at-a-time. gallery-dl platforms are intentionally NOT # 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 # 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). # 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 # Add a platform here to cap it to a single concurrent walk.
# all: every source walks on the operator's ONE user token, and parallel walks SERIALIZED_PLATFORMS = frozenset({"patreon", "subscribestar"})
# on a user account are both how its rate limit trips and what gets it flagged.
SERIALIZED_PLATFORMS = frozenset({"patreon", "subscribestar", "discord"})
_LOCK_PREFIX = "fc:download_lock:" _LOCK_PREFIX = "fc:download_lock:"
+52 -506
View File
@@ -15,56 +15,24 @@ where they already were, a wrong one actively misinforms and then propagates
into whatever reads the association. So the matcher's job is to make a SHORT into whatever reads the association. So the matcher's job is to make a SHORT
list worth reading, not a long list worth trusting. list worth reading, not a long list worth trusting.
## Two routes, because the evidence is of two different kinds ## Signals, and the one deliberately NOT built
CIRCUMSTANTIAL evidence says two things happened near each other. It is
additive, weighted, and no single one of its signals may reach the threshold:
1. **Time proximity.** The Patreon post exists in order to announce the drop, 1. **Time proximity.** The Patreon post exists in order to announce the drop,
so the two are minutes-to-hours apart. Nearly free, and strong. so the two are minutes-to-hours apart. Nearly free, and strong.
2. **The post says so.** These announcements routinely name Discord or carry 2. **The post says so.** These announcements routinely name Discord or carry
an invite link, which is close to a declaration. an invite link, which is close to a declaration.
3. **A shared marker.** The creator's own tie-back — `🍈🍈` in the Patreon
title and `@everyone 🍈 🍈` in the Discord message — gated on how rare that
marker is in THIS artist's posts, because a habitual emoji is punctuation.
IDENTITY evidence says two things are the same thing, and it gets its own 3. **Crop-to-source matching is HELD, on the plan's own instruction** — it is
route (see `IDENTITY_FLOOR`). Two signals, and the stronger one stands rather real work with real false-positive risk, and it is only worth building once
than them being summed — saying "the same piece" twice is not more true: 1 and 2 are shown to be insufficient against the operator's actual artists.
Nothing here should be read as evidence it is unnecessary; it is deferred,
and the thing that would justify it is an empty review queue on a pair the
operator can see with their own eyes.
4. **A shared working name.** The creator exports the teaser and the release Note also that a naive whole-image SigLIP similarity is NOT that signal. A
from one file, and the internal name survives into both platforms cropped teaser and its full version are exactly the pair a whole-image
untouched. Measured on the operator's artist: `ConnFront` ↔ `ConnFront`. comparison handles worst, so adding one as a "bonus" would mostly add noise
This is the only signal that reaches a pair 23.8 hours apart, which while looking like progress.
proximity scores at 0.005.
5. **The drop contains the teaser's image.** Rare, and near-certain when it
happens. It is the one signal needing no cooperation from the creator: it
works on a teaser called `Screenshot 2026-08-13`, and on a creator whose
two platforms share no naming convention.
## The one deliberately NOT built
**Crop-to-source matching stays held, and now for a measured reason rather
than a cautious one.**
It was deferred until the cheap signals could be shown insufficient. They can:
of artist 8's 27 teasers with a drop inside a day, 11 still go unlinked, and
five of those are screenshot teasers carrying no working name at all.
So it was tried, on those exact pairs. Every teaser image was correlated
against every window of every nearby drop image at five scales, with the pairs
the working name independently confirms as ground truth and unrelated
same-artist posts a month away as a control. **It does not separate.** True
pairs score as low as 0.401 while the control reaches 0.605 — the two
distributions overlap, and no threshold divides them.
The reason is the reason the naive version was rejected in the first place,
and it turns out to apply just as hard to the sophisticated one: one artist's
work is stylistically homogeneous, so any whole-image comparison between two
of their pieces is high whether or not it is the same piece. Signal 5 above is
what survived that experiment — it asks a narrower question ("is this the same
image") that the measurement shows is answerable, instead of a broader one
("is this a crop of that") that it shows is not.
## Creator identity comes free, so E4 is not actually a prerequisite ## Creator identity comes free, so E4 is not actually a prerequisite
@@ -88,27 +56,14 @@ from __future__ import annotations
import logging import logging
import re import re
from collections import Counter
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ImageRecord, ImportSettings, Post, PostAssociation from ..models import ImportSettings, Post, PostAssociation
from ..utils.phash import hamming, hash_bits
from ..utils.text import html_to_plain from ..utils.text import html_to_plain
from .discord_grouping import DROP_GROUPER from .discord_grouping import DROP_GROUPER
from .post_naming import (
IDENTITY_FLOOR,
MAX_TOKEN_POSTS,
marker_frequencies,
marker_overlap,
rarity,
shared_identity,
token_frequencies,
working_name_tokens,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -121,83 +76,18 @@ log = logging.getLogger(__name__)
# enough" arithmetic rather than aspirational: on a busy day an artist posts # enough" arithmetic rather than aspirational: on a busy day an artist posts
# several times, and a matcher that could pair on proximity alone would turn # several times, and a matcher that could pair on proximity alone would turn
# every busy day into false pairs. A guard test pins this. # every busy day into false pairs. A guard test pins this.
WEIGHTS = {"proximity": 0.45, "declared": 0.35, "marker": 0.20} WEIGHTS = {"proximity": 0.55, "declared": 0.45}
# A Discord INVITE in the body is close to a declaration; the bare word is # A Discord INVITE in the body is close to a declaration; the bare word is
# weaker but still meaningful, because these posts are short and on-topic. # weaker but still meaningful, because these posts are short and on-topic.
#
# "the server" and its possessives are here because the word `discord` is NOT
# how these creators actually write. Measured across 20,558 Patreon bodies:
# `discord` appears in 486 and `the server` in 37 — but the distribution is the
# point, not the totals. For the artist this step was built for, 21 of 42 posts
# say `discord` and 7 say `the server`, and it is the RECENT ones that say the
# latter: the phrasing drifted once the audience already knew where the server
# was. A vocabulary list written from old posts silently stops matching.
_INVITE = re.compile(r"discord\.(?:gg|com/invite)/", re.I) _INVITE = re.compile(r"discord\.(?:gg|com/invite)/", re.I)
_MENTION = re.compile(r"\b(?:discord|(?:the|our|my)\s+server)\b", re.I) _MENTION = re.compile(r"\bdiscord\b", re.I)
DECLARED_INVITE = 1.0 DECLARED_INVITE = 1.0
DECLARED_MENTION = 0.6 DECLARED_MENTION = 0.6
MAX_CANDIDATES = 25 MAX_CANDIDATES = 25
# How many just-grouped drops one sweep will look around. A ceiling on the
# `or_` the sweep builds, not a policy — a backfill that authors thousands of
# drops at once should not turn one sweep into a full-library rescan, which is
# the manual button's job.
MAX_RECENT_DROPS = 200
# What a shared name must reach before FC links a pair WITHOUT asking.
#
# 1.0, which under post_naming's post-span counting means the name appears in
# exactly these two posts and nowhere else in the artist's library. That is not
# "strong evidence" — within the library it is conclusive, and the remaining
# ways to be wrong are a mis-parse or the creator reusing a name for a genuinely
# different piece on the same day.
#
# Deliberately above IDENTITY_FLOOR, which is what a name needs to PROPOSE.
# The gap between them is the review queue: real evidence, not certain enough
# for FC to act on by itself. Measured on artist 8, 15 name-sharing pairs: 11
# are conclusive, 2 more propose, 2 fall short of both.
AUTO_LINK_FLOOR = 1.0
# When the drop simply CONTAINS the teaser's image — a pHash within this many
# of 256 bits.
#
# 32, the same number and unit `gallery_service._diversify_similar` already
# calls a near-duplicate. Measured on artist 8, comparing every teaser against
# every drop within a day: pairs the working name independently confirms score
# 0, 0 and 20, and the nearest unrelated same-artist pair in a 29-sample
# control scores **108**. A 76-bit gap, so the threshold is not finely tuned
# and does not need to be.
#
# `utils/phash` warns that the hash alone must not decide a MERGE, because
# variants of one piece collide at this distance. That warning does not invert
# here, it is the point: merging destroys a file, so a variant colliding with
# its original is a loss, while this is asking whether two POSTS are about the
# same piece — and a variant of the drop's image is exactly that. Nothing is
# deleted either way, so no pixel confirm is needed to accept.
DUPLICATE_MAX_DISTANCE = 32
@dataclass(frozen=True)
class _Corpus:
"""One artist's rare-token evidence, gathered once rather than per pair.
Both rare-token signals are scoped to a single artist — a working name and
a marker belong to the person who chose them — so the counts are useless
across artists and expensive to rebuild per candidate. A sweep touches an
artist's posts many times over; this is loaded on the first touch and kept
for the life of the service.
"""
tokens_by_post: dict[int, set[str]]
token_posts: Counter[str]
text_by_post: dict[int, str]
marker_posts: Counter[str]
hashes_by_post: dict[int, list[int]]
hash_posts: Counter[int]
def proximity_signal(gap: timedelta, window: timedelta) -> float: def proximity_signal(gap: timedelta, window: timedelta) -> float:
"""1.0 when the two posts are simultaneous, decaying linearly to 0 at the """1.0 when the two posts are simultaneous, decaying linearly to 0 at the
@@ -233,40 +123,6 @@ def declared_signal(description: str | None) -> float:
return 0.0 return 0.0
def shared_image(
left: list[int],
right: list[int],
hash_posts: Counter[int],
*,
max_distance: int = DUPLICATE_MAX_DISTANCE,
max_frequency: int = MAX_TOKEN_POSTS,
) -> float:
"""Strength in [0, 1] that the drop contains the teaser's own image.
IDENTITY evidence, and the only one of the three that needs no cooperation
from the creator — it works on a teaser named `Screenshot 2026-08-13`, and
on a creator whose two platforms share no naming convention at all. Where
it fires it is close to certain; it is simply quiet most of the time,
because a teaser is usually a crop rather than a copy.
Rarity-gated on POSTS like the other two: an image the creator puts on many
posts is a banner, not a piece.
"""
if not left or not right:
return 0.0
best = None
for a in left:
for b in right:
d = hamming(a, b)
if d is None or d > max_distance:
continue
span = max(hash_posts.get(a, 1), hash_posts.get(b, 1), 1)
strength = rarity(span, max_frequency)
if best is None or strength > best:
best = strength
return round(best, 4) if best is not None else 0.0
def weighted_score(signals: dict) -> float: def weighted_score(signals: dict) -> float:
return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4) return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4)
@@ -278,298 +134,81 @@ def _post_time(post: Post) -> datetime:
class PostAssociationService: class PostAssociationService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
self._corpora: dict[int, _Corpus] = {}
async def _corpus(self, artist_id: int) -> _Corpus: async def _decided(self, announcement_id: int) -> set[int]:
if artist_id in self._corpora: """Payload posts already proposed for this announcement, in ANY status.
return self._corpora[artist_id]
paths_by_post: dict[int, list[str]] = {} Dismissed pairs are included deliberately: re-proposing a pair the
hashes_by_post: dict[int, list[int]] = {} operator has already rejected on every subsequent scan is the single
# An image is counted under the post a reader SEES it on: a Discord behaviour that makes a review queue get ignored.
# message absorbed into a drop contributes to the drop, not to itself.
#
# Keyed on `primary_post_id` alone until 2026-09-24, which on the live
# instance meant a drop never had a name or a hash at all — its images
# are owned by its member messages, and the drop only claims them
# through provenance. The identity route therefore never fired there;
# the tests missed it because they attached images to the drop itself,
# which discord_grouping never does.
#
# Counting by the drop also makes the span honest: five wips of one
# piece posted as five messages and grouped into one drop are ONE post
# as far as "how many posts carry this name" is concerned.
owner = Post.__table__.alias("owner")
rows = await self.session.execute(
select(
func.coalesce(owner.c.absorbed_by_post_id, ImageRecord.primary_post_id),
ImageRecord.path, ImageRecord.phash,
)
.select_from(ImageRecord)
.join(owner, owner.c.id == ImageRecord.primary_post_id)
.where(
ImageRecord.artist_id == artist_id,
ImageRecord.primary_post_id.is_not(None),
)
)
for post_id, path, phash in rows:
paths_by_post.setdefault(post_id, []).append(path)
bits = hash_bits(phash)
if bits is not None:
hashes_by_post.setdefault(post_id, []).append(bits)
text_by_post: dict[int, str] = {}
rows = await self.session.execute(
select(Post.id, Post.post_title, Post.description).where(
Post.artist_id == artist_id
)
)
for post_id, title, description in rows:
text_by_post[post_id] = "\n".join(
part for part in (title, html_to_plain(description) or "") if part
)
corpus = _Corpus(
tokens_by_post={
pid: {t for path in paths for t in working_name_tokens(path)}
for pid, paths in paths_by_post.items()
},
# Both counts take POSTS, which is why they are built from these
# groupings rather than from flat lists — see post_naming.
token_posts=token_frequencies(paths_by_post.values()),
text_by_post=text_by_post,
marker_posts=marker_frequencies(text_by_post.values()),
hashes_by_post=hashes_by_post,
# An image the creator puts on many posts — a banner, a watermark
# plate, a recurring title card — is a habit exactly as a character
# name is, and gets gated the same way. Counted on the EXACT hash,
# which is what a reused file produces.
hash_posts=Counter(
h for hs in hashes_by_post.values() for h in set(hs)
),
)
self._corpora[artist_id] = corpus
return corpus
async def _decided(self, announcement_id: int) -> dict[int, PostAssociation]:
"""Pairs already recorded for this announcement, keyed by payload.
Linked and dismissed pairs are never touched again: re-proposing a pair
the operator has already rejected on every subsequent scan is the
single behaviour that makes a review queue get ignored.
A PENDING pair is different — nobody has decided it — so the caller
re-scores it. Otherwise a pair queued by an older, weaker matcher sits
in the queue forever even once the evidence is conclusive, which is the
chore the operator asked FC not to hand them.
""" """
rows = (await self.session.execute( rows = (await self.session.execute(
select(PostAssociation) select(PostAssociation.payload_post_id)
.where(PostAssociation.announcement_post_id == announcement_id) .where(PostAssociation.announcement_post_id == announcement_id)
)).scalars().all() )).scalars().all()
return {a.payload_post_id: a for a in rows} return set(rows)
async def _candidate_groups( async def _candidate_groups(
self, announcement: Post, *, window: timedelta, self, announcement: Post, *, window: timedelta,
) -> list[tuple[Post, datetime]]: ) -> list[Post]:
"""Synthetic Discord groupings by the SAME artist with a message inside """Synthetic Discord groupings by the SAME artist, inside the window.
the window — each with the time of its message CLOSEST to the teaser.
Same-artist is the identity signal and it is free (see the module Same-artist is the identity signal and it is free (see the module
docstring on E4). It is also a hard filter rather than a scored one: docstring on E4). It is also a hard filter rather than a scored one:
two different creators posting minutes apart is a coincidence, not two different creators posting minutes apart is a coincidence, not
evidence, and letting it score at all would mean a busy hour across the evidence, and letting it score at all would mean a busy hour across the
library could out-vote everything else. library could out-vote everything else.
Measured on the MESSAGES, not the drop's own date. A drop is dated by
its first message, and since #4390 merges a creator's trickle into one
drop, that can be days before the release the teaser announces —
"Very early Marin" on Sep 7, `MarinaraSauce_base` on Sep 11. Matching
on the drop's date would put every merged trickle outside the window.
""" """
at = _post_time(announcement) at = _post_time(announcement)
member = Post.__table__.alias("member") sort_key = func.coalesce(Post.post_date, Post.downloaded_at)
member_at = func.coalesce(member.c.post_date, member.c.downloaded_at) return (await self.session.execute(
rows = list((await self.session.execute( select(Post)
select(member.c.absorbed_by_post_id, member_at)
.where( .where(
member.c.artist_id == announcement.artist_id,
member.c.absorbed_by_post_id.is_not(None),
member_at >= at - window,
member_at <= at + window,
)
)).all())
# The drop's own date counts too — the first message's, so it adds
# nothing for a real drop, but it keeps a drop with no member rows
# (hand-built, or one whose messages were removed) matchable.
own_at = func.coalesce(Post.post_date, Post.downloaded_at)
rows += (await self.session.execute(
select(Post.id, own_at).where(
Post.artist_id == announcement.artist_id, Post.artist_id == announcement.artist_id,
Post.synthesized_by == DROP_GROUPER, Post.synthesized_by == DROP_GROUPER,
own_at >= at - window,
own_at <= at + window,
)
)).all()
closest: dict[int, datetime] = {}
for group_id, when in rows:
if group_id not in closest or abs(when - at) < abs(closest[group_id] - at):
closest[group_id] = when
if not closest:
return []
groups = (await self.session.execute(
select(Post).where(
Post.id.in_(list(closest)),
Post.synthesized_by == DROP_GROUPER,
Post.id != announcement.id, Post.id != announcement.id,
sort_key >= at - window,
sort_key <= at + window,
) )
.order_by(sort_key)
.limit(MAX_CANDIDATES)
)).scalars().all() )).scalars().all()
ranked = sorted(groups, key=lambda g: (abs(closest[g.id] - at), g.id))
return [(g, closest[g.id]) for g in ranked[:MAX_CANDIDATES]]
async def _claimed(self, announcement_id: int, payload_id: int) -> bool:
"""Is either end of this pair already spoken for by an accepted link?
An auto-link is FC asserting something the operator never saw, so it
only happens where there is nothing to contradict. A drop already
linked to a different announcement is exactly such a contradiction, and
resolving it is a judgement about which one is right — which is the
operator's, not FC's.
"""
return (await self.session.execute(
select(PostAssociation.id).where(
PostAssociation.status == "linked",
or_(
PostAssociation.payload_post_id == payload_id,
PostAssociation.announcement_post_id == announcement_id,
),
).limit(1)
)).scalar() is not None
async def match_post( async def match_post(
self, announcement_id: int, *, threshold: float, window_hours: float, self, announcement_id: int, *, threshold: float, window_hours: float,
auto_link: bool = False, ) -> int:
) -> tuple[int, int]: """Score one announcement against nearby groupings. Returns proposals made."""
"""Score one announcement against nearby groupings.
Returns `(proposed, linked)` — how many pairs were written, and how
many of those were linked outright rather than queued.
"""
announcement = await self.session.get(Post, announcement_id) announcement = await self.session.get(Post, announcement_id)
if announcement is None or announcement.synthesized_by is not None: if announcement is None or announcement.synthesized_by is not None:
# A synthetic post cannot announce anything — FC wrote it. # A synthetic post cannot announce anything — FC wrote it.
return 0, 0 return 0
window = timedelta(hours=window_hours) window = timedelta(hours=window_hours)
declared = declared_signal(announcement.description) declared = declared_signal(announcement.description)
already = await self._decided(announcement_id) already = await self._decided(announcement_id)
corpus = await self._corpus(announcement.artist_id)
here = corpus.tokens_by_post.get(announcement.id, set())
here_text = corpus.text_by_post.get(announcement.id, "")
here_hashes = corpus.hashes_by_post.get(announcement.id, [])
made = 0 made = 0
scored: list[tuple[Post, float, dict, float]] = [] for group in await self._candidate_groups(announcement, window=window):
for group, group_at in await self._candidate_groups(announcement, window=window): if group.id in already:
prior = already.get(group.id)
if prior is not None and prior.status != "pending":
continue continue
named, token = shared_identity( signals = {
here,
corpus.tokens_by_post.get(group.id, set()),
corpus.token_posts,
)
# The two identity signals answer the same question by different
# means, so the stronger one stands rather than them being summed:
# a name and a shared image both say "the same piece", and saying
# it twice is not more true.
copied = shared_image(
here_hashes,
corpus.hashes_by_post.get(group.id, []),
corpus.hash_posts,
)
identity = max(named, copied)
circumstantial = {
"proximity": proximity_signal( "proximity": proximity_signal(
group_at - _post_time(announcement), window, _post_time(group) - _post_time(announcement), window,
), ),
"declared": declared, "declared": declared,
"marker": marker_overlap(
here_text,
corpus.text_by_post.get(group.id, ""),
corpus.marker_posts,
),
} }
score = weighted_score(circumstantial) score = weighted_score(signals)
# THE TWO ROUTES, and why identity is not simply a fourth weight.
#
# Circumstance and identity answer different questions. Proximity
# and a declaration say two things happened near each other and
# that one of them mentioned Discord; a working name the creator
# uses on these two posts and nowhere else says they are the same
# piece. Averaging those makes the threshold uninterpretable, and
# it costs both: adding identity as a weight dilutes the others
# enough that measured teaser/drop pairs an hour apart stop
# proposing, while capping identity's contribution at its weight
# means the strongest evidence available can never carry a pair on
# its own.
#
# So identity may override, never dilute. Below the floor it is
# recorded for the operator to read and moves nothing — which is
# the conservative direction, since a wrong link asserts that two
# different pieces are one.
if identity >= IDENTITY_FLOOR:
score = max(score, identity)
if score < threshold: if score < threshold:
continue continue
signals = {**circumstantial, "identity": identity}
if copied:
signals["identity_image"] = copied
if token and named >= copied:
# Carried so the queue can say WHY. A review queue that cannot
# explain itself is one the operator learns to click through.
signals["identity_token"] = token
scored.append((group, score, signals, identity))
# Who, if anyone, FC links without asking.
#
# EXACTLY ONE candidate may be conclusive. Two drops sharing a name
# with one teaser at full strength is not a tie to be broken by score —
# it means the name identifies something other than what FC thinks it
# does, and the right response is to queue both and say nothing.
auto_id = None
if auto_link:
conclusive = [c for c in scored if c[3] >= AUTO_LINK_FLOOR]
if len(conclusive) == 1 and not await self._claimed(
announcement.id, conclusive[0][0].id
):
auto_id = conclusive[0][0].id
linked = 0
for group, score, signals, _identity in scored:
status = "linked" if group.id == auto_id else "pending"
if status == "linked":
linked += 1
prior = already.get(group.id)
if prior is not None:
# Re-scored in place: a pending pair keeps its row (and id),
# and only an upgrade to linked counts as news.
prior.score = score
prior.signals = signals
if status == "linked":
prior.status = "linked"
prior.linked_by = "fc"
continue
self.session.add(PostAssociation( self.session.add(PostAssociation(
announcement_post_id=announcement.id, announcement_post_id=announcement.id,
payload_post_id=group.id, payload_post_id=group.id,
score=score, score=score,
signals=signals, signals=signals,
status=status, status="pending",
linked_by="fc" if status == "linked" else None,
)) ))
made += 1 made += 1
return made, linked return made
async def list_pending(self) -> list[dict]: async def list_pending(self) -> list[dict]:
rows = (await self.session.execute( rows = (await self.session.execute(
@@ -593,19 +232,14 @@ class PostAssociationService:
if a is None: if a is None:
return None return None
a.status = "linked" a.status = "linked"
a.linked_by = "operator"
return {"id": a.id, "status": a.status} return {"id": a.id, "status": a.status}
async def dismiss(self, association_id: int) -> dict | None: async def dismiss(self, association_id: int) -> dict | None:
a = await self.session.get(PostAssociation, association_id) a = await self.session.get(PostAssociation, association_id)
if a is None: if a is None:
return None return None
# Kept, not deleted — the row is what remembers the rejection. It is # Kept, not deleted — the row is what remembers the rejection.
# also the undo for a link FC made itself (#4402): the unified card
# dismisses the pair, and the dismissed row stops the next sweep from
# linking it straight back.
a.status = "dismissed" a.status = "dismissed"
a.linked_by = None
return {"id": a.id, "status": a.status} return {"id": a.id, "status": a.status}
async def linked_for(self, post_ids: list[int]) -> dict[int, list[dict]]: async def linked_for(self, post_ids: list[int]) -> dict[int, list[dict]]:
@@ -639,124 +273,36 @@ class PostAssociationService:
return out return out
async def rescan( async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
session: AsyncSession, *, now: datetime | None = None, full: bool = False, """Score every recent non-synthetic post against nearby groupings."""
) -> dict:
"""Score recent non-synthetic posts against nearby groupings.
`full=True` scores EVERY post by an artist who has Discord drops at all —
the manual button's job, for history that predates the feature or that a
trickle merge (#4390) has just rearranged. It used to share the sweep's
48-hour horizon, so the button described as "a first run over a library
that predates the feature" could not reach that library.
"""
settings = await ImportSettings.load(session) settings = await ImportSettings.load(session)
if not settings.discord_link_enabled: if not settings.discord_link_enabled:
return {"enabled": False, "scanned": 0, "proposed": 0, "linked": 0} return {"enabled": False, "scanned": 0, "proposed": 0}
now = now or datetime.now(UTC) now = now or datetime.now(UTC)
window_hours = float(settings.discord_link_window_hours) window_hours = float(settings.discord_link_window_hours)
window = timedelta(hours=window_hours)
# Only look at announcements that could still have a partner in range — # Only look at announcements that could still have a partner in range —
# a full-library rescan is the manual button's job, not the sweep's. # a full-library rescan is the manual button's job, not the sweep's.
horizon = now - timedelta(hours=window_hours * 2) horizon = now - timedelta(hours=window_hours * 2)
sort_key = func.coalesce(Post.post_date, Post.downloaded_at) sort_key = func.coalesce(Post.post_date, Post.downloaded_at)
if full: ids = (await session.execute(
with_drops = select(Post.artist_id).where(
Post.synthesized_by == DROP_GROUPER
).distinct()
ids = set((await session.execute(
select(Post.id).where(
Post.synthesized_by.is_(None),
Post.absorbed_by_post_id.is_(None),
Post.artist_id.in_(with_drops),
)
)).scalars().all())
return await _score(session, settings, ids, window_hours)
ids = set((await session.execute(
select(Post.id).where( select(Post.id).where(
Post.synthesized_by.is_(None), Post.synthesized_by.is_(None),
Post.absorbed_by_post_id.is_(None), Post.absorbed_by_post_id.is_(None),
sort_key >= horizon, sort_key >= horizon,
) )
)).scalars().all()) )).scalars().all()
# ...and announcements sitting next to a drop FC has only JUST authored.
#
# A drop's `post_date` is backdated to its first message, but FC cannot
# write the drop until the message has an embedding and the hourly grouper
# has run — so a drop created this minute can land weeks back in the feed.
# Its neighbours were last swept before it existed, and a sweep keyed only
# on how recent the ANNOUNCEMENT is will never look at them again.
#
# That is #4392's third cause, and it is the one that left a measured 0.800
# pair with an empty review queue on the live instance. The other two were
# about scoring; this one meant nothing was scored at all.
#
# The times are the drops' MESSAGES, not the drops' own dates: a drop that
# grew today by a trickle merge (#4390) is dated by its first stage, days
# earlier, while the teaser sits beside the message that just joined.
recent = (
select(Post.id).where(
Post.synthesized_by == DROP_GROUPER,
func.coalesce(Post.last_grew_at, Post.downloaded_at) >= horizon,
)
.order_by(func.coalesce(Post.last_grew_at, Post.downloaded_at).desc())
.limit(MAX_RECENT_DROPS)
)
member = Post.__table__.alias("member")
drop_times = set((await session.execute(
select(func.coalesce(member.c.post_date, member.c.downloaded_at))
.where(member.c.absorbed_by_post_id.in_(recent))
)).scalars().all())
# Plus each drop's own date — its first message's, so a duplicate for a
# real drop, but what a drop with no member rows is placed by.
drop_times |= set((await session.execute(
select(sort_key).where(Post.id.in_(recent))
)).scalars().all())
# The interval arithmetic is done in Python rather than SQL: a handful of
# literal ranges is portable, and `now - INTERVAL` is not.
ranges = [
and_(sort_key >= at - window, sort_key <= at + window)
for at in drop_times
if at is not None
]
if ranges:
ids |= set((await session.execute(
select(Post.id).where(
Post.synthesized_by.is_(None),
Post.absorbed_by_post_id.is_(None),
or_(*ranges),
)
)).scalars().all())
return await _score(session, settings, ids, window_hours)
async def _score(
session: AsyncSession, settings: ImportSettings, ids: set[int], window_hours: float,
) -> dict:
svc = PostAssociationService(session) svc = PostAssociationService(session)
proposed = 0 proposed = 0
linked = 0 for pid in ids:
# Sorted because `ids` is now a union of two queries: set iteration order proposed += await svc.match_post(
# is arbitrary, and a sweep that visits posts in a different order each
# run is one whose failures cannot be reproduced.
for pid in sorted(ids):
made, auto = await svc.match_post(
pid, pid,
threshold=float(settings.discord_link_threshold), threshold=float(settings.discord_link_threshold),
window_hours=window_hours, window_hours=window_hours,
auto_link=bool(settings.discord_link_auto),
) )
proposed += made
linked += auto
log.info( log.info(
"discord announcement matcher: scanned %d post(s), proposed %d pair(s), " "discord announcement matcher: scanned %d post(s), proposed %d pair(s)",
"linked %d outright", len(ids), proposed,
len(ids), proposed, linked,
) )
return { return {"enabled": True, "scanned": len(ids), "proposed": proposed}
"enabled": True, "scanned": len(ids), "proposed": proposed,
"linked": linked,
}
+1 -39
View File
@@ -19,7 +19,6 @@ from ..models import (
ExternalLink, ExternalLink,
ImageProvenance, ImageProvenance,
ImageRecord, ImageRecord,
ImportSettings,
Post, Post,
PostAttachment, PostAttachment,
Source, Source,
@@ -119,15 +118,6 @@ class PostFeedService:
# from. `around` and `get_post` deliberately do NOT apply this: reaching # from. `around` and `get_post` deliberately do NOT apply this: reaching
# a member by id is how you inspect a grouping. # a member by id is how you inspect a grouping.
stmt = stmt.where(Post.absorbed_by_post_id.is_(None)) stmt = stmt.where(Post.absorbed_by_post_id.is_(None))
# A linked Discord drop is shown ON its teaser's card by reference
# (#4402), so its own card sitting beside that teaser is the same
# release twice. Only then is it left out — an older drop keeps its
# place, because a reference does not take anything out of history.
fold_hours = await self._fold_hours()
if fold_hours > 0:
from .post_unification import fold_clause
stmt = stmt.where(fold_clause(fold_hours))
if artist_id is not None: if artist_id is not None:
stmt = stmt.where(Post.artist_id == artist_id) stmt = stmt.where(Post.artist_id == artist_id)
if platform is not None: if platform is not None:
@@ -179,12 +169,9 @@ class PostFeedService:
thumbs_map = await self._thumbnails_for(post_ids) thumbs_map = await self._thumbnails_for(post_ids)
atts_map = await self._attachments_for(post_ids) atts_map = await self._attachments_for(post_ids)
links_map = await self._links_for(post_ids) links_map = await self._links_for(post_ids)
unified_map = await self._unified_for([p for p, _, _ in rows])
items = [ items = [
self._to_dict( self._to_dict(post, artist, source, thumbs_map, atts_map, links_map)
post, artist, source, thumbs_map, atts_map, links_map, unified_map,
)
for post, artist, source in rows for post, artist, source in rows
] ]
return {"items": items, "next_cursor": next_cursor} return {"items": items, "next_cursor": next_cursor}
@@ -226,7 +213,6 @@ class PostFeedService:
anchor_item = self._to_dict( anchor_item = self._to_dict(
anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map, anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map,
await self._links_for([anchor_post.id]), await self._links_for([anchor_post.id]),
await self._unified_for([anchor_post]),
) )
return { return {
"items": newer["items"] + [anchor_item] + older["items"], "items": newer["items"] + [anchor_item] + older["items"],
@@ -253,7 +239,6 @@ class PostFeedService:
item = self._to_dict( item = self._to_dict(
post, artist, source, thumbs_map, atts_map, post, artist, source, thumbs_map, atts_map,
await self._links_for([post.id]), await self._links_for([post.id]),
await self._unified_for([post]),
) )
item["description_full"] = html_to_plain(post.description) item["description_full"] = html_to_plain(post.description)
# Full (uncapped) translated description for the detail view (#143). # Full (uncapped) translated description for the detail view (#143).
@@ -400,27 +385,9 @@ class PostFeedService:
return await PostAssociationService(self.session).linked_for(post_ids) return await PostAssociationService(self.session).linked_for(post_ids)
async def _unified_for(self, posts: list[Post]) -> dict[int, dict]:
"""The reference set each teaser's card shows (#4402). Local import for
the same reason as `_links_for`: it reaches the association service."""
from .post_unification import PostUnificationService
return await PostUnificationService(self.session).unified_for(posts)
async def _fold_hours(self) -> float:
"""`discord_link_fold_hours`, read without assuming the row exists.
The feed is the one surface that must not fail on a settings row the
caller never needed, so a missing row folds nothing rather than
raising — which is also exactly how the feed behaved before this.
"""
settings = await self.session.get(ImportSettings, 1)
return float(settings.discord_link_fold_hours) if settings is not None else 0.0
def _to_dict( def _to_dict(
self, post: Post, artist: Artist, source: Source | None, self, post: Post, artist: Artist, source: Source | None,
thumbs_map: dict, atts_map: dict, links_map: dict | None = None, thumbs_map: dict, atts_map: dict, links_map: dict | None = None,
unified_map: dict | None = None,
) -> dict: ) -> dict:
plain_full = html_to_plain(post.description) if post.description else None plain_full = html_to_plain(post.description) if post.description else None
if plain_full is None: if plain_full is None:
@@ -469,11 +436,6 @@ class PostFeedService:
# post is the drop). Always a list so the UI never branches on # post is the drop). Always a list so the UI never branches on
# absence. # absence.
"associations": (links_map or {}).get(post.id, []), "associations": (links_map or {}).get(post.id, []),
# #4402. On a teaser with a linked drop: what the card shows BY
# REFERENCE — the drop's images, the piece's older variants, and
# the text of each — plus who made each link, so one FC made by
# itself can say so and offer the undo. None on every other post.
"unified": (unified_map or {}).get(post.id),
# Non-null on a chat message a synthetic post absorbed. The feed # Non-null on a chat message a synthetic post absorbed. The feed
# filters these out, but `around`/`get_post` still reach them, and # filters these out, but `around`/`get_post` still reach them, and
# the UI uses this to explain why a post it linked to is not in the # the UI uses this to explain why a post it linked to is not in the
-426
View File
@@ -1,426 +0,0 @@
"""The creator's own working name for a piece, recovered from a filename.
Milestone 388. Pure functions, no DB and no ML — the whole point is that this
signal is free.
## What this is for
Two of the operator's artists post a censored or cropped teaser on Patreon and
the real release in their Discord. Matching those by IMAGE is the pair a
whole-image comparison handles worst: the teaser is a crop with a censor bar,
which is exactly the local edit that moves a perceptual hash and blurs a
semantic embedding.
But the creator names both exports after the same internal working title, and
that name survives into both platforms untouched. Measured on the live instance
2026-09-24, artist 8:
01_((0-k <-> 0-k_base (1.3h apart)
01_680LC <-> 680LC_Border (21.0h apart)
01_cnni18x <-> cnni18x (21.5h apart)
Three pairs, no false positives, and **two of them are 21 hours apart** — far
enough that time proximity scores them ~0.10 and could never propose them. The
naming signal is orthogonal to the timing one: each finds pairs the other
cannot, which is why both are kept rather than one being tuned to cover both.
## Why a filename and not a perceptual hash
A shared working-name token is IDENTITY evidence — `680lc` appearing on both
platforms is not a coincidence. Proximity is CIRCUMSTANTIAL: it says two things
happened near each other, never that they are the same thing. The distinction
drives the weighting in `post_association_service`, and it is why a rare enough
token is allowed to carry a proposal on its own while no amount of circumstance
is.
## The one false-positive class found, and why the fix is shaped this way
A first pass matched `01_Screenshot 2026-08-13 000004` to
`Screenshot_2026-08-13_032144` on the token `2026-08-13`, twice.
A screenshot filename is a camera artifact. It carries no working name, and the
date inside it collides across platforms on the same day BY CONSTRUCTION — the
teaser and the release are posted the same day, so their screenshot names
always share a date token. That is a signal that fires precisely when it is
least informative.
So a filename with no working name contributes NOTHING, rather than the
plausible-looking date match it could be squeezed for. Re-run with that rule:
the same three true pairs, zero false. Half of this creator's recent teasers
are screenshots, and those pairs are simply out of this signal's reach — which
is where crop-to-source matching earns its cost, and nowhere else.
"""
from __future__ import annotations
import re
from collections import Counter
from collections.abc import Iterable
from pathlib import PurePosixPath
# A screenshot name, on either platform. Patreon's importer writes
# `01_Screenshot 2026-08-13 000004`; gallery-dl's Discord naming writes
# `Screenshot_2026-09-22_003651`. Matched after the index/message prefixes are
# stripped, so both shapes reach this as a bare `Screenshot ...`.
# NOT `\b` after "shot": `\b` needs a word/non-word transition and `_` is a
# WORD character, so `Screenshot_2026-08-13_032144` — gallery-dl's Discord
# spelling — sailed straight past the guard while the space-separated Patreon
# spelling was caught. Found by running this against the live library rather
# than by reading it. Assert the next character is not a letter instead.
_SCREENSHOT = re.compile(r"^(?:screen[ _-]?shot(?![a-z])|\u30b9\u30af\u30ea\u30fc\u30f3\u30b7\u30e7\u30c3\u30c8|\u30b9\u30af\u30b7\u30e7)", re.I)
# The importer's per-post media index: `01_`, `02_`. Not part of any name.
_MEDIA_INDEX = re.compile(r"^\d{1,3}_")
# gallery-dl's Discord filename pattern (#3999):
# `{date:%Y%m%d}_{message_id}_{num:>02}_{filename}`.
_DISCORD_PREFIX = re.compile(r"^\d{8}_\d{6,}_\d{1,3}_")
# The legacy era (#4002): images sit FLAT at the artist root as
# `<post id>_media_<media id>_<name>`. Stripping it is not cosmetic — the
# SCREENSHOT guard below matches from the start of the stem, so while this
# prefix was left on, a legacy screenshot never looked like one. Measured on
# tamadaheijun: `109078417_media_334848471_Screenshot 2025-07-27 182450ab`
# sailed through and contributed `2025-07-27`, which is precisely the
# same-day date collision this module was built to refuse.
_LEGACY_PREFIX = re.compile(r"^\d+_media_\d+_")
# The importer's content-hash suffix, `__<10 hex>`, sometimes doubled on files
# that went through an older import era.
_HASH_SUFFIX = re.compile(r"(?:__[0-9a-f]{10})+$")
# Generic export decorations. Stripped as SUFFIXES so the stem survives:
# `cnni18x_wip3` and `cnni18x` must yield the same token, or a work-in-progress
# would never match the piece it became.
_DECORATION = re.compile(
r"(?:[_-]?(?:wip|base|final|alt|alts|edit|edits|border|clean|raw|hd|full|"
r"censored|uncensored|nsfw|sfw|ver|v)\d*)+$",
re.I,
)
# Tokens that carry no identity even when they survive the rules above.
_STOPWORDS = frozenset({
"img", "image", "untitled", "new", "test", "page", "final", "copy",
"post", "media", "file", "avatar", "cover", "banner", "icon", "splash",
# Each of these was MEASURED carrying a false match in the 3..6 frequency
# band, where the rarity gate still admits a token: `capture` across six
# unrelated knuxy posts, `the` and `patreon` out of legacy title-derived
# names, `main` out of `Anya Main CST`, `timeline` and `gif` out of
# tamadaheijun's exports.
"the", "gif", "main", "patreon", "capture", "timeline", "screenshot",
# Literally the string "None": issue #3999's Discord naming rendered
# `{user[name]}` as it for ~1,600 files, so it is the single most common
# "name" in the library and identifies nothing.
"none",
# gallery-dl's fallback when a Discord attachment has no filename of its
# own. Measured on artist 8: four unrelated images across 1,974 days, and
# the one false family the leading-name rule admitted inside 60 days.
"image0",
})
# A bare year: still needed for the TEXT signal, where words and numbers are
# tokenised separately.
_YEAR = re.compile(r"^(?:19|20)\d{2}$")
# An identity token must contain a LETTER. This replaces separate "all digits"
# and "bare year" rules with the property behind both, and it is the rule that
# holds once hyphens are kept inside tokens for `0-k`'s sake: without it
# `2025-07-27` and `3-0002` survive as single tokens, and both were measured
# linking unrelated posts — the second across three of them, out of
# tamadaheijun's `timeline 3-0002` exports.
#
# `0-k`, `680lc`, `cnni18x` and `p59` all keep a letter and are unaffected.
_HAS_LETTER = re.compile(r"[A-Za-z]")
MIN_TOKEN_LEN = 3
# A token appearing in more than this many of ONE ARTIST's POSTS is a habit,
# not an identity — a character name, a series tag, a recurring export preset.
#
# POSTS, not files, and the difference is not bookkeeping. Counting files
# punishes a piece for having many exports, which is the one thing a working
# name is GUARANTEED to do. Measured across the operator's four dual-platform
# artists: knuxy's comic pages carry `p217` on four files spread over exactly
# two posts — the Patreon post and the Discord drop — and file-counting scored
# every one of ~200 such tokens at half strength for it. Counting posts scores
# them 1.00 while still catching the real habits, which span many posts:
# tamadaheijun's `comic2` spans 8, conto's `seth2` 5, `maid` 4.
#
# Six rather than two, although two posts IS the shape of a teaser and its
# drop, because a creator legitimately revisits one working name: a wip post,
# then an alt, then the release. Measured on artist 8, `cnni18x` and `680lc`
# each span four posts and both are genuine. IDENTITY_FLOOR below is what
# decides how much span a link may carry on its own.
MAX_TOKEN_POSTS = 6
# A shared name at or above this strength is enough to propose a link with NO
# corroboration — it is identity evidence, and the whole reason this module
# exists is that identity survives where circumstance does not.
#
# 0.75 is a token spanning three posts or fewer. Measured on artist 8: of the
# 15 same-artist pairs that share a name, 13 clear this bar, including the
# operator's own example (`0-k`, three posts, 1.3h apart). The two that do not
# — `680lc` and `cnni18x`, four posts each, 21h apart — are real pairs this
# signal will not carry alone; they are the measured cost of not admitting the
# four-post band, where conto's `illustration9` and `maid` also sit.
IDENTITY_FLOOR = 0.75
# A LEADING name spanning this many of ONE ARTIST's posts is a habit — a
# character the creator returns to — not one piece's trickle. Used wherever a
# name gathers a FAMILY: the teaser card's variants (#4401) and the Discord
# grouper's trickle merge (#4390), so the two cannot disagree about what a
# family is.
#
# Its own value rather than MAX_TOKEN_POSTS (6), which is calibrated for
# PAIRING two posts and measured too tight for a family. On artist 8,
# `tentacooler` spans 6 posts over 7 days and `0-k1` 6 posts over 10: both real
# families, both gated out at 6. At 8, `anya` (7) passes the cap — and has no
# pair inside any family window, which is what the window is for.
FAMILY_MAX_POSTS = 8
def _strip_prefixes(stem: str) -> str:
"""Remove the framing each platform's importer adds around the real name."""
stem = _DISCORD_PREFIX.sub("", stem)
stem = _LEGACY_PREFIX.sub("", stem)
stem = _MEDIA_INDEX.sub("", stem)
return _HASH_SUFFIX.sub("", stem)
def _ordered_tokens(path: str) -> list[str]:
"""The identity-bearing tokens of one filename, in the order written.
The one tokenizer both public readings share, so the set of names and the
leading name cannot disagree about what counts as a name.
"""
stem = _strip_prefixes(PurePosixPath(path).stem)
if _SCREENSHOT.match(stem.strip()):
return []
out: list[str] = []
# Hyphens are kept INSIDE tokens — `0-k` is a real working name on the live
# instance, and splitting on hyphen would reduce it to a single character
# and then discard it for being too short.
for raw in re.split(r"[^0-9A-Za-z-]+", stem.lower()):
tok = _DECORATION.sub("", raw).strip("-")
if len(tok) < MIN_TOKEN_LEN:
continue
if tok in _STOPWORDS or not _HAS_LETTER.search(tok):
continue
if tok not in out:
out.append(tok)
return out
def working_name_tokens(path: str) -> set[str]:
"""The identity-bearing tokens in one image's filename.
Returns an EMPTY set for a name that carries no working title — a
screenshot, a bare number, a stopword. Empty means "no evidence", which the
caller must treat as silence rather than as a weak match; see the module
docstring for the false positive that rule exists for.
"""
return set(_ordered_tokens(path))
def leading_name(path: str) -> str | None:
"""The FIRST identity token of a filename — the piece, not its decoration.
Creators lead with what the piece is and trail with what this export of it
is: `Year_20k_wip1`, `not_sombra_21-cumpeen`, `Tentacooler_c_ins`. Content
words sit at the tail, and they span too FEW posts for any frequency cap
to catch — `nude`, `cum` and `top` are on three of artist 8's posts each.
Position is what separates them from a name.
Measured on artist 8, Discord messages 2-60 days apart sharing a gated
token: 121 pairs. The 106 sharing the leading name all read as one piece's
trickle; of the 15 sharing only a trailing word, 14 are sibling pieces
(`Bea_Machamp_Shiny_*` / `Bea_Machoke_Shiny_*`) and one is a plain
collision (`Undyne_insert_bottom_only-C` / `Lichgalclc_Lingerie_Bottom_21`).
None when the name carries no identity at all — see `working_name_tokens`.
"""
tokens = _ordered_tokens(path)
return tokens[0] if tokens else None
def token_frequencies(posts: Iterable[Iterable[str]]) -> Counter[str]:
"""How many of ONE ARTIST's POSTS each working-name token appears in.
Takes posts — each an iterable of that post's image paths — rather than a
flat list of paths, because the unit of the count is the post. See
MAX_TOKEN_POSTS for what that buys; the short version is that a piece with
six exports in one post has used its name once.
Scoped to the ARTIST, not the library: a working name belongs to the person
who chose it, and the same string can be one creator's piece and another's
boilerplate. Built once per artist per sweep, not per candidate pair.
"""
counts: Counter[str] = Counter()
for paths in posts:
counts.update({t for path in paths for t in working_name_tokens(path)})
return counts
def rarity(freq: int, max_frequency: int) -> float:
"""Rarity of one token within an artist's own corpus, in [0, 1].
Shared by EVERY rarity-gated signal deliberately. They carried one each
until 2026-09-24, and the copies drifted: the filename signal grew a
frequency gate and the marker signal never did, so a creator's habitual
emoji scored the same 1.00 as a marker they had used twice. One
definition cannot drift from itself.
Full strength at 2 rather than 1: a genuine match means the token is on
at least two things, so demanding uniqueness would reject every real
pair. Decays to zero AT the cap rather than falling off it, so nothing
sits on a cliff edge.
"""
if freq <= 2:
return 1.0
if freq >= max_frequency:
return 0.0
return (max_frequency - freq) / (max_frequency - 2)
def shared_identity(
left: Iterable[str],
right: Iterable[str],
frequencies: Counter[str],
*,
max_frequency: int = MAX_TOKEN_POSTS,
) -> tuple[float, str | None]:
"""Strength in [0, 1] that two sets of filenames name the SAME piece.
Returns `(strength, token)` — the token is carried back so the proposal can
say WHY it was made. A review queue that cannot explain itself is one the
operator learns to click through without reading.
Strength is a function of the winning token's rarity within the artist's
own library, not of how many tokens matched. One decisive token beats three
vague ones, and a token appearing across forty of this artist's posts is a
habit rather than an identity however exactly it matches.
`frequencies` must be the POST counts from `token_frequencies`.
"""
shared = {t for t in set(left) & set(right) if frequencies.get(t, 0) <= max_frequency}
if not shared:
return 0.0, None
# The rarest shared token decides — one decisive token beats three vague
# ones.
token = min(shared, key=lambda t: (frequencies.get(t, 0), -len(t), t))
strength = round(rarity(max(frequencies.get(token, 1), 1), max_frequency), 4)
# A token sitting exactly ON the cap decays to zero, and naming it anyway
# would hand the review queue a reason that carries no weight — "matched on
# loislanetb2", with nothing behind it. Measured: that token is on 6 of this
# artist's images. Report a token only when it is doing work.
return (strength, token) if strength > 0 else (0.0, None)
# --- the body/title signal ---------------------------------------------------
#
# The same idea applied to TEXT. The operator's example pair carries `🍈🍈` in
# the Patreon title and `@everyone 🍈 🍈` in the Discord message — a marker the
# creator uses to tie the two together, which no vocabulary list would predict.
#
# Rarity-gated, exactly as the filename signal is, and the gate is here because
# the first pass did NOT have one. Measured on artist 8, 300 posts:
#
# 💦 11 posts (4%) 🫴 6 🌰 5 🍗 5 🫣 4
#
# 💦 is punctuation for this creator — about one post in twenty-five. Ungated it
# scored a full 1.00 and was the DECIDING term in a proposal that proximity
# alone (0.441) could not carry. A habitual marker riding along with proximity
# is just proximity wearing a hat, which is the exact failure the matcher's
# threshold sits above 0.55 to prevent. The operator's 🍈🍈 is the opposite
# case: two posts, and they are the pair itself.
_WORD = re.compile(r"[0-9A-Za-z]{3,}")
# Anything outside the Basic Multilingual Plane's text ranges: emoji, symbols,
# kaomoji parts. These are the tokens creators actually use as markers, and
# they are rare enough in prose to be evidence on their own.
# U+1F000-1FAFF is the emoji planes; U+2190-2BFF covers arrows, dingbats and
# the miscellaneous-symbol blocks, which already contains U+2600-27BF.
_SYMBOL = re.compile(r"[\U0001F000-\U0001FAFF\u2190-\u2BFF]")
_COMMON_TEXT = frozenset({
"the", "and", "for", "you", "new", "out", "now", "this", "that", "with",
"everyone", "here", "post", "all", "art", "one", "get", "has", "are",
})
# A marker in more than this many of ONE ARTIST's posts is a signature, not a
# tie-back. A marker tying an announcement to its drop lands on two posts —
# the two.
#
# Tighter than MAX_TOKEN_POSTS. Both count posts, so the numbers are directly
# comparable and the gap between them is the claim being made: a working name
# is the creator's private label for one piece and may honestly recur as they
# revisit it, while a marker is public decoration and stops being evidence the
# moment it is reused. Measured on artist 8: 💦 spans 13 posts, the word
# "like" 43, and 🌗 — a real tie-back — exactly 2.
MAX_MARKER_POSTS = 4
def text_markers(text: str | None) -> set[str]:
"""Distinctive tokens in a post body or title: symbols, and rare-ish words.
Symbols count individually rather than as a run, so `🍈🍈` and `🍈 🍈` —
which is how the same marker appears on the two platforms — reduce to the
same token. Spacing is a platform's rendering, not the creator's intent.
"""
if not text:
return set()
out = {m.group(0) for m in _SYMBOL.finditer(text)}
out |= {
w.lower() for w in _WORD.findall(text)
if w.lower() not in _COMMON_TEXT and not _YEAR.match(w)
}
return out
def marker_frequencies(texts: Iterable[str | None]) -> Counter[str]:
"""How many of ONE ARTIST's posts each marker appears in.
Per POST, not per occurrence: a creator who repeats an emoji six times in
one body has used it once as far as identity goes. Scoped to the artist for
the same reason `token_frequencies` is — a marker is a personal habit, and
one creator's signature is another's whole vocabulary.
"""
counts: Counter[str] = Counter()
for t in texts:
counts.update(text_markers(t))
return counts
def marker_overlap(
left: str | None,
right: str | None,
frequencies: Counter[str],
*,
max_frequency: int = MAX_MARKER_POSTS,
) -> float:
"""Strength in [0, 1] that two texts share a DELIBERATE marker.
`frequencies` is required rather than defaulted to "no gate". An ungated
call is the bug this signature exists to make impossible to write by
accident, and a default would have kept it one keyword away.
Symbols weigh full and words a quarter, because prose shares words by
accident: a creator who writes "commission" in both posts on a Tuesday has
told us nothing that the timestamps did not already say.
There is no divisor. An earlier pass halved the total so that a long body
could not out-vote a short one, which the rarity gate now does properly —
and halving meant the operator's own 🍈🍈 pair, a marker on exactly two
posts, could reach only 0.5. One marker the creator uses nowhere else is
the whole signal, not half of it.
"""
shared = text_markers(left) & text_markers(right)
if not shared:
return 0.0
score = sum(
(1.0 if _SYMBOL.match(t) else 0.25) * rarity(frequencies.get(t, 1), max_frequency)
for t in shared
)
return round(min(1.0, score), 4)
-417
View File
@@ -1,417 +0,0 @@
"""The unified post card — a teaser shows what it points at (#4402, #4401).
Milestone 388. A Patreon teaser is a POINTER: a cropped, censored fragment
whose job is to say "the full set is in Discord". Until this module the card
rendered the fragment and a text link, and the reader had to make the join FC
had already made.
Operator, 2026-09-24: *"the teaser from the patreon post doesn't show the items
that it's supposed to reference so I'm trying to unify the teaser post with the
content it's meant to draw attention to."*
## A reference, never an absorption
`discord_grouping` folds chat messages into a synthetic post by transferring
ownership (`absorbed_by_post_id`). That is the wrong primitive here, and the
operator said so directly: *"the nested items on the unified post are a
duplicate or reference of existing content. that's why they can show similar
items and not erase or invalidate the way the discord items landed."*
So nothing here writes. The Discord posts keep their own rows, dates and
places in the feed; the teaser's card DISPLAYS them. That is also what makes
reaching back for older variants safe at all: a wrong reference shows one
extra thumbnail in one place, where a wrong regrouping would move content.
## What a teaser references
1. The Discord drops a `linked` PostAssociation joins it to (#4392) — accepted
by the operator, or linked by FC on a conclusive name match.
2. The rest of that piece's VARIANT FAMILY (#4401): the wips, alts and censor
passes a creator trickles out under one working name, days or weeks apart.
Families are found by the creator's LEADING working name, not by any shared
token and not by image similarity — see `post_naming.leading_name` for the
measurement, and lesson #4400 for why a whole-image comparison between two
works by one artist cannot separate "same piece" from "same artist".
"""
from __future__ import annotations
from collections import Counter
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta
from sqlalchemy import and_, exists, extract, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from ..models import (
ImageProvenance,
ImageRecord,
ImportSettings,
Post,
PostAssociation,
Source,
)
from ..utils.phash import hamming, hash_bits
from ..utils.text import html_to_plain, truncate_at_word
from .discord_grouping import PLATFORM as DISCORD
from .gallery_service import thumbnail_url
from .post_association_service import DUPLICATE_MAX_DISTANCE
from .post_naming import FAMILY_MAX_POSTS, leading_name, rarity, token_frequencies
# The text each referenced post contributes to the card, per post. The card
# clamps it again; this keeps a long Discord thread from making the feed
# payload the size of the thread.
TEXT_LIMIT = 280
@dataclass(frozen=True)
class Candidate:
"""One image as the family search sees it."""
image_id: int
post_id: int
path: str
phash: int | None
at: datetime
def family(
seed: Iterable[Candidate],
pool: Iterable[Candidate],
name_posts: Counter[str],
hash_posts: Counter[int],
*,
anchor: datetime,
window: timedelta,
max_posts: int = FAMILY_MAX_POSTS,
) -> list[Candidate]:
"""The images in `pool` that belong to the same piece as `seed`.
A member shares a seed image's LEADING working name, or is a perceptual
near-duplicate of one (the same file re-posted), and lies within `window`
of `anchor` — the teaser's date. Oldest first, so the card reads as the
trickle it was.
ONE hop from the seed, never transitive. Every measured family is one hop
from any of its members, because the members share the name; chaining is
what lets a family drift from `Year_20k` to whatever `Year_20k_Base`'s
other tokens happen to touch.
Both identity routes are rarity-gated against `max_posts`, exactly as the
matcher gates them. A leading name the creator uses across many posts is a
character, and a hash on many posts is a banner.
"""
seed = list(seed)
names = {
name for c in seed
if (name := leading_name(c.path)) is not None
and rarity(name_posts.get(name, 0), max_posts) > 0
}
hashes = [
c.phash for c in seed
if c.phash is not None and rarity(hash_posts.get(c.phash, 0), max_posts) > 0
]
taken = {c.image_id for c in seed}
out: list[Candidate] = []
for c in pool:
if c.image_id in taken or abs(c.at - anchor) > window:
continue
named = leading_name(c.path) in names
copied = c.phash is not None and any(
(d := hamming(c.phash, h)) is not None and d <= DUPLICATE_MAX_DISTANCE
for h in hashes
)
if named or copied:
out.append(c)
taken.add(c.image_id)
return sorted(out, key=lambda c: (c.at, c.image_id))
def _when(post: Post) -> datetime:
return post.post_date or post.downloaded_at
def _text(post: Post) -> str | None:
plain = html_to_plain(post.description) if post.description else None
if not plain or not plain.strip():
return None
return truncate_at_word(plain.strip(), TEXT_LIMIT)[0]
@dataclass
class _Artist:
"""Everything the family search needs about one artist, loaded once."""
rows: dict[int, tuple] # image_id -> (post_id, path, phash, sha, mime, thumb)
posts: dict[int, Post]
platform: dict[int, str | None] # post_id -> platform
name_posts: Counter[str]
hash_posts: Counter[int]
class PostUnificationService:
def __init__(self, session: AsyncSession):
self.session = session
self._artists: dict[int, _Artist] = {}
async def _artist(self, artist_id: int) -> _Artist:
if artist_id in self._artists:
return self._artists[artist_id]
posts: dict[int, Post] = {}
platform: dict[int, str | None] = {}
for post, plat in (await self.session.execute(
select(Post, Source.platform)
.outerjoin(Source, Post.source_id == Source.id)
.where(Post.artist_id == artist_id)
)).all():
posts[post.id] = post
platform[post.id] = plat
rows: dict[int, tuple] = {}
paths_by_post: dict[int, list[str]] = {}
hashes_by_post: dict[int, set[int]] = {}
for img_id, post_id, path, phash, sha, mime, thumb in (await self.session.execute(
select(
ImageRecord.id, ImageRecord.primary_post_id, ImageRecord.path,
ImageRecord.phash, ImageRecord.sha256, ImageRecord.mime,
ImageRecord.thumbnail_path,
).where(
ImageRecord.artist_id == artist_id,
ImageRecord.primary_post_id.is_not(None),
)
)).all():
bits = hash_bits(phash)
rows[img_id] = (post_id, path, bits, sha, mime, thumb)
paths_by_post.setdefault(post_id, []).append(path)
if bits is not None:
hashes_by_post.setdefault(post_id, set()).add(bits)
# Counted over EVERY post the artist has, exactly as the matcher's
# corpus counts them — a family is judged against the whole library,
# not against the slice inside the window, or a character name would
# look rare in any quiet month.
found = _Artist(
rows=rows,
posts=posts,
platform=platform,
name_posts=token_frequencies(paths_by_post.values()),
hash_posts=Counter(h for hs in hashes_by_post.values() for h in hs),
)
self._artists[artist_id] = found
return found
async def _drop_images(self, drop_ids: list[int]) -> dict[int, list[int]]:
"""drop post id -> its image ids, through provenance as the feed reads them.
A synthetic drop owns no image outright: its images belong to the
member messages, and `discord_grouping` gives the drop a provenance row
for each. The primary_post_id arm keeps any image that has one and no
row, the same union `PostFeedService._thumbnails_for` takes.
"""
out: dict[int, list[int]] = {pid: [] for pid in drop_ids}
if not drop_ids:
return out
links = (
select(
ImageProvenance.image_record_id.label("image_id"),
ImageProvenance.post_id.label("post_id"),
)
.where(ImageProvenance.post_id.in_(drop_ids))
.union(
select(
ImageRecord.id.label("image_id"),
ImageRecord.primary_post_id.label("post_id"),
).where(ImageRecord.primary_post_id.in_(drop_ids))
)
.subquery()
)
for img_id, pid in (await self.session.execute(
select(links.c.image_id, links.c.post_id).order_by(links.c.image_id)
)).all():
out[pid].append(img_id)
return out
async def unified_for(self, posts: Iterable[Post]) -> dict[int, dict]:
"""post id -> the card's reference set, for each post that HAS one.
Only teasers get one: a post with at least one `linked` association on
the announcing side. Every other post is absent from the result, and
the card renders exactly as it did before this module existed.
"""
teasers = {p.id: p for p in posts if p.synthesized_by is None}
if not teasers:
return {}
links = (await self.session.execute(
select(PostAssociation)
.where(
PostAssociation.status == "linked",
PostAssociation.announcement_post_id.in_(list(teasers)),
)
.order_by(PostAssociation.id)
)).scalars().all()
if not links:
return {}
settings = await self.session.get(ImportSettings, 1)
window = timedelta(days=float(
settings.discord_family_window_days if settings is not None else 60.0
))
drop_images = await self._drop_images(
sorted({a.payload_post_id for a in links})
)
by_teaser: dict[int, list[PostAssociation]] = {}
for a in links:
by_teaser.setdefault(a.announcement_post_id, []).append(a)
out: dict[int, dict] = {}
for teaser_id, assocs in by_teaser.items():
teaser = teasers[teaser_id]
artist = await self._artist(teaser.artist_id)
out[teaser_id] = self._compose(teaser, assocs, drop_images, artist, window)
return out
def _compose(
self,
teaser: Post,
assocs: list[PostAssociation],
drop_images: dict[int, list[int]],
artist: _Artist,
window: timedelta,
) -> dict:
def candidate(img_id: int) -> Candidate | None:
row = artist.rows.get(img_id)
if row is None:
return None
post_id, path, bits, *_ = row
post = artist.posts.get(post_id)
if post is None:
return None
return Candidate(img_id, post_id, path, bits, _when(post))
drop_ids = [a.payload_post_id for a in assocs]
shown = [i for d in drop_ids for i in drop_images.get(d, [])]
own = [i for i, row in artist.rows.items() if row[0] == teaser.id]
seed = [c for i in own + shown if (c := candidate(i)) is not None]
# Variants come from Discord only. That is where a creator trickles
# them out, it is the corpus the family rule was measured on, and it
# keeps one teaser from pulling a DIFFERENT teaser's crop onto its card.
pool = [
c for i, row in artist.rows.items()
if artist.platform.get(row[0]) == DISCORD
and (c := candidate(i)) is not None
]
variants = family(
seed, pool, artist.name_posts, artist.hash_posts,
anchor=_when(teaser), window=window,
)
def thumb(img_id: int, post_id: int, role: str) -> dict | None:
row = artist.rows.get(img_id)
if row is None:
return None
_pid, _path, _bits, sha, mime, tp = row
return {
"image_id": img_id,
"thumbnail_url": thumbnail_url(tp, sha, mime),
"mime": mime,
"post_id": post_id,
"role": role,
}
own_ids = set(own)
thumbnails: list[dict] = []
seen: set[int] = set(own_ids)
for drop_id in drop_ids:
for img_id in drop_images.get(drop_id, []):
if img_id in seen:
continue
if (t := thumb(img_id, drop_id, "drop")) is not None:
thumbnails.append(t)
seen.add(img_id)
for c in variants:
if c.image_id in seen:
continue
if (t := thumb(c.image_id, c.post_id, "variant")) is not None:
thumbnails.append(t)
seen.add(c.image_id)
# The text of every item the card unifies — the operator's *"the
# unified card should also contain the text for any of the items
# unified on it"*. A drop's own description already joins its member
# messages, so a variant's text is its MESSAGE, read off the member
# post that owns the image. A line said twice (`@everyone 🍈🍈` on
# every message of a drop) is shown once.
texts: list[dict] = []
said: set[str] = set()
def add_text(post: Post | None, role: str) -> None:
if post is None:
return
text = _text(post)
if text is None or text in said:
return
said.add(text)
texts.append({
"post_id": post.id,
"role": role,
"date": _when(post).isoformat(),
"text": text,
})
for drop_id in drop_ids:
add_text(artist.posts.get(drop_id), "drop")
for post_id in dict.fromkeys(c.post_id for c in variants):
add_text(artist.posts.get(post_id), "variant")
return {
"links": [
{
"association_id": a.id,
"post_id": a.payload_post_id,
# "fc" | "operator" | None (linked before the column
# existed — an operator accept, every one of them).
"linked_by": a.linked_by,
"token": (a.signals or {}).get("identity_token"),
}
for a in assocs
],
"thumbnails": thumbnails,
"variant_count": sum(1 for t in thumbnails if t["role"] == "variant"),
"texts": texts,
}
def fold_clause(fold_hours: float):
"""WHERE clause: this post is NOT a linked drop sitting beside its teaser.
Operator: *"discord 'posts' land as normal and only hidden from the post
view they're posted the same day."* Everything else stays — an older
variant the teaser also references is history, and a reference does not
remove it from history.
Built on `post_date`/`downloaded_at`, not the feed's `resurfaced_at`-led
sort key: whether two posts are the same release is a question about when
they were published, not about where the feed has since moved one.
"""
teaser = aliased(Post)
# The SQL-standard EXTRACT(epoch FROM …), which every Postgres accepts.
gap = func.abs(extract(
"epoch",
func.coalesce(Post.post_date, Post.downloaded_at)
- func.coalesce(teaser.post_date, teaser.downloaded_at),
))
return ~exists(
select(PostAssociation.id)
.join(teaser, teaser.id == PostAssociation.announcement_post_id)
.where(and_(
PostAssociation.payload_post_id == Post.id,
PostAssociation.status == "linked",
gap <= fold_hours * 3600,
))
)
+1 -36
View File
@@ -115,32 +115,6 @@ async def active_platform_cooldowns(session: AsyncSession) -> dict[str, datetime
return active return active
def backfill_ready(source: Source) -> bool:
"""A deep walk the operator started, with budget left and no failure
backing it off — due NOW rather than at its next scheduled check.
A backfill runs one time-boxed chunk per download (plan #693), and nothing
queued the next chunk: each waited for the source's regular interval. At
the 8-hour default a freshly armed backfill sat untouched until the next
check (the operator armed one on 2026-09-25 and saw nothing happen) and a
five-chunk walk took most of two days. The tick's in-flight guard keeps
one chunk at a time per source and the platform lock one walk per
platform, so "due every tick" means "next chunk as soon as the last one
ends".
The failure gate is what keeps a broken source from retrying every
minute: any failed chunk raises `consecutive_failures`, which drops the
source back onto its backed-off interval. A chunk that fails to progress
twice marks the walk stalled (download_service), which ends it here too.
"""
co = source.config_overrides or {}
return (
co.get("_backfill_state") == "running"
and (source.backfill_runs_remaining or 0) > 0
and not (source.consecutive_failures or 0)
)
async def select_due_sources(session: AsyncSession) -> list[Source]: async def select_due_sources(session: AsyncSession) -> list[Source]:
"""Sources where (enabled, artist.auto_check) and now >= last_checked_at + effective_interval. """Sources where (enabled, artist.auto_check) and now >= last_checked_at + effective_interval.
@@ -149,9 +123,6 @@ async def select_due_sources(session: AsyncSession) -> list[Source]:
cooldown is the preventive half of the burst-prevention pair (per-source cooldown is the preventive half of the burst-prevention pair (per-source
consecutive_failures backoff handles the offending source itself). consecutive_failures backoff handles the offending source itself).
A running backfill (`backfill_ready`) is due on every tick, and whether
or not its artist is on auto-check — the operator started it by hand.
Ordering: last_checked_at ASC NULLS FIRST, then id. Never-checked Ordering: last_checked_at ASC NULLS FIRST, then id. Never-checked
sources go first, then the longest-since-checked, so the most overdue sources go first, then the longest-since-checked, so the most overdue
sources hit Celery's FIFO download queue first. Anti-starvation: if sources hit Celery's FIFO download queue first. Anti-starvation: if
@@ -164,6 +135,7 @@ async def select_due_sources(session: AsyncSession) -> list[Source]:
.options(selectinload(Source.artist)) .options(selectinload(Source.artist))
.join(Artist, Source.artist_id == Artist.id) .join(Artist, Source.artist_id == Artist.id)
.where(Source.enabled.is_(True)) .where(Source.enabled.is_(True))
.where(Artist.auto_check.is_(True))
.order_by(Source.last_checked_at.asc().nulls_first(), Source.id) .order_by(Source.last_checked_at.asc().nulls_first(), Source.id)
)).scalars().all() )).scalars().all()
@@ -175,11 +147,6 @@ async def select_due_sources(session: AsyncSession) -> list[Source]:
for s in rows: for s in rows:
if s.platform in cooldowns: if s.platform in cooldowns:
continue continue
if backfill_ready(s):
due.append(s)
continue
if not s.artist.auto_check:
continue
interval = compute_effective_interval(s, s.artist, settings) interval = compute_effective_interval(s, s.artist, settings)
if s.last_checked_at is None: if s.last_checked_at is None:
due.append(s) due.append(s)
@@ -196,8 +163,6 @@ def compute_next_check_at(
"""Return the projected datetime of the next check, or None if never checked.""" """Return the projected datetime of the next check, or None if never checked."""
if source.last_checked_at is None: if source.last_checked_at is None:
return None return None
if backfill_ready(source):
return datetime.now(UTC)
interval = compute_effective_interval(source, artist, settings) interval = compute_effective_interval(source, artist, settings)
return source.last_checked_at + timedelta(seconds=interval) return source.last_checked_at + timedelta(seconds=interval)
+54 -141
View File
@@ -31,20 +31,18 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from sqlalchemy import func from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ServiceSeen from ..models import ServiceSeen
from .worker_lanes import LANES, lane_for_node
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# There is no refresh TTL any more. It existed because the HEALTH REQUEST # How stale the roster may be before a health request refreshes it. Comfortably
# refreshed the roster, rate-limited to 20s so that a page open in two tabs # under the staleness thresholds that decide a service is missing, so the
# did not inspect twice as often. `size_worker_lanes` owns the refresh now, on # verdict is never limited by how often anyone looked.
# `SWEEP_PERIOD_SECONDS`, so the cadence is a schedule rather than a side REFRESH_TTL_SECONDS = 20.0
# effect of someone looking.
# celery inspect is a broker round trip and this sits on a request path, so it # celery inspect is a broker round trip and this sits on a request path, so it
# gets a deadline (rule 156). A broker that has stopped answering must make the # gets a deadline (rule 156). A broker that has stopped answering must make the
@@ -52,44 +50,16 @@ log = logging.getLogger(__name__)
# page that exists to explain it. # page that exists to explain it.
INSPECT_TIMEOUT_SECONDS = 2.0 INSPECT_TIMEOUT_SECONDS = 2.0
# How many broadcast round trips `_inspect_celery_sync` makes. Named, because
# the wrapper's budget is derived from it and the two must not drift.
#
# `active_queues()` and `active()` are separate broadcasts, and a broadcast
# with no `destination` cannot know how many replies to expect — so each one
# waits out its full timeout rather than returning on the last reply. The sync
# call therefore costs ~2 x INSPECT_TIMEOUT_SECONDS in the ordinary case, not
# once.
INSPECT_ROUND_TRIPS = 2
# Slack for the thread handoff. `asyncio.to_thread` hands work to the default
# executor, and on a loaded web process — the operator's showcase page pulling
# ninety thumbnails a second — the thread may not even be scheduled inside the
# budget, let alone finish.
#
# This exists because the wrapper used to allow `INSPECT_TIMEOUT_SECONDS * 2`,
# which LOOKS like a safety factor and is exactly the worst case with nothing
# left over. Observed on the operator's first consolidated deploy, 2026-09-23:
# a TimeoutError traceback per refresh while the two inspect calls were
# working perfectly. A budget equal to the work is a budget that fails under
# any load at all.
INSPECT_SLACK_SECONDS = 3.0
# Queue set -> the name an operator recognises. Sorted-tuple keys, because the # Queue set -> the name an operator recognises. Sorted-tuple keys, because the
# order celery reports them in is not guaranteed. # order celery reports them in is not guaranteed.
# #
# DERIVED from `worker_lanes.LANES` (milestone 422 step 1) rather than written # A deployment that slices CELERY_QUEUES differently falls through to the raw
# out here. It was a hand-kept second copy of the same fact, and it had already # queue list rather than being given a name this table invented for it: a
# drifted: `maintenance_long` is a live lane with four task routes pointing at
# it and a dedicated worker in the operator's stack, and this map did not know
# it — so the System tab labelled it `Worker (maintenance_long)`. One list of
# lanes now names them everywhere.
#
# A deployment that slices CELERY_QUEUES differently still falls through to the
# raw queue list rather than being given a name this code invented for it: a
# wrong-but-confident label on a status page is worse than an ugly true one. # wrong-but-confident label on a status page is worse than an ugly true one.
ROLE_NAMES: dict[tuple[str, ...], str] = { ROLE_NAMES: dict[tuple[str, ...], str] = {
lane.queue_key: lane.display_name for lane in LANES ("default", "download", "import", "thumbnail"): "Worker",
("maintenance", "scan"): "Scheduler",
("ml",): "ML worker",
} }
@@ -110,29 +80,12 @@ def _inspect_celery_sync() -> dict[tuple[str, ...], dict]:
from ..celery_app import celery as celery_app from ..celery_app import celery as celery_app
insp = celery_app.control.inspect(timeout=INSPECT_TIMEOUT_SECONDS) insp = celery_app.control.inspect(timeout=INSPECT_TIMEOUT_SECONDS)
# TWO broadcasts, each waiting out its own timeout — see
# INSPECT_ROUND_TRIPS, which the caller's budget is derived from. Adding a
# third call here without updating that constant puts the wrapper back
# under the work it is waiting for.
active_queues = insp.active_queues() or {} active_queues = insp.active_queues() or {}
active_tasks = insp.active() or {} active_tasks = insp.active() or {}
grouped: dict[tuple[str, ...], dict] = {} grouped: dict[tuple[str, ...], dict] = {}
for hostname, queues in active_queues.items(): for hostname, queues in active_queues.items():
# Keyed on the LANE's queue set when the node name identifies one, so key = tuple(sorted({q["name"] for q in queues}))
# a lane keeps the same roster row whether or not it is consuming.
#
# Grouping on the ACTIVE queues alone meant a lane at cap 0 — which
# cancels its consumers — reported an empty set, landed under the key
# `celery:`, and rendered as a phantom row named `Worker ()` while its
# real row went stale beside it. Both symptoms on the operator's
# screen, 2026-09-23, from this one line.
#
# Deriving the key from `lane.queue_key` rather than inventing a new
# one keeps every existing row: it is the same string the lane already
# had while it was running.
lane = lane_for_node(hostname)
key = lane.queue_key if lane else tuple(sorted({q["name"] for q in queues}))
entry = grouped.setdefault(key, {"hostnames": [], "active": 0}) entry = grouped.setdefault(key, {"hostnames": [], "active": 0})
entry["hostnames"].append(hostname) entry["hostnames"].append(hostname)
entry["active"] += len(active_tasks.get(hostname, [])) entry["active"] += len(active_tasks.get(hostname, []))
@@ -141,13 +94,10 @@ def _inspect_celery_sync() -> dict[tuple[str, ...], dict]:
return grouped return grouped
def touch_service_stmt(*, key: str, kind: str, display_name: str, details: dict): async def touch_service(
"""The upsert that records a check-in, as a statement. session: AsyncSession, *, key: str, kind: str, display_name: str, details: dict
) -> None:
Built here rather than inline so the async caller (an agent lease, over """Record that a part checked in just now.
the API) and the sync one (the sizing sweep, in a celery task) run the
SAME write. Two spellings of one upsert is the kind of duplication that
stays correct right up until one of them gains a column.
Upsert rather than read-modify-write: several web processes and several Upsert rather than read-modify-write: several web processes and several
agents can be doing this at once, and the last writer is simply the most agents can be doing this at once, and the last writer is simply the most
@@ -158,7 +108,7 @@ def touch_service_stmt(*, key: str, kind: str, display_name: str, details: dict)
stmt = pg_insert(ServiceSeen).values( stmt = pg_insert(ServiceSeen).values(
key=key, kind=kind, display_name=display_name, details=details, key=key, kind=kind, display_name=display_name, details=details,
) )
return stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=[ServiceSeen.key], index_elements=[ServiceSeen.key],
set_={ set_={
"kind": stmt.excluded.kind, "kind": stmt.excluded.kind,
@@ -167,75 +117,7 @@ def touch_service_stmt(*, key: str, kind: str, display_name: str, details: dict)
"last_seen_at": func.now(), "last_seen_at": func.now(),
}, },
) )
await session.execute(stmt)
async def touch_service(
session: AsyncSession, *, key: str, kind: str, display_name: str, details: dict
) -> None:
"""Record that a part checked in just now."""
await session.execute(touch_service_stmt(
key=key, kind=kind, display_name=display_name, details=details,
))
def _roster_rows(grouped: dict[tuple[str, ...], dict]) -> list[dict]:
"""The `touch_service` arguments for everything that answered.
Split from the write so the async and sync refreshes below share the
mapping as well as the statement — what a roster row IS should not depend
on which kind of session is writing it.
"""
return [
{
"key": "celery:" + ",".join(queues),
"kind": "celery",
"display_name": role_display_name(queues),
"details": {
"queues": list(queues),
"hostnames": entry["hostnames"],
"replicas": len(entry["hostnames"]),
"active": entry["active"],
},
}
for queues, entry in grouped.items()
]
def refresh_celery_roster_sync(session) -> None:
"""The roster refresh, from the sizing sweep's sync session.
## Why the sweep owns this now
It used to run on the request path, rate-limited to once every 20s by the
newest celery row. So the roster only advanced while somebody had a
browser open — the liveness of the workers was a function of whether
anyone was looking at them, which is the observer-effect version of the
bug this roster exists to prevent.
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?"*
Now a timer writes it and the page only reads. The cadence is
`SWEEP_PERIOD_SECONDS`, and `api/system_health` asserts it leaves headroom
under the staleness thresholds — because a sweep period and a stale
threshold chosen in different files and never compared is exactly how the
idle GPU agent came to read as stopped (lesson #4355).
Never raises. A failure means the roster does not advance, and the rows
going stale is then a TRUE report about a broker nobody can reach.
"""
try:
grouped = _inspect_celery_sync()
except Exception:
log.warning(
"service roster: celery inspect failed; roster not refreshed",
exc_info=True,
)
return
for row in _roster_rows(grouped):
session.execute(touch_service_stmt(**row))
session.commit()
async def refresh_celery_roster(session: AsyncSession) -> None: async def refresh_celery_roster(session: AsyncSession) -> None:
@@ -249,14 +131,45 @@ async def refresh_celery_roster(session: AsyncSession) -> None:
try: try:
grouped = await asyncio.wait_for( grouped = await asyncio.wait_for(
asyncio.to_thread(_inspect_celery_sync), asyncio.to_thread(_inspect_celery_sync),
timeout=( timeout=INSPECT_TIMEOUT_SECONDS * 2,
INSPECT_TIMEOUT_SECONDS * INSPECT_ROUND_TRIPS
+ INSPECT_SLACK_SECONDS
),
) )
except Exception: except Exception:
log.warning("service roster: celery inspect failed; roster not refreshed", exc_info=True) log.warning("service roster: celery inspect failed; roster not refreshed", exc_info=True)
return return
for row in _roster_rows(grouped): for queues, entry in grouped.items():
await touch_service(session, **row) await touch_service(
session,
key="celery:" + ",".join(queues),
kind="celery",
display_name=role_display_name(queues),
details={
"queues": list(queues),
"hostnames": entry["hostnames"],
"replicas": len(entry["hostnames"]),
"active": entry["active"],
},
)
async def refresh_if_stale(session: AsyncSession) -> None:
"""Refresh the celery roster if nobody has for REFRESH_TTL_SECONDS.
Rate-limited by the data rather than by a lock: the gate is the newest
last_seen_at across the celery rows, which every web process can see. Two
processes racing through the gate costs one redundant inspect and writes
the same values twice, so the benign outcome needs no coordination to
prevent.
"""
newest = (
await session.execute(
select(func.max(ServiceSeen.last_seen_at)).where(ServiceSeen.kind == "celery")
)
).scalar_one_or_none()
if newest is not None:
age = (await session.execute(select(func.now()))).scalar_one() - newest
if age.total_seconds() < REFRESH_TTL_SECONDS:
return
await refresh_celery_roster(session)
+2 -8
View File
@@ -18,7 +18,6 @@ from ..models import (
Source, Source,
) )
from .db_helpers import failing_sources_clause from .db_helpers import failing_sources_clause
from .download_backends import uses_native_ingester
from .gallery_dl import ErrorType from .gallery_dl import ErrorType
from .membership_reconcile import KEPT_KEY, STOPPED_KEY from .membership_reconcile import KEPT_KEY, STOPPED_KEY
from .membership_roster import gated_reasons_for_sources from .membership_roster import gated_reasons_for_sources
@@ -126,11 +125,6 @@ class SourceRecord:
"backfill_posts": self.backfill_posts, "backfill_posts": self.backfill_posts,
"tier_gated_count": self.tier_gated_count, "tier_gated_count": self.tier_gated_count,
"gated_reason": self.gated_reason, "gated_reason": self.gated_reason,
# Recover / recapture exist only on the native ingester. Sent so the
# UI asks the backend's own predicate instead of keeping a copy of
# the platform list — the copy said "patreon, subscribestar" for a
# day after Discord went native (milestone 428).
"native_ingester": uses_native_ingester(self.platform),
} }
@@ -557,8 +551,8 @@ class SourceService:
whole source); the two flags are mutually exclusive, so arming recapture whole source); the two flags are mutually exclusive, so arming recapture
clears bypass_seen. Clears prior cursor/chunk/stall state so it walks clears bypass_seen. Clears prior cursor/chunk/stall state so it walks
fresh from the top. The flag is cleared on completion (download_service) fresh from the top. The flag is cleared on completion (download_service)
and on stop. Recapture needs the native ingester's post-record capture, and on stop. Recapture is Patreon-only (the native ingester's post-record
so the UI offers it on native sources only (`native_ingester`).""" capture); inert elsewhere. The UI gates the action to Patreon sources."""
source = (await self.session.execute( source = (await self.session.execute(
select(Source).where(Source.id == source_id) select(Source).where(Source.id == source_id)
)).scalar_one_or_none() )).scalar_one_or_none()
+2 -9
View File
@@ -742,15 +742,8 @@ class SubscribeStarClient:
@staticmethod @staticmethod
def post_meta(post: dict) -> dict: def post_meta(post: dict) -> dict:
"""Title + date. Title is None — SubscribeStar has no title field, and """Title + date for the preview sample. Title is synthesized from the body
the importer synthesizes one from the body. (SubscribeStar has no title field)."""
`date` is the contract the core's REVISIT WINDOW reads (2026-09-23):
ISO-8601 or None. NAIVE here, because `_parse_ss_datetime` renders a
parsed local timestamp with no zone; the core reads a naive date as UTC
rather than discarding it, since a date it refuses to read is a post
the window can never reach.
"""
attrs = post.get("attributes") or {} attrs = post.get("attributes") or {}
return {"title": None, "date": attrs.get("published_at")} return {"title": None, "date": attrs.get("published_at")}
@@ -184,20 +184,10 @@ class SubscribeStarDownloader(BaseNativeDownloader):
sidecar_path.write_text(json.dumps(data, indent=2)) sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path return sidecar_path
def write_post_record( def write_post_record(self, post: dict, artist_slug: str) -> PostRecordOutcome:
self, post: dict, artist_slug: str, *, revisit: bool = False,
) -> PostRecordOutcome:
"""Write the post-first `_post.json` (body/links/metadata) — the sole """Write the post-first `_post.json` (body/links/metadata) — the sole
writer of the post record on the native path. SubscribeStar's body is writer of the post record on the native path. SubscribeStar's body is
already in the feed HTML, so no detail-fetch is needed. already in the feed HTML, so no detail-fetch is needed."""
`revisit=True` is the tick re-reading a post it already captured
(ingest_core's revisit window). The no-detail-fetch half of that
contract is free here — there is no detail endpoint — but the
don't-blank-a-stored-body half still applies: a chunk that parsed with
no content must not overwrite a body we already have. Same guarantee as
the Patreon downloader, for the same reason, so a walk behaves the same
on both platforms."""
attrs = post.get("attributes") or {} attrs = post.get("attributes") or {}
title = attrs.get("title") if isinstance(attrs.get("title"), str) else None 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 post_type = attrs.get("post_type") if isinstance(attrs.get("post_type"), str) else None
@@ -206,12 +196,6 @@ class SubscribeStarDownloader(BaseNativeDownloader):
return PostRecordOutcome( return PostRecordOutcome(
path=None, post_type=post_type, title=title, body_chars=0, path=None, post_type=post_type, title=title, body_chars=0,
) )
if revisit:
body = attrs.get("content")
if not (isinstance(body, str) and body.strip()):
return PostRecordOutcome(
path=None, post_type=post_type, title=title, body_chars=0,
)
post_dir = self.images_root / artist_slug / "subscribestar" / post_dir_name(post) post_dir = self.images_root / artist_slug / "subscribestar" / post_dir_name(post)
post_dir.mkdir(parents=True, exist_ok=True) post_dir.mkdir(parents=True, exist_ok=True)
path = self._write_sidecar_data(post, post_dir / "_post.json") path = self._write_sidecar_data(post, post_dir / "_post.json")
-996
View File
@@ -1,996 +0,0 @@
"""Read and change a lane's live pool, over the broker.
Milestone 422 step 2. The half of the milestone that does something.
## No docker socket is involved, and that is the point
Milestone 365 put "acting on the state" out of scope because restarting a
dead worker needs a docker socket the web container deliberately does not
have. That is true of RESTARTING a container. It is not true of changing how
much work a RUNNING worker does: celery's remote control sends a message over
the broker and the worker resizes its own pool. Same Redis the app already
uses, no new privilege, no new surface.
pool_grow / pool_shrink how many slots a lane runs
add_consumer / cancel_consumer whether it consumes its queues at all
The operator ruled the socket out independently (2026-09-22: *"this feature
is a very invasive idea in my mind and I'd like to avoid it"*), and nothing
here raises the question.
## The setting is PER PROCESS, not per lane total
`pool_grow(n, destination=[...])` adds n slots to EACH destination it names.
While the stack still runs several containers per lane — the operator's
production `worker` is `replicas: 2` — a single delta applied to a lane's
total would be wrong for every replica.
So `slots` means what `CELERY_CONCURRENCY` means: the pool size of one
process. The reconcile below drives EACH replica to that number
independently, computing its own delta from that replica's current pool, so
replicas that have drifted apart (one restarted, one was grown) converge
rather than being moved in lockstep from a shared baseline.
After step 5 there is one process per lane and the distinction disappears.
It matters now, and getting it wrong now would be invisible — the totals
would simply be double what the UI claimed.
## Why reserved() is read alongside the queue depth
Celery PREFETCHES: a worker pulls more messages than it can run and holds
them in memory. Those have already left the Redis list, so `LLEN` — which is
what `/api/system/activity/queues` reports — can read 0 while thirty tasks
are waiting inside a worker. Any judgement about backlog that uses only LLEN
under-reports, which matters for the UI and is disqualifying for step 7's
autoscaler.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import TaskRun, WorkerLane, WorkerLaneSample
from .worker_lanes import (
LANES,
LANES_BY_QUEUE_KEY,
MIN_POOL_SLOTS,
Lane,
derived_ceiling,
lane_for_node,
)
log = logging.getLogger(__name__)
# celery control is a broker round trip on a request path, so it gets a
# deadline (rule 156) — the same reasoning and the same budget as
# service_roster's inspect. A broker that stopped answering must make this
# report "not present", which is true, rather than hang the page.
CONTROL_TIMEOUT_SECONDS = 2.0
# The WORST case of `inspect_lanes_sync`, for callers that need a deadline.
#
# One broadcast plus three targeted reads. The targeted three normally return
# as soon as the named nodes answer; each can still cost a full timeout if a
# node disappears mid-read, so the bound stays four.
CONTROL_ROUND_TRIPS = 4
# Slack for the `asyncio.to_thread` handoff. A budget equal to the work is a
# budget that fails under load — the roster carried exactly that bug into the
# operator's first consolidated deploy and logged a TimeoutError per refresh
# while the inspect calls underneath were working fine.
CONTROL_SLACK_SECONDS = 3.0
INSPECT_BUDGET_SECONDS = (
CONTROL_TIMEOUT_SECONDS * CONTROL_ROUND_TRIPS + CONTROL_SLACK_SECONDS
)
@dataclass
class LaneLiveState:
"""What `celery inspect` says about one lane right now.
`present=False` is NOT "zero slots" — it is "nothing answered". A lane
whose worker is restarting, or whose broker is unreachable, must read as
unknown rather than as stopped: an unswept absence is not a verdict
(snippet #3969). The reconcile in step 3 skips an absent lane rather than
correcting it, which is only safe because this distinction is kept.
"""
present: bool = False
replicas: int = 0
active: int = 0
reserved: int = 0
hostnames: list[str] = field(default_factory=list)
# The queues this lane is actually consuming right now, across replicas.
# Distinct from the lane's CONFIGURED queues: `cancel_consumer` stops a
# worker consuming one without changing what it was started with, which
# is how `enabled=false` is implemented. The reconcile needs this to tell
# "already disabled" from "needs disabling" — without it, it would re-send
# add_consumer for every queue on every tick forever (lesson #4183).
consuming: set[str] = field(default_factory=set)
# Pool size PER HOSTNAME, not aggregated. The resize below computes each
# replica's own delta from its own current pool, so replicas that have
# drifted apart converge instead of being moved in lockstep from a shared
# baseline — which is what an aggregate here would silently reintroduce.
pools: dict[str, int] = field(default_factory=dict)
@property
def pool(self) -> int | None:
"""One number for the UI. `max` rather than a sum: `slots` means the
pool size of ONE process (see the module docstring), so the largest
replica is the honest answer to "what is this lane set to". None when
no replica reported — unknown, never zero."""
return max(self.pools.values()) if self.pools else None
@property
def capacity(self) -> int:
"""Total slots across replicas — how many tasks this lane can run at
once. Distinct from `pool`, and the two must not be confused: `pool`
is the DIAL (one process's size, what grow/shrink move), `capacity` is
the CAPABILITY. Asking "is this lane saturated" compares `active`,
which is summed across replicas, against this — against `pool` it
would call two half-busy replicas of 4 saturated at 4 active."""
return sum(self.pools.values())
def _lane_for_queues(queues: tuple[str, ...]) -> Lane | None:
return LANES_BY_QUEUE_KEY.get(tuple(sorted(queues)))
def inspect_lanes_sync() -> dict[str, LaneLiveState]:
"""Live state per lane name. Sync — callers wrap in asyncio.to_thread.
Never raises. Every lane is present in the result; ones nothing answered
for carry `present=False`, so a caller cannot accidentally read a missing
lane as an empty one by iterating only what came back.
"""
out = {lane.name: LaneLiveState() for lane in LANES}
try:
from ..celery_app import celery as celery_app
# ONE broadcast, then three TARGETED reads.
#
# A broadcast with no `destination` cannot know how many replies to
# expect, so it waits out its whole timeout rather than returning on
# the last one. Four of those is four full timeouts — about eight
# seconds — and `lane_view` sits on the Settings card, so that was the
# load time of the Worker lanes page every time it was opened.
#
# Naming the destinations lets celery stop as soon as those nodes have
# answered, which for workers in this same container is milliseconds.
# The worst case is unchanged: a node that vanishes between the
# broadcast and the targeted reads costs a full timeout waiting for a
# reply that is not coming.
insp = celery_app.control.inspect(timeout=CONTROL_TIMEOUT_SECONDS)
active_queues = insp.active_queues() or {}
# Nothing answered — and the three reads below exist only to describe
# what did. Returning here also makes the broker-down case FAST
# (one timeout, not four), which is exactly when the healthcheck and
# the card need an answer rather than a long wait.
if not active_queues:
return out
targeted = celery_app.control.inspect(
destination=sorted(active_queues),
timeout=CONTROL_TIMEOUT_SECONDS,
)
stats = targeted.stats() or {}
active = targeted.active() or {}
reserved = targeted.reserved() or {}
except Exception:
log.warning("worker_control: celery inspect failed", exc_info=True)
return out
for hostname, queues in active_queues.items():
# The NODE NAME first — see `lane_for_node`. A lane at cap 0 has its
# consumers cancelled and answers with an empty queue list, which
# matches no lane, so attributing by queues alone dropped every lane
# the operator had turned off and reported it as "not answering".
lane = lane_for_node(hostname) or _lane_for_queues(
tuple(q["name"] for q in queues)
)
if lane is None:
# A deployment slicing CELERY_QUEUES differently. Reported by the
# roster under its raw queue list; it simply has no lane row to
# control, which is honest rather than an error.
continue
state = out[lane.name]
state.present = True
state.replicas += 1
state.hostnames.append(hostname)
state.active += len(active.get(hostname, []))
state.reserved += len(reserved.get(hostname, []))
state.consuming.update(q["name"] for q in queues)
# Absent on a worker whose stats did not answer, which leaves
# pool=None — unknown, not zero.
pool = pool_size((stats.get(hostname) or {}).get("pool") or {})
if pool is not None:
state.pools[hostname] = pool
for state in out.values():
state.hostnames.sort()
return out
def pool_size(pool_stats: dict) -> int | None:
"""How many processes a prefork pool is running NOW, from `inspect stats`.
The length of `processes`, not `max-concurrency`. Celery reports
`max-concurrency` as the pool's `limit`, set once at boot; `pool_grow` and
`pool_shrink` hand straight to billiard and never touch it (celery 5.6
`concurrency/prefork.py`: `self.grow = P.grow`). So it read 1 forever on a
lane booted at 1, and the sizing sweep, computing `target - 1` on every
tick, grew a scheduler capped at 2 to six processes while the System tab
showed "6 / 1" (2026-09-24) — and could never shrink one, since 1 - 1 is 0.
`max-concurrency` is the fallback only for a pool that lists no processes
(a non-prefork pool), where it is the best number there is.
"""
procs = pool_stats.get("processes")
if isinstance(procs, list):
return len(procs)
limit = pool_stats.get("max-concurrency")
return limit if isinstance(limit, int) else None
def effective_slots(target: int) -> int:
"""What a pool can actually be set to. Never below one process.
Used wherever a target is COMPARED as well as wherever one is sent: a
reconcile that compares against the unclamped number sees a difference
that no control message can ever close, and re-sends it every tick.
"""
return max(MIN_POOL_SLOTS, target)
def set_lane_slots_sync(
lane: Lane, target: int, live: LaneLiveState | None = None,
) -> tuple[bool, str | None]:
"""Drive every replica of `lane` to `target` slots. Returns (applied, err).
Per-replica deltas rather than one shared delta: see the module docstring.
A replica already at the target is issued nothing at all, which is what
makes step 3's periodic reconcile converge instead of re-sending a grow of
zero forever (lesson #4183 — an enforcer without a reachable fixed point
re-does its own work every tick).
`applied=False` is not a failure of the SETTING. The caller has already
stored the value; this says only that the live push did not land, and the
reconcile will carry it when the lane answers again.
"""
target = effective_slots(target)
try:
from ..celery_app import celery as celery_app
if live is None:
live = inspect_lanes_sync()[lane.name]
if not live.present:
return False, "lane is not running"
if not live.pools:
return False, "worker did not report its pool size"
control = celery_app.control
unreported = [h for h in live.hostnames if h not in live.pools]
for hostname, current in live.pools.items():
delta = target - current
if delta > 0:
control.pool_grow(delta, destination=[hostname])
elif delta < 0:
control.pool_shrink(-delta, destination=[hostname])
if unreported:
# Resized what could be resized, and said which could not. Silence
# here would leave a replica running at a size the UI claims it is
# not, with nothing anywhere recording the gap.
return False, f"no pool size reported by {', '.join(sorted(unreported))}"
return True, None
except Exception as exc: # noqa: BLE001 — reported, never raised at a caller
log.warning("worker_control: could not resize %s", lane.name, exc_info=True)
return False, str(exc)
def set_lane_enabled_sync(
lane: Lane, enabled: bool, live: LaneLiveState | None = None,
) -> tuple[bool, str | None]:
"""Start or stop `lane` consuming its queues, without killing the process.
`cancel_consumer` rather than a shutdown: a stopped consumer keeps its
worker alive and answering `inspect`, so a disabled lane stays visible and
can be turned back on. A killed worker would read as absent, which is the
same signal as a crash — and the whole point of the roster (#365) is that
those two must not look alike.
"""
try:
from ..celery_app import celery as celery_app
if live is None:
live = inspect_lanes_sync()[lane.name]
if not live.present:
return False, "lane is not running"
control = celery_app.control
for queue in lane.queues:
if enabled:
control.add_consumer(queue, destination=live.hostnames)
else:
control.cancel_consumer(queue, destination=live.hostnames)
return True, None
except Exception as exc: # noqa: BLE001
log.warning(
"worker_control: could not %s %s",
"enable" if enabled else "disable", lane.name, exc_info=True,
)
return False, str(exc)
# --- the settings half, which is async ----------------------------------------
#
# Sync celery control above, async DB below, in one module. Same split
# `service_roster` already runs (`_inspect_celery_sync` beside `touch_service`)
# — the boundary is the transport, not the concern, and "control the workers"
# is one concern.
async def _rows_by_name(session: AsyncSession) -> dict[str, WorkerLane]:
"""Every lane's row, creating any that are missing from its LANES defaults.
Self-heals rather than depending on a migration having run for a lane
added later: alembic 0103 seeded the four that existed on 2026-09-22, and
a fifth added to LANES afterwards gets its row the first time anything
asks. Without this, a new lane would read as absent and the UI would
simply not show it.
"""
rows = {
row.name: row
for row in (await session.execute(select(WorkerLane))).scalars()
}
missing = [lane for lane in LANES if lane.name not in rows]
for lane in missing:
row = WorkerLane(name=lane.name, slots_cap=lane.default_slots_cap)
session.add(row)
rows[lane.name] = row
if missing:
await session.commit()
return rows
@dataclass(frozen=True)
class LaneSample:
"""What the sizing sweep last measured about one lane.
The same fields `LaneLiveState` carries, plus the queue depth and WHEN —
because this one is read from a table rather than from the broker, and a
reading with no timestamp invites being presented as current.
`measured_at=None` means no sweep has written this lane yet: a fresh
install inside its first period, or a stack whose beat is not running.
Distinct from `present=False` (something asked, nothing answered), and the
UI says different things about the two.
"""
present: bool = False
replicas: int = 0
pool: int | None = None
active: int = 0
reserved: int = 0
queue_depth: int | None = None
measured_at: datetime | None = None
def _lane_depth(lane: Lane, depths: dict[str, int | None]) -> int | None:
"""A lane's backlog across its queues — None when NOTHING answered.
A queue the broker did not answer for must not be summed as zero: an
unknown depth is not an empty one, and reporting a buried lane as idle is
the direction that matters.
"""
known = [depths.get(q) for q in lane.queues]
if not any(d is not None for d in known):
return None
return sum(d for d in known if d is not None)
def store_lane_samples_sync(session, live: dict[str, LaneLiveState], depths) -> None:
"""Write what the sweep just measured. SYNC — the celery task owns a sync
session, and this is the only place these rows are written.
Upsert per lane, last writer wins, same shape as `service_roster`'s
`touch_service`: two processes sweeping at once is a benign race that
needs no coordination, because both are recording what they actually saw.
A lane that did not answer is STILL written, with `present=False`. Skipping
it would leave the previous reading in place and let the page go on showing
a pool that is no longer there — the stale row would read as a current one
(lesson #4202: the row is the thing that has to change).
"""
now = datetime.now(UTC)
for lane in LANES:
state = live.get(lane.name) or LaneLiveState()
values = {
"lane": lane.name,
"present": state.present,
"replicas": state.replicas,
"pool": state.pool,
"active": state.active,
"reserved": state.reserved,
"queue_depth": _lane_depth(lane, depths),
"measured_at": now,
}
stmt = pg_insert(WorkerLaneSample).values(**values)
session.execute(stmt.on_conflict_do_update(
index_elements=[WorkerLaneSample.lane],
set_={k: v for k, v in values.items() if k != "lane"},
))
session.commit()
@dataclass
class LaneSettings:
"""What the DATABASE knows about the lanes — read and finished with before
anything touches the broker.
This exists because holding a Postgres connection across a celery round
trip is what made the System tab block the whole site (operator,
2026-09-23: *"something about changing the cap number is blocking to the
website"*).
`lane_view` used to take the session and keep it open through an inspect
whose budget is eleven seconds — and that page polls every fifteen. With a
lane not answering, every inspect ran to nearly its full budget, so each
poll pinned a connection for ten seconds. SQLAlchemy's default pool is
five connections plus ten overflow; a couple of browser tabs, the health
endpoint doing the same thing, and a cap change adding two more inspects
exhausts that, and every OTHER request then waits on a connection.
Nothing was slow in itself. The slowness was a scarce resource held across
it, which is why it surfaced as the whole site stalling rather than as one
slow page.
"""
caps: dict[str, int]
oldest_by_queue: dict[str, datetime]
# The sizing sweep's last reading per lane. Since 2026-09-23 this is where
# the live numbers come from: the endpoint no longer inspects at all.
samples: dict[str, LaneSample] = field(default_factory=dict)
async def lane_settings(session: AsyncSession) -> LaneSettings:
"""Every DB read the lane view needs, in one short-lived session.
Which is now ALL of them. `lane_view` below takes what this returns and
talks to nothing.
"""
rows = await _rows_by_name(session)
samples = {
row.lane: LaneSample(
present=row.present,
replicas=row.replicas,
pool=row.pool,
active=row.active,
reserved=row.reserved,
queue_depth=row.queue_depth,
measured_at=row.measured_at,
)
for row in (
await session.execute(select(WorkerLaneSample))
).scalars()
}
return LaneSettings(
caps={name: row.slots_cap for name, row in rows.items()},
oldest_by_queue=await _oldest_running_by_queue(session),
samples=samples,
)
def lane_view(settings: LaneSettings) -> list[dict]:
"""Every lane: what is configured, what was last measured, what it may
grow to. NO broker call, and no database — `settings` is the whole input.
## It used to inspect, on every request
Four broadcast round trips on an eleven-second budget, on a page that
polls every fifteen seconds. 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?"*
There was one, and it had expired. The docstring here used to say the
endpoint was deliberately uncached because *"this is the surface an
operator watches while dragging a stepper, and a cached reply would show
them the value from before their own change"*. True while a cap change
refetched the table — and that refetch is exactly what was removed in
`1353d34`, so the UI now patches its own row from the write's reply and
nothing depends on this being live.
Meanwhile `size_worker_lanes` was already inspecting on a timer to decide
pool sizes: the same numbers, computed, used, and discarded, while the
browser asked the broker for them again four times a minute.
So the sweep writes `worker_lane_sample` and this reads it. The reading is
up to `SWEEP_PERIOD_SECONDS` old, and `measured_at` travels with it so the
UI can say so rather than implying it is current.
`pending` is still the honest backlog — depth PLUS reserved — because
celery prefetches and LLEN alone reads 0 while a worker holds tasks in
memory.
"""
oldest = settings.oldest_by_queue
now = datetime.now(UTC)
out = []
for lane in LANES:
cap = settings.caps[lane.name]
# A lane with no row yet is not-measured, which is distinct from
# measured-as-absent. The default carries `measured_at=None`, and the
# UI says "not measured yet" rather than "not answering".
sample = settings.samples.get(lane.name) or LaneSample()
depth = sample.queue_depth
out.append({
"name": lane.name,
"display_name": lane.display_name,
"queues": list(lane.queues),
"slots_cap": cap,
"ceiling": derived_ceiling(lane),
# DERIVED, never stored. A cap of zero means no consumers, so
# "off" and "may use no workers" cannot disagree.
"enabled": cap > 0,
"memory_bound": lane.memory_bound,
"optional": lane.optional,
# What raising this lane's cap will download, so the UI can say
# WHICH model and how big BEFORE the first slot is asked for
# rather than after a multi-GB fetch has started. `measured`
# travels with the numbers: the UI must not present an estimate
# as a fact.
"models": [
{
"repo": m.repo,
"download_bytes": m.approx_download_bytes,
"resident_bytes": m.approx_resident_bytes,
"measured": m.measured,
}
for m in lane.models
],
"live": {
"present": sample.present,
"replicas": sample.replicas,
"pool": sample.pool,
"active": sample.active,
"reserved": sample.reserved,
},
"queue_depth": depth,
"pending": None if depth is None else depth + sample.reserved,
# When the numbers above were read. Per lane rather than one for
# the response, because a lane whose row has never been written
# has no reading at all and must not borrow another lane's.
"measured_at": (
sample.measured_at.isoformat() if sample.measured_at else None
),
# How long the oldest still-running task on this lane has been
# going, in minutes. Read from `task_run`, not from the sweep, so
# this one IS current. The operator asked for a trigger here —
# grow a lane whose tasks run past some duration — and it stayed a
# REPORT: a long task does not finish sooner because the lane
# gained a slot, so scaling on it would spend memory to change
# nothing. Shown so they can see a lane wedged on one slow job,
# which is the genuinely useful half of the idea.
"oldest_running_minutes": _minutes_since(
min(
(oldest[q] for q in lane.queues if q in oldest),
default=None,
),
now,
),
})
return out
async def _oldest_running_by_queue(session: AsyncSession) -> dict[str, datetime]:
"""When the longest-running unfinished task on each queue started.
Read from `task_run`, which is OUR OWN table on OUR OWN wall clock, and
deliberately not from celery's `inspect active()`. Those entries carry a
`time_start` taken from the WORKER's `time.monotonic()` — a clock with an
arbitrary origin per process. Subtracting it from this process's wall
clock produces a number that looks like a duration and is meaningless, and
it would be meaningless in the direction that matters: plausible.
`task_run` also already carries the per-queue staleness thresholds the
recovery sweep uses, so a row still `running` here is one the system
itself considers legitimately in flight rather than abandoned.
"""
result = await session.execute(
select(TaskRun.queue, func.min(TaskRun.started_at))
.where(TaskRun.status == "running", TaskRun.finished_at.is_(None))
.group_by(TaskRun.queue)
)
return {queue: started for queue, started in result if started is not None}
def _minutes_since(started: datetime | None, now: datetime) -> int | None:
"""Whole minutes, or None when nothing is running. Never negative: a row
written by a container whose clock is a few seconds ahead must read as 0
rather than as a task that starts in the future."""
if started is None:
return None
return max(0, int((now - started).total_seconds() // 60))
def _queue_depths_sync() -> dict[str, int | None]:
"""Redis LLEN per queue. None for one that did not answer — see lane_view.
Sync; the caller threads it. A per-queue try/except so one bad queue does
not cost the whole report, matching `api/system_activity._read_queues_sync`.
"""
import redis
from ..config import get_config
out: dict[str, int | None] = {}
try:
client = redis.Redis.from_url(get_config().celery_broker_url)
except Exception:
log.warning("worker_control: no broker for queue depths", exc_info=True)
return {q: None for lane in LANES for q in lane.queues}
for lane in LANES:
for queue in lane.queues:
try:
out[queue] = int(client.llen(queue))
except Exception: # noqa: BLE001 — a hiccup must not break the UI
out[queue] = None
return out
class LaneUpdateRefused(ValueError):
"""A requested value is outside what the lane may hold. Carries the reason
the UI shows — a greyed control with no explanation reads as a bug."""
async def store_lane_cap(
session: AsyncSession, lane: Lane, slots_cap: int,
) -> int:
"""Validate and store the cap. Returns the PREVIOUS cap. DB only.
Split from the live push for the reason `LaneSettings` gives at length: a
Postgres connection must not be held across a celery round trip. Everything
here is fast and finished with before `push_lane_cap` starts.
"""
rows = await _rows_by_name(session)
row = rows[lane.name]
ceiling = derived_ceiling(lane)
if slots_cap < 0:
raise LaneUpdateRefused("a cap cannot be negative")
if slots_cap > ceiling:
raise LaneUpdateRefused(
f"a cap of {slots_cap} is above what this container can hold "
f"({ceiling} for {lane.display_name})"
)
was_cap = row.slots_cap
row.slots_cap = slots_cap
await session.commit()
# The previous value, because the push needs the DIRECTION: lowering a cap
# has to reach the running lane now, and raising one has nothing to say.
return was_cap
async def push_lane_cap(lane: Lane, slots_cap: int, *, was_cap: int) -> dict:
"""Make the running lane obey a cap that is already stored. NO database.
Runs OFF the request path since 2026-09-23 — the endpoint stores the cap,
answers, and hands this to a background task (operator: *"the change
should be queued so that it isn't blocking of the webui"*). Nothing here
changed as a result except who waits for it: the return value is now read
by the log rather than by a browser, and every branch below already
treated failure as "the sizing pass will carry it".
## What is pushed, and what is not
Consumers follow the cap immediately in BOTH directions: zero means off,
and off must take effect when it is asked for rather than up to a minute
later.
The pool is only ever pushed DOWNWARD. Raising a cap is permission, not a
request — growing on permission would put workers on a lane with nothing
to do — so the sizing pass spends it on its next tick if there is work.
That also makes the common case (raising a cap) free: no broker round trip
AT ALL, which is the difference between a control that answers instantly
and one that takes ten seconds. Keyed on the previous cap rather than on
"is it on" — the first cut only knew on/off, so it inspected on every
raise to find out whether the pool needed lowering, and the control it was
meant to make instant still waited out an inspect.
A failed push is not a failed setting. The value is already stored and the
sizing pass carries it within a minute; `applied: false` with a reason
lets the UI say "saved, not yet live" rather than "that didn't work"
(lesson #4202 — a live change that does not survive, with nothing saying
so).
"""
was_on, now_on = was_cap > 0, slots_cap > 0
applied, error = True, None
if now_on != was_on:
applied, error = await asyncio.to_thread(set_lane_enabled_sync, lane, now_on)
if applied and not now_on:
# Down to the floor at once. The pool cannot be emptied, so "off" is
# one parked process with its consumers cancelled.
applied, error = await asyncio.to_thread(
set_lane_slots_sync, lane, MIN_POOL_SLOTS,
)
elif applied and now_on and slots_cap < was_cap:
# LOWERED on a running lane. Only this direction needs a message, and
# only when the pool is actually above the new cap — so it reads the
# live pool rather than resizing blind. A raise never reaches here.
#
# Bounded (rule 156): `to_thread` on its own is an await with no
# deadline, and this runs in a background task where a hang would be
# silent rather than visible as a slow page. On a timeout the lane is
# simply not resized here and the sizing sweep carries it.
try:
live = await asyncio.wait_for(
asyncio.to_thread(inspect_lanes_sync),
timeout=INSPECT_BUDGET_SECONDS,
)
except TimeoutError:
log.warning(
"worker_control: inspect exceeded %ss lowering %s; leaving the "
"pool to the sizing pass", INSPECT_BUDGET_SECONDS, lane.name,
)
return _cap_result(lane, slots_cap, now_on, applied, error, False)
current = live[lane.name].pool
if current is not None and current > slots_cap:
applied, error = await asyncio.to_thread(
set_lane_slots_sync, lane, slots_cap, live=live[lane.name],
)
# Raising the cap off zero is what triggers the model download (milestone
# 422 step 6). Never at boot: that made every start of the ML role reach
# HuggingFace for ~3.5GB, and rule 164 permits a runtime fetch only for a
# feature that is optional and clearly OFF.
#
# On the TRANSITION, so re-saving a cap on a lane already running does not
# re-enqueue.
#
# NOT gated on the consumer change having landed, which it was until
# 2026-09-23. The reasoning then was that enqueueing onto a queue nothing
# consumes leaves the task pending — true, and it is the right place for
# it to wait. Gated, a cap raised while the lane was restarting stored the
# cap, let the sizing pass start the consumers a minute later, and left
# the lane running with no model, because nothing else ever asks for one.
# A task parked on the `ml` queue is picked up the moment that happens.
fetching = False
if now_on and not was_on and lane.models:
fetching = _enqueue_model_fetch()
# Nobody is waiting on this any more, so the log is where a push that did
# not land has to be visible. Not an error: the value is stored and the
# sizing pass carries it within a minute.
if not applied:
log.info(
"worker_control: %s cap %s stored, not pushed (%s); "
"the sizing pass will carry it",
lane.name, slots_cap, error,
)
return _cap_result(lane, slots_cap, now_on, applied, error, fetching)
def _cap_result(
lane: Lane, slots_cap: int, now_on: bool, applied: bool,
error: str | None, fetching: bool,
) -> dict:
"""The push's outcome. One builder, because `push_lane_cap` has two exits
and a second literal would be free to disagree with the first."""
return {
"name": lane.name,
"slots_cap": slots_cap,
"ceiling": derived_ceiling(lane),
"enabled": now_on,
"applied": applied,
"apply_error": error,
# Tells the UI to say a download has started rather than leaving the
# operator to wonder why a lane they just turned on is busy.
"fetching_models": fetching,
}
def _enqueue_model_fetch() -> bool:
"""Queue the model download. Returns whether it was accepted.
Import inside the function: `backend.app.tasks.ml` pulls in torch, and web
must not pay that import cost on a module that every settings request
touches.
Never raises. A broker that will not take the task is worth reporting, but
the SETTING has already been stored and the lane is already enabled — so
failing the whole request here would roll back nothing and tell the
operator their change did not happen when it did.
"""
try:
from ..tasks.ml import ensure_models
ensure_models.delay()
return True
except Exception: # noqa: BLE001 — reported, never raised at a caller
log.warning("worker_control: could not enqueue the model fetch", exc_info=True)
return False
# --- the sizing pass: one sweep, always on ------------------------------------
#
# This replaced BOTH `reconcile_lanes_sync` (step 3) and `autoscale_lanes_sync`
# (step 7) on 2026-09-23. They were two enforcers over one number, and the
# whole of step 7's hardest reasoning — a stored value that is a FLOOR, a
# target of `max(stored, current)` so the reconcile does not undo what the
# autoscaler added — existed only to stop them fighting. Delete one of them and
# the problem is not solved, it is absent.
#
# 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 there is one pass, it runs every minute, it reads the live pool rather
# than any stored number, and the only thing it obeys is the cap.
#
# It also subsumes what the reconcile existed for. `pool_grow` is not durable:
# a worker restarted by its supervisor comes back at its ENV concurrency,
# silently below what the lane should be running. This pass reads the live
# pool every minute and sizes from the backlog, so that worker is corrected on
# the next tick — sooner than the five-minute reconcile managed, and without a
# second sweep that could disagree with this one.
# How much work justifies a slot. `pending` is depth + reserved, so it already
# counts what celery has prefetched into worker memory — one task, one slot.
#
# Growth is IMMEDIATE and shrink is one slot per tick, deliberately asymmetric.
# A backlog of four thousand should not take an hour to reach the cap, and a
# lane that idles for one minute should not drop every process it has: the
# cost of being one slot too large for a minute is a sleeping process, and the
# cost of being too small is work not happening. For ML the asymmetry matters
# most — every new slot reloads a multi-GB model, so the slow shrink is what
# stops a quiet patch from paying that cost again a minute later.
SHRINK_STEP = 1
@dataclass
class LaneSizing:
"""What the pass did to one lane, and why — in the operator's terms.
A reason on every outcome including "held", because a sizing pass that
only speaks when it acts is one nobody can debug when it does not.
"""
lane: str
action: str # "grew" | "shrank" | "held" | "skipped"
slots: int
reason: str
def wanted_slots(cap: int, active: int, pending: int | None) -> int:
"""How many workers this lane has work for right now, within its cap.
One slot per task in flight or waiting, floored at one process and
ceilinged by the cap. `pending` of None means the broker did not answer
for this lane's queues — an unknown backlog is not an empty one (snippet
#3969), so it contributes nothing rather than being read as zero.
A cap of zero still returns one: billiard cannot run an empty pool, and
the parked process is what `add_consumer` lands on when the cap goes back
up. "Off" is expressed by cancelling consumers, not by emptying the pool.
"""
if cap <= 0:
return MIN_POOL_SLOTS
return max(MIN_POOL_SLOTS, min(cap, active + (pending or 0)))
def size_lanes_sync(
caps: dict[str, int],
*,
live: dict[str, LaneLiveState] | None = None,
depths: dict[str, int | None] | None = None,
) -> list[LaneSizing]:
"""Size every lane to its backlog, within the cap. The whole control loop.
`caps` is lane name -> slots_cap, read from the database by the caller.
This function touches no database: the celery task that schedules it owns
the session, and keeping the DB out of here is what lets it be called from
anywhere that already knows the caps.
`live` and `depths` are the measurements. Passing them in is not an
optimisation — it is how the caller gets to KEEP them. The sweep now
stores what it measured (`worker_lane_sample`) so the System tab reads a
table instead of inspecting on every page load, and that is only possible
if the same reading serves both purposes. Measured here when not given, so
every existing caller and test is unaffected.
## It must converge and then go quiet
One `inspect` for all lanes, and `set_lane_slots_sync` issues nothing to a
replica already at its target. A settled system therefore performs one
broker round trip plus one LLEN sweep per tick and sends no control
messages at all — the reachable fixed point lesson #4183 is about. An
enforcer that re-sent a grow of zero every tick would churn forever and
bury a real correction in its own noise.
## An absent lane is SKIPPED, not corrected
`present=False` means nothing answered — a worker restarting, or an
unreachable broker. It does NOT mean zero slots. Deciding from that would
be a verdict drawn from an unswept read, and here it is worse than
useless: there is nothing to send the message to.
"""
if live is None:
live = inspect_lanes_sync()
if depths is None:
depths = _queue_depths_sync()
out: list[LaneSizing] = []
for lane in LANES:
cap = caps.get(lane.name)
if cap is None:
continue
state = live[lane.name]
if not state.present:
out.append(LaneSizing(lane.name, "skipped", 0, "lane is not answering"))
continue
# Consumers first, and only when they DISAGREE. Sending add_consumer
# for every queue on every tick of a settled system is the exact churn
# above, and invisible: add_consumer on a queue already consumed is
# harmless and reports success.
should_consume = cap > 0
if should_consume != state.consuming.issuperset(lane.queues):
ok, err = set_lane_enabled_sync(lane, should_consume, live=state)
if not ok:
out.append(LaneSizing(
lane.name, "held", state.pool or 0,
f"could not {'start' if should_consume else 'stop'} "
f"consuming: {err}",
))
continue
current = state.pool
if current is None:
out.append(LaneSizing(
lane.name, "held", 0, "worker did not report its pool size",
))
continue
depth = _lane_depth(lane, depths)
pending = None if depth is None else depth + state.reserved
want = wanted_slots(cap, state.active, pending)
if want > current:
new = want
verb = "grew"
elif want < current:
# One at a time on the way down. See SHRINK_STEP.
new = max(want, current - SHRINK_STEP)
verb = "shrank"
else:
out.append(LaneSizing(
lane.name, "held", current,
f"{pending if pending is not None else '?'} waiting, "
f"{state.active} busy, cap {cap}",
))
continue
ok, err = set_lane_slots_sync(lane, new, live=state)
if not ok:
out.append(LaneSizing(
lane.name, "held", current, f"could not resize: {err}",
))
continue
out.append(LaneSizing(
lane.name, verb, new,
f"{pending if pending is not None else '?'} waiting, "
f"{state.active} busy, cap {cap}",
))
return out
-464
View File
@@ -1,464 +0,0 @@
"""The worker lanes: what they are, and how many slots each may be given.
Milestone 422 step 1. This module is the ONE place that knows the lane set;
`models/worker_lane.py` holds only what the operator can change about them.
## Why the queues are here and not in the table
A lane's queue set is not a preference — it is decided by `celery_app.py`'s
`task_routes`, which is what puts a backup on `maintenance_long` and a
thumbnail on `thumbnail`. An operator cannot move a task to another lane, so
storing the queues as settings would create a row that can disagree with the
routing table, and nothing would notice until a queue had no consumer.
So: queues and display names are code, slots and caps are data. The table
stores three numbers and a flag, and nothing that could contradict celery.
This also collapses a duplicate rather than adding one.
`service_roster.ROLE_NAMES` was a second copy of "queue set -> the name an
operator recognises", and it had already drifted: `maintenance_long` is a
live lane with four task routes pointing at it, and the roster did not know
its name, so the System tab rendered it as `Worker (maintenance_long)`. That
map is now derived from `LANES` below, so a lane added here is named
everywhere at once.
## Why the ceiling is derived rather than configured
Consolidating the stack into one container (step 5) widens the OOM blast
radius: today an ml-worker that exhausts memory is killed by Docker on its
own, and web keeps serving. In one container the kernel picks a victim from
the whole cgroup, and it may pick hypercorn — so a tagging task can take the
UI down with it, on exactly the modest hardware least able to spare the
memory.
Operator, 2026-09-22: *"ram isn't an issue for me but some users might run
this on weaker hardware and I don't want it to kill their servers."*
So the maximum is computed from what the container actually has, and the
operator's own `slots_cap` must fit under it. Three numbers, not two, and the
ordering is the point:
slots <= slots_cap <= derived_ceiling
(live) (operator) (this module)
The operator can always lower their cap. They cannot raise it past what the
box can hold. The derived ceiling is never stored — a row that outlived a
change in container limits must not carry a stale one.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
# --- the lanes ---------------------------------------------------------------
GIB = 1024 ** 3
@dataclass(frozen=True)
class ModelRequirement:
"""A model a lane must download before it can do anything.
Surfaced to the UI so the operator is told WHICH model, how big, and what
it costs to hold — before they turn the lane on, not after a multi-GB
download has already started. The lane is optional and its cost is not
obvious from its name, which is the whole reason this is structured data
rather than a sentence in a component.
`measured=False` means the numbers are ESTIMATES and the UI must say so.
They come from the checkpoint's parameter count and dtype, not from a
build — and a number presented as fact decides whether someone's server
survives, so it is labelled rather than rounded confidently.
"""
# The Hugging Face repo id, which is the honest answer to "which model".
repo: str
# Roughly what the download costs, for the operator's bandwidth and disk.
approx_download_bytes: int
# Roughly what ONE slot holds while running. Prefork forks a child per
# slot and each loads its own copy, so this multiplies.
approx_resident_bytes: int
measured: bool = False
# SigLIP so400m — the only model FabledCurator itself downloads.
#
# What it is for, which is NOT obvious from the lane's name: it produces the
# image embeddings that back similarity search, duplicate grouping and the
# tag heads. WD14 tagging is the GPU AGENT's job, not this lane's — the
# comment in celery_app.py naming both is stale since B3 (#1238), when the
# agent took over and this lane was left as the CPU embed fallback for stacks
# running no agent at all (see MLSettings.cpu_embed_enabled).
#
# Both numbers are ESTIMATES, derived from the checkpoint rather than from a
# build: ~877M parameters at fp32 is ~3.5GB of weights, and holding them plus
# activations and the torch runtime is what the resident figure covers. They
# err high. Replace them with measurements — download the repo and read its
# size; run one embed and read the worker child's VmHWM — and set
# `measured=True` when you do.
SIGLIP_MODEL = ModelRequirement(
repo="google/siglip-so400m-patch14-384",
approx_download_bytes=3_500_000_000,
approx_resident_bytes=4 * GIB,
measured=False,
)
@dataclass(frozen=True)
class Lane:
"""A worker lane. `name` is the stable key the settings row is keyed on.
Keyed on a lane NAME rather than a container hostname for the reason
`models/service_seen.py` gives at length: celery's worker names here are
`celery@<container id>` and are minted fresh on every deploy, so anything
keyed on them records a death and a birth every time the stack updates.
"""
name: str
display_name: str
queues: tuple[str, ...]
# Which `entrypoint.sh` role starts this lane. NOT always the lane name:
# `maintenance_long` is the plain `worker` role pointed at a different
# queue, exactly as docker-compose starts it today (`command: ["worker"]`
# with CELERY_QUEUES=maintenance_long). Recorded here so the generated
# supervisord config and the compose file cannot disagree about it.
entrypoint_role: str
# THE cap a lane starts with — and, since 2026-09-23, the only number an
# operator sets for it. How many workers actually run is the autoscaler's
# job; this is the most it may use. Zero means the lane is off.
#
# One, and zero for ML. Deliberately far below the operator's own
# production numbers, which are tuned for their hardware and are not a
# sane first boot for a stranger — and low enough that a busy instance
# tells them to raise it rather than quietly consuming the machine.
default_slots_cap: int
# True when a slot costs a copy of the ML model rather than just a process.
# Such a lane is bounded by memory AS WELL AS by cores, never instead of.
memory_bound: bool = False
# CPU threads ONE slot uses. More than one for a lane whose work is an
# inference library with its own thread pool: `services/ml/embedder.py`
# calls `torch.set_num_threads` with this number, so a slot is four cores'
# worth of demand rather than one process's.
#
# It lives here because the CEILING has to know it. It was a private
# constant in the embedder with a comment saying "keep N_replicas x this
# within the cores allotted to ML" — a rule stated where nothing could
# enforce it. Nothing did: the ML ceiling was computed from memory alone,
# so a large-memory host offered ~49 slots, the operator took them, and
# 2026-09-23's log shows ~200 torch threads fighting over the box —
# embeds at 107-246s each, and the daily CCIP sweep sharing that pool
# timing out at 1800s.
threads_per_slot: int = 1
# Models this lane downloads the first time it is enabled. Empty for every
# lane that needs none, which is how the UI knows whether to warn at all.
models: tuple[ModelRequirement, ...] = ()
# An optional lane is one the product works without. Shown as such, so
# nobody turns on a multi-GB download believing it is required.
optional: bool = False
@property
def queue_key(self) -> tuple[str, ...]:
"""The sorted queue set, which is how `service_seen` identifies a
running worker. The join between what is configured here and what
`celery inspect` reports."""
return tuple(sorted(self.queues))
# ONE CAP PER LANE, and that is the whole of what an operator sets.
#
# 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."*
#
# Until then a lane had THREE operator values — `slots`, `slots_cap` and
# `autoscale` — because the manual dial was built first (steps 2-4) and the
# autoscaler arrived last (step 7) as an opt-in beside a control that already
# existed. Nothing ever asked whether the dial should still exist once
# something could move it automatically. It should not: "how many are running
# right now" is a measurement, not a preference.
#
# One of each, and ML at zero. ML at zero is also rule 164's carve-out: a cap
# of zero means no consumers, so a fresh install never loads a model or
# reaches HuggingFace, and raising the cap is what triggers the fetch.
#
# These are far below the operator's own production numbers, and deliberately
# so — they are what a stranger's first boot should do, not what a tuned
# machine can. The UI is what closes that gap: a lane sitting at its cap with
# a backlog says so, and says raising the cap is the fix. Without that a
# conservative default is just a slow instance nobody knows how to speed up.
LANES: tuple[Lane, ...] = (
Lane(
name="worker",
display_name="Worker",
queues=("default", "import", "thumbnail", "download"),
entrypoint_role="worker",
default_slots_cap=1,
),
Lane(
name="scheduler",
display_name="Scheduler",
queues=("maintenance", "scan"),
entrypoint_role="scheduler",
default_slots_cap=1,
),
Lane(
name="maintenance_long",
display_name="Long maintenance",
queues=("maintenance_long",),
entrypoint_role="worker",
default_slots_cap=1,
),
Lane(
name="ml",
display_name="ML tagging",
queues=("ml",),
entrypoint_role="ml-worker",
default_slots_cap=0,
memory_bound=True,
threads_per_slot=4,
models=(SIGLIP_MODEL,),
optional=True,
),
)
LANES_BY_NAME: dict[str, Lane] = {lane.name: lane for lane in LANES}
LANES_BY_QUEUE_KEY: dict[tuple[str, ...], Lane] = {
lane.queue_key: lane for lane in LANES
}
# --- what the container actually has -----------------------------------------
# cgroup v2 first, then v1. A container started without an explicit memory
# limit reports "max" on v2 and a sentinel near 2**63 on v1; both mean "no
# limit", and the answer then is the host's RAM.
_CGROUP_V2_MEMORY = Path("/sys/fs/cgroup/memory.max")
_CGROUP_V1_MEMORY = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
_CGROUP_V2_CPU = Path("/sys/fs/cgroup/cpu.max")
_CGROUP_V1_CPU_QUOTA = Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
_CGROUP_V1_CPU_PERIOD = Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
# A v1 "unlimited" is PAGE_SIZE-aligned LONG_MAX, not a round number, so it is
# recognised by magnitude rather than by equality. Anything claiming more than
# a petabyte is a sentinel, not a machine.
_UNLIMITED_ABOVE = 1 << 50
# DERIVED from the model requirement above, never restated. The ceiling and
# the number shown to the operator before they enable the lane have to be the
# same figure, or the UI promises something the cap will then refuse.
ML_BYTES_PER_SLOT = SIGLIP_MODEL.approx_resident_bytes
# Held back for hypercorn and the non-ML lanes before any ML slot is offered.
# In the consolidated container these share one cgroup with ML, and they are
# the processes an OOM kill must not take (see the module docstring).
RESERVED_BYTES = 2 * GIB
# The smallest pool a lane can actually run: ONE process, never zero.
#
# billiard refuses to remove the last worker in a pool, so a lane asked to
# shrink to nothing gets `ValueError("Can't shrink pool. All processes
# busy!")` and the sizing pass re-sends the doomed message forever. Found on
# the operator's live deploy, 2026-09-23.
#
# It is also what makes "off" expressible: a lane at cap 0 keeps this one
# parked process with its consumers cancelled, so it still answers `inspect`
# (and so reads as present rather than crashed), and `add_consumer` has
# something to reach when the cap goes back up.
#
# Lives HERE rather than in `worker_control` because `gen_supervisord` needs
# it at container boot and must not import the models package to get it.
MIN_POOL_SLOTS = 1
# The floor a cores-derived ceiling never goes below. A single-core box still
# needs to be able to run its lanes; the ceiling exists to stop absurd values,
# not to make a small machine unusable.
MIN_CEILING = 1
# How often `size_worker_lanes` runs — the beat schedule, and the freshness of
# everything the System tab shows.
#
# It is here, in the import-light module, because three places have to agree
# about it and they are in different packages: the beat entry in `celery_app`,
# the sample the sweep writes (`worker_lane_sample`), and the roster's
# staleness thresholds in `api/system_health`, which now depend on this sweep
# rather than on a browser being open.
#
# 30s, down from 60s, because the sweep became the ONLY writer of the celery
# roster on 2026-09-23. A part is called stale after 90s of silence, so a
# 60-second sweep left one missed tick between "normal" and "everything is
# yellow". That is the shape of lesson #4355 — a reader's threshold and an
# emitter's cadence chosen in different files and never compared — and the
# fix is headroom plus a test that asserts it, not a number that happens to
# work today.
#
# The cost is one inspect every 30s instead of every 60s; the saving is every
# inspect that used to run on a request path, which with a single tab open
# was roughly four a minute against this two. Consequence worth knowing: the
# pass also SHRINKS an idle lane by one slot per tick, so an idle lane now
# gives its workers back twice as fast. That is the direction the operator
# asked for — *"idle instances quiet down when not running"*.
SWEEP_PERIOD_SECONDS = 30.0
# What an unreadable limit yields. Low rather than unlimited, on purpose: not
# knowing how much memory there is must never read as "plenty". An unswept
# absence is not a verdict.
UNKNOWN_CEILING = 1
def _read_int(path: Path) -> int | None:
try:
raw = path.read_text().strip()
except OSError:
return None
if raw == "max":
return None
try:
return int(raw)
except ValueError:
return None
def container_memory_bytes() -> int | None:
"""The memory this container may use, or None when it cannot be read.
None means UNKNOWN, never UNLIMITED. Every caller must treat it as the
conservative case — the whole point of the ceiling is to protect a machine
whose size we are unsure of.
"""
for path in (_CGROUP_V2_MEMORY, _CGROUP_V1_MEMORY):
value = _read_int(path)
if value is not None and value < _UNLIMITED_ABOVE:
return value
if value is not None:
# A sentinel: the cgroup exists but sets no limit, so the real
# bound is the host's.
break
try:
return os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
except (ValueError, OSError, AttributeError):
return None
def container_cpu_count() -> int | None:
"""Effective cores, honouring a cgroup CPU quota.
`os.cpu_count()` reports the HOST's cores from inside a container, so a
quota of 2.0 on a 32-core host would otherwise offer 32 slots. The
operator's own stack sets `cpus: '4.0'` on ml-worker, so this is a real
configuration here and not a hypothetical.
"""
quota: float | None = None
try:
raw = _CGROUP_V2_CPU.read_text().strip().split()
if raw and raw[0] != "max":
quota = int(raw[0]) / int(raw[1])
except (OSError, ValueError, IndexError, ZeroDivisionError):
pass
if quota is None:
q = _read_int(_CGROUP_V1_CPU_QUOTA)
p = _read_int(_CGROUP_V1_CPU_PERIOD)
if q is not None and p and q > 0:
quota = q / p
if quota is not None and quota > 0:
return max(1, int(quota))
return os.cpu_count()
def lane_for_node(hostname: str) -> Lane | None:
"""`ml@7f3c9a1b` -> the ml lane. None for a node this build did not name.
## Why the node name, and not the queues it is consuming
Because a lane that is OFF is consuming nothing, and "nothing" identifies
no lane at all.
Both the roster and `inspect_lanes_sync` used to map a worker to its lane
through `active_queues()`. That is exact while the lane is running and
useless the moment it is not: a lane at cap 0 has its consumers cancelled,
so it answers the broadcast with an EMPTY queue list, matches no lane, and
is dropped. Three things followed, and the operator saw all three at once
on 2026-09-23:
1. The lanes table showed the lane as **not answering** — which is the
signal for a crashed worker, not for one the operator turned off.
2. The roster grew a phantom row called **`Worker ()`**, the empty queue
set rendered as a display name, "running" beside the real lane's row
going stale.
3. **The container went unhealthy.** `healthcheck._lanes_ok` requires
every lane in the table to be present, and its docstring asserted the
opposite of what the code did — *"a disabled lane still runs its
process with its consumers cancelled, so it answers inspect and is
healthy"*. It answers; it is not attributed. ML ships at cap 0, so a
fresh install would have been permanently unhealthy, and Swarm
restarts an unhealthy task forever.
The node name survives all of that: `gen_supervisord` sets
`CELERY_NODENAME={lane.name}` per program and the entrypoint passes it to
`celery -n`, so the identity travels with the PROCESS rather than with
what it happens to be doing. Falls back to the queue set for a deployment
that sets no node name — the multi-service compose stack, where every node
is `celery@<host>`.
"""
return LANES_BY_NAME.get(hostname.split("@", 1)[0])
def _cpu_bound_slots(lane: Lane) -> int:
"""How many slots this container's cores can feed, at `threads_per_slot`.
Never zero: a machine with fewer cores than one slot wants still runs the
lane, just slowly. That is a real trade an operator may want, and refusing
to offer the lane at all on a small box would make ML unreachable there —
unlike the memory bound, where the honest answer IS zero, because the
first task would OOM the container rather than merely be slow.
"""
cores = container_cpu_count()
if cores is None:
return UNKNOWN_CEILING
return max(MIN_CEILING, cores // lane.threads_per_slot)
def derived_ceiling(lane: Lane) -> int:
"""The most slots `lane` may be given on this container.
Never stored. Recomputed on every read so a container whose limits changed
is bounded by what it has NOW rather than by what it had when its row was
written.
"""
by_cpu = _cpu_bound_slots(lane)
if not lane.memory_bound:
return by_cpu
total = container_memory_bytes()
if total is None:
log.warning(
"worker_lanes: cannot read a memory limit; capping %s at %d",
lane.name, UNKNOWN_CEILING,
)
return UNKNOWN_CEILING
usable = total - RESERVED_BYTES
if usable < ML_BYTES_PER_SLOT:
# Honestly zero. A box that cannot hold one model alongside the web
# process must be told it cannot run tagging, not sold a slot that
# will OOM the container the first time it is used.
return 0
# BOTH bounds, whichever binds first. Memory alone was the whole answer
# until 2026-09-23, and on a large-memory host that is the wrong one: RAM
# said ~49 slots, and each of those slots wants `threads_per_slot` cores.
# The operator raised the cap to what the dial offered and the lane
# starved itself — a control is not allowed to offer a number the machine
# cannot feed.
return min(int(usable // ML_BYTES_PER_SLOT), by_cpu)
def ceilings() -> dict[str, int]:
"""Every lane's ceiling, for the settings API and the UI."""
return {lane.name: derived_ceiling(lane) for lane in LANES}
-5
View File
@@ -177,10 +177,6 @@ def download_source(self, source_id: int, _serialize_waits: int = 0) -> int:
settings = ImportSettings.load_sync(sync_session) settings = ImportSettings.load_sync(sync_session)
rate_limit = settings.download_rate_limit_seconds rate_limit = settings.download_rate_limit_seconds
validate_files = settings.download_validate_files validate_files = settings.download_validate_files
# How far back a tick keeps looking for EDITED posts. Read here
# with the other downloader knobs, off the row this block is
# already holding open.
revisit_days = settings.download_revisit_days
gdl = GalleryDLService( gdl = GalleryDLService(
images_root=IMAGES_ROOT, images_root=IMAGES_ROOT,
@@ -211,7 +207,6 @@ def download_source(self, source_id: int, _serialize_waits: int = 0) -> int:
# the walk). Same factory the importer's sync session # the walk). Same factory the importer's sync session
# comes from — a different DB connection per checkout. # comes from — a different DB connection per checkout.
sync_session_factory=SyncFactory, sync_session_factory=SyncFactory,
revisit_days=revisit_days,
) )
return await svc.download_source(source_id) return await svc.download_source(source_id)
finally: finally:
+6 -103
View File
@@ -473,10 +473,6 @@ def prune_task_runs() -> dict:
(recover_stalled_task_runs) is the mechanism that flips them to (recover_stalled_task_runs) is the mechanism that flips them to
terminal state; prune doesn't touch in-flight state. terminal state; prune doesn't touch in-flight state.
- 'retry' rows: treated as failures (>7d). - 'retry' rows: treated as failures (>7d).
- The NEWEST row of each task is never deleted, whatever its age: it is
what the beat scheduler reads to know when a job last ran (#4408).
Without it a weekly job's last run would be pruned after a day, and beat
would think it had never run and fire it on every restart.
Returns dict of how many rows were deleted in each bucket. Returns dict of how many rows were deleted in each bucket.
""" """
@@ -484,19 +480,16 @@ def prune_task_runs() -> dict:
now = datetime.now(UTC) now = datetime.now(UTC)
ok_cutoff = now - timedelta(seconds=TASK_RUN_KEEP_OK_SECONDS) ok_cutoff = now - timedelta(seconds=TASK_RUN_KEEP_OK_SECONDS)
fail_cutoff = now - timedelta(seconds=TASK_RUN_KEEP_FAILURE_SECONDS) fail_cutoff = now - timedelta(seconds=TASK_RUN_KEEP_FAILURE_SECONDS)
newest = select(func.max(TaskRun.id)).group_by(TaskRun.task_name)
with SessionLocal() as session: with SessionLocal() as session:
ok_deleted = session.execute( ok_deleted = session.execute(
delete(TaskRun) delete(TaskRun)
.where(TaskRun.status == "ok") .where(TaskRun.status == "ok")
.where(TaskRun.finished_at < ok_cutoff) .where(TaskRun.finished_at < ok_cutoff)
.where(TaskRun.id.not_in(newest))
).rowcount or 0 ).rowcount or 0
fail_deleted = session.execute( fail_deleted = session.execute(
delete(TaskRun) delete(TaskRun)
.where(TaskRun.status.in_(["error", "timeout", "retry"])) .where(TaskRun.status.in_(["error", "timeout", "retry"]))
.where(TaskRun.finished_at < fail_cutoff) .where(TaskRun.finished_at < fail_cutoff)
.where(TaskRun.id.not_in(newest))
).rowcount or 0 ).rowcount or 0
session.commit() session.commit()
return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted} return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted}
@@ -1182,7 +1175,7 @@ def group_discord_drops() -> str:
return "disabled" return "disabled"
return ( return (
f"sources={res['sources']} created={res['posts_created']} " f"sources={res['sources']} created={res['posts_created']} "
f"joined={res['images_joined']} merged={res['drops_merged']}" f"joined={res['images_joined']}"
) )
@@ -1191,16 +1184,12 @@ def group_discord_drops() -> str:
soft_time_limit=900, time_limit=1200, soft_time_limit=900, time_limit=1200,
) )
def match_post_associations() -> str: def match_post_associations() -> str:
"""Milestone 388 E5: which Patreon post announced which Discord drop. """Milestone 388 E5: propose which Patreon post announced which Discord drop.
A CONCLUSIVE pair — one the creator's own working name identifies, where Proposes only — every pair lands in a review queue and nothing is linked
that name appears in these two posts and nowhere else in their library — is until the operator accepts. Maintenance lane for the same reason as the
linked outright when `discord_link_auto` is on, because there is nothing grouper: no inference, no ML library, and it must not depend on the
there for the operator to adjudicate. Everything weaker lands in the review optional ml-worker being present.
queue and stays unlinked until they accept it.
Maintenance lane for the same reason as the grouper: no inference, no ML
library, and it must not depend on the optional ml-worker being present.
""" """
import asyncio import asyncio
@@ -1359,89 +1348,3 @@ def sync_memberships() -> str:
if res.get("suggested") is not None: if res.get("suggested") is not None:
parts.append(f"suggested={res['suggested']}") parts.append(f"suggested={res['suggested']}")
return " ".join(parts) or "no platforms" return " ".join(parts) or "no platforms"
@celery.task(name="backend.app.tasks.maintenance.size_worker_lanes")
def size_worker_lanes() -> dict:
"""Size every lane to its backlog, within the cap the operator set.
ONE sweep, replacing `reconcile_worker_lanes` and `autoscale_worker_lanes`
(2026-09-23). They were two enforcers over one number: the reconcile drove
the pool to a stored `slots`, the autoscaler moved it away from that same
value, and most of the autoscaler's design existed to keep the reconcile
from undoing its work. Deleting the stored number deletes the conflict.
It still does what the reconcile existed for. `pool_grow` is not durable —
a worker restarted by its supervisor comes back at its ENV concurrency,
silently below what the lane should run — and this reads the LIVE pool
every minute, so that worker is corrected on the next tick rather than
after five.
Returns every lane's outcome INCLUDING the ones it held, each with a
reason. A pass that only speaks when it acts cannot be debugged on the day
it does not.
## It is also the only thing that MEASURES, since 2026-09-23
It always inspected the broker to decide pool sizes, and then threw the
reading away — while `/api/system/workers` ran the same inspect on every
page load and the System tab polls it four times a minute. Operator:
*"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?"*
So one inspect now feeds three things: the sizing decision, the stored
sample the System tab reads, and the celery roster. No request path
touches the broker any more.
Order matters. The sample is stored BEFORE the roster refresh, because
that refresh does its own broadcast and a broker that has just started
failing must not cost us the reading we already have.
"""
from ..models import WorkerLane
from ..services.service_roster import refresh_celery_roster_sync
from ..services.worker_control import (
_queue_depths_sync,
inspect_lanes_sync,
size_lanes_sync,
store_lane_samples_sync,
)
# Read INSIDE the session. Reading a column off a detached instance
# happens to work while the attribute is still loaded and stops working
# the moment anything expires it — a failure that would appear long after
# this line, in a sweep nobody is watching.
with _sync_session_factory()() as session:
caps = {
row.name: row.slots_cap
for row in session.execute(select(WorkerLane)).scalars()
}
if not caps:
# Migration 0103/0105 seed these, so an empty table means they have
# not run yet. Nothing to assert — and inventing defaults here would
# let this task disagree with the seed it is meant to be enforcing.
return {"sized": []}
# Measured ONCE, here, and then used three times. Passing them down is
# what makes the reading keepable rather than an implementation detail of
# a function that returns decisions.
live = inspect_lanes_sync()
depths = _queue_depths_sync()
sized = size_lanes_sync(caps, live=live, depths=depths)
with _sync_session_factory()() as session:
store_lane_samples_sync(session, live, depths)
refresh_celery_roster_sync(session)
for d in sized:
if d.action not in ("held", "skipped"):
log.info(
"worker lanes: %s %s to %s slots — %s",
d.lane, d.action, d.slots, d.reason,
)
return {
"sized": [
{"lane": d.lane, "action": d.action, "slots": d.slots,
"reason": d.reason}
for d in sized
],
}
+6 -45
View File
@@ -488,7 +488,7 @@ def scheduled_ccip_auto_apply() -> str:
from ..models import ImageRegion, MLSettings, Tag, TagKind from ..models import ImageRegion, MLSettings, Tag, TagKind
from ..models.tag import image_tag from ..models.tag import image_tag
from ..services.ml.ccip import _FIGURE_KINDS, char_maxima from ..services.ml.ccip import _FIGURE_KINDS
from ..services.ml.training_data import _applied_or_rejected, _l2norm from ..services.ml.training_data import _applied_or_rejected, _l2norm
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
@@ -553,22 +553,11 @@ def scheduled_ccip_auto_apply() -> str:
by_img: dict[int, list] = {} by_img: dict[int, list] = {}
for iid, vec in rows: for iid, vec in rows:
by_img.setdefault(iid, []).append(vec) by_img.setdefault(iid, []).append(vec)
if not by_img: for iid, vecs in by_img.items():
continue q = _l2norm(np.asarray(vecs, dtype=np.float32), np) # (nq, 768)
colmax = (q @ allref.T).max(axis=0) # (total,)
# One matmul per BLOCK of figures, not one per image. This loop ran charmax = np.maximum.reduceat(colmax, seg) # (n_chars,)
# over every image in the library on every daily run and did a for ci in np.where(charmax >= thr)[0]:
# matmul too small to pay for itself each time; it hit the 1800s
# soft limit on the operator's instance on 2026-09-23. Same
# arithmetic — see `char_maxima`.
iids = list(by_img)
charmax = char_maxima(
[_l2norm(np.asarray(by_img[i], dtype=np.float32), np) for i in iids],
allref, seg, np,
) # (n_img, n_chars)
for row, iid in enumerate(iids):
for ci in np.where(charmax[row] >= thr)[0]:
t = ref_tags[int(ci)] t = ref_tags[int(ci)]
if iid in skip[t]: if iid in skip[t]:
continue continue
@@ -679,31 +668,3 @@ def scheduled_retract_auto_tags() -> str:
with SessionLocal() as session: with SessionLocal() as session:
n_ccip = retract_auto_applied_ccip(session) n_ccip = retract_auto_applied_ccip(session)
return f"head={n_head} ccip={n_ccip}" return f"head={n_head} ccip={n_ccip}"
@celery.task(name="backend.app.tasks.ml.ensure_models", bind=True)
def ensure_models(self) -> dict:
"""Fetch the models this lane needs, if they are not already present.
Milestone 422 step 6. This used to run in `entrypoint.sh` before celery
started, which made every boot of the ML role reach HuggingFace for
~3.5GB — a startup dependency on a third party, for a feature the operator
may never use. Rule 164 permits a runtime fetch only for something
"optional and clearly off", so it moved here: enqueued the moment the lane
is ENABLED, never at boot.
Being a task rather than a startup step is what makes it visible: it gets
a TaskRun row like any other, so the download shows in Activity with a
duration and a status, and a failure is something the operator can see and
retry rather than a container that quietly never became useful.
Idempotent — `download_models` fetches only what is missing — so enabling
an already-provisioned lane costs one no-op task rather than a re-download.
That matters because the reconcile may enqueue it again.
"""
from ..scripts.download_models import main as download
rc = download()
if rc != 0:
raise RuntimeError(f"model download failed with exit code {rc}")
return {"ok": True}
-27
View File
@@ -80,33 +80,6 @@ def _seek_first_frame(pil_image) -> None:
pass pass
def hash_bits(hex_str: str | None) -> int | None:
"""A stored pHash hex string as an integer, or None if it is missing or
unparseable. Fails CLOSED, like every other gate in this module.
Parsed to an int rather than an imagehash object because the caller that
needs this compares one image against many: `int.bit_count()` on an XOR is
a machine instruction, where rebuilding a 16x16 boolean array per
comparison is not.
"""
if not hex_str:
return None
try:
return int(hex_str, 16)
except (TypeError, ValueError):
return None
def hamming(a: int | None, b: int | None) -> int | None:
"""Bits differing between two parsed hashes, or None if either is absent.
Out of 256 at HASH_SIZE 16.
"""
if a is None or b is None:
return None
return (a ^ b).bit_count()
def compute_phash(pil_image) -> str | None: def compute_phash(pil_image) -> str | None:
"""Perceptual hash of an opened PIL image, as a hex string. None on any """Perceptual hash of an opened PIL image, as a hex string. None on any
failure (videos/unreadable/non-image). failure (videos/unreadable/non-image).
+10 -12
View File
@@ -15,7 +15,7 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
## Secondary runtime image ## Secondary runtime image
node:24-bookworm-slim — `build.yml`'s `extension-test` job only. node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only.
`.forgejo/workflows/release.yml` runs on `ci-python:3.14` like everything else `.forgejo/workflows/release.yml` runs on `ci-python:3.14` like everything else
and installs nothing: it needs git and stdlib python, and builds no image. and installs nothing: it needs git and stdlib python, and builds no image.
@@ -29,8 +29,8 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
- `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs - `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs
- `npm install --no-audit --no-fund` — in `frontend-build` job - `npm install --no-audit --no-fund` — in `frontend-build` job
- `npm install --no-audit --no-fund` — in `build.yml`'s `extension-test` job (web-ext + vitest) - `npm install --no-audit --no-fund` — in `extension.yml`'s `lint` job (web-ext + vitest)
- `unzip` — in `extension-test`'s "Verify XPI contents" step, installed via apt - `unzip` — in `extension.yml`'s "Verify XPI contents" step, installed via apt
only when absent (`node:24-bookworm-slim` may or may not carry it). Debian only when absent (`node:24-bookworm-slim` may or may not carry it). Debian
package, ~2s. Not worth baking into a shared image for a single consumer, per package, ~2s. Not worth baking into a shared image for a single consumer, per
`docs/process.md`'s ">1 project" rule. `docs/process.md`'s ">1 project" rule.
@@ -46,8 +46,8 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
- Integration uses Fabled-Git Actions `services:` + socket-discovered bridge IPs - Integration uses Fabled-Git Actions `services:` + socket-discovered bridge IPs
because `act_runner` (swarm-runner v0.6+) puts services on the default because `act_runner` (swarm-runner v0.6+) puts services on the default
bridge with no embedded DNS. The pattern is documented in the rulebook's bridge with no embedded DNS. The pattern is documented in the rulebook's
`fabled-git.md` "CI philosophy" section and FC's `build.yml` integration lane `fabled-git.md` "CI philosophy" section and FC's `ci.yml` is the canonical
is the canonical example. example.
- No `package-lock.json` is tracked yet (FC's `feedback_no_local_runs` - No `package-lock.json` is tracked yet (FC's `feedback_no_local_runs`
memory bans `npm install` locally). Using `npm install` rather than memory bans `npm install` locally). Using `npm install` rather than
`npm ci` until a lockfile lands. `npm ci` until a lockfile lands.
@@ -83,7 +83,7 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
digit `0` or starts 1-9, and there are at most four. `2026.08.29.0201` is digit `0` or starts 1-9, and there are at most four. `2026.08.29.0201` is
rejected; `2026.8.29.201` is the same value one character narrower per rejected; `2026.8.29.201` is the same value one character narrower per
segment, and rule 148 defines comparison as numeric per segment, so nothing is segment, and rule 148 defines comparison as numeric per segment, so nothing is
reordered. `build.yml`'s `extension-version` lane asserts the derived string reordered. `ci.yml`'s `extension-version` lane asserts the derived string
against that exact regex, plus a `YYYY.M.D.HHMM` shape check that would catch against that exact regex, plus a `YYYY.M.D.HHMM` shape check that would catch
a regression to the pre-318 `1.0.<minutes>` — which AMO would accept and which a regression to the pre-318 `1.0.<minutes>` — which AMO would accept and which
orders below everything already signed. Checking here is the whole point: AMO orders below everything already signed. Checking here is the whole point: AMO
@@ -91,8 +91,7 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
`scripts/artifacts.sh version extension` **delegates** to `packaging.sh` so `scripts/artifacts.sh version extension` **delegates** to `packaging.sh` so
the two cannot answer differently. the two cannot answer differently.
- Every job that derives anything checks out with `fetch-depth: 0` — all four - Every job that derives anything checks out with `fetch-depth: 0` — all four
publishing `build.yml` jobs, its `extension-version` and `backend-lint-and-test` `build.yml` jobs, `ci.yml`'s `extension-version` and `backend-lint-and-test`
lanes
(for `tests/test_artifact_paths.py` and `test_artifact_identity.py`), and (for `tests/test_artifact_paths.py` and `test_artifact_identity.py`), and
`release.yml`, which additionally walks the tag graph. A depth-1 clone sees `release.yml`, which additionally walks the tag graph. A depth-1 clone sees
one commit and derives a wrong, too-low value **rather than failing**, so the one commit and derives a wrong, too-low value **rather than failing**, so the
@@ -100,8 +99,8 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
- **`scripts/artifacts.sh` is the same shape one level up: one definition per - **`scripts/artifacts.sh` is the same shape one level up: one definition per
artifact of what it is built from, and the two values derived from it.** artifact of what it is built from, and the two values derived from it.**
`revision` (12 hex of the newest commit touching that set) and `version` `revision` (12 hex of the newest commit touching that set) and `version`
(`YYYY.MM.DD.HHMM` UTC, rule 148). Three artifacts, three independent (`YYYY.MM.DD.HHMM` UTC, rule 148). Four artifacts, four independent answers,
answers, so a push touching only `agent/` leaves web and the extension alone. so a push touching only `agent/` leaves web and ml alone.
`tests/test_artifact_paths.py` reads each Dockerfile and asserts every COPY `tests/test_artifact_paths.py` reads each Dockerfile and asserts every COPY
source is covered, so adding a COPY without updating the script fails CI. source is covered, so adding a COPY without updating the script fails CI.
- **A file that DECIDES an artifact's identity belongs in its set even though it - **A file that DECIDES an artifact's identity belongs in its set even though it
@@ -154,8 +153,7 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
layer store at all**, where the old `docker` driver at least reused whatever layer store at all**, where the old `docker` driver at least reused whatever
the runner's dockerd happened to hold. Measured on run 4896, the first builds the runner's dockerd happened to hold. Measured on run 4896, the first builds
after the driver moved: web 3m44s (was 2m23s), ml 3m49s (was 3m20s), agent after the driver moved: web 3m44s (was 2m23s), ml 3m49s (was 3m20s), agent
11m12s (was 9m26s) — every one slower. (`ml` was its own build then; #4311 11m12s (was 9m26s) — every one slower. A `:buildcache` tag is read by every
retired it once it became the same bytes as web under a second name.) A `:buildcache` tag is read by every
build that runs, is one moving ref per image, holds cache blobs rather than a build that runs, is one moving ref per image, holds cache blobs rather than a
shippable artifact, and is overwritten in place, so it is not a return of the shippable artifact, and is overwritten in place, so it is not a return of the
per-version tags milestone 318 withdrew (#3114). per-version tags milestone 318 withdrew (#3114).
+1 -1
View File
@@ -47,7 +47,7 @@ services:
ml-worker: ml-worker:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile.ml
environment: environment:
LOG_LEVEL: DEBUG LOG_LEVEL: DEBUG
volumes: volumes:
-104
View File
@@ -1,104 +0,0 @@
# FabledCurator in three containers — the install path.
#
# docker compose -f docker-compose.single.yml up -d
#
# Milestone 422 step 5. FabledCurator runs web and every worker lane inside
# ONE container, with Postgres and Redis beside it. How much work each lane
# does is then a dial in the web UI (Settings -> Activity -> Worker lanes),
# live, with no compose edit and no restart.
#
# THE MULTI-SERVICE STACK IS NOT REPLACED. `docker-compose.yml` still runs the
# five app services separately and is the right shape for a Swarm deployment
# spread across hosts, where per-service rolling rollback and placement
# constraints matter. This file is the adopter path: one box, one command.
#
# What consolidating costs, stated here rather than discovered later:
# - Everything shares one host, so there is no spreading work across nodes.
# - Rollback is all-or-nothing; there is no rolling back `web` alone.
# - One stop timeout for the whole container, sized to the slowest lane.
#
# NOT a cost, recorded so it is not rediscovered and raised again: the
# multi-service stack mounts /images:ro on ml-worker and one container cannot
# mount one path two ways. Operator ruled that a non-issue (2026-09-22) — it
# is the same codebase either way.
#
# FabledCurator has no authentication. Whatever can reach ${PORT} is an
# administrator, including over the stored platform session cookies. Do not
# publish this port beyond a network you trust — see "Before you expose it"
# in README.md.
services:
redis:
image: redis:8-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: ${DB_USER:-curator}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-curator}
volumes:
- postgres_data:/var/lib/postgresql/data
# pgvector index builds and the gallery's TABLESAMPLE reads both want more
# shared memory than docker's 64MB default.
shm_size: 512m
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-curator} -d ${DB_NAME:-curator}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
fabledcurator:
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
# No `command:`. Everything — hypercorn plus one celery process per lane
# under supervisord — is what the image does by default, and supervisord's
# config is generated from the application's own lane table so the two
# cannot disagree. `command: ["all"]` still works and means the same thing.
# Sized to the SLOWEST lane, not the average. maintenance_long runs DB
# backups, library audits and translation backfill, and gets 180s to
# finish a chunk; the lanes stop in parallel, so this covers the max
# rather than their sum. Below this, a routine restart becomes a SIGKILL
# mid-backup — which is recoverable (the work is chunked and idempotent)
# but wastes however long it had run.
stop_grace_period: 200s
# No healthcheck here either. The image declares one that reads the role
# it is running, and for this one that means BOTH halves: hypercorn
# answers AND every lane is answering the broker. A web-only check would
# report a healthy container while every lane inside it had crashed —
# the failure mode consolidation creates, since docker can no longer see
# the lanes as separate services.
environment:
DB_USER: ${DB_USER:-curator}
DB_PASSWORD: ${DB_PASSWORD:-postgres}
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: ${DB_NAME:-curator}
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-change-me-before-you-expose-this}
EXTENSION_API_KEY: ${EXTENSION_API_KEY:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports:
- "${PORT:-8080}:8080"
volumes:
- ${IMAGES_DIR:-./images}:/images
# Read-only. The filesystem scan copies out of here and never writes to
# it, so a mistake cannot reach the source library.
- ${IMPORT_DIR:-./import}:/import:ro
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
restart: unless-stopped
volumes:
redis_data:
postgres_data:
+2 -17
View File
@@ -48,7 +48,7 @@ x-celery-healthcheck: &celery_healthcheck
services: services:
redis: redis:
image: redis:8-alpine image: redis:7-alpine
volumes: volumes:
- redis_data:/data - redis_data:/data
healthcheck: healthcheck:
@@ -180,10 +180,6 @@ services:
environment: environment:
<<: *app_env <<: *app_env
CELERY_QUEUES: default,import,thumbnail,download CELERY_QUEUES: default,import,thumbnail,download
# Names the celery node for the roster. A lane whose consumers are
# cancelled reports no queues, so the NODE is the only thing left that
# identifies it — see services/worker_lanes.lane_for_node.
CELERY_NODENAME: worker
CELERY_CONCURRENCY: "2" CELERY_CONCURRENCY: "2"
# /downloads dropped — nothing in the app references it (operator-flagged # /downloads dropped — nothing in the app references it (operator-flagged
# 2026-06-07: it wasn't mapped in prod and everything worked). # 2026-06-07: it wasn't mapped in prod and everything worked).
@@ -204,10 +200,6 @@ services:
environment: environment:
<<: *app_env <<: *app_env
CELERY_QUEUES: maintenance,scan CELERY_QUEUES: maintenance,scan
# Names the celery node for the roster. A lane whose consumers are
# cancelled reports no queues, so the NODE is the only thing left that
# identifies it — see services/worker_lanes.lane_for_node.
CELERY_NODENAME: scheduler
volumes: volumes:
- ./images:/images - ./images:/images
- ./import:/import - ./import:/import
@@ -231,10 +223,6 @@ services:
environment: environment:
<<: *app_env <<: *app_env
CELERY_QUEUES: maintenance_long CELERY_QUEUES: maintenance_long
# Names the celery node for the roster. A lane whose consumers are
# cancelled reports no queues, so the NODE is the only thing left that
# identifies it — see services/worker_lanes.lane_for_node.
CELERY_NODENAME: maintenance_long
CELERY_CONCURRENCY: "1" CELERY_CONCURRENCY: "1"
# Only /images: backups write to /images/_backups, audits read /images, and # Only /images: backups write to /images/_backups, audits read /images, and
# the admin tasks (re-extract/cascade-delete/normalize) operate on /images. # the admin tasks (re-extract/cascade-delete/normalize) operate on /images.
@@ -245,7 +233,7 @@ services:
redis: { condition: service_healthy } redis: { condition: service_healthy }
ml-worker: ml-worker:
image: git.fabledsword.com/bvandeusen/fabledcurator:latest image: git.fabledsword.com/bvandeusen/fabledcurator-ml:latest
command: ["ml-worker"] command: ["ml-worker"]
# A single GPU inference pass can run tens of seconds — let it finish. # A single GPU inference pass can run tens of seconds — let it finish.
stop_grace_period: 120s stop_grace_period: 120s
@@ -253,9 +241,6 @@ services:
deploy: *deploy_policy deploy: *deploy_policy
environment: environment:
<<: *app_env <<: *app_env
# See the worker service — the node name is what identifies a lane
# whose consumers are cancelled.
CELERY_NODENAME: ml
volumes: volumes:
- ./images:/images:ro - ./images:/images:ro
- ./models:/models - ./models:/models
+9 -120
View File
@@ -1,78 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Defaults to the whole application (see the Dockerfile's CMD). Kept in step ROLE="${1:-web}"
# with that CMD deliberately: they are two doors to the same decision, and a
# disagreement between them would only show up as `docker run --entrypoint`
# behaving differently from `docker run`.
ROLE="${1:-all}"
# CELERY NODE NAME. Every celery role below starts with `-n $CELERY_NODENAME@%h`.
#
# Celery's default node name is `celery@<hostname>`, and in the single-
# container layout all four lanes share one hostname — so all four registered
# as the SAME node. celery's own words for it, observed on run 7319:
#
# DuplicateNodenameWarning: Received multiple replies from node name:
# celery@72adc5b706a7
#
# `inspect` then collapses four replies into one dict key and the last one
# wins, so three lanes read as absent and WHICH three varies per call:
#
# lanes not answering: maintenance_long, ml, worker
# lanes not answering: maintenance_long, scheduler, worker
#
# That is fatal twice over. The composite healthcheck can never pass, so the
# container is permanently unhealthy; and `pool_grow(destination=[hostname])`
# addresses a lane BY that name, so the UI dial and the autoscaler would have
# resized whichever lane happened to answer rather than the one asked for.
#
# The generated supervisord config sets this per lane. Unset — which is every
# service in the multi-service stack — it falls back to `celery`, exactly
# celery's own default, so `celery@$HOSTNAME` healthchecks there still work.
: "${CELERY_NODENAME:=celery}"
# RECORD THE ROLE, for the image's own HEALTHCHECK to read.
#
# The container is the only thing that knows what it was asked to run, and
# before this every compose and stack file had to restate it as a healthcheck
# of its own. The Dockerfile now declares one check that dispatches on this.
#
# Written ONCE, by the outermost invocation. The `all` role starts the other
# roles through this same script under supervisord, and those children must
# not overwrite the container's role with their own — a lane starting would
# turn the composite check into a web-only one, silently. FC_ROLE is exported,
# so a child sees it set and skips.
#
# Best effort: a read-only /tmp is not a reason to refuse to boot. The
# healthcheck treats a missing file as "nothing to check" rather than as a
# failure, for the same reason.
if [ -z "${FC_ROLE:-}" ]; then
export FC_ROLE="$ROLE"
printf '%s\n' "$ROLE" > "${FC_ROLE_FILE:-/tmp/fc-role}" 2>/dev/null || true
fi
# WAIT FOR POSTGRES AND REDIS before doing anything that needs them.
#
# Swarm has no ordering primitive — it ignores `depends_on` entirely — so
# every service in a stack starts at once and this container races its own
# database on every cold deploy.
#
# The multi-service stack hid how sharp that is: a `web` task that failed
# `alembic upgrade head` against a still-initialising Postgres simply died,
# and Swarm restarted it until it worked. Consolidation removes that. Each
# supervisord program gets `startretries=3`, so three quick failures put the
# program in FATAL and leave it there — supervisord keeps running, the
# container keeps running, and the application never starts. It would present
# as a permanently unhealthy container whose image was fine and whose
# database merely took twenty seconds to come up.
#
# Skipped for `shell`, which exists precisely for the case where something
# else is broken and you want a prompt rather than a gate.
case "$ROLE" in
shell|bash) ;;
*) python -m backend.app.scripts.wait_for_deps ;;
esac
shift || true shift || true
case "$ROLE" in case "$ROLE" in
@@ -99,7 +28,6 @@ case "$ROLE" in
CONCURRENCY="${CELERY_CONCURRENCY:-2}" CONCURRENCY="${CELERY_CONCURRENCY:-2}"
echo "[entrypoint] Starting Celery worker queues=$QUEUES concurrency=$CONCURRENCY" echo "[entrypoint] Starting Celery worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \ exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--loglevel=info \ --loglevel=info \
-Q "$QUEUES" \ -Q "$QUEUES" \
--concurrency="$CONCURRENCY" --concurrency="$CONCURRENCY"
@@ -107,61 +35,22 @@ case "$ROLE" in
scheduler) scheduler)
QUEUES="${CELERY_QUEUES:-maintenance,scan}" QUEUES="${CELERY_QUEUES:-maintenance,scan}"
# Honours CELERY_CONCURRENCY like the `worker` role does. It was hardcoded echo "[entrypoint] Starting Celery beat+worker queues=$QUEUES"
# to 1, which was harmless while only compose started this lane and set no
# concurrency for it — but the generated supervisord config (milestone 422
# step 5) passes one, and a value silently ignored at boot would leave the
# lane at 1 until the reconcile sweep noticed, with nothing saying why.
CONCURRENCY="${CELERY_CONCURRENCY:-1}"
echo "[entrypoint] Starting Celery beat+worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \ exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--beat \ --beat \
--loglevel=info \ --loglevel=info \
-Q "$QUEUES" \ -Q "$QUEUES" \
--concurrency="$CONCURRENCY" --concurrency=1
;; ;;
ml-worker) ml-worker)
# NO MODEL DOWNLOAD HERE (milestone 422 step 6). This used to run echo "[entrypoint] Ensuring ML models present in /models..."
# download_models before celery started, which made every boot of this python -m backend.app.scripts.download_models
# role reach HuggingFace for ~3.5GB. Rule 164 permits a runtime fetch only echo "[entrypoint] Starting ML Celery worker (ml queue)"
# for a feature that is "optional and clearly off" — so the fetch moved to
# the moment the operator ENABLES the lane, where it is visible, retryable
# and attributable, instead of being a silent precondition of starting.
#
# The worker therefore starts with no model present, which is correct: it
# is not consuming the ml queue until the lane is enabled, and enabling it
# is what enqueues ensure_models.
QUEUES="${CELERY_QUEUES:-ml}"
CONCURRENCY="${CELERY_CONCURRENCY:-1}"
echo "[entrypoint] Starting ML Celery worker queues=$QUEUES concurrency=$CONCURRENCY"
exec celery -A backend.app.celery_app:celery worker \ exec celery -A backend.app.celery_app:celery worker \
-n "${CELERY_NODENAME:-celery}@%h" \
--loglevel=info \ --loglevel=info \
-Q "$QUEUES" \ -Q ml \
--concurrency="$CONCURRENCY" --concurrency=1
;;
all)
# The single-container layout (milestone 422 step 5): hypercorn plus one
# celery process per lane, under supervisord, in one container beside
# Postgres and Redis.
#
# The config is GENERATED from services/worker_lanes.LANES rather than
# checked in, so the processes this container runs and the lanes the
# application believes in cannot disagree — see the generator's docstring
# for why a static .conf would have been a fifth copy of the queue names.
#
# supervisord is PID 1 here and never reads the database. Every lane boots
# at its LANES default; the reconcile sweep raises it to whatever the
# operator stored, within one tick. That ordering is deliberate: settings
# adjust a baseline that already works, and can never prevent a boot.
CONF="${SUPERVISOR_CONF:-/tmp/supervisord.conf}"
echo "[entrypoint] Generating $CONF from the lane table"
python -m backend.app.scripts.gen_supervisord > "$CONF"
echo "[entrypoint] Starting supervisord (web + worker lanes)"
exec supervisord -c "$CONF"
;; ;;
shell|bash) shell|bash)
@@ -174,7 +63,7 @@ case "$ROLE" in
*) *)
echo "[entrypoint] Unknown role: $ROLE" >&2 echo "[entrypoint] Unknown role: $ROLE" >&2
echo "[entrypoint] Valid roles: all | web | worker | scheduler | maintenance_long | ml | ml-worker | shell | alembic" >&2 echo "[entrypoint] Valid roles: web | worker | scheduler | ml-worker | shell | alembic" >&2
exit 1 exit 1
;; ;;
esac esac
+1 -1
View File
@@ -68,7 +68,7 @@ rejected, and at most four segments are allowed. The extension therefore emits
**the same numbers unpadded**: `2026.8.29.201` where the rest of the family **the same numbers unpadded**: `2026.8.29.201` where the rest of the family
says `2026.08.29.0201`. Rule 148 already defines comparison as numeric per says `2026.08.29.0201`. Rule 148 already defines comparison as numeric per
segment, under which the two are equal, so nothing is reordered by the choice segment, under which the two are equal, so nothing is reordered by the choice
and left-padding each segment recovers the family string exactly. `build.yml`'s and left-padding each segment recovers the family string exactly. `ci.yml`'s
`extension-version` lane checks the derived string against that regex on every `extension-version` lane checks the derived string against that regex on every
push — the cheap place to find out, because AMO 409s on re-signing and a push — the cheap place to find out, because AMO 409s on re-signing and a
rejected version is burned for good. rejected version is burned for good.
+3 -24
View File
@@ -128,8 +128,7 @@ browser.webRequest.onBeforeSendHeaders.addListener(
saveDiscordToken(auth.value); saveDiscordToken(auth.value);
} }
}, },
// ptb/canary are Discord's beta clients; their API calls carry the same token. { urls: ['https://discord.com/api/*'] },
{ urls: ['https://discord.com/api/*', 'https://*.discord.com/api/*'] },
['requestHeaders'], ['requestHeaders'],
); );
@@ -214,17 +213,7 @@ browser.runtime.onMessage.addListener(async (msg) => {
if (key === 'discord') { if (key === 'discord') {
if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
await api.uploadCredentials('discord', 'token', discordToken); await api.uploadCredentials('discord', 'token', discordToken);
// Then have FC try it against a Discord source, so a token Discord return { success: true };
// has already revoked shows up here rather than at the next check.
// A failed verify never undoes the upload: valid=null means FC could
// not test (no Discord source yet), not that the token is bad.
let verify = null;
try {
verify = await api.verifyCredential('discord');
} catch (e) {
verify = { valid: null, reason: e.message };
}
return { success: true, verify };
} }
return { error: 'Unsupported platform.' }; return { error: 'Unsupported platform.' };
} catch (e) { } catch (e) {
@@ -267,17 +256,7 @@ browser.runtime.onMessage.addListener(async (msg) => {
case 'ADD_AS_SOURCE': case 'ADD_AS_SOURCE':
try { try {
return await api.quickAddSource(msg.url, { return await api.quickAddSource(msg.url);
artistId: msg.artistId ?? null,
artistName: msg.artistName ?? null,
});
} catch (e) {
return { error: e.message };
}
case 'SEARCH_ARTISTS':
try {
return { artists: await api.searchArtists(msg.q || '') };
} catch (e) { } catch (e) {
return { error: e.message }; return { error: e.message };
} }
-42
View File
@@ -40,45 +40,3 @@
from { transform: translateY(20px); opacity: 0; } from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; } to { transform: translateY(0); opacity: 1; }
} }
/* Discord Add panel — sits above the chip. Same slate/parchment palette. */
.fc-panel {
all: revert;
position: fixed; bottom: 76px; right: 24px; z-index: 2147483647;
box-sizing: border-box; width: 320px; max-width: calc(100vw - 48px);
padding: 14px 16px; border-radius: 10px;
background: rgb(20, 23, 26); color: rgb(232, 228, 216);
border: 1px solid rgb(60, 64, 70);
font: 14px/1.4 system-ui, sans-serif;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
.fc-panel__title { font-weight: 600; color: rgb(244, 186, 122); }
.fc-panel__sub { color: rgb(170, 166, 156); font-size: 12px; margin-bottom: 8px; }
.fc-panel__label {
margin: 10px 0 4px; font-size: 11px; letter-spacing: 0.06em;
text-transform: uppercase; color: rgb(170, 166, 156);
}
.fc-panel__radio { display: flex; gap: 8px; align-items: center; padding: 2px 0; cursor: pointer; }
.fc-panel__radio input { margin: 0; accent-color: rgb(244, 186, 122); }
.fc-panel__input {
all: revert; box-sizing: border-box; width: 100%;
padding: 7px 9px; border-radius: 6px;
border: 1px solid rgb(70, 74, 80); background: rgb(12, 14, 16); color: inherit;
font: inherit;
}
.fc-panel__input:focus { outline: 2px solid rgb(244, 186, 122); outline-offset: -1px; }
.fc-panel__results { display: flex; flex-direction: column; max-height: 160px; overflow-y: auto; }
.fc-panel__result {
all: revert; text-align: left; padding: 6px 9px; border: none; border-radius: 4px;
background: transparent; color: inherit; font: inherit; cursor: pointer;
}
.fc-panel__result:hover, .fc-panel__result:focus { background: rgb(36, 40, 46); }
.fc-panel__hint { margin-top: 8px; font-size: 12px; color: rgb(170, 166, 156); }
.fc-panel__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.fc-panel__btn {
all: revert; padding: 6px 14px; border-radius: 999px; cursor: pointer;
border: 1px solid rgb(70, 74, 80); background: transparent; color: inherit;
font: 500 13px/1.2 system-ui, sans-serif;
}
.fc-panel__btn--primary { border-color: rgb(244, 186, 122); background: rgb(244, 186, 122); color: rgb(20, 23, 26); }
.fc-panel__btn:disabled { opacity: 0.5; cursor: default; }
+58 -181
View File
@@ -5,61 +5,42 @@
// Cached probe result for the current URL so click-handlers know which // Cached probe result for the current URL so click-handlers know which
// action to dispatch without round-tripping again. // action to dispatch without round-tripping again.
let currentProbe = null; let currentProbe = null;
// Bumped on every evaluate(): a probe that answers after the operator has
// navigated on is for a page they've left, and must not repaint the chip.
let generation = 0;
let lastUrl = window.location.href;
evaluate(); evaluate();
// SPA navigation. Patreon, SubscribeStar and above all Discord change const reEval = () => evaluate();
// channel/page without a reload. Patching history.pushState from here never window.addEventListener('popstate', reEval);
// worked: a content script runs in an isolated world, so the page's own const origPush = history.pushState;
// pushState is not the function we'd replace. Polling the URL is the one history.pushState = function () { origPush.apply(this, arguments); reEval(); };
// signal that sees every navigation, and costs a string compare.
window.addEventListener('popstate', () => onUrlMaybeChanged());
setInterval(onUrlMaybeChanged, 500);
function onUrlMaybeChanged() {
if (window.location.href === lastUrl) return;
lastUrl = window.location.href;
closePanel();
evaluate();
}
async function evaluate() { async function evaluate() {
const mine = ++generation;
const url = window.location.href; const url = window.location.href;
const platform = getPlatformFromUrl(url); const platform = getPlatformFromUrl(url);
const onArtist = platform && isArtistPage(url, platform); const onArtist = platform && isArtistPage(url, platform);
const btn = document.getElementById('fc-add-source-btn');
if (!onArtist) { if (!onArtist) {
removeButton(); if (btn) btn.remove();
currentProbe = null; currentProbe = null;
return; return;
} }
// Ask the backend what state the URL is in BEFORE drawing the button, so // On artist pages, ask the backend what state the URL is in BEFORE
// the chip renders the right state on first paint instead of flashing the // injecting the button — so the chip can render the right state on
// generic "Add" copy and updating afterwards. // first paint instead of flashing the generic "Add" copy and
// updating afterwards.
let probe; let probe;
try { try {
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url }); probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
} catch (e) { } catch (e) {
probe = { error: e?.message || 'probe failed' }; probe = { error: e?.message || 'probe failed' };
} }
if (mine !== generation) return;
currentProbe = probe; currentProbe = probe;
if (probe?.state === 'unknown_platform') { if (probe?.state === 'unknown_platform') {
removeButton(); if (btn) btn.remove();
return; return;
} }
renderButton(probe); renderButton(probe);
} }
function removeButton() {
document.getElementById('fc-add-source-btn')?.remove();
closePanel();
}
function renderButton(probe) { function renderButton(probe) {
let btn = document.getElementById('fc-add-source-btn'); let btn = document.getElementById('fc-add-source-btn');
if (!btn) { if (!btn) {
@@ -70,35 +51,53 @@
} }
// Reset state classes so re-renders (SPA navigation) don't stack. // Reset state classes so re-renders (SPA navigation) don't stack.
btn.className = 'fc-add-source-btn'; btn.className = 'fc-add-source-btn';
btn.classList.add(`fc-add-source-btn--${chipState(probe)}`); btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`);
btn.textContent = chipLabel(probe, PLATFORMS[probe?.platform]?.name || probe?.platform || ''); btn.textContent = labelFor(probe);
btn.disabled = false; btn.disabled = false;
} }
function stateModifier(probe) {
if (!probe || probe.error) return 'new';
return ({
source_match: 'source-match',
artist_match: 'artist-match',
new: 'new',
})[probe.state] || 'new';
}
function labelFor(probe) {
if (!probe || probe.error) return '+ Add to FabledCurator';
const platformName = platformDisplayName(probe.platform);
const artistName = probe.artist?.name;
switch (probe.state) {
case 'source_match':
return `✓ In FabledCurator · ${platformName}`;
case 'artist_match':
return `+ Add ${platformName} source to ${artistName || 'artist'}`;
case 'new':
default:
return '+ Add to FabledCurator';
}
}
function platformDisplayName(key) {
return PLATFORMS[key]?.name || key || '';
}
async function onClick() { async function onClick() {
const btn = document.getElementById('fc-add-source-btn'); const btn = document.getElementById('fc-add-source-btn');
if (!btn) return; if (!btn) return;
btn.disabled = true;
const original = btn.textContent;
const probe = currentProbe; const probe = currentProbe;
if (probe?.state === 'source_match') { if (probe?.state === 'source_match') {
await openArtist(btn, probe.artist?.slug);
return;
}
// A Discord URL names a channel, not a creator — ask which artist.
if (probe?.platform === 'discord') {
if (document.getElementById('fc-discord-panel')) closePanel();
else openDiscordPanel(probe);
return;
}
await add(btn, { url: window.location.href });
}
async function openArtist(btn, slug) {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Opening…'; btn.textContent = 'Opening…';
try { try {
const r = await browser.runtime.sendMessage({ type: 'OPEN_ARTIST_PAGE', slug }); const r = await browser.runtime.sendMessage({
type: 'OPEN_ARTIST_PAGE',
slug: probe.artist?.slug,
});
if (r?.error) showToast(`Error: ${r.error}`, 'error'); if (r?.error) showToast(`Error: ${r.error}`, 'error');
} catch (e) { } catch (e) {
showToast(`Error: ${e.message}`, 'error'); showToast(`Error: ${e.message}`, 'error');
@@ -106,155 +105,33 @@
btn.disabled = false; btn.disabled = false;
btn.textContent = original; btn.textContent = original;
} }
return;
} }
// One add, shared by the one-click chip and the Discord panel. Resolves
// true on success.
async function add(btn, request) {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Adding…'; btn.textContent = 'Adding…';
try { try {
const r = await browser.runtime.sendMessage({ type: 'ADD_AS_SOURCE', ...request }); const r = await browser.runtime.sendMessage({
type: 'ADD_AS_SOURCE',
url: window.location.href,
});
if (r?.error) { if (r?.error) {
showToast(`Error: ${r.error}`, 'error'); showToast(`Error: ${r.error}`, 'error');
return false; } else {
} const verb = r.created_source ? 'Added' : 'Already a source for';
const verb = r.created_source ? 'Added to' : 'Already a source for';
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success'); showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
// Re-probe so the chip flips green without waiting for a navigation. // Re-probe so the chip flips green without waiting for the next
// navigation.
evaluate(); evaluate();
return true; return;
}
} catch (e) { } catch (e) {
showToast(`Error: ${e.message}`, 'error'); showToast(`Error: ${e.message}`, 'error');
return false;
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = original; btn.textContent = original;
} }
} }
// ---- Discord Add panel ----
// Where: this channel or the whole server. Who: the suggested artist, one
// found by search, or a new one by name. Built with createElement only —
// server, channel and artist names are other people's text.
function el(tag, props = {}, children = []) {
const node = document.createElement(tag);
const { class: className, text, ...rest } = props;
if (className) node.className = className;
if (text != null) node.textContent = text;
Object.assign(node, rest);
for (const c of children) node.appendChild(c);
return node;
}
function closePanel() {
document.getElementById('fc-discord-panel')?.remove();
}
function openDiscordPanel(probe) {
closePanel();
const d = probe.discord || {};
const choice = discordPanelDefaults(probe);
let searchSeq = 0;
const scopeRow = (value, label, disabled) => {
const input = el('input', {
type: 'radio', name: 'fc-discord-scope', value,
checked: choice.scope === value, disabled,
});
input.addEventListener('change', () => { choice.scope = value; refresh(); });
return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]);
};
const nameInput = el('input', {
type: 'text', class: 'fc-panel__input', value: choice.artistName,
placeholder: 'Artist name — search or type a new one',
autocomplete: 'off', spellcheck: false,
});
const results = el('div', { class: 'fc-panel__results' });
const hint = el('div', { class: 'fc-panel__hint' });
const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' });
const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' });
const panel = el('div', { id: 'fc-discord-panel', class: 'fc-panel' }, [
el('div', { class: 'fc-panel__title', text: 'Add Discord source' }),
el('div', { class: 'fc-panel__sub', text: serverLabel(d) }),
el('div', { class: 'fc-panel__label', text: 'Follow' }),
scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id),
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
el('div', { class: 'fc-panel__label', text: 'Artist' }),
nameInput,
results,
hint,
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
]);
function refresh() {
const req = discordAddRequest(choice);
addBtn.disabled = !req;
if (!req) hint.textContent = 'Pick an artist or type a name.';
else if (req.artistId != null) hint.textContent = `Connects to ${choice.artist.name} in FabledCurator.`;
else hint.textContent = `Adds to “${req.artistName}” — created if FabledCurator has no artist by that name.`;
}
function showResults(rows) {
results.replaceChildren(...rows.map((a) => {
const row = el('button', { class: 'fc-panel__result', text: a.name });
row.addEventListener('click', () => {
choice.artist = { id: a.id, name: a.name };
choice.artistName = a.name;
nameInput.value = a.name;
results.replaceChildren();
refresh();
});
return row;
}));
}
let debounce = null;
nameInput.addEventListener('input', () => {
choice.artistName = nameInput.value;
refresh();
clearTimeout(debounce);
const q = nameInput.value.trim();
if (!q) { results.replaceChildren(); return; }
debounce = setTimeout(async () => {
const mine = ++searchSeq;
let r;
try {
r = await browser.runtime.sendMessage({ type: 'SEARCH_ARTISTS', q });
} catch {
return;
}
if (mine !== searchSeq || r?.error) return;
showResults(r.artists || []);
}, 200);
});
// Keep Discord's global shortcuts from eating keystrokes meant for us.
panel.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Escape') closePanel();
if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click();
});
cancelBtn.addEventListener('click', closePanel);
addBtn.addEventListener('click', async () => {
const req = discordAddRequest(choice);
if (!req) return;
const btn = document.getElementById('fc-add-source-btn');
addBtn.disabled = true;
const ok = await add(btn || addBtn, req);
if (ok) closePanel();
else refresh();
});
document.body.appendChild(panel);
refresh();
nameInput.focus();
}
function showToast(text, kind) { function showToast(text, kind) {
const t = document.createElement('div'); const t = document.createElement('div');
t.className = `fc-toast fc-toast--${kind}`; t.className = `fc-toast fc-toast--${kind}`;
+2 -19
View File
@@ -80,17 +80,6 @@ class FabledCuratorAPI {
getCredentials() { getCredentials() {
return this.request('GET', '/credentials'); return this.request('GET', '/credentials');
} }
// Test the STORED credential against one of the platform's sources — the
// same check the web UI's Verify button runs. {valid: true|false|null, reason}.
verifyCredential(platform) {
return this.request('POST', `/credentials/${encodeURIComponent(platform)}/verify`);
}
// Artist search for the Discord Add panel — the web UI's autocomplete.
searchArtists(q, limit = 8) {
const qs = new URLSearchParams({ q, limit: String(limit) }).toString();
return this.request('GET', `/artists/autocomplete?${qs}`);
}
// FC-3a — sources. // FC-3a — sources.
listSources() { listSources() {
@@ -101,14 +90,8 @@ class FabledCuratorAPI {
} }
// FC-3g — extension-specific. // FC-3g — extension-specific.
// artistId connects the source to an existing artist, artistName to the quickAddSource(url) {
// artist of that name (created if new); with neither the server derives the return this.request('POST', '/extension/quick-add-source', { url });
// artist from the URL. A Discord channel always sends one.
quickAddSource(url, { artistId = null, artistName = null } = {}) {
const body = { url };
if (artistId != null) body.artist_id = artistId;
else if (artistName) body.artist_name = artistName;
return this.request('POST', '/extension/quick-add-source', body);
} }
probeSource(url) { probeSource(url) {
// Read-only existence check. Drives the content-script chip's // Read-only existence check. Drives the content-script chip's
-80
View File
@@ -1,80 +0,0 @@
/**
* The content script's decisions, kept apart from its DOM so the specs can
* load them (test/chip.spec.js): which state the chip shows, what it says,
* and what the Discord Add panel starts out proposing.
*
* `probe` is /api/extension/probe's answer; `platformName` is the display
* name (PLATFORMS[key].name), passed in so this file needs no other lib.
*/
function chipState(probe) {
if (!probe || probe.error) return 'new';
return ({ source_match: 'source-match', artist_match: 'artist-match', new: 'new' })[probe.state] || 'new';
}
function chipLabel(probe, platformName) {
if (!probe || probe.error) return '+ Add to FabledCurator';
const artist = probe.artist?.name || 'artist';
if (probe.platform === 'discord') {
const d = probe.discord || {};
if (probe.state === 'source_match') {
return probe.covered_by_server
? `✓ Whole server in FabledCurator · ${artist}`
: `✓ In FabledCurator · ${artist}`;
}
// Every other Discord state opens the panel: the artist is always chosen.
return d.channel_id ? `+ Add ${channelLabel(d)} to FabledCurator` : '+ Add server to FabledCurator';
}
switch (probe.state) {
case 'source_match':
return `✓ In FabledCurator · ${platformName}`;
case 'artist_match':
return `+ Add ${platformName} source to ${artist}`;
default:
return '+ Add to FabledCurator';
}
}
/** `#name` when the probe could read it, else a neutral "this channel". */
function channelLabel(d) {
return d.channel_name ? `#${d.channel_name}` : 'this channel';
}
/** `name` when the probe could read it, else "this server". */
function serverLabel(d) {
return d.server_name || 'this server';
}
/**
* What the Discord Add panel opens with. The channel is the default scope
* when there is one: a server source walks every channel the token can read,
* which is rarely what a single art channel wants. The artist is the probe's
* suggestion (the owner of another source on this server), else a new artist
* named after the server.
*/
function discordPanelDefaults(probe) {
const d = probe?.discord || {};
const suggested = probe?.state === 'artist_match' && probe.artist ? probe.artist : null;
return {
scope: d.channel_id ? 'channel' : 'server',
channelUrl: d.channel_url || null,
serverUrl: d.server_url || null,
artist: suggested ? { id: suggested.id, name: suggested.name } : null,
artistName: suggested ? suggested.name : (d.server_name || ''),
};
}
/**
* The quick-add body for the panel's current choice. A picked artist goes by
* id — names can collide once slugified — and a typed name creates (or
* finds) that artist. null when there is nothing valid to send.
*/
function discordAddRequest(choice) {
const url = choice.scope === 'server' ? choice.serverUrl : choice.channelUrl;
if (!url) return null;
if (choice.artist && choice.artist.id != null && choice.artist.name === choice.artistName) {
return { url, artistId: choice.artist.id };
}
const name = (choice.artistName || '').trim();
return name ? { url, artistName: name } : null;
}
+1 -17
View File
@@ -57,8 +57,7 @@ const PLATFORMS = {
domains: ['.discord.com', 'discord.com'], domains: ['.discord.com', 'discord.com'],
authType: 'token', authType: 'token',
color: '#5865F2', color: '#5865F2',
// ptb/canary are Discord's beta clients — same channels, same token. urlPattern: /^https?:\/\/(www\.)?discord\.com/,
urlPattern: /^https?:\/\/((www|ptb|canary)\.)?discord\.com/,
note: 'Open Discord in browser to capture token', note: 'Open Discord in browser to capture token',
}, },
}; };
@@ -81,23 +80,8 @@ const PLATFORM_ARTIST_PATTERNS = {
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i, patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i, subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i, hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
// A Discord URL names a server or channel, not a creator: the backend's slug
// is `<server>[/<channel>]` and the Add panel asks which artist it belongs
// to. A message jump link still names its channel; DMs (@me) and thread
// links don't match. Mirrors extension_service._PLATFORM_PATTERNS.
discord: /^https?:\/\/(?:www\.|ptb\.|canary\.)?discord\.com\/channels\/\d+(?:\/\d+)?(?:\/\d+)?\/?(?:[?#].*)?$/i,
}; };
/**
* `{serverId, channelId}` from a Discord channel/server URL the artist
* pattern accepts, else null. channelId is null for a whole-server URL.
*/
function parseDiscordUrl(url) {
if (!PLATFORM_ARTIST_PATTERNS.discord.test(url || '')) return null;
const m = /\/channels\/(\d+)(?:\/(\d+))?/.exec(url);
return m ? { serverId: m[1], channelId: m[2] || null } : null;
}
function getPlatformFromUrl(url) { function getPlatformFromUrl(url) {
for (const [key, platform] of Object.entries(PLATFORMS)) { for (const [key, platform] of Object.entries(PLATFORMS)) {
if (platform.urlPattern.test(url)) return key; if (platform.urlPattern.test(url)) return key;
-55
View File
@@ -1,55 +0,0 @@
/**
* The popup's wording, kept apart from its DOM so the specs can load it
* (test/popup-format.spec.js). Classic script, like the rest of lib/.
*/
/**
* One line of state for a source row, from /api/sources' fields, with the
* status class the popup colours it by ('ready' | 'error' | 'no-cookies' for
* a warning | '' for plain). The most actionable fact wins: an error before
* a running backfill, a backfill before the last-checked time.
*/
function sourceStatus(src, now = Date.now()) {
if (!src.enabled) return { text: 'Disabled', kind: '' };
if (src.last_error) {
const first = String(src.last_error).split('\n')[0].trim();
const short = first.length > 90 ? `${first.slice(0, 89)}…` : first;
return { text: `Error — ${short}`, kind: 'error' };
}
if (src.backfill_state === 'running') {
const n = src.backfill_chunks || 0;
return { text: n ? `Backfilling — ${n} chunk${n === 1 ? '' : 's'} done` : 'Backfill queued', kind: 'ready' };
}
if (src.backfill_state === 'stalled') return { text: 'Backfill stalled', kind: 'no-cookies' };
if (!src.last_checked_at) return { text: 'Not checked yet', kind: '' };
return { text: `Checked ${relativeTime(src.last_checked_at, now)}`, kind: '' };
}
// Same buckets and wording as the web UI's canonical formatRelative
// (frontend/src/utils/date.js, snippet #3959) — the extension can't import it (classic
// scripts, separate package), so it mirrors it: "42s ago", "5m ago", "3h ago",
// "2d ago", and "Never" for a missing or unreadable time.
function relativeTime(iso, now = Date.now()) {
const t = iso ? Date.parse(iso) : NaN;
if (Number.isNaN(t)) return 'Never';
const abs = Math.abs(now - t) / 1000;
let body;
if (abs < 60) body = `${Math.floor(abs)}s`;
else if (abs < 3600) body = `${Math.floor(abs / 60)}m`;
else if (abs < 86400) body = `${Math.floor(abs / 3600)}h`;
else body = `${Math.floor(abs / 86400)}d`;
return `${body} ago`;
}
/**
* The popup message after a Discord token export, from FC's verify of the
* stored token: {text, kind} with kind 'success' | 'warning' | 'error'.
* valid=null is "FC couldn't test it" (no Discord source yet, or a network
* hiccup) — a warning with FC's reason, never a failure.
*/
function tokenExportMessage(verify) {
if (!verify) return { text: 'Discord: token exported', kind: 'success' };
if (verify.valid === true) return { text: `Discord: token exported and verified ✓ — ${verify.reason}`, kind: 'success' };
if (verify.valid === false) return { text: `Discord: token exported, but FC's check failed — ${verify.reason}`, kind: 'error' };
return { text: `Discord: token exported (not verified — ${verify.reason})`, kind: 'warning' };
}
+2 -3
View File
@@ -56,10 +56,9 @@
"*://*.patreon.com/*", "*://*.patreon.com/*",
"*://*.subscribestar.com/*", "*://*.subscribestar.com/*",
"*://*.subscribestar.adult/*", "*://*.subscribestar.adult/*",
"*://*.hentai-foundry.com/*", "*://*.hentai-foundry.com/*"
"*://*.discord.com/*"
], ],
"js": ["lib/platforms.js", "lib/chip.js", "content/content-script.js"], "js": ["lib/platforms.js", "content/content-script.js"],
"css": ["content/content-script.css"], "css": ["content/content-script.css"],
"run_at": "document_idle" "run_at": "document_idle"
} }
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "1.0.11", "version": "1.0.11",
"private": true, "private": true,
"description": "Firefox extension for FabledCurator", "description": "Firefox extension for FabledCurator",
"comment_ignore_files": "The --ignore-files list comes from scripts/packaging.sh, the single source of truth shared with build.yml's guard and the derived-version patch count. `set -f` is REQUIRED before the substitution: without it the shell globs `test/**` against the working tree and silently narrows the pattern to whatever files happen to exist.", "comment_ignore_files": "The --ignore-files list comes from scripts/packaging.sh, the single source of truth shared with ci.yml's guard and the derived-version patch count. `set -f` is REQUIRED before the substitution: without it the shell globs `test/**` against the working tree and silently narrows the pattern to whatever files happen to exist.",
"scripts": { "scripts": {
"lint": "set -f; web-ext lint --source-dir=. --no-config-discovery --ignore-files $(sh scripts/packaging.sh ignore)", "lint": "set -f; web-ext lint --source-dir=. --no-config-discovery --ignore-files $(sh scripts/packaging.sh ignore)",
"start": "set -f; web-ext run --source-dir=. --no-config-discovery --ignore-files $(sh scripts/packaging.sh ignore) --firefox=firefox", "start": "set -f; web-ext run --source-dir=. --no-config-discovery --ignore-files $(sh scripts/packaging.sh ignore) --firefox=firefox",
-1
View File
@@ -47,7 +47,6 @@
</section> </section>
<script src="../lib/platforms.js"></script> <script src="../lib/platforms.js"></script>
<script src="../lib/popup-format.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </html>
+5 -17
View File
@@ -159,11 +159,7 @@ async function exportPlatformCookies(key, card) {
try { try {
const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key }); const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key });
if (r.error) showError(r.error); if (r.error) showError(r.error);
else if (key === 'discord') { else {
const m = tokenExportMessage(r.verify);
showStatusMessage(m.text, m.kind);
await loadPlatformStatus();
} else {
const n = r.cookieCount ?? null; const n = r.cookieCount ?? null;
const verifiedSuffix = r.verified ? ' (verified ✓)' : ''; const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
const msg = n !== null const msg = n !== null
@@ -209,10 +205,7 @@ async function loadSources() {
c.appendChild(mutedNote('No sources yet.')); c.appendChild(mutedNote('No sources yet.'));
return; return;
} }
// Grouped by artist so a creator's Patreon and Discord sit together. for (const src of r.sources) c.appendChild(createSourceRow(src));
const sorted = [...r.sources].sort((a, b) =>
(a.artist_name || '').localeCompare(b.artist_name || '') || a.id - b.id);
for (const src of sorted) c.appendChild(createSourceRow(src));
} }
function createSourceRow(src) { function createSourceRow(src) {
@@ -222,16 +215,11 @@ function createSourceRow(src) {
info.className = 'info'; info.className = 'info';
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'name'; name.className = 'name';
const platformName = PLATFORMS[src.platform]?.name || src.platform; name.textContent = `${src.platform} · #${src.id}`;
name.textContent = `${src.artist_name || `Source #${src.id}`} · ${platformName}`;
const state = sourceStatus(src);
const st = document.createElement('div');
st.className = `status ${state.kind}`;
st.textContent = state.text;
const url = document.createElement('div'); const url = document.createElement('div');
url.className = 'url'; url.className = 'url';
url.textContent = src.url; url.textContent = src.url;
info.appendChild(name); info.appendChild(st); info.appendChild(url); info.appendChild(name); info.appendChild(url);
const play = document.createElement('button'); const play = document.createElement('button');
play.className = 'play'; play.className = 'play';
play.textContent = '▶'; play.textContent = '▶';
@@ -241,7 +229,7 @@ function createSourceRow(src) {
const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id }); const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id });
play.disabled = false; play.disabled = false;
if (r.error) showError(r.error); if (r.error) showError(r.error);
else showSuccess(`Check queued for ${src.artist_name || `source #${src.id}`} (${platformName})`); else showSuccess(`Triggered check for source #${src.id}`);
}); });
row.appendChild(info); row.appendChild(play); row.appendChild(info); row.appendChild(play);
return row; return row;
-37
View File
@@ -134,42 +134,5 @@
{ "url": "https://www.hentai-foundry.com/pictures/popular", "why": "gallery listing, not a user" }, { "url": "https://www.hentai-foundry.com/pictures/popular", "why": "gallery listing, not a user" },
{ "url": "https://www.hentai-foundry.com/", "why": "site root" } { "url": "https://www.hentai-foundry.com/", "why": "site root" }
] ]
},
"discord": {
"match": [
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222",
"slug": "111111111111111111/222222222222222222",
"why": "a channel: the slug is server/channel -- a Discord URL names a place, not a creator, so the artist is chosen in the Add panel"
},
{
"url": "https://discord.com/channels/111111111111111111",
"slug": "111111111111111111",
"why": "a whole server"
},
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222/333333333333333333",
"slug": "111111111111111111/222222222222222222",
"why": "a message jump link still names its channel"
},
{
"url": "https://ptb.discord.com/channels/111111111111111111/222222222222222222",
"slug": "111111111111111111/222222222222222222",
"why": "the ptb and canary clients serve the same channels"
},
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222/",
"slug": "111111111111111111/222222222222222222",
"why": "trailing slash is tolerated"
}
],
"no_match": [
{ "url": "https://discord.com/channels/@me", "why": "the DM list is not a source" },
{ "url": "https://discord.com/channels/@me/222222222222222222", "why": "a DM is not a source" },
{ "url": "https://discord.com/app", "why": "the app shell, no server open" },
{ "url": "https://discord.com/channels/111111111111111111/222222222222222222/threads/444444444444444444", "why": "thread links are left to the manual Add form" },
{ "url": "https://discord.com/servers/111111111111111111", "why": "a server-discovery page, not a channel" }
]
} }
} }
-108
View File
@@ -1,108 +0,0 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { chipState, chipLabel, discordPanelDefaults, discordAddRequest } = loadLib('chip.js', [
'chipState',
'chipLabel',
'discordPanelDefaults',
'discordAddRequest'
])
const discord = (extra = {}) => ({
platform: 'discord',
slug: '111/222',
discord: {
server_id: '111',
channel_id: '222',
server_name: 'Studio',
channel_name: 'drops',
server_url: 'https://discord.com/channels/111',
channel_url: 'https://discord.com/channels/111/222'
},
...extra
})
describe('chip state and label', () => {
it('keeps the one-click wording on creator platforms', () => {
expect(chipLabel({ state: 'new', platform: 'patreon' }, 'Patreon')).toBe('+ Add to FabledCurator')
expect(
chipLabel({ state: 'artist_match', platform: 'patreon', artist: { name: 'Atole' } }, 'Patreon')
).toBe('+ Add Patreon source to Atole')
expect(chipLabel({ state: 'source_match', platform: 'patreon' }, 'Patreon')).toBe(
'✓ In FabledCurator · Patreon'
)
})
it('offers the channel by name on Discord, whatever the suggestion', () => {
expect(chipLabel(discord({ state: 'new' }), 'Discord')).toBe('+ Add #drops to FabledCurator')
expect(chipLabel(discord({ state: 'artist_match', artist: { name: 'A' } }), 'Discord')).toBe(
'+ Add #drops to FabledCurator'
)
})
it('says "this channel" when the token could not read the name', () => {
const p = discord({ state: 'new' })
p.discord.channel_name = null
expect(chipLabel(p, 'Discord')).toBe('+ Add this channel to FabledCurator')
})
it('says whose source a Discord channel already is, and when the server covers it', () => {
const artist = { name: 'Tamada', slug: 'tamada' }
expect(chipLabel(discord({ state: 'source_match', artist, covered_by_server: false }), 'Discord')).toBe(
'✓ In FabledCurator · Tamada'
)
expect(chipLabel(discord({ state: 'source_match', artist, covered_by_server: true }), 'Discord')).toBe(
'✓ Whole server in FabledCurator · Tamada'
)
})
it('falls back to the generic add when the probe failed', () => {
expect(chipState({ error: 'x' })).toBe('new')
expect(chipLabel({ error: 'x' }, '')).toBe('+ Add to FabledCurator')
expect(chipState({ state: 'source_match' })).toBe('source-match')
})
})
describe('Discord Add panel', () => {
it('opens on the channel with the suggested artist preselected', () => {
const d = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
expect(d.scope).toBe('channel')
expect(d.artist).toEqual({ id: 7, name: 'Tamada' })
expect(d.artistName).toBe('Tamada')
})
it('proposes a new artist named after the server when nothing is suggested', () => {
const d = discordPanelDefaults(discord({ state: 'new' }))
expect(d.artist).toBe(null)
expect(d.artistName).toBe('Studio')
})
it('sends a picked artist by id', () => {
const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
expect(discordAddRequest(choice)).toEqual({
url: 'https://discord.com/channels/111/222',
artistId: 7
})
})
it('sends a typed name once the picked artist has been edited away', () => {
const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
choice.artistName = 'Tamada Alt'
expect(discordAddRequest(choice)).toEqual({
url: 'https://discord.com/channels/111/222',
artistName: 'Tamada Alt'
})
})
it('adds the whole server when the operator picks it', () => {
const choice = discordPanelDefaults(discord({ state: 'new' }))
choice.scope = 'server'
expect(discordAddRequest(choice).url).toBe('https://discord.com/channels/111')
})
it('has nothing to send without an artist', () => {
const choice = discordPanelDefaults(discord({ state: 'new' }))
choice.artistName = ' '
expect(discordAddRequest(choice)).toBe(null)
})
})
+12 -42
View File
@@ -7,14 +7,10 @@ import { loadLib } from './helpers/loadLib.js'
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8')) const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8'))
const { getPlatformFromUrl, isArtistPage, parseDiscordUrl, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib(
loadLib('platforms.js', [ 'platforms.js',
'getPlatformFromUrl', ['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS']
'isArtistPage', )
'parseDiscordUrl',
'PLATFORMS',
'PLATFORM_ARTIST_PATTERNS'
])
describe('getPlatformFromUrl', () => { describe('getPlatformFromUrl', () => {
it('identifies each platform from a domain URL', () => { it('identifies each platform from a domain URL', () => {
@@ -92,11 +88,8 @@ describe('isArtistPage', () => {
) )
}) })
it('matches Discord server and channel pages, not DMs (milestone 429)', () => { it('returns false for a platform with no artist pattern (discord)', () => {
expect(isArtistPage('https://discord.com/channels/111/222', 'discord')).toBe(true)
expect(isArtistPage('https://discord.com/channels/111', 'discord')).toBe(true)
expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false) expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false)
expect(isArtistPage('https://discord.com/channels/@me/222', 'discord')).toBe(false)
}) })
it('returns false for an unknown platform key', () => { it('returns false for an unknown platform key', () => {
@@ -107,8 +100,8 @@ describe('isArtistPage', () => {
describe('platform table integrity', () => { describe('platform table integrity', () => {
it('gives every artist pattern a corresponding platform entry', () => { it('gives every artist pattern a corresponding platform entry', () => {
// A pattern keyed to a platform that no longer exists is dead code that // A pattern keyed to a platform that no longer exists is dead code that
// silently never fires; the reverse (a platform with no pattern) would be // silently never fires; the reverse (a platform with no pattern) is the
// a platform the button never offers, a product choice rather than an error. // legitimate discord case, so only this direction is an error.
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) { for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
expect(Object.keys(PLATFORMS)).toContain(key) expect(Object.keys(PLATFORMS)).toContain(key)
} }
@@ -132,8 +125,7 @@ describe('platform table integrity', () => {
const samples = { const samples = {
patreon: 'https://www.patreon.com/cw/Atole', patreon: 'https://www.patreon.com/cw/Atole',
subscribestar: 'https://subscribestar.adult/someone', subscribestar: 'https://subscribestar.adult/someone',
hentaifoundry: 'https://www.hentai-foundry.com/user/someone', hentaifoundry: 'https://www.hentai-foundry.com/user/someone'
discord: 'https://ptb.discord.com/channels/111/222'
} }
for (const [key, url] of Object.entries(samples)) { for (const [key, url] of Object.entries(samples)) {
expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true) expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true)
@@ -160,7 +152,7 @@ describe('manifest.json agrees with the platform table', () => {
) )
expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy() expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy()
// The content script exists to draw the Add-as-source button, so a // The content script exists to draw the Add-as-source button, so a
// platform with no artist pattern has no business here. // platform with no artist pattern (discord) has no business here.
expect( expect(
PLATFORM_ARTIST_PATTERNS[owner[0]], PLATFORM_ARTIST_PATTERNS[owner[0]],
`"${m}" injects for ${owner[0]}, which has no artist pattern` `"${m}" injects for ${owner[0]}, which has no artist pattern`
@@ -240,8 +232,9 @@ describe('the JS<->Py artist-pattern mirror (#3093)', () => {
it('has samples for every platform that has an artist pattern', () => { it('has samples for every platform that has an artist pattern', () => {
// The guard's own coverage check: without it, deleting a platform's // The guard's own coverage check: without it, deleting a platform's
// samples would make this block pass by testing less. Discord joined at // samples would make this block pass by testing less. Discord is
// milestone 429 — its slug is server/channel and the artist is chosen. // deliberately in neither — it is channel-based, with no creator page to
// put a button on, so it has no artist pattern on either side.
expect(Object.keys(samples).sort()).toEqual(Object.keys(PLATFORM_ARTIST_PATTERNS).sort()) expect(Object.keys(samples).sort()).toEqual(Object.keys(PLATFORM_ARTIST_PATTERNS).sort())
}) })
@@ -254,26 +247,3 @@ describe('the JS<->Py artist-pattern mirror (#3093)', () => {
} }
}) })
}) })
describe('parseDiscordUrl', () => {
it('reads the server and channel ids the Add panel offers', () => {
expect(parseDiscordUrl('https://discord.com/channels/111/222')).toEqual({
serverId: '111',
channelId: '222'
})
expect(parseDiscordUrl('https://discord.com/channels/111/222/333')).toEqual({
serverId: '111',
channelId: '222'
})
expect(parseDiscordUrl('https://discord.com/channels/111')).toEqual({
serverId: '111',
channelId: null
})
})
it('returns null for anything the artist pattern rejects', () => {
expect(parseDiscordUrl('https://discord.com/channels/@me/222')).toBe(null)
expect(parseDiscordUrl('https://discord.com/app')).toBe(null)
expect(parseDiscordUrl('')).toBe(null)
})
})
-53
View File
@@ -1,53 +0,0 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { sourceStatus, relativeTime, tokenExportMessage } = loadLib('popup-format.js', [
'sourceStatus',
'relativeTime',
'tokenExportMessage'
])
const NOW = Date.parse('2026-09-25T12:00:00Z')
const src = (extra = {}) => ({ enabled: true, last_error: null, backfill_state: null, ...extra })
describe('sourceStatus', () => {
it('puts an error first, trimmed to its first line', () => {
const s = sourceStatus(src({ last_error: 'Discord rejected the token\nstack…', backfill_state: 'running' }), NOW)
expect(s).toEqual({ text: 'Error — Discord rejected the token', kind: 'error' })
})
it('shows a running backfill and its progress', () => {
expect(sourceStatus(src({ backfill_state: 'running', backfill_chunks: 0 }), NOW).text).toBe('Backfill queued')
expect(sourceStatus(src({ backfill_state: 'running', backfill_chunks: 3 }), NOW).text).toBe(
'Backfilling — 3 chunks done'
)
})
it('says when a source was last checked, or that it never was', () => {
expect(sourceStatus(src({ last_checked_at: '2026-09-25T11:55:00Z' }), NOW).text).toBe('Checked 5m ago')
expect(sourceStatus(src({ last_checked_at: null }), NOW).text).toBe('Not checked yet')
})
it('shows a disabled source as disabled, whatever else it carries', () => {
expect(sourceStatus(src({ enabled: false, last_error: 'x' }), NOW).text).toBe('Disabled')
})
})
describe('relativeTime', () => {
it('uses the web UI formatRelative buckets', () => {
expect(relativeTime('2026-09-25T11:59:18Z', NOW)).toBe('42s ago')
expect(relativeTime('2026-09-25T09:00:00Z', NOW)).toBe('3h ago')
expect(relativeTime('2026-09-23T12:00:00Z', NOW)).toBe('2d ago')
expect(relativeTime('garbage', NOW)).toBe('Never')
})
})
describe('tokenExportMessage', () => {
it('distinguishes verified, rejected and untested tokens', () => {
expect(tokenExportMessage({ valid: true, reason: 'Token valid (me)' }).kind).toBe('success')
expect(tokenExportMessage({ valid: false, reason: 'Discord rejected the token' }).kind).toBe('error')
const untested = tokenExportMessage({ valid: null, reason: 'No enabled source' })
expect(untested.kind).toBe('warning')
expect(untested.text).toContain('No enabled source')
})
})
+4 -4
View File
@@ -10,7 +10,7 @@ const readText = (...seg) => readFileSync(path.join(EXT_DIR, ...seg), 'utf8')
// Only the git-free subcommands are exercised here: `version` shells out to // Only the git-free subcommands are exercised here: `version` shells out to
// git, and the extension lane runs on node:24-bookworm-slim which may not ship // git, and the extension lane runs on node:24-bookworm-slim which may not ship
// it. That one is covered where git is guaranteed — build.yml's extension-version // it. That one is covered where git is guaranteed — ci.yml's extension-version
// lane and build.yml both run on ci-python. // lane and build.yml both run on ci-python.
const packaging = (cmd) => const packaging = (cmd) =>
execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], { execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], {
@@ -39,7 +39,7 @@ describe('packaging.sh — the single definition of what ships', () => {
it('emits glob patterns literally, never expanded against the working tree', () => { it('emits glob patterns literally, never expanded against the working tree', () => {
// The script iterates its lists with deliberate word-splitting, so it must // The script iterates its lists with deliberate word-splitting, so it must
// run with pathname expansion off. Without that, invoking it from a cwd // run with pathname expansion off. Without that, invoking it from a cwd
// where test/ exists (exactly how build.yml and vitest call it) expands // where test/ exists (exactly how ci.yml and vitest call it) expands
// `test/**` into the individual spec files, and the pathspec silently stops // `test/**` into the individual spec files, and the pathspec silently stops
// covering anything added later. // covering anything added later.
const pathspec = packaging('pathspec') const pathspec = packaging('pathspec')
@@ -116,7 +116,7 @@ describe('consumers delegate rather than keeping their own copy', () => {
) )
it('no workflow hardcodes the packaged-file set', () => { it('no workflow hardcodes the packaged-file set', () => {
// build.yml used to substitute `packaging.sh pathspec` directly, for the // ci.yml used to substitute `packaging.sh pathspec` directly, for the
// manual-bump guard that milestone 271 step 5 retired. Nothing inlines the // manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
// set today, and nothing should start to: a literal :(exclude)extension/... // set today, and nothing should start to: a literal :(exclude)extension/...
// in a workflow means someone bypassed the shared definition, which is // in a workflow means someone bypassed the shared definition, which is
@@ -157,7 +157,7 @@ describe('extension version', () => {
// //
// It is still asserted, for one reason: `npm run build` locally packages // It is still asserted, for one reason: `npm run build` locally packages
// whatever is committed, so a value AMO would reject turns a local build // whatever is committed, so a value AMO would reject turns a local build
// into a confusing failure with no CI signal ahead of it. build.yml checks // into a confusing failure with no CI signal ahead of it. ci.yml checks
// the same grammar against the DERIVED value, which is the one AMO sees. // the same grammar against the DERIVED value, which is the one AMO sees.
for (const file of ['manifest.json', 'package.json']) { for (const file of ['manifest.json', 'package.json']) {
expect(read(file).version, `${file} version is not AMO-shaped`).toMatch(AMO) expect(read(file).version, `${file} version is not AMO-shaped`).toMatch(AMO)
-8
View File
@@ -153,14 +153,6 @@ const health = computed(() => {
label: (worst?.detail || 'A part has stopped') + suffix, label: (worst?.detail || 'A part has stopped') + suffix,
} }
} }
// Running but slow — a GPU agent that fell back to the CPU (#4410). Worth
// the amber dot: nothing else anywhere says so.
if (overall === 'degraded') {
return {
icon: 'mdi-speedometer-slow', color: 'warning',
label: (worst?.detail || 'A part is running degraded') + suffix,
}
}
if (overall === 'stale') { if (overall === 'stale') {
return { return {
icon: 'mdi-alert', color: 'warning', icon: 'mdi-alert', color: 'warning',
@@ -103,9 +103,9 @@ const stats = computed(() => {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(var(--fc-chrome-rgb), 0.92) 0%, rgba(20, 23, 26, 0.92) 0%,
rgba(var(--fc-chrome-rgb), 0.65) 60%, rgba(20, 23, 26, 0.65) 60%,
rgba(var(--fc-chrome-rgb), 0) 100% rgba(20, 23, 26, 0) 100%
); );
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
@@ -329,7 +329,7 @@ function pushFilter(mutate) {
opaque than the bar/nav so the controls stay legible. */ opaque than the bar/nav so the controls stay legible. */
.fc-filterbar-wrap :deep(.v-field), .fc-filterbar-wrap :deep(.v-field),
.fc-filterbar-wrap :deep(.v-btn-group) { .fc-filterbar-wrap :deep(.v-btn-group) {
background-color: rgba(var(--v-theme-background), 0.72); background-color: rgba(20, 23, 26, 0.72);
} }
/* Media toggle (All / Images / Videos) as ONE cohesive segmented control. /* Media toggle (All / Images / Videos) as ONE cohesive segmented control.
FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each
@@ -213,7 +213,7 @@ function nextFrame() {
--fc-side-w: 320px; --fc-side-w: 320px;
/* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav, /* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
mid-opacity + blur so the page behind shows through faintly. */ mid-opacity + blur so the page behind shows through faintly. */
background: rgba(var(--v-theme-background), 0.65); background: rgba(20, 23, 26, 0.65);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
outline: none; outline: none;
@@ -221,7 +221,7 @@ function nextFrame() {
} }
.fc-viewer__close, .fc-viewer__nav { .fc-viewer__close, .fc-viewer__nav {
position: absolute; top: 50%; position: absolute; top: 50%;
background: rgba(var(--v-theme-background), 0.7); background: rgba(20, 23, 26, 0.7);
color: rgb(var(--v-theme-parchment, 232 228 216)); color: rgb(var(--v-theme-parchment, 232 228 216));
border: 1px solid rgb(var(--v-theme-surface-light)); border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 50%; border-radius: 50%;
@@ -359,7 +359,7 @@ function nextFrame() {
z-index: 1; z-index: 1;
/* Opaque obsidian so the scrolling panel never bleeds through the /* Opaque obsidian so the scrolling panel never bleeds through the
haze behind the pinned image. */ haze behind the pinned image. */
background: rgb(var(--v-theme-background)); background: rgb(20, 23, 26);
} }
.fc-viewer__side { .fc-viewer__side {
width: 100%; width: 100%;

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