bvandeusenandClaude Opus 5 573228b9da
CI / lint (push) Failing after 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 34s
Build images / build-ml (push) Successful in 53s
Build images / build-web (push) Successful in 44s
CI / backend-lint-and-test (push) Successful in 1m6s
CI / integration (push) Successful in 4m5s
db: finish reconciling the models with the deployed schema (#3275)
Closes the residue the first reconciliation pass left, and corrects a
factual error I put into the record.

sha256 was NOT missing a uniqueness guarantee. I read
`op.create_index("ix_image_record_sha256", ...)` at 0001 line 151 and
concluded duplicates were possible, without reading line 149 two lines
above it:

    sa.UniqueConstraint("sha256", name="uq_image_record_sha256"),

Uniqueness has held since the initial schema. The database expresses it
as a CONSTRAINT plus a separate non-unique lookup index; the model said
`unique=True, index=True`, which is one UNIQUE index under a different
name. Same guarantee, different objects — which is exactly why the two
schemas did not line up. The model now declares both objects. No DDL.
0088's docstring, which repeated the claim, is corrected in place.

Two real divergences, both the MODEL over-claiming:

  * source: uq_source_artist_platform_url (alembic 0010) was declared
    nowhere in the models — source.py had no __table_args__ at all — so
    autogenerate would have proposed DROPPING it.
  * head_metrics_snapshot.tag_id: model said NOT NULL, 0060 created it
    nullable. Left nullable; the FK already cascades.

Seven constraints renamed to what the chain actually created, rather than
what base.py's naming convention renders: uq_series_page_image,
uq_series_chapter_anchor_page, fk_series_chapter_anchor_page,
fk_image_record_artist_id, fk_image_provenance_from_attachment, and the
two hand-shortened fk_tsr_* names from 0003.

Float server_defaults now mirror their own migration, per column. The
chain is MIXED: a plain string renders DEFAULT '0.90'::double precision,
sa.text() renders DEFAULT 0.90, and the migrations used both. Seven
columns take text(); the rest stay strings. Two literals also disagreed
outright — process_{auto_apply,conflict}_threshold said 0.9/0.5 against
the migration's 0.90/0.50.

baseline.yml gains two things. A repair for a SECOND generator defect in
the same class as the missing pgvector import: base.py's ck convention
contains %(constraint_name)s, so it applies even to a NAMED
CheckConstraint — autogenerate writes the already-rendered name into the
migration and running it applies the convention again, yielding
ck_ml_settings_ck_ml_settings_singleton. That is round-tripping damage,
not a claim the models make, so it is undone rather than counted.

And the diff now runs twice. Column ORDER differs permanently between a
schema built by 87 ADD COLUMNs and one built in a single shot — the
operator's database keeps chain order forever, a fresh install gets model
order — so a check that failed on it could never pass. The second pass
SORTS column lines within each CREATE TABLE instead of deleting them,
which cannot hide a column present on one side only, or one whose type,
nullability or default differs. Ordered diff is reported as information;
the order-insensitive one is the verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
2026-08-31 00:24:00 -04:00

FabledCurator

Self-hosted media curation — gallery, ML tagging, and subscription-driven downloading in one app. Part of the FabledSword family.

Combines what was ImageRepo (gallery, ML, importer) and GallerySubscriber (gallery-dl wrapper, subscriptions, credential capture) into a single product.

Status

In production. main is continuously deployed — every merge to main builds and publishes :latest images, so whatever is on main is what is running. Day-to-day work happens on dev, which publishes :dev images.

Versions and tags

Three image tags exist, and no others:

Tag Branch Meaning
:latest main Production. Moves on every merge.
:c-<sha> main Immutable — the rollback unit, all three images together.
:dev dev The rolling test channel. Moves on every push.

There are deliberately no version tags. Nothing pins one, and a per-build name nobody reads is upkeep for a model FC does not run (family rule 145; the reasoning is note #3127 §5). Rolling back is docker pull …:c-<sha>.

Each artifact still has a version, derived rather than chosen: the commit time of the newest change to that artifact's own shipped files, as YYYY.MM.DD.HHMM UTC (rule 148). Four artifacts, four independent versions — a push touching only agent/ re-versions the agent and leaves web and ml alone, and CI skips the builds whose content did not move.

Because no registry name carries it, the running instance's own report is the only answer to "which build is this?". The foot of Settings shows FabledCurator 2026.08.29.0201 · dev, and /api/health returns the same two fields.

Release tags are optional bookmarks — FC went twelve weeks without one and nothing was wrong. Pushing v<version> publishes a Forgejo release listing the commits since the previous tag; it builds no image.

What's in here

Five deployable pieces, built by .forgejo/workflows/build.yml:

Piece Built from Image Role
Web / workers Dockerfile fabledcurator Quart API + the built Vue SPA in one image. entrypoint.sh picks the role: web, worker, scheduler. 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. Run it for a burst, stop it to reclaim the card. See agent/README.md.
Firefox extension extension/ signed XPI MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both dev and main (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See extension/README.md.
Data pgvector/pgvector:pg16, redis:7-alpine Postgres with pgvector for embeddings; Redis as the Celery broker.

Quick start

For local development and testing, just:

docker compose up -d
# UI: http://localhost:8080

That uses sane dev defaults baked into docker-compose.yml and the dev override (docker-compose.override.yml, auto-merged) — local builds, DEBUG logging, exposed Postgres + Redis ports on the host. No .env required.

For a production-like deployment, override the dev defaults via shell env or a .env file (see .env.example for the variable names) and use:

docker compose -f docker-compose.yml up -d
# (skips the override so containers pull registry images)

The GPU agent is deployed separately, on the machine with the card — agent/docker-compose.yml, not this stack.

Deployment posture

FabledCurator is designed to run inside a self-hosted homelab environment over plain HTTP. If you want TLS, terminate it at your reverse proxy. The app does not generate certificates, redirect to HTTPS, or set HSTS.

CI / Forgejo setup

Four workflows: ci.yml (lint, extension-version check, backend unit tests, frontend build, integration), extension.yml (extension lint, vitest, XPI content verification), build.yml (sign + publish), and release.yml, which runs only on a v* tag and publishes a changelog without building anything.

The toolchain each job runs in is its container.image, not its runs-on label. runs-on: python-ci only schedules the job onto a runner; every job then names the image it actually wants. ci-requirements.md is the current, authoritative list of images and per-job installs — read that rather than a copy here, so the two can't drift.

The repo expects one secret:

  • RELEASE_TOKEN — a Forgejo PAT with:

    • write:package + read:package — for docker push to git.fabledsword.com
    • write:release — for the ext-<version> releases that cache the signed XPI
    • write:issue — for issue-management automation

    Generate at https://git.fabledsword.com/user/settings/applications. The injected GITHUB_TOKEN cannot be used because it lacks write:package.

AMO signing additionally needs MOZILLA_AMO_JWT_KEY / MOZILLA_AMO_JWT_SECRET. It runs on both channels and is cached per version: because the version is derived from commit time, dev and main derive the same number for the same source, so main finds dev's signature already cached and makes no second AMO call. That cache is why signing must be one-shot — AMO rejects a re-signed version.

License

Personal project; use at your own discretion.

S
Description
Self-hosted media curation — gallery, ML tagging, and subscription-driven downloads. Part of the FabledSword family. (Merge of ImageRepo + GallerySubscriber.)
Readme AGPL-3.0
8.6 MiB
2026-08-29 13:46:09 -04:00
Languages
Python 73.1%
Vue 17.8%
JavaScript 8%
Shell 0.5%
CSS 0.3%
Other 0.2%