Compare commits

..
2 Commits
Author SHA1 Message Date
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
643 changed files with 9724 additions and 82987 deletions
-213
View File
@@ -1,213 +0,0 @@
# TEMPORARY — milestone 328 steps 1-2. Delete once the baseline is stamped.
#
# Squashing 87 alembic revisions into one baseline has exactly one dangerous
# failure: the generated baseline does not reproduce the schema the chain
# produced, `alembic stamp` writes a version string anyway (it validates
# NOTHING), and the divergence surfaces on the next real migration against the
# operator's live data.
#
# So this workflow does the comparison in CI, where a pgvector Postgres already
# gets built from the chain on every integration run, and nothing is at risk.
# It answers one question: does `upgrade head` on the collapsed chain produce a
# byte-identical schema to `upgrade head` on the 87-revision chain?
#
# The chain is read from git rather than from the working tree, so this keeps
# working AFTER the old revisions are deleted — `chain_ref` names a commit that
# still has them. That is what makes this the proof for step 1 and the
# pre-flight for step 2, rather than a one-shot script.
#
# While the chain is still present it also autogenerates a candidate baseline
# from the models and prints it. That is a starting point, NOT the answer:
# autogenerate reads SQLAlchemy metadata, and three things here do not live
# there —
# * CREATE EXTENSION vector (0001)
# * CREATE EXTENSION tsm_system_rows (0004)
# * the HNSW index on image_record.siglip_embedding, which is raw SQL
# because alembic's create_index cannot express `USING hnsw (...)` (0036)
# plus any CHECK constraint or server_default that a migration added without
# the model declaring it. Those must be hand-added, and the diff below is what
# proves none were missed.
name: Alembic baseline
on:
workflow_dispatch:
inputs:
chain_ref:
description: 'Commit/tag that still carries the full 0001..0087 chain'
type: string
default: '0a5bbe8'
jobs:
compare:
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
steps:
- uses: actions/checkout@v4
with:
# Full history is the point: `chain_ref` is read out of git, so a
# shallow clone would not have the revisions to compare against.
fetch-depth: 0
- name: Resolve the Postgres service and install deps
run: |
set -eux
# Same service-IP dance as ci.yml's integration job; see the long
# comment there for why the job name must stay separator-free.
PG=$(docker ps --filter "name=compare" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
echo "PG_CONTAINER=$PG" >> "$GITHUB_ENV"
echo "DB_HOST=$PG_IP" >> "$GITHUB_ENV"
for i in $(seq 1 60); do
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
sleep 2
done
if command -v uv >/dev/null 2>&1; then
uv pip install --system -r requirements.txt
else
pip install -r requirements.txt
fi
# DB 1: the 87-revision chain, read out of git at `chain_ref`.
#
# A git worktree rather than a checkout, so the current tree — which is
# what we are testing — is left completely alone.
- name: Build the schema the OLD chain produces
env:
CHAIN_REF: ${{ github.event.inputs.chain_ref }}
run: |
set -eux
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_chain
git worktree add /tmp/chain "$CHAIN_REF"
ls /tmp/chain/alembic/versions/*.py | wc -l
cd /tmp/chain
DB_NAME=fc_chain alembic upgrade head
cd -
docker exec "$PG_CONTAINER" pg_dump -U fabledcurator --schema-only \
--no-owner --no-privileges -d fc_chain > chain.sql
wc -l chain.sql
# A candidate baseline, autogenerated from the models against an EMPTY
# database so every table shows up as a create. Printed for a human to
# finish — it will be missing the three raw-SQL items named at the top.
#
# Gated on the TREE, not on a workflow input. A `type: boolean` input
# read back as `github.event.inputs.generate == 'true'` silently
# evaluated false on this runner (run 4960 skipped this step entirely
# with no diagnostic) — the same `github.event.inputs` typing quirk
# build.yml already works around. The file count is the real question
# anyway: there is nothing to generate once the chain is collapsed.
- name: Autogenerate a candidate baseline
run: |
set -eux
if [ "$(ls alembic/versions/*.py | wc -l)" -le 1 ]; then
echo "already collapsed — nothing to generate"
exit 0
fi
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_gen
# Hide the existing revisions so alembic sees an empty history and
# emits the whole schema rather than a delta.
mkdir -p /tmp/versions_held
mv alembic/versions/*.py /tmp/versions_held/ 2>/dev/null || true
DB_NAME=fc_gen alembic revision --autogenerate -m "baseline" || true
# Printed rather than uploaded: ci-requirements.md records that this
# runner cannot do actions/upload-artifact@v4+, and the repo dropped
# the action entirely in 2026-05, so the job log is the retrieval
# channel actually proven here.
#
# base64, not the raw file. A plain `cat` of the ~33KB candidate was
# TRUNCATED MID-LINE by the runner on run 4964 — it stopped inside
# `sa.Column('mime', sa.String(length=128)` and carried straight on
# to the next traced command, with the step still green. A silent
# cut in the middle of a schema definition is the worst possible
# failure here, because the truncated text still looks like a
# plausible file.
#
# base64 at a fixed narrow width gives many short lines instead of
# few long ones, and — the actual point — a checksum and a line
# count that make truncation DETECTABLE rather than invisible.
set +x
F=$(ls alembic/versions/*.py | head -1)
B64=$(base64 -w 120 "$F")
echo "===== BEGIN CANDIDATE BASELINE (base64) ====="
echo "$B64"
echo "===== END CANDIDATE BASELINE ====="
echo "candidate-sha256: $(sha256sum "$F" | cut -d' ' -f1)"
echo "candidate-bytes: $(wc -c < "$F")"
echo "candidate-b64-lines: $(echo "$B64" | wc -l)"
set -x
# Put the tree back exactly as it was; this job never mutates state.
rm -f alembic/versions/*.py
mv /tmp/versions_held/*.py alembic/versions/ 2>/dev/null || true
# DB 2: whatever the CURRENT tree's alembic/versions produces. Before the
# squash that is the same 87 revisions and the diff is trivially clean —
# which is worth running once as a control, so a clean diff after the
# squash means something.
- name: Build the schema the CURRENT tree produces
run: |
set -eux
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_base
ls alembic/versions/*.py | wc -l
DB_NAME=fc_base alembic upgrade head
docker exec "$PG_CONTAINER" pg_dump -U fabledcurator --schema-only \
--no-owner --no-privileges -d fc_base > baseline.sql
wc -l baseline.sql
# The verdict.
#
# pg_dump orders dumpable objects by name within type, not by creation
# order, so two schemas built by different routes are directly
# comparable. Normalisation is deliberately minimal, because a filter
# that hides a real difference is the one way this check passes when it
# should fail — blank lines, SQL comments, trailing whitespace, and:
#
# \restrict / \unrestrict — a per-invocation RANDOM NONCE that newer
# pg_dump emits to fence the dump against injection during restore. It
# differs on every run by construction, so it is noise by definition,
# not a schema difference. Measured on run 4960, the control: two dumps
# of the SAME schema came back 1123 lines each and differed on exactly
# these two lines and nothing else. That control is what licenses this
# filter — it was observed to be the only false positive, rather than
# assumed to be one.
- name: Diff
run: |
set -eu
norm() {
grep -vE '^\s*(--|$)' "$1" \
| grep -vE '^\\(un)?restrict ' \
| sed 's/[[:space:]]*$//'
}
norm chain.sql > a.txt
norm baseline.sql > b.txt
echo "normalised: chain=$(wc -l < a.txt) lines, current=$(wc -l < b.txt) lines"
if diff -u a.txt b.txt > schema.diff; then
echo "SCHEMAS IDENTICAL — the collapsed chain reproduces the old one."
else
echo "SCHEMAS DIFFER — $(grep -cE '^[+-]' schema.diff) changed lines:"
cat schema.diff
echo
echo "The baseline is wrong, not the database. Do not stamp."
exit 1
fi
File diff suppressed because it is too large Load Diff
+28 -181
View File
@@ -1,126 +1,17 @@
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.
# - backend-lint-and-test: ruff + `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]
branches: [main]
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:
@@ -132,41 +23,16 @@ jobs:
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
run: pip install -r requirements.txt pytest pytest-asyncio
- name: Ruff lint
run: ruff check backend/ tests/ alembic/
# 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"
@@ -184,32 +50,24 @@ jobs:
# 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.
# `npm run check` (vue-tsc --noEmit) skipped: the frontend is pure JS
# with no .ts files and no JSDoc annotations, so vue-tsc has nothing
# to type-check. Re-enable once we add a tsconfig.json and either
# convert to TS or add JSDoc.
- 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:
# This act_runner (swarm-runner v0.6.1) puts service containers on the
# default bridge with NO service-name DNS, and publishing fixed host
# ports collides with the operator's running docker-compose dev stack on
# the same shared daemon. Workaround: publish NO host ports, and reach
# each service by its bridge IP — discovered at runtime via the mounted
# docker socket (the ci-python image ships /usr/bin/docker). Default-bridge
# containers can talk by IP (only embedded DNS is missing), so IP
# addressing is reliable here. Everything runs in ONE step so resolved
# values don't depend on cross-step env passing. Pattern documented in
# FabledRulebook/forgejo.md "CI philosophy".
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -240,14 +98,15 @@ jobs:
--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)
# Scope to THIS job's service containers (act_runner names them
# ...JOB-integration...); the operator's compose stack uses the
# same images but different names, so it won't match.
PG=$(docker ps --filter "name=JOB-integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=JOB-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")
@@ -255,23 +114,11 @@ jobs:
export DB_HOST="$PG_IP"
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
# Wait for Postgres to accept TCP (bash /dev/tcp; no extra tools).
for i in $(seq 1 60); do
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
sleep 2
done
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
pytest tests/ -v -m integration
-87
View File
@@ -1,87 +0,0 @@
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."
-80
View File
@@ -1,80 +0,0 @@
name: Release
# A `v*` tag publishes a changelog. It does NOT build anything.
#
# Milestone 318 step 2 removed the tag trigger from build.yml: by the time
# anyone tags a commit, `main` has already built and published it, and a
# rebuild would re-push `:c-<sha>` — which rule 145 forbids even when the
# source matches, since image configs carry timestamps and "same source" does
# not mean "same manifest". That left the tag with no consequence at all.
#
# This is the consequence it has instead. Step 6 put the derived version in the
# Settings footer, so an operator can say WHICH build they are running; this
# says what is IN it that was not in the one they ran last month. Both halves
# of one question (note #3127 §5).
#
# Nothing here runs on a schedule and nothing auto-tags on merge. Release tags
# are bookmarks — cut one when you will want to point at that day by name,
# otherwise don't (note #3127 §0). FC went twelve weeks between v26.06.04.0 and
# the next one and nothing was wrong. A schedule would turn an optional
# bookmark back into ceremony, which is the thing this milestone is removing.
#
# Cutting the tag is an explicit operator action under rule 2 ("`main` — never
# without explicit request", which since 2026-08-28 covers PR, merge and tag
# alike). This lane only decides what happens once they do.
#
# Requires repo secret RELEASE_TOKEN with the `write:release` scope — the same
# PAT build.yml uses for the ext-<version> XPI asset cache.
on:
push:
tags: ['v*']
# So a release body can be regenerated after the fact — the publisher PATCHes
# an existing release rather than falling through on a conflict, so re-running
# this on a tag rewrites the body instead of silently keeping the first one
# (note #3127 §6.7).
workflow_dispatch:
inputs:
tag:
description: 'Tag to (re)publish notes for'
required: true
jobs:
changelog:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
with:
# Load-bearing twice over: the previous release is found by walking
# ancestry back through the tag graph, and the cross-check against
# the derived web version calls artifacts.sh, which reads commit
# times. A shallow clone would find no previous tag and emit the
# entire history as the changelog — plausible-looking and wrong.
fetch-depth: 0
ref: ${{ github.event.inputs.tag || github.ref }}
# The `:c-<sha>` rollback refs are only real if `main` built this commit.
# The script checks that against origin/main and downgrades the claim to
# "unverified" when it cannot resolve one; fetching it here means that
# downgrade stays an actual signal instead of firing on every release.
- name: Make main's history resolvable
run: git fetch --no-tags --quiet origin +main:refs/remotes/origin/main || true
# TAG goes through the environment, not through `${{ }}` inside the
# run block. The value is operator-supplied, and an expression expanded
# into a shell line is expanded BEFORE the shell sees it — there is no
# quoting that makes that safe. On a tag push it is empty and the script
# falls back to GITHUB_REF.
- name: Publish the derived changelog
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.event.inputs.tag }}
run: |
set -eu
if [ -n "${TAG:-}" ]; then
python3 scripts/release_notes.py "$TAG"
else
python3 scripts/release_notes.py
fi
-4
View File
@@ -61,12 +61,8 @@ Thumbs.db
# Claude Code per-user local overrides (shared .claude/settings.json is OK to commit)
.claude/settings.local.json
# Transient scheduler lock/state (committed by accident in 3f30327)
.claude/scheduled_tasks.lock
.claude/scheduled_tasks*.json
# Alembic / DB scratch
alembic/versions/__pycache__/
*.sqlite
*.sqlite-journal
.superpowers/
+3 -29
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.25
# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS frontend-builder
FROM node:22-alpine AS frontend-builder
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./
# No package-lock.json is tracked yet (we don't run npm locally per
@@ -18,16 +18,13 @@ ENV PYTHONUNBUFFERED=1 \
# System deps: ffmpeg (transcode + thumbnails, FC-2), unar (archives, FC-2),
# libpq for psycopg, postgresql-client + zstd for FC-5 backup/restore
# (pg_dump + tar --zstd), image libs, megatools (mega.nz public-link downloads
# for off-platform file-host links, #830 — `megatools dl`; Debian-native, no
# external MEGA apt repo needed).
# (pg_dump + tar --zstd), image libs.
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
unar \
libpq5 \
postgresql-client \
zstd \
megatools \
libjpeg62-turbo \
libwebp7 \
libpng16-16 \
@@ -47,29 +44,6 @@ RUN chmod +x entrypoint.sh
COPY --from=frontend-builder /build/dist ./frontend/dist
# Which channel this image belongs to — `dev` or `main` (milestone 271 step 7).
# build.yml passes it; /api/extension/manifest reports it beside the version so
# an operator can tell which channel an install came from without the channel
# ever touching the version string.
#
# Empty by default, deliberately: a locally-built image then reports NO channel
# rather than claiming to be one, and the manifest omits the field entirely —
# indistinguishable from an image built before the field existed, which is
# exactly the shape every reader already has to handle.
#
# Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and
# these are the values that differ between builds of otherwise identical
# source — put them any earlier and the two channels could never share a
# cached pip install.
#
# FC_VERSION is what the instance reports about itself in the UI. Since
# milestone 318 stopped publishing version image tags, that self-report is
# the only answer to "which build is this?" — nothing else names it.
ARG FC_CHANNEL=""
ENV FC_CHANNEL=${FC_CHANNEL}
ARG FC_VERSION=""
ENV FC_VERSION=${FC_VERSION}
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]
+1 -1
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.25
# syntax=docker/dockerfile:1.7
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \
+6 -69
View File
@@ -6,50 +6,7 @@ Combines what was [ImageRepo](https://git.fabledsword.com/bvandeusen/ImageRepo)
## 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. |
Pre-v1. Not yet functional.
## Quick start
@@ -72,42 +29,22 @@ 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 repo's workflows expect:
**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:
- **Runner label `python-ci`** — a Forgejo runner with Python 3.14, ruff, and Node 22 pre-installed. Both `ci.yml` and `build.yml` use this label. The runner image (`runner-base:python-ci`) is built from `CI-Runner/CI-python/` in the operator's workspace; `make push` from that directory builds and pushes a new image when toolchain pins change.
- **Repo secret `RELEASE_TOKEN`** — a Forgejo PAT with the following scopes:
- `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
- `write:release` — for future release-cutting workflows
- `write:issue` — for future 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.
-33
View File
@@ -1,33 +0,0 @@
# 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
# cuDNN 9 — the plain -runtime image lacks it: "libcudnn.so.9: cannot open
# shared object file"); ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
# Stays on the CUDA-12 / cuDNN-9 line the default onnxruntime-gpu + torch are
# built against (CUDA 13 has only nascent ONNX Runtime support).
FROM nvidia/cuda:12.9.2-cudnn-runtime-ubuntu24.04
# PIP_BREAK_SYSTEM_PACKAGES: Ubuntu 24.04 marks its system Python as externally
# managed (PEP 668), so a global `pip install` errors without this. It's a
# single-purpose container — we own the whole environment, so installing into
# the system site-packages is fine (and simplest — no venv on PATH to manage).
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1 PIP_BREAK_SYSTEM_PACKAGES=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# torch from the CUDA-12.4 wheel index; its wheels bundle their own CUDA + cuDNN
# so they run on the 12.9 base and coexist with onnxruntime-gpu. Installed first
# + separately so the GPU build of torch is deterministic and layer-cached.
RUN pip3 install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY fc_agent ./fc_agent
# imgutils ONNX models + the transformers SigLIP weights both cache here; mount
# a volume to persist them across restarts (the SigLIP download is ~3.5 GB once).
ENV HF_HOME=/models
EXPOSE 8770
# The control UI; the worker is started from it (or POST /start).
CMD ["uvicorn", "fc_agent.app:app", "--host", "0.0.0.0", "--port", "8770"]
-71
View File
@@ -1,71 +0,0 @@
# FabledCurator GPU agent
A desktop-GPU worker that embeds characters (CCIP) + figure crops for
FabledCurator. It talks to FC **only over HTTP** — it leases jobs, fetches image
pixels, runs the models on your GPU, and posts results back. Your FC database and
Redis stay private; the agent never touches them.
You run it when you want a burst and stop it to reclaim the card.
## 0. Host prerequisite — NVIDIA Container Toolkit
Docker needs the toolkit to hand the GPU to a container (else: *"could not select
device driver nvidia with capabilities [[gpu]]"*). On Arch/CachyOS:
```sh
sudo pacman -S nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# verify:
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
## 1. Get a token
In FC: **Settings → Tagging → GPU agent → Generate token** (or Rotate). Copy it.
## 2. Pull (CI publishes it alongside the web/ml images)
```sh
docker pull git.fabledsword.com/bvandeusen/fabledcurator-agent:latest
```
> Local build for development instead: `docker build -t fc-gpu-agent agent/`
## 3. Run (on the machine with the GPU)
```sh
docker run --rm --gpus all -p 8770:8770 \
-e FC_URL=http://curator.traefik.internal \
-e FC_TOKEN=<paste-the-token> \
-v fc-agent-models:/models \
git.fabledsword.com/bvandeusen/fabledcurator-agent:latest
```
Then open <http://localhost:8770> — the control page. Click **Start** to begin
draining the queue; **Pause**/**Stop** to yield the GPU. The `-v fc-agent-models`
volume caches the downloaded ONNX models so restarts are fast.
Kick off a backfill from FC (**GPU agent card → Queue character embedding**), then
watch the queue counts on the control page (or FC's card) drain.
## Config (env)
| var | default | meaning |
|---|---|---|
| `FC_URL` | `http://localhost:8000` | FC base URL |
| `FC_TOKEN` | — | the bearer token (required) |
| `AGENT_ID` | `desktop-agent` | identifies this agent's leases |
| `BATCH_SIZE` | `4` | jobs leased per round (still processed one at a time) |
| `CCIP_MODEL` | imgutils default | CCIP model name |
| `DETECTOR_LEVEL` | `m` | person-detector size: `n` < `s` < `m` < `x` |
| `POLL_IDLE_SECONDS` | `10` | wait between empty leases |
## ⚠️ Verify on first run
This part can't be CI-tested (no GPU/models in CI), so confirm against your
installed `dghs-imgutils` (`pip show dghs-imgutils`) — see `fc_agent/models.py`:
- `imgutils.detect.detect_person(image, level=...)` returns
`[((x0,y0,x1,y1), label, score), ...]`.
- `imgutils.metrics.ccip_extract_feature(image, model=...)` returns a vector
(768-d for caformer). If you want the F1-0.94 variant, set
`CCIP_MODEL=ccip-caformer_b36-24` (verify the exact string in imgutils).
If FC's matcher under/over-fires, tune the cosine threshold in
`backend/app/services/ml/ccip.py` (`DEFAULT_SIM_THRESHOLD`) and use
`GET /api/ccip/overview` + `/api/ccip/images/<id>` to spot-check.
## CPU fallback
Swap `onnxruntime-gpu``onnxruntime` in `requirements.txt` and drop `--gpus all`
to grind it slowly on the server instead. Same agent, no card.
-73
View File
@@ -1,73 +0,0 @@
# FabledCurator GPU agent — desktop run via docker compose.
#
# Usage:
# 1. Generate a token: FC → Settings → Tagging → GPU agent → Generate token.
# 2. Create a .env next to this file:
# FC_URL=http://curator.traefik.internal
# FC_TOKEN=<paste-the-token>
# # optional: CCIP_MODEL=ccip-caformer_b36-24 (the F1-0.94 variant)
# 3. docker compose up -d (pulls the published image)
# 4. Open http://localhost:8770 → Start. Pause/Stop hands the GPU back.
# docker compose down to stop the container entirely.
#
# Surviving a curator redeploy (you're away, can't touch the agent):
# - A running agent rides out curator being unreachable on its own — it retries
# leasing with capped backoff and resumes when the server is back. In-flight
# work is handed back (not failed), so a redeploy never poisons good jobs.
# - AUTO_START=1 (below) also resumes the worker if the AGENT container itself
# restarts (host reboot / crash via `restart: unless-stopped`) — no click.
#
# Needs the NVIDIA Container Toolkit installed on the host for --gpus.
services:
fc-gpu-agent:
image: git.fabledsword.com/bvandeusen/fabledcurator-agent:latest
pull_policy: always
ports:
- "8770:8770"
environment:
FC_URL: ${FC_URL:-http://curator.traefik.internal}
FC_TOKEN: ${FC_TOKEN:?set FC_TOKEN in .env (FC → GPU agent → Generate token)}
CCIP_MODEL: ${CCIP_MODEL:-}
DETECTOR_LEVEL: ${DETECTOR_LEVEL:-m}
BATCH_SIZE: ${BATCH_SIZE:-4}
# Resume the worker automatically on container start (survive a reboot /
# crash-restart while you're away). Set to 0 to require a manual Start.
AUTO_START: ${AUTO_START:-1}
# Autoscale the worker count (throughput hill-climb that finds the sweet
# spot + backs off under VRAM pressure). On by default; toggle live in the
# control UI. Set to 0 to start in manual mode.
AUTO_SCALE: ${AUTO_SCALE:-1}
# Aggregate download cap in MB/s (stills + video streams combined), so the
# agent can't saturate the desktop's network and wreck browsing — WiFi
# especially. 0 = unlimited; tunable live in the control UI.
BANDWIDTH_LIMIT_MB_S: ${BANDWIDTH_LIMIT_MB_S:-8}
# Crop embedder (SigLIP concept bag): float16 keeps VRAM low on a shared
# desktop GPU; the model itself is announced by the server.
SIGLIP_DTYPE: ${SIGLIP_DTYPE:-float16}
# Crop PROPOSERS (extra YOLO detectors → more/better concept crops). Each
# downloads its weights once (cached on the models volume) and self-disables
# if the download/load fails. Blank any one to turn it off.
# PERSON_WEIGHTS: general COCO person detector (Western/realistic figures),
# merged with the anime detector. yolo11n.pt (~6 MB, auto-downloaded).
# ANATOMY_WEIGHTS: booru_yolo anime/furry/NSFW components (~40 MB). NB the
# repo states no license — fine for private use. yolov8n_as01.pt is the
# 6 MB nano if you want lighter than yolov11m_aa22.pt.
# PANEL_WEIGHTS: mosesb comic-panel detector (Apache-2.0), "hf_repo::file".
PERSON_WEIGHTS: ${PERSON_WEIGHTS:-yolo11n.pt}
ANATOMY_WEIGHTS: ${ANATOMY_WEIGHTS:-https://github.com/aperveyev/booru_yolo/raw/main/models/yolov11m_aa22.pt}
PANEL_WEIGHTS: ${PANEL_WEIGHTS:-mosesb/best-comic-panel-detection::best.pt}
volumes:
# Persist the downloaded ONNX models so restarts are fast.
- fc-agent-models:/models
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
volumes:
fc-agent-models:
View File
-407
View File
@@ -1,407 +0,0 @@
"""FastAPI control surface for the agent (served on localhost).
Start / stop the download→GPU pipeline, tune the downloader count live (the
workload is download-bound, so downloaders are the dial that trades desktop
bandwidth for throughput), and watch GPU load + buffer occupancy + progress +
the server-side queue. Config is env-seeded; the downloader count is adjustable
here on the fly (GPU consumers autoscale between 1 and 2 on their own).
"""
import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from . import logbuf
from .config import Config
from .gpu import read_gpu
from .worker import Worker
log = logging.getLogger("fc_agent.app")
# Bump on every agent change. The page embeds this and /status reports it; the UI
# warns to reload when they differ — so a stale browser-cached page can't be
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle the GPU models release their VRAM and reload on the next job (env IDLE_UNLOAD_SECONDS, 0=off) · sleep mode sheds to one downloader"
logbuf.install()
cfg = Config.from_env()
worker = Worker(cfg)
app = FastAPI(title="FabledCurator GPU agent")
@app.middleware("http")
async def _no_store(request, call_next):
# The control page is a static string and the status/gpu/logs polls are
# live data — never let the browser cache either, or a freshly-pulled agent
# image still shows the OLD UI until a hard refresh (operator-flagged
# 2026-06-30).
resp = await call_next(request)
resp.headers["Cache-Control"] = "no-store"
return resp
@app.on_event("startup")
def _maybe_autostart() -> None:
# With AUTO_START set, a container restart (host reboot, or `restart:
# unless-stopped` after a crash) resumes the worker on its own — the slots
# then ride out a still-down curator via lease backoff. Lets the agent
# survive a redeploy with nobody at the desktop to click Start.
if cfg.auto_start and cfg.token:
worker.start()
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return _PAGE.replace("__BUILD__", VERSION)
@app.post("/start")
def start():
log.info("UI: Start button pressed") # the press; worker logs the transition
worker.start()
return JSONResponse(worker.status())
@app.post("/stop")
def stop():
log.info("UI: Stop button pressed")
worker.stop()
return JSONResponse(worker.status())
@app.post("/concurrency")
async def concurrency(request: Request):
body = await request.json()
worker.set_concurrency(int(body.get("value", 1)))
return JSONResponse(worker.status())
@app.post("/auto")
async def auto(request: Request):
body = await request.json()
worker.set_auto(bool(body.get("value", True)))
return JSONResponse(worker.status())
@app.post("/bandwidth")
async def bandwidth(request: Request):
body = await request.json()
worker.set_bandwidth(float(body.get("value", 0)))
return JSONResponse(worker.status())
@app.get("/gpu")
def gpu():
# GPU meters poll this on their own fast cadence. It only reads local
# nvidia-smi — no curator round-trip — so the util/VRAM bars stay live even
# when /status is slow waiting on the (sometimes busy) curator queue call.
g = read_gpu() or {}
us = worker.util_smooth()
if us is not None:
g["util_smooth"] = round(us, 1) # autoscaler's EWMA — the UI bar tracks this
return JSONResponse(g)
@app.get("/logs")
def logs():
return JSONResponse({"lines": list(logbuf.LINES)})
@app.get("/status")
def status():
# Pure in-memory read: worker.status() is lock-free and the queue snapshot is
# kept fresh by a background poller — NO inline curator call, so this can't
# stall the status view when curator is buried under a big backlog.
worker.note_ui() # a browser is watching → keep the queue snapshot warm
s = worker.status()
s["fc_url"] = cfg.fc_url
s["configured"] = bool(cfg.token)
s["queue"] = worker.latest_queue()
s["build"] = VERSION
return JSONResponse(s)
_PAGE = """<!doctype html><html><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>FabledCurator · GPU agent</title>
<style>
:root{--bg:#0f1216;--panel:#181c22;--panel2:#1e232b;--bd:#2a313b;--fg:#e9edf2;
--mut:#8b97a6;--acc:#e8923a;--grn:#46c46a;--red:#e8584d;--amb:#e8b23a}
*{box-sizing:border-box}
body{font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:0;
background:radial-gradient(1200px 600px at 50% -10%,#1a2029,#0f1216);color:var(--fg)}
.wrap{max-width:820px;margin:0 auto;padding:28px 20px 28px;height:100vh;
box-sizing:border-box;overflow:hidden;display:flex;flex-direction:column}
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
.brand{display:flex;align-items:center;gap:10px;font-size:19px;font-weight:700;letter-spacing:.2px}
.logo{color:var(--acc);font-size:20px}
.brand .sub{color:var(--mut);font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:.12em}
.conn{display:flex;align-items:center;gap:8px;color:var(--mut);font-size:13px;font-weight:600}
.dot{width:9px;height:9px;border-radius:50%;background:var(--mut);box-shadow:0 0 0 0 rgba(0,0,0,0)}
.dot.green{background:var(--grn);box-shadow:0 0 10px 1px rgba(70,196,106,.5)}
.dot.amber{background:var(--amb)} .dot.red{background:var(--red)}
.meta{color:var(--mut);margin:0 0 18px;font-size:13px}
code{background:#11151a;border:1px solid var(--bd);padding:2px 7px;border-radius:6px;
font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#cdd6e0}
.card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--bd);
border-radius:14px;padding:16px 18px;margin-bottom:14px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset}
.card-h{font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;
color:var(--mut);margin-bottom:14px}
.controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.spacer{flex:1}
.btn{font:600 14px system-ui;padding:.5rem 1rem;border:1px solid transparent;border-radius:9px;
cursor:pointer;color:#fff;transition:.12s}
.btn:hover{transform:translateY(-1px)}
.btn[disabled]{opacity:.45;pointer-events:none;transform:none}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.tile .n.busy{color:var(--acc);animation:pulse 1s ease-in-out infinite}
.btn.start{background:linear-gradient(180deg,#2f9c4c,#247a3c)}
.btn.stop{background:linear-gradient(180deg,#3a3f48,#2a2f37);color:#e9edf2;border-color:var(--bd)}
.switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-weight:600;user-select:none}
.switch input{display:none}
.switch .track{width:38px;height:22px;border-radius:11px;background:#2a313b;position:relative;transition:.15s}
.switch .track:after{content:"";position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;
background:#cdd6e0;transition:.15s}
.switch input:checked+.track{background:var(--acc)}
.switch input:checked+.track:after{transform:translateX(16px);background:#fff}
.stepper{display:inline-flex;align-items:center;gap:6px}
.step{background:#262c34;color:var(--fg);border:1px solid var(--bd);border-radius:8px;
width:30px;height:32px;font:700 16px system-ui;cursor:pointer}
.step:hover{border-color:var(--acc)}
#conc,#bw{width:3.4rem;height:32px;text-align:center;font:700 16px system-ui;background:#11151a;
color:var(--fg);border:1px solid var(--bd);border-radius:8px}
.unit{color:var(--mut);font-size:12px;font-weight:600}
.hint{color:var(--mut);font-size:12px;margin-top:12px}
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
.tile{background:#13171d;border:1px solid var(--bd);border-radius:10px;padding:12px 8px;text-align:center}
.tile .n{font:800 22px ui-monospace,monospace;line-height:1.1}
.tile .n.warn{color:var(--red)} .tile .n.ok{color:var(--grn)}
.tile .l{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-top:4px}
.meters{display:flex;flex-direction:column;gap:10px;margin-bottom:14px}
.meter-h{display:flex;justify-content:space-between;font-size:12px;color:var(--mut);margin-bottom:4px}
.meter-h b{color:var(--fg);font-variant-numeric:tabular-nums}
.bar{height:9px;border-radius:5px;background:#11151a;border:1px solid var(--bd);overflow:hidden}
.bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,#3a7d57,var(--grn));transition:width .4s}
#utilbar{background:linear-gradient(90deg,#9a5a1f,var(--acc))}
#bufbar{background:linear-gradient(90deg,#2f5a9a,#4a86d8)}
.queue{font:13px ui-monospace,monospace;color:var(--mut)}
.banner{margin:0 0 14px;padding:.7rem .9rem;border-radius:10px;background:#3a2f12;
border:1px solid #5a4a17;color:#ffd98a;font-size:13px}
.logs-h{display:flex;align-items:center;justify-content:space-between}
.grow{flex:1;display:flex;flex-direction:column;min-height:0}
.grow .logs{flex:1;min-height:0}
.copybtn{font:600 11px system-ui;letter-spacing:.04em;text-transform:uppercase;
background:#262c34;color:var(--fg);border:1px solid var(--bd);border-radius:7px;
padding:5px 11px;cursor:pointer}
.copybtn:hover{border-color:var(--acc)}
.logs{margin:0;background:#0b0e12;border:1px solid var(--bd);border-radius:10px;padding:12px;
overflow:auto;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;
color:#b9c4d0;white-space:pre-wrap;word-break:break-word}
</style></head><body>
<div class=wrap>
<header>
<div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div>
<div class=conn><span class="dot" id=dot></span><span id=connlbl>—</span></div>
</header>
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__BUILD__</code></p>
<div id=verbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3">
a newer agent version is running — reload this page (Ctrl+Shift+R) to update the controls
</div>
<div id=banner class=banner style=display:none>
curator unreachable — holding work + retrying, resumes on its own (no restart needed)
</div>
<section class=card>
<div class=card-h>Control</div>
<div class=controls>
<button class="btn start" id=startbtn onclick=act('start')>▶ Start</button>
<button class="btn stop" id=stopbtn onclick=act('stop')>■ Stop</button>
<div class=spacer></div>
<label class=switch><input type=checkbox id=autochk onchange="setauto(this.checked)"><span class=track></span>Auto</label>
<div class=stepper>
<button class=step onclick=setc(-1)></button>
<input id=conc type=number min=1 value=1 onchange="setv(this.value)">
<button class=step onclick=setc(1)>+</button>
</div>
<div class=stepper title="aggregate download cap, downloads + video streams combined — 0 = unlimited">
<input id=bw type=number min=0 step=1 value=8 onchange="setbw(this.value)">
<span class=unit>MB/s</span>
</div>
</div>
<div class=hint id=conchint>auto-tuning downloaders to keep the GPU fed · max 8</div>
</section>
<section class=card>
<div class=card-h>Status</div>
<div class=tiles>
<div class=tile><div class=n id=state>—</div><div class=l>state</div></div>
<div class=tile><div class=n id=jpm>—</div><div class=l>jobs / min</div></div>
<div class=tile><div class=n id=dpm>—</div><div class=l>downloads / min</div></div>
<div class=tile><div class="n ok" id=done>0</div><div class=l>processed</div></div>
<div class=tile><div class=n id=err>0</div><div class=l>errors</div></div>
<div class=tile><div class=n id=waited>0</div><div class=l>waited out</div></div>
</div>
<div class=meters>
<div class=meter><div class=meter-h><span>GPU util</span><b id=utillbl>—</b></div>
<div class=bar><i id=utilbar></i></div></div>
<div class=meter><div class=meter-h><span>VRAM</span><b id=vramlbl>—</b></div>
<div class=bar><i id=gpubar></i></div></div>
<div class=meter><div class=meter-h><span>buffer occupancy</span><b id=buflbl>—</b></div>
<div class=bar><i id=bufbar></i></div></div>
</div>
<div class=queue id=pipe>downloaders — · consumers — · on GPU 0</div>
<div class=queue id=queue>queue —</div>
</section>
<section class="card grow">
<div class="card-h logs-h">Logs
<button class=copybtn id=copybtn onclick=copyLogs()>Copy</button>
</div>
<pre class=logs id=logs>waiting for activity…</pre>
</section>
</div>
<script>
const PAGE_BUILD="__BUILD__"
let CAP=8
// Optimistic transitional state on click, then apply the POST's own status
// response (it returns worker.status()) for instant feedback — don't wait on the
// separate /status poll, which can lag behind the curator queue call.
async function act(p){
pending(p==='start'?'starting':'stopping')
// Abort a slow POST after 8s so the buttons never stay stuck — the periodic
// /status refresh (now always fast) recovers the true state either way.
const ac=new AbortController(); const to=setTimeout(()=>ac.abort(),8000)
try{ applyStatus(await (await fetch('/'+p,{method:'POST',signal:ac.signal})).json()) }
catch{ refresh() /* on abort/error, repaint the real state from /status */ }
finally{ clearTimeout(to) }
}
function pending(label){
// Instant optimistic feedback on click; applyStatus (POST response, then the
// periodic poll) then owns the real state + which buttons are enabled.
state.textContent=label; state.className='n busy'
dot.className='dot amber'
startbtn.disabled=true; stopbtn.disabled=true
}
function setc(d){ if(conc.disabled)return; setv((parseInt(conc.value||'1'))+d) }
async function setv(v){
v=Math.max(1,Math.min(CAP,parseInt(v)||1)); conc.value=v
await fetch('/concurrency',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:v})});refresh()
}
async function setauto(on){
await fetch('/auto',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:on})});refresh()
}
async function setbw(v){
v=Math.max(0,parseFloat(v)||0); bw.value=v
await fetch('/bandwidth',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({value:v})});refresh()
}
async function refresh(){
let s; try{ s=await (await fetch('/status')).json() }catch{ return }
applyStatus(s)
}
function applyStatus(s){
// NB: don't write a separate `capn` element here — conchint.textContent below
// rewrites the whole hint (incl. the max), and any child element nested in it
// would be destroyed by that write, breaking the NEXT applyStatus call.
CAP=s.max_concurrency||8
// The backend owns the state now (stopped|starting|running|stopping) and drives
// every transition, so the pill is always truthful — no client-side guessing
// from active>0, which used to wedge on "stopping" forever.
const st=s.state||'stopped'
const running=st==='running'
const busy=(st==='starting'||st==='stopping')
// Stale-page guard: if the server is a newer build than this page, the cached
// controls may misbehave — tell the operator to reload.
if(s.build && s.build!==PAGE_BUILD) verbanner.style.display='block'
state.textContent=st
state.className='n'+(busy?' busy':'')
// Buttons follow the real state so you can't fight a transition: Start only
// from stopped; Stop only while up; both disabled through "stopping" until the
// backend truthfully lands on "stopped".
startbtn.disabled=(st!=='stopped')
stopbtn.disabled=!(running||st==='starting')
// Throughput rates arrive READY from the backend (jobs/min ≈ GPU throughput,
// dl/min ≈ fetch throughput), computed there on a fixed cadence — so they show
// a real number no matter how often this tab polls (a backgrounded tab throttles
// its timers, which used to leave a client-side delta-rate blank forever).
jpm.textContent=(s.jobs_per_min!=null)?Math.round(s.jobs_per_min):''
dpm.textContent=(s.downloads_per_min!=null)?Math.round(s.downloads_per_min):''
done.textContent=s.processed
err.textContent=s.errors; err.className='n'+(s.errors>0?' warn':'')
waited.textContent=s.transient||0
// Instantaneous pool state → demoted to the sub-line, where its jumpiness reads
// as live churn rather than a "broken" headline metric.
// '=== false' (not falsy) so a stale page that doesn't send models_loaded shows
// nothing; when the idle monitor unloads, the VRAM meter drops alongside this.
pipe.textContent='downloaders '+(s.downloaders!=null?s.downloaders:'')+' · consumers '+(s.consumers!=null?s.consumers:'')+' · on GPU '+(s.active||0)
+' · net '+(s.net_mb_s!=null?s.net_mb_s.toFixed(1):'')+' MB/s'
+(s.bandwidth_limit_mb_s>0?(' / cap '+s.bandwidth_limit_mb_s):'')
+(s.models_loaded===false?' · GPU models unloaded (idle — reload on next job)':'')
if(document.activeElement!==bw && s.bandwidth_limit_mb_s!=null) bw.value=s.bandwidth_limit_mb_s
// Buffer occupancy bar (also driven here so it tracks the /status cadence).
if(s.buffer!=null && s.buffer_max){ const p=Math.round(100*s.buffer/s.buffer_max)
buflbl.textContent=s.buffer+' / '+s.buffer_max; bufbar.style.width=p+'%' }
// Auto on → dial reflects the auto-chosen count (read-only); off → manual.
if(document.activeElement!==autochk) autochk.checked=!!s.auto
conc.disabled=!!s.auto; conc.style.opacity=s.auto?0.55:1
conchint.textContent=(s.auto?('auto-tuning downloaders to keep the GPU fed · max '+CAP):('manual downloaders · max '+CAP))
+(s.idle?' · idle — queue empty, lease poll backed off (new work noticed within ~15 min)'
:(s.bw_capped?' · holding at the bandwidth cap (more downloaders would not go faster)':''))
if(document.activeElement!==conc) conc.value=s.concurrency
conc.max=CAP
// Connection pill + queue come only from the /status poll (the Start/Stop POST
// responses skip the slow curator call to stay snappy) — guard so an action
// response doesn't blank them.
if('configured' in s){
const ok=s.configured
fc.textContent=s.fc_url; cfg.textContent=ok?'set':'MISSING'
// Pill colour + label track the real state: green only when running AND
// curator is answering; amber for the transient states + a running-but-
// unreachable curator; grey when stopped; red with no token.
let dc='dot', lbl='stopped'
if(!ok){ dc='dot red'; lbl='no token' }
else if(st==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable' }
else if(st==='starting'){ dc='dot amber'; lbl='starting…' }
else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' }
dot.className=dc; connlbl.textContent=lbl
banner.style.display=(st==='running' && !s.queue)?'block':'none'
queue.textContent=s.queue?('queue · pending '+s.queue.pending+' · in flight '+s.queue.leased+' · done '+s.queue.done+' · errored '+s.queue.error):'queue · unreachable'
}
}
// GPU meters poll their OWN endpoint on a fast cadence — kept off /status so a
// slow curator queue call can't freeze the bars (they only stale on refresh).
let UAVG=null // smoothed util for the bar (raw util swings 0↔99; show the trend)
async function refreshGpu(){
let g; try{ g=await (await fetch('/gpu')).json() }catch{ return }
if(g && g.util_pct!=null){
// Prefer the agent's own EWMA (util_smooth) when running; otherwise smooth
// the raw reading here so a stopped agent's bar still glides, not jumps.
const raw=g.util_pct
UAVG = (g.util_smooth!=null) ? g.util_smooth
: (UAVG==null ? raw : 0.25*raw + 0.75*UAVG)
const used=g.mem_used_mb, tot=g.mem_total_mb||1
utillbl.textContent=Math.round(UAVG)+'% · '+g.temp_c+'°C'; utilbar.style.width=Math.round(UAVG)+'%'
vramlbl.textContent=used+' / '+tot+' MB'; gpubar.style.width=Math.round(100*used/tot)+'%'
} else { UAVG=null; utillbl.textContent='n/a'; vramlbl.textContent='n/a (CPU?)'; utilbar.style.width='0%'; gpubar.style.width='0%' }
}
async function refreshLogs(){
try{
const r=await (await fetch('/logs')).json()
const el=logs, atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<40
el.textContent=(r.lines&&r.lines.length)?r.lines.join('\\n'):'waiting for activity…'
if(atBottom) el.scrollTop=el.scrollHeight
}catch{}
}
async function copyLogs(){
const txt=logs.textContent||''
try{ await navigator.clipboard.writeText(txt) }
catch{ const t=document.createElement('textarea'); t.value=txt; document.body.appendChild(t);
t.select(); try{document.execCommand('copy')}catch{}; t.remove() }
copybtn.textContent='Copied'; setTimeout(()=>{copybtn.textContent='Copy'},1200)
}
refresh(); refreshGpu(); refreshLogs()
setInterval(refresh,3000); setInterval(refreshGpu,1500); setInterval(refreshLogs,2500)
</script></body></html>"""
-140
View File
@@ -1,140 +0,0 @@
"""HTTP client for the FabledCurator GPU-job API.
The agent's ONLY contact with FC — lease/submit/heartbeat/fail + fetch image
bytes, all over HTTP with the bearer token. No DB/Redis.
"""
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class FcClient:
def __init__(self, base_url: str, token: str, agent_id: str):
self.base = base_url.rstrip("/")
self.agent_id = agent_id
# Main session: NO in-request retry — lease/fetch are cheap to redo and
# the worker loop already backs off + re-leases on failure. (Auto-retrying
# a lease could double-claim a batch if a response is lost.)
self.s = self._session(token)
# Submit session: retry in-place, because by submit time the GPU work is
# already DONE — a momentary blip (dropped connection, gateway 5xx during
# a curator redeploy) must not throw that work away and force a full
# re-download + recompute on another agent. A duplicate submit after a
# lost response is harmless: the job is already closed, so it just returns
# 409 lease_invalid (a no-op). Idempotent enough to retry POST safely.
retry = Retry(
total=3, connect=3, read=3, status=3,
backoff_factor=0.5, # ~0.5s, 1s, 2s between tries
status_forcelist=(500, 502, 503, 504), # transient server/gateway
allowed_methods=frozenset({"POST"}),
raise_on_status=False, # let raise_for_status decide
)
self._submit_s = self._session(token, retry)
@staticmethod
def _session(token: str, retry: Retry | None = None) -> requests.Session:
s = requests.Session()
s.headers["Authorization"] = f"Bearer {token}"
# Many worker threads share a Session; the default pool (10) would
# throttle them + spam "connection pool is full". Size it for the cap.
adapter = HTTPAdapter(
pool_connections=64, pool_maxsize=64, max_retries=retry or 0
)
s.mount("http://", adapter)
s.mount("https://", adapter)
return s
def _submit(self, path: str, payload: dict) -> dict:
"""POST to a submit endpoint on the RETRYING session (by submit time the
GPU work is done — a blip must not throw it away), raise on a hard error,
and return the parsed JSON. `agent_id` is added to every body."""
r = self._submit_s.post(
f"{self.base}{path}",
json={"agent_id": self.agent_id, **payload},
timeout=120,
)
r.raise_for_status()
return r.json()
def _post_quiet(self, path: str, payload: dict) -> None:
"""Fire-and-forget POST on the main session — heartbeat/fail/release are
best-effort, so a transport error is swallowed (the worker's own retry and
the server's orphan-recovery cover a lost call). `agent_id` is added."""
try:
self.s.post(
f"{self.base}{path}",
json={"agent_id": self.agent_id, **payload},
timeout=30,
)
except requests.RequestException:
pass
def lease(self, batch_size: int) -> list[dict]:
r = self.s.post(
f"{self.base}/api/gpu/jobs/lease",
json={"agent_id": self.agent_id, "batch_size": batch_size},
timeout=30,
)
r.raise_for_status()
return r.json().get("jobs", [])
def submit(self, job_id: int, regions: list[dict], replace_kinds: list[str]) -> dict:
return self._submit("/api/gpu/jobs/submit", {
"job_id": job_id, "regions": regions, "replace_kinds": replace_kinds,
})
def submit_embedding(self, job_id: int, embedding: list, version: str) -> dict:
"""Post a whole-image SigLIP embedding (the 'embed' task) → image_record."""
return self._submit("/api/gpu/jobs/submit_embedding", {
"job_id": job_id, "embedding": embedding, "embedding_version": version,
})
def heartbeat(self, job_ids: list[int]) -> None:
self._post_quiet("/api/gpu/jobs/heartbeat", {"job_ids": job_ids})
def fail(self, job_id: int, error: str) -> None:
self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error})
def release(self, job_ids: list[int]) -> None:
# Graceful hand-back on stop so orphaned work is re-leased at once.
if not job_ids:
return
self._post_quiet("/api/gpu/jobs/release", {"job_ids": job_ids})
def fetch_image(self, image_url: str, throttle=None) -> bytes:
# image_url is a server-relative path ("/images/...").
# timeout=(connect, read): the read timeout is BETWEEN-BYTES, not total,
# so a large-but-flowing download still completes — but a stuck/dead
# connection (curator overloaded) fails in 60s instead of hanging a
# downloader for 180s and piling up concurrent stuck requests on curator.
# With a throttle (the worker's shared TokenBucket), the body is streamed
# in chunks and each chunk is charged to the global bandwidth budget —
# pausing between reads lets TCP flow control pace curator's send side.
with self.s.get(
f"{self.base}{image_url}", timeout=(10, 60), stream=throttle is not None
) as r:
r.raise_for_status()
if throttle is None:
return r.content
buf = bytearray()
for chunk in r.iter_content(chunk_size=262_144):
throttle.take(len(chunk))
buf.extend(chunk)
return bytes(buf)
def is_reachable(self) -> bool:
"""Cheap 'is curator responding at all right now?' check. Used to decide,
when a video can't be sampled, between a transient outage (keep retrying —
survives a redeploy) and an unprocessable file (fail it, don't loop)."""
try:
r = self.s.get(f"{self.base}/api/gpu/status", timeout=5)
return r.status_code < 500
except requests.RequestException:
return False
def queue_status(self) -> dict:
# Short timeout: this backs the UI /status poll, so a busy curator must
# not hang the page for long (the GPU meters poll /gpu separately).
r = self.s.get(f"{self.base}/api/gpu/status", timeout=5)
r.raise_for_status()
return r.json()
-100
View File
@@ -1,100 +0,0 @@
"""Agent config, all from env (the control container is configured at run)."""
# Lazy annotations so the `from_env(cls) -> Config` self-reference is a string,
# not evaluated at class-definition time — otherwise it NameErrors on the agent's
# Python 3.10 (CI lints on 3.14, where PEP 649 hides this).
from __future__ import annotations
import os
from dataclasses import dataclass
def _bool_env(name: str, default: str = "") -> bool:
"""A boolean env var — present + truthy ('1'/'true'/'yes') → True."""
return os.environ.get(name, default).lower() in ("1", "true", "yes")
@dataclass
class Config:
fc_url: str # base URL of the FabledCurator web service
token: str # the bearer token from Settings → Tagging → GPU agent
agent_id: str # identifies this agent's leases
batch_size: int # jobs a worker leases per round
concurrency: int # INITIAL parallel workers (tunable live from the UI)
ccip_model: str # imgutils CCIP model name ("" → imgutils default)
detector_level: str # imgutils person-detector level: n|s|m|x
poll_idle_seconds: float # wait between empty leases
embed_dtype: str # torch dtype for the crop embedder: float16|float32
embed_model_override: str # force a SigLIP-family model ("" → use the one
# the server announces in the lease)
auto_start: bool # start the worker pool on boot (so a container restart
# resumes processing without anyone clicking Start)
auto_scale: bool # autoscale the worker count (throughput hill-climb)
# Crop PROPOSERS (extra YOLO detectors that say where to crop). Each weight
# spec is an ultralytics name | http(s) URL | "hf_repo::file" ("" = off).
person_weights: str # general COCO person detector (Western/realistic figs)
person_conf: float
anatomy_weights: str # booru_yolo anime/furry/NSFW components
anatomy_conf: float
panel_weights: str # comic-panel detector
panel_conf: float
max_components: int # cap anatomy component crops per frame
max_panels: int # cap panel crops per frame
max_figures: int # cap figure boxes per frame (each = a CCIP call + crop)
max_regions: int # hard cap on total regions per JOB (submit-size backstop)
dedupe_iou: float # crops overlapping >= this (same kind) are near-dupes,
# dropped before the embed; >=1.0 disables it
frame_dedupe_distance: int # video frames whose dHash differs by < this many
# bits are near-duplicates, dropped before detect;
# higher keeps more frames, 0 disables
ffmpeg_timeout: float # hard ceiling (s) for ffmpeg-from-URL video sampling;
# generous so a SLOW media link still completes
bandwidth_limit_mb_s: float # aggregate download cap in MEGABYTES/s across
# all downloaders + video streams (0 = unlimited);
# tunable live from the agent UI
idle_unload_seconds: float # after this long with the GPU idle (nothing in
# flight, queue empty or Stopped), unload the
# SigLIP embedder + YOLO proposers to free their
# VRAM; they reload lazily on the next job. A
# 24/7 agent otherwise squats on ~5GB doing
# nothing. 0 disables (keep models warm forever).
@classmethod
def from_env(cls) -> Config:
return cls(
fc_url=os.environ.get("FC_URL", "http://localhost:8000").rstrip("/"),
token=os.environ.get("FC_TOKEN", ""),
agent_id=os.environ.get("AGENT_ID", "desktop-agent"),
batch_size=int(os.environ.get("BATCH_SIZE", "4")),
concurrency=int(os.environ.get("CONCURRENCY", "1")),
ccip_model=os.environ.get("CCIP_MODEL", ""),
detector_level=os.environ.get("DETECTOR_LEVEL", "m"),
poll_idle_seconds=float(os.environ.get("POLL_IDLE_SECONDS", "10")),
embed_dtype=os.environ.get("SIGLIP_DTYPE", "float16"),
embed_model_override=os.environ.get("EMBED_MODEL_NAME", ""),
auto_start=_bool_env("AUTO_START"),
auto_scale=_bool_env("AUTO_SCALE", "true"),
person_weights=os.environ.get("PERSON_WEIGHTS", "yolo11n.pt"),
person_conf=float(os.environ.get("PERSON_CONF", "0.35")),
anatomy_weights=os.environ.get("ANATOMY_WEIGHTS", ""),
anatomy_conf=float(os.environ.get("ANATOMY_CONF", "0.30")),
panel_weights=os.environ.get("PANEL_WEIGHTS", ""),
panel_conf=float(os.environ.get("PANEL_CONF", "0.30")),
max_components=int(os.environ.get("MAX_COMPONENTS", "8")),
max_panels=int(os.environ.get("MAX_PANELS", "8")),
max_figures=int(os.environ.get("MAX_FIGURES", "8")),
max_regions=int(os.environ.get("MAX_REGIONS", "128")),
dedupe_iou=float(os.environ.get("DEDUPE_IOU", "0.85")),
frame_dedupe_distance=int(os.environ.get("FRAME_DEDUPE_DISTANCE", "8")),
ffmpeg_timeout=float(os.environ.get("FFMPEG_TIMEOUT", "1200")),
# Default 8 MB/s (~64 Mbit/s): ~20% of the measured ~300 Mbit/s home
# WiFi, so browsing stays snappy while the agent works — yet MORE
# sweep throughput than the self-inflicted congestion collapse this
# replaces (2026-07-02: 8 unthrottled downloaders bufferbloated the
# link to ~1-1.5 MB/s per stream, browser included). Raise it (or 0)
# from the agent UI on wired/faster networks.
bandwidth_limit_mb_s=float(os.environ.get("BANDWIDTH_LIMIT_MB_S", "8")),
# 5 min: long enough that a lull between job bursts doesn't thrash the
# (few-second) reload, short enough that an agent left running with an
# empty queue hands its VRAM back promptly.
idle_unload_seconds=float(os.environ.get("IDLE_UNLOAD_SECONDS", "300")),
)
-36
View File
@@ -1,36 +0,0 @@
"""Crop primitive — vendored from backend/app/services/ml/crops.py so the agent
is self-contained. Keep in sync if the floor logic changes."""
from PIL import Image
MIN_CROP_FRACTION = 0.10
MIN_CROP_PX = 64
def crop_region(
img: Image.Image,
bbox: tuple[float, float, float, float],
*,
pad: float = 0.0,
min_fraction: float = MIN_CROP_FRACTION,
min_px: int = MIN_CROP_PX,
) -> Image.Image | None:
"""Crop a NORMALIZED bbox (x, y, w, h in [0,1]); None if below the size
floor (max of a fraction-of-short-side and an absolute pixel floor)."""
iw, ih = img.size
x, y, w, h = bbox
px, py, pw, ph = x * iw, y * ih, w * iw, h * ih
if pad:
px -= pw * pad / 2.0
py -= ph * pad / 2.0
pw *= (1.0 + pad)
ph *= (1.0 + pad)
left = max(0, int(round(px)))
top = max(0, int(round(py)))
right = min(iw, int(round(px + pw)))
bottom = min(ih, int(round(py + ph)))
if right <= left or bottom <= top:
return None
floor = max(min_px, int(min_fraction * min(iw, ih)))
if min(right - left, bottom - top) < floor:
return None
return img.crop((left, top, right, bottom)).convert("RGB")
-233
View File
@@ -1,233 +0,0 @@
"""Region PROPOSERS — small YOLO detectors that decide WHERE to crop. They run
on the agent GPU and their boxes feed the crop → SigLIP → max-over-bag pipeline:
- person (general COCO yolo11n): full-figure boxes for realistic / Western art
the anime person-detector misses; NMS-merged with imgutils detect_person and
fed to CCIP (identity) + a concept crop.
- anatomy (booru_yolo): anime / furry / NSFW torso components (head, cat-head,
boob, hip, …) — concept crops aligned to the operator's tag vocabulary.
- panel (mosesb): a comic page → panel regions → concept crops.
Each proposer is INDEPENDENTLY optional + guarded: a bad weight path or an
inference error disables just that proposer (logged) and never breaks the
worker, which still falls back to imgutils detection. Weights resolve from an
ultralytics builtin name ("yolo11n.pt"), an http(s) URL, or "hf_repo::file"
cached under HF_HOME so the download happens once.
"""
import logging
import os
import threading
import types
from pathlib import Path
log = logging.getLogger("fc_agent.detectors")
_CACHE = Path(os.environ.get("HF_HOME", "/models")) / "yolo"
def _resolve(spec: str) -> str | None:
"""A local weights path (downloading if needed) or an ultralytics builtin
name. None if the spec is empty/unresolvable."""
if not spec:
return None
if "::" in spec: # hf_repo::filename
repo, _, fname = spec.partition("::")
from huggingface_hub import hf_hub_download
return hf_hub_download(
repo_id=repo, filename=fname, cache_dir=str(_CACHE)
)
if spec.startswith(("http://", "https://")):
_CACHE.mkdir(parents=True, exist_ok=True)
dest = _CACHE / spec.rsplit("/", 1)[-1]
if not dest.is_file():
import requests
r = requests.get(spec, timeout=300)
r.raise_for_status()
dest.write_bytes(r.content)
return str(dest)
return spec # ultralytics builtin name
def _iou(a, b) -> float:
ax, ay, aw, ah = a
bx, by, bw, bh = b
ix = max(0.0, min(ax + aw, bx + bw) - max(ax, bx))
iy = max(0.0, min(ay + ah, by + bh) - max(ay, by))
inter = ix * iy
union = aw * ah + bw * bh - inter
return inter / union if union > 0 else 0.0
def nms_merge(boxes, iou_thresh: float = 0.6):
"""Greedy NMS over (bbox_norm, score, label) from possibly several detectors,
so the same figure found by two of them collapses to one (higher-score) box."""
kept = []
for bb, sc, lb in sorted(boxes, key=lambda b: b[1], reverse=True):
if all(_iou(bb, k[0]) < iou_thresh for k in kept):
kept.append((bb, sc, lb))
return kept
def dedupe_crops(pending, iou_thresh: float = 0.85):
"""Greedy high-IoU dedupe over a list of (crop, region_template) pairs, run
just before the batched SigLIP embed so we never embed the same region twice.
Figure boxes are already NMS-merged and each YOLO self-NMSes, but the combined
per-frame pile (figure→concept anatomy component→concept panel) can still
carry genuine near-duplicates across proposers — e.g. a figure box that nearly
coincides with an anatomy component on a solo bust, or overlapping booru head
classes on one head. Those embed the same region twice, wasting GPU and a slot
against max_regions.
Boxes are compared ONLY within the same output kind and dropped when they
overlap at >= iou_thresh, keeping the highest-scoring one. The HIGH default
threshold is deliberate: it collapses only true near-identical boxes while
preserving intentional nested crops across scopes (a whole figure vs a small
head component sit well below it) and distinct kinds (concept vs panel). A
value >= 1.0 effectively disables it (nothing but an exact box matches)."""
kept = []
kept_boxes: dict = {} # kind -> [bbox, ...] already kept
for crop, tmpl in sorted(
pending, key=lambda p: p[1].get("score") or 0.0, reverse=True
):
bb = tmpl.get("bbox")
prior = kept_boxes.setdefault(tmpl.get("kind"), [])
if bb is not None and any(_iou(bb, kb) >= iou_thresh for kb in prior):
continue
prior.append(bb)
kept.append((crop, tmpl))
return kept
class YoloProposer:
"""One lazily-loaded ultralytics YOLO. detect(image) → [(bbox_norm, score,
label)] with bbox normalized (x, y, w, h) in [0,1]. Self-disables on any
load/inference failure."""
def __init__(self, name, weights, conf=0.25, keep_labels=None):
self.name = name
self._spec = weights
self._conf = conf
self._keep = [k.lower() for k in keep_labels] if keep_labels else None
self._model = None
self._ok = True
self._lock = threading.Lock()
def _load(self):
if self._model is not None or not self._ok:
return
with self._lock:
if self._model is not None or not self._ok:
return
try:
from ultralytics import YOLO
path = _resolve(self._spec)
if path is None:
self._ok = False
return
self._model = YOLO(path)
# Disable ultralytics' load-time Conv+BN fusion. AutoBackend fuses
# the graph on the first predict; some checkpoints (yolo11n, the
# comic-panel model) crash that step with "'Conv' object has no
# attribute 'bn'" (a partially-fused / version-mismatched graph),
# which silently disabled those proposers (operator-flagged
# 2026-07-01). Unfused inference is correct — only marginally
# slower — and this is robust across ultralytics versions; if a
# future version ignores the override, the detect() guard below
# still self-disables the proposer instead of spamming per image.
inner = getattr(self._model, "model", None)
if inner is not None:
inner.fuse = types.MethodType(lambda self, *a, **k: self, inner)
log.info("detector %s loaded (%s)", self.name, path)
except Exception as exc: # noqa: BLE001
log.warning("detector %s disabled (load failed): %s", self.name, exc)
self._ok = False
def detect(self, image):
self._load()
if self._model is None:
return []
try:
res = self._model.predict(image, conf=self._conf, verbose=False)[0]
except Exception as exc: # noqa: BLE001
# Permanently self-disable on the FIRST inference failure rather than
# re-throwing (and re-logging) on every image forever — an unfixable
# model fault degrades to "this proposer is off", logged once.
log.warning("detector %s disabled (inference failed): %s", self.name, exc)
self._ok = False
self._model = None
return []
iw, ih = image.size
names = getattr(res, "names", None) or {}
out = []
for b in res.boxes:
label = str(names.get(int(b.cls), int(b.cls))).lower()
if self._keep is not None and not any(k in label for k in self._keep):
continue
x0, y0, x1, y1 = (float(v) for v in b.xyxy[0].tolist())
out.append((
(x0 / iw, y0 / ih, (x1 - x0) / iw, (y1 - y0) / ih),
float(b.conf), label,
))
return out
def unload(self) -> None:
"""Drop the loaded YOLO so its VRAM can be reclaimed; detect() reloads it
lazily on the next job. Leaves _ok untouched — a healthy proposer comes
back, but one that self-disabled on a fault stays off."""
with self._lock:
self._model = None
class Proposers:
"""The agent's proposer set, built from config. Each detector is optional —
an empty weight spec leaves that proposer off."""
def __init__(self, cfg):
self.cfg = cfg
self._person = (
YoloProposer("person-coco", cfg.person_weights,
conf=cfg.person_conf, keep_labels=["person"])
if cfg.person_weights else None
)
self._anatomy = (
YoloProposer("anatomy", cfg.anatomy_weights, conf=cfg.anatomy_conf)
if cfg.anatomy_weights else None
)
self._panel = (
YoloProposer("panel", cfg.panel_weights, conf=cfg.panel_conf)
if cfg.panel_weights else None
)
def figures(self, image, base_boxes):
"""Merge imgutils person boxes (base_boxes: [(bbox, score)]) with the
general COCO person detector → NMS'd figure boxes [(bbox, score, label)],
capped to the highest-scoring max_figures. Uncapped, a busy/huge image
(many characters) yields hundreds of boxes → hundreds of per-figure CCIP
calls + crops → a 30s+ job and an oversized submit (operator-flagged)."""
boxes = [(bb, sc if sc is not None else 1.0, "person") for bb, sc in base_boxes]
if self._person is not None:
boxes += self._person.detect(image)
return nms_merge(boxes)[: self.cfg.max_figures] # nms_merge is score-desc
@staticmethod
def _top(detector, image, cap: int):
"""Top-`cap` detections by score from an optional proposer (None → the
proposer is off → []). Shared by the anatomy + panel proposers, which
differ only in which detector and which cap."""
if detector is None:
return []
return sorted(detector.detect(image), key=lambda b: b[1], reverse=True)[:cap]
def components(self, image):
return self._top(self._anatomy, image, self.cfg.max_components)
def panels(self, image):
return self._top(self._panel, image, self.cfg.max_panels)
def unload(self) -> None:
"""Release every loaded proposer's YOLO (idle VRAM reclaim). The worker
also drops its reference to this Proposers and rebuilds a fresh one via
_proposers_for on the next job, so this is belt-and-braces."""
for p in (self._person, self._anatomy, self._panel):
if p is not None:
p.unload()
-92
View File
@@ -1,92 +0,0 @@
"""Crop EMBEDDER for the concept bag — model-agnostic (CLIP/SigLIP-family).
The server trains its per-concept heads in the embedding space of whatever model
its `embedder_model_version` names; a crop must be embedded with the SAME model
or its vector lands in a different coordinate system and every head misfires. So
the model identity (HF name + version) is ANNOUNCED BY THE SERVER in the lease —
nothing here is hardcoded to SigLIP. Whatever name the server sends is loaded via
transformers `get_image_features` (the CLIP/SigLIP-family image-tower call); a
non-CLIP backbone (e.g. a DINO encoder) would need its own pooling adapter.
torch on CUDA, fp16 by default to keep VRAM low on a shared desktop GPU — the
tiny fp16-vs-fp32 difference is negligible for the linear heads (cosine ~0.999).
A single inference lock serializes the forward pass: the pipeline is I/O-bound,
so the GPU isn't the bottleneck, and one model shared across worker threads is
safest behind a lock.
"""
import threading
import numpy as np
from PIL import Image
class CropEmbedder:
def __init__(self, model_name: str, dtype: str = "float16"):
self._name = model_name
self._dtype_name = dtype
self._model = None
self._processor = None
self._torch = None
self._device = None
self._dt = None
self._load_lock = threading.Lock()
self._infer_lock = threading.Lock()
@property
def model_name(self) -> str:
return self._name
def load(self) -> None:
if self._model is not None:
return
with self._load_lock:
if self._model is not None:
return
import torch
from transformers import AutoImageProcessor, AutoModel
self._torch = torch
self._device = "cuda" if torch.cuda.is_available() else "cpu"
dt = getattr(torch, self._dtype_name, torch.float16)
if self._device == "cpu":
dt = torch.float32 # fp16 matmul is unsupported/slow on CPU
self._dt = dt
self._processor = AutoImageProcessor.from_pretrained(self._name)
model = AutoModel.from_pretrained(self._name, torch_dtype=dt)
model.eval().to(self._device)
self._model = model
def embed(self, image: Image.Image) -> list[float]:
"""A crop → its embedding as a plain float list, ready to POST."""
return self.embed_batch([image])[0]
def embed_batch(self, images: list) -> list[list[float]]:
"""Embed many crops in ONE forward pass — far better GPU utilisation +
only one lock acquisition than embedding each crop separately (which
starved the GPU and serialised the whole pool)."""
if not images:
return []
self.load()
torch = self._torch
enc = self._processor(images=images, return_tensors="pt")
pixel_values = enc["pixel_values"].to(self._device, self._dt)
with self._infer_lock, torch.no_grad():
out = self._model.get_image_features(pixel_values=pixel_values)
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
arr = pooled.float().cpu().numpy().astype(np.float32)
return [row.reshape(-1).tolist() for row in arr]
def unload(self) -> bool:
"""Drop the loaded model so its VRAM can be reclaimed — the idle monitor
calls this after a spell with no work so an idle agent doesn't squat on
the card; the next embed() reloads it lazily (a few seconds). Held under
BOTH the load and inference locks so it can never race a concurrent load
or an in-flight forward pass. Returns True if a model was actually
released (the caller then runs one empty_cache() to hand the freed blocks
back to the driver)."""
with self._load_lock, self._infer_lock:
if self._model is None:
return False
self._model = None
self._processor = None
return True
-65
View File
@@ -1,65 +0,0 @@
"""GPU load readout via nvidia-smi (present in the container thanks to the
NVIDIA Container Toolkit's `utility` capability). Returns None if unavailable —
the UI just shows n/a (e.g. CPU-fallback run).
Reads are CACHED and de-duplicated: the UI meter polls fast, /status reads it,
and the autoscaler samples it — if each spawned its own `nvidia-smi` (slow on a
busy GPU) those blocking subprocesses would pile up in the server's thread pool
and make the Start/Stop buttons feel dead. So a short TTL serves recent callers
from cache, and only ONE probe runs at a time (others get the last value)."""
import subprocess
import threading
import time
_TTL = 1.0 # seconds a sample is reused before re-probing
_lock = threading.Lock()
_cache: dict | None = None
_cache_t = 0.0
_probing = False
def _probe() -> dict | None:
try:
out = subprocess.run(
[
"nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu",
"--format=csv,noheader,nounits",
],
capture_output=True, text=True, timeout=5, check=True,
).stdout.strip().splitlines()
except (OSError, subprocess.SubprocessError):
return None
if not out:
return None
parts = [p.strip() for p in out[0].split(",")]
try:
return {
"util_pct": int(float(parts[0])),
"mem_used_mb": int(float(parts[1])),
"mem_total_mb": int(float(parts[2])),
"temp_c": int(float(parts[3])),
}
except (ValueError, IndexError):
return None
def read_gpu(max_age: float = _TTL) -> dict | None:
"""Latest GPU reading, cached. Serves from cache when fresh; when stale,
exactly one caller re-probes while the rest get the last value — so request
threads never block behind more than one `nvidia-smi`."""
global _cache, _cache_t, _probing
now = time.monotonic()
with _lock:
fresh = _cache is not None and (now - _cache_t) < max_age
if fresh or _probing: # fresh, or a probe is already running
return _cache
_probing = True
try:
val = _probe()
finally:
with _lock:
_cache = val
_cache_t = time.monotonic()
_probing = False
return val
-44
View File
@@ -1,44 +0,0 @@
"""In-memory log ring buffer so the control UI can show recent agent logs
(detector loads, job errors, autoscaler decisions, outage back-offs) without
needing `docker logs`. A bounded deque holds the last N formatted lines; a
logging.Handler appends to it; the UI polls /logs."""
import logging
from collections import deque
LINES: deque[str] = deque(maxlen=400)
class RingHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
LINES.append(self.format(record))
except Exception:
pass
_installed = False
def install(level: int = logging.INFO) -> None:
"""Attach the ring handler to the root logger once. fc_agent module loggers
propagate to root, so their records land here."""
global _installed
if _installed:
return
_installed = True
h = RingHandler()
h.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s", "%H:%M:%S")
)
root = logging.getLogger()
root.addHandler(h)
if root.level == logging.NOTSET or root.level > level:
root.setLevel(level)
# Keep the buffer signal-rich: silence the chatty HTTP/download libs (every
# HF model fetch logs per-request) so the console shows agent activity —
# detector loads, job errors, autoscale moves — not request spam.
for noisy in (
"uvicorn.access", "ultralytics", "httpx", "httpcore",
"huggingface_hub", "urllib3", "filelock",
):
logging.getLogger(noisy).setLevel(logging.WARNING)
-253
View File
@@ -1,253 +0,0 @@
"""Image + video handling. Stills load directly; videos are sampled into frames
(ffmpeg) at the cadence FC sends — so a video becomes a bag of per-frame
instances, each with a timestamp."""
import io
import logging
import os
import signal
import subprocess
import tempfile
import time
from PIL import Image, ImageFile
from .throttle import PidReadMeter
log = logging.getLogger("fc_agent.media")
# Load slightly-truncated images (a few missing trailing bytes) instead of
# raising — matches the server embedder. These are common in scraped libraries
# and would otherwise fail the job 3× then error (operator-flagged 2026-06-30).
ImageFile.LOAD_TRUNCATED_IMAGES = True
# Disable PIL's decompression-bomb guard: this is a TRUSTED local library, not an
# untrusted upload surface, so a legitimately huge image (high-res scans/prints,
# 90M+ pixels) must load. The default 89M-pixel limit only WARNS, but PIL raises
# DecompressionBombError at 2× (~179M px) — which would fail those jobs outright
# (operator-flagged 2026-06-30, images of 9095M px).
Image.MAX_IMAGE_PIXELS = None
def is_video(mime: str) -> bool:
return bool(mime) and (mime.startswith("video/") or mime in {"image/gif"})
def _dhash(img: Image.Image, size: int = 8) -> int:
"""Difference hash: compare adjacent pixels of a (size+1 × size) grayscale
thumbnail → a `size*size`-bit fingerprint. Cheap (64 comparisons on a 72-px
thumbnail) and robust to scaling/compression noise — near-identical frames
hash within a few bits, a real scene change moves many."""
small = img.convert("L").resize((size + 1, size))
px = list(small.getdata())
bits = 0
for row in range(size):
base = row * (size + 1)
for col in range(size):
bits = (bits << 1) | int(px[base + col] > px[base + col + 1])
return bits
def dedupe_frames(
frames: list[tuple[float, Image.Image]], min_distance: int
) -> list[tuple[float, Image.Image]]:
"""Drop visually near-duplicate frames. A near-static video sampled into many
frames re-runs the WHOLE detect→CCIP→SigLIP chain on ~identical frames — the
dominant video load. Greedy perceptual-hash dedup: keep a frame only if its
dHash differs from every already-kept frame by >= min_distance bits (Hamming),
so a static run collapses to one frame while genuinely distinct scenes all
survive. Order + timestamps preserved. CPU-only (64-bit int XORs), so it runs
in the decode stage and spares the GPU the skipped frames entirely.
min_distance is the coarseness dial: higher keeps more frames (safer for brief
localized changes an 8×8 hash can miss), 0 disables. The first frame is always
kept (nothing to compare against)."""
if min_distance <= 0 or len(frames) <= 1:
return frames
kept: list[tuple[float, Image.Image]] = []
hashes: list[int] = []
for t, frame in frames:
h = _dhash(frame)
if all(bin(h ^ k).count("1") >= min_distance for k in hashes):
hashes.append(h)
kept.append((t, frame))
return kept
def to_rgb(img: Image.Image) -> Image.Image:
"""RGB, flattening any transparency onto white first. A naive convert('RGB')
on a palette-with-transparency image (common for character PNGs on a clear
background) lets PIL guess the transparent pixels — usually black artifacts
that bleed into the crop + the embedding (and the "should be converted to
RGBA" warning). Compositing over white gives a clean, consistent background."""
if img.mode in ("RGBA", "LA", "PA") or (
img.mode == "P" and "transparency" in img.info
):
img = img.convert("RGBA")
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
return Image.alpha_composite(bg, img).convert("RGB")
return img.convert("RGB")
def load_image(data: bytes) -> Image.Image:
return to_rgb(Image.open(io.BytesIO(data)))
# ffmpeg reconnect flags — resume a dropped HTTP transfer (a slow/contended media
# store can cut a long stream) instead of failing the whole job. Relies only on
# HTTP + Range, which every FC deployment serves → environment-agnostic.
_RECONNECT = [
"-reconnect", "1", "-reconnect_streamed", "1",
"-reconnect_on_network_error", "1", "-reconnect_delay_max", "5",
]
def _collect_frames(
tmp: str, interval: float, cap: int
) -> list[tuple[float, Image.Image]]:
out: list[tuple[float, Image.Image]] = []
names = sorted(n for n in os.listdir(tmp) if n.startswith("f_"))
for i, name in enumerate(names[:cap]):
with Image.open(os.path.join(tmp, name)) as im:
out.append((round(i * interval, 2), to_rgb(im)))
return out
def _terminate(proc: subprocess.Popen) -> None:
"""Stop an ffmpeg cleanly, then hard-kill if it ignores SIGTERM."""
try:
# A bandwidth-paused (SIGSTOPped) process can't receive SIGTERM until it
# resumes — always CONT first so termination is prompt, not queued.
proc.send_signal(signal.SIGCONT)
except OSError:
pass
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
def _pause(proc: subprocess.Popen, seconds: float, should_stop) -> bool:
"""SIGSTOP ffmpeg for ~`seconds` of bandwidth debt, staying responsive to
Stop. While paused, the kernel socket buffer fills and TCP flow control
stalls curator's send side — that's the throttle. SIGCONT is ALWAYS sent
before returning. False = a Stop arrived mid-pause."""
try:
proc.send_signal(signal.SIGSTOP)
except OSError:
return True # already exited — nothing to pause
try:
end = time.monotonic() + seconds
while (left := end - time.monotonic()) > 0:
if should_stop and should_stop():
return False
time.sleep(min(0.5, left))
return True
finally:
try:
proc.send_signal(signal.SIGCONT)
except OSError:
pass
def sample_frames_from_url(
url: str, interval_seconds: float, max_frames: int,
*, headers: str = "", timeout: float = 1200.0, should_stop=None,
governor=None,
) -> tuple[list[tuple[float, Image.Image]], str | None]:
"""Sample frames by pointing ffmpeg STRAIGHT at the media URL — it Range-reads
only the video index + up to max_frames worth of content, so the agent never
downloads the whole file (VR/4K originals run 800MB+ and would buffer ~1GB in
RAM and get cut off mid-download). Reconnect flags resume a dropped transfer;
the timeout is the per-video ceiling (a slow/reconnecting stream can otherwise
run for minutes). `should_stop` is polled while ffmpeg runs so a Stop KILLS the
subprocess at once — otherwise a downloader stuck in a long decode keeps the
agent "working" long after Stop. `governor` (the worker's shared TokenBucket)
meters ffmpeg's network reads from outside via /proc/<pid>/io and SIGSTOPs
the process into budget, so video streaming honors the same aggregate
bandwidth cap as still downloads.
Returns (frames, reason): frames is empty on failure/stop/timeout, and
`reason` then carries the SPECIFIC cause (ffmpeg's stderr tail / timeout) so
the caller can put it in the job's error — a bare "no frames" hid a filter
bug as "unprocessable" for weeks. None reason on success."""
interval = max(0.5, float(interval_seconds or 4.0))
cap = max(1, int(max_frames or 64))
hdr = ["-headers", headers] if headers else []
# select (NOT the fps filter): always keep the FIRST frame, then one per
# `interval` seconds of timestamp. fps=1/N emits round(duration/N) frames,
# which is ZERO for any clip shorter than ~N/2 seconds — a whole class of
# short animation loops failed as "unprocessable" that way (operator-flagged
# 2026-07-02: 0.5s/1.75s clips). scale=out_range=full converts limited-range
# yuv420p to full range so the mjpeg (jpg) encoder accepts it at default
# strictness instead of erroring on "non full-range YUV".
vf = (
f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{interval})',"
"scale=out_range=full"
)
with tempfile.TemporaryDirectory() as tmp:
pattern = os.path.join(tmp, "f_%05d.jpg")
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", *_RECONNECT, *hdr,
"-i", url, "-vf", vf, "-fps_mode", "vfr",
"-frames:v", str(cap), "-q:v", "3", pattern]
# ffmpeg's stderr goes to a file (not a PIPE, which could fill and
# deadlock; not DEVNULL, which is how a filter bug hid as "unprocessable"
# for weeks) — on failure its tail is logged so the operator can see WHY.
errpath = os.path.join(tmp, "stderr.txt")
try:
with open(errpath, "wb") as errf:
proc = subprocess.Popen(
cmd, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=errf,
)
meter = PidReadMeter(proc.pid) if governor is not None else None
# Poll rather than block, so a Stop (or the per-video timeout) can
# kill a slow/wedged ffmpeg promptly instead of waiting it out.
start = time.monotonic()
while True:
try:
proc.wait(timeout=0.5)
break
except subprocess.TimeoutExpired:
stopped = should_stop and should_stop()
if stopped or (time.monotonic() - start > timeout):
_terminate(proc)
if stopped:
return [], "stopped"
log.warning("ffmpeg timed out after %.0fs: %s",
timeout, url)
return [], f"ffmpeg timed out after {timeout:.0f}s"
if meter is not None:
read = meter.delta()
if read is None: # /proc gone → stop governing
meter = None
elif (debt := governor.charge(read)) > 0:
# Over budget: pause ffmpeg until the bucket
# recovers. Pause time counts toward `timeout`
# (it stays the wedge backstop either way).
if not _pause(proc, debt, should_stop):
_terminate(proc)
return [], "stopped"
except (OSError, ValueError) as exc:
return [], f"ffmpeg not runnable: {exc}"
frames = _collect_frames(tmp, interval, cap)
if not frames:
reason = f"ffmpeg exit {proc.returncode}: {_tail(errpath)}"
log.warning("ffmpeg produced no frames for %s%s", url, reason)
return [], reason
return frames, None
def _tail(path: str, limit: int = 300) -> str:
"""Last `limit` chars of a (stderr) file, flattened — for failure logs."""
try:
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - limit))
return f.read().decode("utf-8", "replace").replace("\n", " ").strip()
except OSError:
return "?"
-39
View File
@@ -1,39 +0,0 @@
"""imgutils model wrappers — the figure DETECTOR + the CCIP EMBEDDER.
⚠️ VERIFY ON FIRST RUN: the exact imgutils function names/signatures + the CCIP
model string can drift between dghs-imgutils releases. These are the two seams to
check against your installed version (`pip show dghs-imgutils`):
- detect_person(image, level=...) -> [((x0,y0,x1,y1), label, score), ...]
- ccip_extract_feature(image, model=...) -> a vector (768-d for caformer)
imgutils auto-downloads the ONNX models from HuggingFace on first use; GPU is
used when onnxruntime-gpu is installed.
"""
import numpy as np
from PIL import Image
def detect_figures(image: Image.Image, level: str = "m") -> list[tuple[tuple, float | None]]:
"""Person/figure bounding boxes, NORMALIZED (x, y, w, h in [0,1]) + score.
Returns [] if detection finds nothing (caller falls back to whole-image)."""
from imgutils.detect import detect_person
iw, ih = image.size
out = []
for (x0, y0, x1, y1), _label, score in detect_person(image, level=level):
out.append((
(x0 / iw, y0 / ih, (x1 - x0) / iw, (y1 - y0) / ih),
float(score),
))
return out
def ccip_vector(image: Image.Image, model: str | None = None) -> list[float]:
"""The CCIP identity embedding of a (cropped) character image, as a plain
float list ready to POST."""
from imgutils.metrics import ccip_extract_feature
feat = (
ccip_extract_feature(image, model=model)
if model else ccip_extract_feature(image)
)
return np.asarray(feat, dtype=np.float32).reshape(-1).tolist()
-111
View File
@@ -1,111 +0,0 @@
"""Global download-bandwidth governor (one token bucket for the whole agent).
The agent lives on someone's desktop and shares that desktop's network —
typically WiFi, where saturating the link doesn't just slow other apps: it
bufferbloats the airtime (RTT 21→45ms) and collapses EVERY connection,
the operator's browser included. Measured 2026-07-02: the idle link moved
~38 MB/s single-stream, but under the 8-downloader sweep every stream on the
machine crawled at ~1-1.5 MB/s. So the cap is on the AGGREGATE, not per
stream: still downloads pump their chunks through take(), and ffmpeg video
streams — whose sockets live in a subprocess we can't wrap — are metered from
outside via /proc/<pid>/io and paused (SIGSTOP) into budget using charge()'s
debt signal; TCP flow control then stalls the sender while ffmpeg sleeps.
Accounting is post-paid (charge the bytes first, then wait out any debt): the
bytes have already crossed the network by the time we count them, and it means
a chunk larger than one second of budget can never deadlock the bucket.
Stdlib-only on purpose — unit-tested in CI, where the agent's ML deps
don't exist.
"""
import threading
import time
class TokenBucket:
"""Thread-safe token bucket in bytes/second. rate 0 = unlimited.
`consumed` is the monotonic total of bytes charged (throttled or not) —
the worker's rate loop derives the UI's "net MB/s" readout from it.
"""
def __init__(self, rate_bytes_per_s: float = 0.0):
self._cond = threading.Condition()
self._rate = max(0.0, float(rate_bytes_per_s))
# Burst = one second of budget: enough that chunked reads stay smooth,
# small enough that a burst can't meaningfully lift the average.
self._level = self._rate
self._stamp = time.monotonic()
self.consumed = 0
@property
def rate(self) -> float:
return self._rate
def set_rate(self, rate_bytes_per_s: float) -> None:
"""Retune live (the UI dial). Waiters re-check immediately, so raising
the cap (or lifting it with 0) unblocks a mid-download wait at once."""
with self._cond:
self._refill_locked() # settle elapsed time at the OLD rate
self._rate = max(0.0, float(rate_bytes_per_s))
self._level = min(self._level, self._rate)
self._cond.notify_all()
def _refill_locked(self) -> None:
now = time.monotonic()
self._level = min(self._rate, self._level + (now - self._stamp) * self._rate)
self._stamp = now
def take(self, n: int) -> None:
"""Charge n bytes and block until the budget recovers (stills path)."""
with self._cond:
self.consumed += n
if self._rate <= 0:
return
self._refill_locked()
self._level -= n
while self._level < 0:
# Wake early on set_rate; cap the wait so a big debt is paid in
# re-checked slices rather than one long uninterruptible sleep.
self._cond.wait(min(-self._level / self._rate, 0.5))
if self._rate <= 0:
return
self._refill_locked()
def charge(self, n: int) -> float:
"""Charge n bytes WITHOUT blocking; return seconds of debt (0 = within
budget). The ffmpeg governor can't block the subprocess's own reads, so
it SIGSTOPs the process for (about) the returned debt instead."""
with self._cond:
self.consumed += n
if self._rate <= 0:
return 0.0
self._refill_locked()
self._level -= n
return max(0.0, -self._level / self._rate)
class PidReadMeter:
"""Cumulative read-bytes meter for a subprocess, via /proc/<pid>/io.
`rchar` counts every read() syscall's bytes — for a streaming ffmpeg the
network reads dominate, so the delta is a good-enough aggregate-bandwidth
signal (it's a governor, not a billing meter). Returns None when /proc is
unavailable (process exited, or a non-Linux host): the caller then simply
doesn't govern — degrade to unthrottled rather than break video sampling.
"""
def __init__(self, pid: int):
self._path = f"/proc/{pid}/io"
self._last = 0
def delta(self) -> int | None:
try:
with open(self._path, "rb") as f:
for line in f:
if line.startswith(b"rchar:"):
total = int(line.split()[1])
d, self._last = total - self._last, total
return max(0, d)
except (OSError, ValueError):
return None
return None
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
# CCIP + figure detection (ONNX models, auto-downloaded from HuggingFace).
dghs-imgutils>=0.4
# GPU inference for the ONNX models. Swap to onnxruntime (CPU) for a slow
# server-side fallback run.
onnxruntime-gpu
# The crop EMBEDDER (concept bag). torch is installed separately in the
# Dockerfile from the CUDA-12.4 wheel index so the GPU build is deterministic;
# transformers loads whatever SigLIP-family model the server announces.
transformers>=4.45
# Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic
# panel) that decide where to crop. Uses the torch already installed above.
ultralytics>=8.3
# Control surface + HTTP.
fastapi
uvicorn[standard]
requests
pillow
numpy
-7
View File
@@ -1,7 +0,0 @@
# The agent runs on the CUDA base image's Python 3.12 (Ubuntu 24.04) — NOT the
# 3.14 that CI's ci-python image and the repo-root ruff.toml target. Pin the
# agent to py312 so ruff enforces 3.12 compatibility and never auto-applies a
# 3.14-only fix (e.g. unquoting a self-referential annotation, which PEP 649
# makes safe on 3.14 but NameErrors on 3.12). Inherit the root lint rules.
extend = "../ruff.toml"
target-version = "py312"
+1 -25
View File
@@ -1,28 +1,13 @@
"""Alembic environment — reads DATABASE_URL from app config."""
import os
import re
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool, text
from sqlalchemy import engine_from_config, pool
from alembic import context
from backend.app.config import get_config
from backend.app.models import Base
# Fail a blocked migration FAST instead of hanging forever. Migrations run
# against the live DB while workers hold locks; 0040's `ALTER series_page` queued
# behind a tag-merge that held a series_page lock for minutes (the merge runs an
# unindexed full scan over image_record while repointing series_page) and hung
# with no timeout — silent, indefinite (operator-flagged 2026-06-07). With a
# lock_timeout a blocked DDL errors ("canceling statement due to lock timeout")
# and the entrypoint's `alembic upgrade head` exits non-zero, so the deploy
# retries / surfaces loudly rather than wedging. Override via env when a known
# slow-lock window is expected.
_MIGRATION_LOCK_TIMEOUT = os.environ.get("MIGRATION_LOCK_TIMEOUT", "30s")
if not re.fullmatch(r"\d+\s*(ms|s|min)?", _MIGRATION_LOCK_TIMEOUT.strip()):
_MIGRATION_LOCK_TIMEOUT = "30s" # ignore a malformed override
config = context.config
if config.config_file_name is not None:
@@ -53,15 +38,6 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
# Session-level lock_timeout for every DDL statement in this run. Set
# (and commit) before alembic opens its own transaction so the GUC
# persists on this connection regardless of how alembic structures its
# transactions. Value is from our own env, so f-string interpolation is
# safe (and it's been pattern-validated above); SET takes no bind params.
connection.execute(
text(f"SET lock_timeout = '{_MIGRATION_LOCK_TIMEOUT}'")
)
connection.commit()
context.configure(
connection=connection,
target_metadata=target_metadata,
@@ -0,0 +1,277 @@
"""initial unified schema
Revision ID: 0001
Revises:
Create Date: 2026-05-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
"artist",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("slug", sa.String(length=255), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("is_subscription", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("auto_check", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("check_interval_seconds", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.PrimaryKeyConstraint("id", name="pk_artist"),
sa.UniqueConstraint("name", name="uq_artist_name"),
sa.UniqueConstraint("slug", name="uq_artist_slug"),
)
op.create_table(
"source",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("artist_id", sa.Integer(), nullable=False),
sa.Column("platform", sa.String(length=64), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("config_overrides", sa.JSON(), nullable=True),
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("check_interval_override", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["artist_id"], ["artist.id"], name="fk_source_artist_id_artist", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name="pk_source"),
)
op.create_index("ix_source_artist_id", "source", ["artist_id"])
op.create_table(
"credential",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("platform", sa.String(length=64), nullable=False),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("encrypted_blob", sa.LargeBinary(), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
sa.Column(
"captured_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_credential"),
sa.UniqueConstraint("platform", name="uq_credential_platform"),
)
op.create_table(
"post",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("external_post_id", sa.String(length=128), nullable=False),
sa.Column("post_url", sa.Text(), nullable=True),
sa.Column("post_title", sa.Text(), nullable=True),
sa.Column("post_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("raw_metadata", sa.JSON(), nullable=True),
sa.Column(
"downloaded_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["source_id"], ["source.id"], name="fk_post_source_id_source", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name="pk_post"),
sa.UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
)
op.create_index("ix_post_source_id", "post", ["source_id"])
op.create_table(
"image_record",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("sha256", sa.String(length=64), nullable=False),
sa.Column("phash", sa.String(length=32), nullable=True),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column("mime", sa.String(length=64), nullable=False),
sa.Column("width", sa.Integer(), nullable=True),
sa.Column("height", sa.Integer(), nullable=True),
sa.Column("thumbnail_path", sa.Text(), nullable=True),
sa.Column(
"origin",
sa.Enum(
"downloaded",
"imported_filesystem",
"uploaded",
name="origin_enum",
),
nullable=False,
),
sa.Column("primary_post_id", sa.Integer(), nullable=True),
sa.Column("wd14_predictions", sa.JSON(), nullable=True),
sa.Column("wd14_model_version", sa.String(length=128), nullable=True),
sa.Column("siglip_embedding", Vector(1152), nullable=True),
sa.Column("siglip_model_version", sa.String(length=128), nullable=True),
sa.Column("centroid_scores", sa.JSON(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["primary_post_id"],
["post.id"],
name="fk_image_record_primary_post_id_post",
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name="pk_image_record"),
sa.UniqueConstraint("path", name="uq_image_record_path"),
sa.UniqueConstraint("sha256", name="uq_image_record_sha256"),
)
op.create_index("ix_image_record_sha256", "image_record", ["sha256"])
op.create_index("ix_image_record_phash", "image_record", ["phash"])
op.create_index("ix_image_record_primary_post_id", "image_record", ["primary_post_id"])
op.create_table(
"image_provenance",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("image_record_id", sa.Integer(), nullable=False),
sa.Column("post_id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("captured_metadata", sa.JSON(), nullable=True),
sa.Column(
"captured_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["image_record_id"],
["image_record.id"],
name="fk_image_provenance_image_record_id_image_record",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["post_id"],
["post.id"],
name="fk_image_provenance_post_id_post",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["source_id"],
["source.id"],
name="fk_image_provenance_source_id_source",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_image_provenance"),
)
op.create_index("ix_image_provenance_image_record_id", "image_provenance", ["image_record_id"])
op.create_index("ix_image_provenance_post_id", "image_provenance", ["post_id"])
op.create_index("ix_image_provenance_source_id", "image_provenance", ["source_id"])
op.create_table(
"tag",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("namespace", sa.String(length=64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.PrimaryKeyConstraint("id", name="pk_tag"),
sa.UniqueConstraint("name", name="uq_tag_name"),
)
op.create_index("ix_tag_name", "tag", ["name"])
op.create_index("ix_tag_namespace", "tag", ["namespace"])
op.create_table(
"image_tag",
sa.Column("image_record_id", sa.Integer(), nullable=False),
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False, server_default="manual"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["image_record_id"],
["image_record.id"],
name="fk_image_tag_image_record_id_image_record",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tag_id"], ["tag.id"], name="fk_image_tag_tag_id_tag", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("image_record_id", "tag_id", name="pk_image_tag"),
)
op.create_table(
"download_event",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("post_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column(
"started_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("bytes_downloaded", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("files_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["source_id"],
["source.id"],
name="fk_download_event_source_id_source",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["post_id"],
["post.id"],
name="fk_download_event_post_id_post",
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name="pk_download_event"),
)
op.create_index("ix_download_event_source_id", "download_event", ["source_id"])
op.create_index("ix_download_event_post_id", "download_event", ["post_id"])
def downgrade() -> None:
op.drop_table("download_event")
op.drop_table("image_tag")
op.drop_table("tag")
op.drop_table("image_provenance")
op.drop_table("image_record")
op.execute("DROP TYPE IF EXISTS origin_enum")
op.drop_table("post")
op.drop_table("credential")
op.drop_table("source")
op.drop_table("artist")
op.execute("DROP EXTENSION IF EXISTS vector")
@@ -0,0 +1,208 @@
"""fc2a: tag kinds, import_task, import_batch, integrity_status
Revision ID: 0002
Revises: 0001
Create Date: 2026-05-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TAG_KINDS = (
"artist",
"character",
"fandom",
"general",
"series",
"archive",
"post",
"meta",
"rating",
)
def upgrade() -> None:
# --- Tag kind enum + fandom_id ---
tag_kind = sa.Enum(*TAG_KINDS, name="tag_kind")
tag_kind.create(op.get_bind(), checkfirst=True)
op.add_column(
"tag",
sa.Column("kind", tag_kind, nullable=False, server_default="general"),
)
op.add_column(
"tag",
sa.Column("fandom_id", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_tag_fandom_id_tag",
"tag",
"tag",
["fandom_id"],
["id"],
ondelete="SET NULL",
)
# Drop the old global uniqueness on name; add kind+fandom-aware uniqueness.
op.drop_constraint("uq_tag_name", "tag", type_="unique")
op.drop_index("ix_tag_name", table_name="tag")
op.execute(
"""
CREATE UNIQUE INDEX uq_tag_name_kind_fandom
ON tag (name, kind, COALESCE(fandom_id, 0))
"""
)
# CHECK: fandom_id is only allowed for character kind.
op.create_check_constraint(
"ck_tag_fandom_requires_character",
"tag",
"(fandom_id IS NULL) OR (kind = 'character')",
)
# Drop the old namespace column — superseded by kind.
op.drop_index("ix_tag_namespace", table_name="tag")
op.drop_column("tag", "namespace")
# --- ImportBatch ---
op.create_table(
"import_batch",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("triggered_by", sa.String(length=32), nullable=False),
sa.Column("source_path", sa.Text(), nullable=False),
sa.Column("scan_mode", sa.String(length=16), nullable=False),
sa.Column(
"started_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("total_files", sa.Integer(), nullable=False, server_default="0"),
sa.Column("imported", sa.Integer(), nullable=False, server_default="0"),
sa.Column("skipped", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("status", sa.String(length=16), nullable=False, server_default="running"),
sa.PrimaryKeyConstraint("id", name="pk_import_batch"),
)
op.create_index("ix_import_batch_status", "import_batch", ["status"])
# --- ImportTask ---
op.create_table(
"import_task",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("batch_id", sa.Integer(), nullable=False),
sa.Column("source_path", sa.Text(), nullable=False),
sa.Column("task_type", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
sa.Column("result_image_id", sa.Integer(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("size_bytes", sa.BigInteger(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["batch_id"],
["import_batch.id"],
name="fk_import_task_batch_id_import_batch",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["result_image_id"],
["image_record.id"],
name="fk_import_task_result_image_id_image_record",
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name="pk_import_task"),
)
op.create_index("ix_import_task_batch_id", "import_task", ["batch_id"])
op.create_index("ix_import_task_status", "import_task", ["status"])
op.create_index(
"ix_import_task_created_at_desc",
"import_task",
[sa.text("created_at DESC")],
)
# --- ImportSettings (single-row table) ---
op.create_table(
"import_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("import_scan_path", sa.Text(), nullable=False, server_default="/import"),
sa.Column("min_width", sa.Integer(), nullable=False, server_default="0"),
sa.Column("min_height", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"skip_transparent", sa.Boolean(), nullable=False, server_default=sa.false()
),
sa.Column(
"transparency_threshold",
sa.Float(),
nullable=False,
server_default="0.9",
),
sa.Column(
"skip_single_color", sa.Boolean(), nullable=False, server_default=sa.false()
),
sa.Column(
"single_color_threshold",
sa.Float(),
nullable=False,
server_default="0.95",
),
sa.Column("single_color_tolerance", sa.Integer(), nullable=False, server_default="30"),
sa.PrimaryKeyConstraint("id", name="pk_import_settings"),
sa.CheckConstraint("id = 1", name="ck_import_settings_singleton"),
)
# Seed the single row immediately so callers can always SELECT id=1.
op.execute("INSERT INTO import_settings (id) VALUES (1)")
# --- ImageRecord additions ---
op.add_column(
"image_record",
sa.Column(
"integrity_status",
sa.String(length=24),
nullable=False,
server_default="unknown",
),
)
op.create_index(
"ix_image_record_integrity_status",
"image_record",
["integrity_status"],
)
def downgrade() -> None:
op.drop_index("ix_image_record_integrity_status", table_name="image_record")
op.drop_column("image_record", "integrity_status")
op.drop_table("import_settings")
op.drop_index("ix_import_task_created_at_desc", table_name="import_task")
op.drop_index("ix_import_task_status", table_name="import_task")
op.drop_index("ix_import_task_batch_id", table_name="import_task")
op.drop_table("import_task")
op.drop_index("ix_import_batch_status", table_name="import_batch")
op.drop_table("import_batch")
op.drop_constraint("ck_tag_fandom_requires_character", "tag", type_="check")
op.execute("DROP INDEX uq_tag_name_kind_fandom")
op.add_column("tag", sa.Column("namespace", sa.String(length=64), nullable=True))
op.create_index("ix_tag_namespace", "tag", ["namespace"])
op.create_index("ix_tag_name", "tag", ["name"], unique=False)
op.create_unique_constraint("uq_tag_name", "tag", ["name"])
op.drop_constraint("fk_tag_fandom_id_tag", "tag", type_="foreignkey")
op.drop_column("tag", "fandom_id")
op.drop_column("tag", "kind")
sa.Enum(name="tag_kind").drop(op.get_bind(), checkfirst=True)
+172
View File
@@ -0,0 +1,172 @@
"""fc2b: ML pipeline — allowlist, aliases, centroids, ml_settings
Revision ID: 0003
Revises: 0002
Create Date: 2026-05-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
revision: str = "0003"
down_revision: Union[str, None] = "0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 3.1 rename wd14_* -> tagger_*
op.alter_column("image_record", "wd14_predictions", new_column_name="tagger_predictions")
op.alter_column(
"image_record", "wd14_model_version", new_column_name="tagger_model_version"
)
# 3.2 tag_allowlist
op.create_table(
"tag_allowlist",
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.Column(
"min_confidence", sa.Float(), nullable=False, server_default="0.95"
),
sa.Column(
"added_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["tag_id"], ["tag.id"], name="fk_tag_allowlist_tag_id_tag",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("tag_id", name="pk_tag_allowlist"),
sa.CheckConstraint(
"min_confidence > 0 AND min_confidence <= 1",
name="ck_tag_allowlist_confidence_range",
),
)
# 3.3 tag_suggestion_rejection
op.create_table(
"tag_suggestion_rejection",
sa.Column("image_record_id", sa.Integer(), nullable=False),
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.Column(
"rejected_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["image_record_id"], ["image_record.id"],
name="fk_tsr_image_record_id_image_record", ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tag_id"], ["tag.id"], name="fk_tsr_tag_id_tag", ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"image_record_id", "tag_id", name="pk_tag_suggestion_rejection"
),
)
op.create_index(
"ix_tag_suggestion_rejection_tag", "tag_suggestion_rejection", ["tag_id"]
)
# 3.4 tag_alias
op.create_table(
"tag_alias",
sa.Column("alias_string", sa.String(length=255), nullable=False),
sa.Column("alias_category", sa.String(length=32), nullable=False),
sa.Column("canonical_tag_id", sa.Integer(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["canonical_tag_id"], ["tag.id"],
name="fk_tag_alias_canonical_tag_id_tag", ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"alias_string", "alias_category", name="pk_tag_alias"
),
)
op.create_index("ix_tag_alias_canonical", "tag_alias", ["canonical_tag_id"])
# 3.5 tag_reference_embedding (centroids)
op.create_table(
"tag_reference_embedding",
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.Column("embedding", Vector(1152), nullable=False),
sa.Column("reference_count", sa.Integer(), nullable=False),
sa.Column("model_version", sa.String(length=128), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["tag_id"], ["tag.id"],
name="fk_tag_reference_embedding_tag_id_tag", ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("tag_id", name="pk_tag_reference_embedding"),
)
# 3.6 ml_settings singleton
op.create_table(
"ml_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"suggestion_threshold_artist", sa.Float(), nullable=False,
server_default="0.30",
),
sa.Column(
"suggestion_threshold_character", sa.Float(), nullable=False,
server_default="0.50",
),
sa.Column(
"suggestion_threshold_copyright", sa.Float(), nullable=False,
server_default="0.50",
),
sa.Column(
"suggestion_threshold_general", sa.Float(), nullable=False,
server_default="0.95",
),
sa.Column(
"centroid_similarity_threshold", sa.Float(), nullable=False,
server_default="0.55",
),
sa.Column(
"min_reference_images", sa.Integer(), nullable=False,
server_default="5",
),
sa.Column(
"tagger_model_version", sa.String(length=128), nullable=False,
server_default="camie-tagger-v2",
),
sa.Column(
"embedder_model_version", sa.String(length=128), nullable=False,
server_default="siglip-so400m-patch14-384",
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.PrimaryKeyConstraint("id", name="pk_ml_settings"),
sa.CheckConstraint("id = 1", name="ck_ml_settings_singleton"),
)
op.execute("INSERT INTO ml_settings (id) VALUES (1)")
def downgrade() -> None:
op.drop_table("ml_settings")
op.drop_table("tag_reference_embedding")
op.drop_index("ix_tag_alias_canonical", table_name="tag_alias")
op.drop_table("tag_alias")
op.drop_index(
"ix_tag_suggestion_rejection_tag", table_name="tag_suggestion_rejection"
)
op.drop_table("tag_suggestion_rejection")
op.drop_table("tag_allowlist")
op.alter_column(
"image_record", "tagger_model_version", new_column_name="wd14_model_version"
)
op.alter_column(
"image_record", "tagger_predictions", new_column_name="wd14_predictions"
)
@@ -0,0 +1,23 @@
"""fc2c-i: enable tsm_system_rows for scalable random sampling
Revision ID: 0004
Revises: 0003
Create Date: 2026-05-15
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0004"
down_revision: Union[str, None] = "0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS tsm_system_rows")
def downgrade() -> None:
op.execute("DROP EXTENSION IF EXISTS tsm_system_rows")
@@ -0,0 +1,50 @@
"""fc2c-iii-a: series_page ordered membership
Revision ID: 0005
Revises: 0004
Create Date: 2026-05-16
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0005"
down_revision: Union[str, None] = "0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"series_page",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("series_tag_id", sa.Integer(), nullable=False),
sa.Column("image_id", sa.Integer(), nullable=False),
sa.Column("page_number", sa.Integer(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True),
nullable=False, server_default=sa.func.now(),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
nullable=False, server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["series_tag_id"], ["tag.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["image_id"], ["image_record.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("image_id", name="uq_series_page_image"),
)
op.create_index(
"ix_series_page_series_tag_id", "series_page", ["series_tag_id"]
)
def downgrade() -> None:
op.drop_index("ix_series_page_series_tag_id", table_name="series_page")
op.drop_table("series_page")
@@ -0,0 +1,30 @@
"""fc2d: import_settings.phash_threshold
Revision ID: 0006
Revises: 0005
Create Date: 2026-05-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0006"
down_revision: Union[str, None] = "0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"import_settings",
sa.Column(
"phash_threshold", sa.Integer(),
nullable=False, server_default="10",
),
)
def downgrade() -> None:
op.drop_column("import_settings", "phash_threshold")
@@ -0,0 +1,31 @@
"""fc2d-iv: post.description + post.attachment_count
Revision ID: 0007
Revises: 0006
Create Date: 2026-05-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: Union[str, None] = "0006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"post", sa.Column("description", sa.Text(), nullable=True)
)
op.add_column(
"post",
sa.Column("attachment_count", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("post", "attachment_count")
op.drop_column("post", "description")
@@ -0,0 +1,52 @@
"""fc2d-vii-c: image_record.artist_id + backfill + drop artist tags
Revision ID: 0008
Revises: 0007
Create Date: 2026-05-18
Internal forward-correctness migration (the big legacy-import migration
stays deferred). downgrade() does NOT recreate deleted artist tags;
downgrade is dev-only and the data is reconstructable by re-import.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from backend.app.utils.artist_backfill import (
BACKFILL_PRIMARY_SQL,
BACKFILL_PROVENANCE_SQL,
BACKFILL_TAG_SQL,
DELETE_ARTIST_TAGS_SQL,
)
revision: str = "0008"
down_revision: Union[str, None] = "0007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"image_record",
sa.Column("artist_id", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_image_record_artist_id", "image_record", "artist",
["artist_id"], ["id"], ondelete="SET NULL",
)
op.create_index(
"ix_image_record_artist_id", "image_record", ["artist_id"],
)
op.execute(BACKFILL_PRIMARY_SQL)
op.execute(BACKFILL_PROVENANCE_SQL)
op.execute(BACKFILL_TAG_SQL)
op.execute(DELETE_ARTIST_TAGS_SQL)
def downgrade() -> None:
op.drop_index("ix_image_record_artist_id", table_name="image_record")
op.drop_constraint(
"fk_image_record_artist_id", "image_record", type_="foreignkey"
)
op.drop_column("image_record", "artist_id")
@@ -0,0 +1,68 @@
"""fc2d-iii: post_attachment + import_batch.attachments
Revision ID: 0009
Revises: 0008
Create Date: 2026-05-19
Internal forward-correctness migration (big legacy-import migration
stays deferred). No backfill — no attachments exist yet.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0009"
down_revision: Union[str, None] = "0008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"post_attachment",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"post_id", sa.Integer(),
sa.ForeignKey("post.id", ondelete="SET NULL"), nullable=True,
),
sa.Column(
"artist_id", sa.Integer(),
sa.ForeignKey("artist.id", ondelete="SET NULL"), nullable=True,
),
sa.Column("sha256", sa.String(64), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("original_filename", sa.Text(), nullable=False),
sa.Column("ext", sa.String(32), nullable=False),
sa.Column("mime", sa.String(128), nullable=True),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column(
"captured_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
)
op.create_index(
"ix_post_attachment_sha256", "post_attachment", ["sha256"],
unique=True,
)
op.create_index(
"ix_post_attachment_post_id", "post_attachment", ["post_id"],
)
op.create_index(
"ix_post_attachment_artist_id", "post_attachment", ["artist_id"],
)
op.add_column(
"import_batch",
sa.Column(
"attachments", sa.Integer(), nullable=False,
server_default="0",
),
)
def downgrade() -> None:
op.drop_column("import_batch", "attachments")
op.drop_index("ix_post_attachment_artist_id", table_name="post_attachment")
op.drop_index("ix_post_attachment_post_id", table_name="post_attachment")
op.drop_index("ix_post_attachment_sha256", table_name="post_attachment")
op.drop_table("post_attachment")
@@ -0,0 +1,32 @@
"""fc3a: unique(source.artist_id, source.platform, source.url)
Revision ID: 0010
Revises: 0009
Create Date: 2026-05-20
Enforces FC-3a's dedup invariant at the DB level. No backfill — no
existing rows are expected to collide; if they do the migration will
fail loudly (intended).
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0010"
down_revision: Union[str, None] = "0009"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_unique_constraint(
"uq_source_artist_platform_url",
"source",
["artist_id", "platform", "url"],
)
def downgrade() -> None:
op.drop_constraint(
"uq_source_artist_platform_url", "source", type_="unique"
)
@@ -0,0 +1,41 @@
"""fc3b: rename credential.kind -> credential_type, drop status, add last_verified
Revision ID: 0011
Revises: 0010
Create Date: 2026-05-20
Aligns the credential table with the GallerySubscriber wire-field names
so the existing browser extension can POST to FC unmodified. Greenfield —
no rows exist in production yet, so no data preservation logic is
needed; the rename uses ALTER COLUMN rather than copy-then-drop.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0011"
down_revision: Union[str, None] = "0010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column("credential", "kind", new_column_name="credential_type")
op.drop_column("credential", "status")
op.add_column(
"credential",
sa.Column("last_verified", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("credential", "last_verified")
op.add_column(
"credential",
sa.Column(
"status", sa.String(length=32), nullable=False,
server_default="active",
),
)
op.alter_column("credential", "credential_type", new_column_name="kind")
+36
View File
@@ -0,0 +1,36 @@
"""fc3b: app_setting key/value table
Revision ID: 0012
Revises: 0011
Create Date: 2026-05-20
A simple key/value table for small app settings that don't fit
ImportSettings. Initially seeds only `extension_api_key` (done in
create_app on first boot — not in the migration, to keep it
deterministic and independent of randomness).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0012"
down_revision: Union[str, None] = "0011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"app_setting",
sa.Column("key", sa.String(length=64), primary_key=True),
sa.Column("value", sa.Text(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
nullable=False, server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("app_setting")
@@ -0,0 +1,52 @@
"""fc3c: download_event.metadata + import_settings downloader fields
Revision ID: 0013
Revises: 0012
Create Date: 2026-05-20
Additive only. download_event.metadata is the rich JSONB blob FC-3c
populates per run (run_stats, stdout/stderr, quarantined paths, import
summary). import_settings gains two operator-tunable downloader knobs:
download_rate_limit_seconds (gallery-dl extractor.sleep) and
download_validate_files (toggle the magic-byte validator).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0013"
down_revision: Union[str, None] = "0012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"download_event",
sa.Column(
"metadata", postgresql.JSONB,
nullable=False, server_default=sa.text("'{}'::jsonb"),
),
)
op.add_column(
"import_settings",
sa.Column(
"download_rate_limit_seconds", sa.Float(),
nullable=False, server_default="3.0",
),
)
op.add_column(
"import_settings",
sa.Column(
"download_validate_files", sa.Boolean(),
nullable=False, server_default=sa.true(),
),
)
def downgrade() -> None:
op.drop_column("import_settings", "download_validate_files")
op.drop_column("import_settings", "download_rate_limit_seconds")
op.drop_column("download_event", "metadata")
+58
View File
@@ -0,0 +1,58 @@
"""fc3d: scheduling + source health columns
Revision ID: 0014
Revises: 0013
Create Date: 2026-05-21
Additive only. source.consecutive_failures (default 0, DownloadService
finalize hook owns the writes). import_settings gains the three
scheduling knobs (global default interval, event retention, failure
warning threshold).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0014"
down_revision: Union[str, None] = "0013"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"source",
sa.Column(
"consecutive_failures", sa.Integer(),
nullable=False, server_default="0",
),
)
op.add_column(
"import_settings",
sa.Column(
"download_schedule_default_seconds", sa.Integer(),
nullable=False, server_default="28800",
),
)
op.add_column(
"import_settings",
sa.Column(
"download_event_retention_days", sa.Integer(),
nullable=False, server_default="90",
),
)
op.add_column(
"import_settings",
sa.Column(
"download_failure_warning_threshold", sa.Integer(),
nullable=False, server_default="5",
),
)
def downgrade() -> None:
op.drop_column("import_settings", "download_failure_warning_threshold")
op.drop_column("import_settings", "download_event_retention_days")
op.drop_column("import_settings", "download_schedule_default_seconds")
op.drop_column("source", "consecutive_failures")
@@ -0,0 +1,51 @@
"""fc5: migration_run table
Revision ID: 0015
Revises: 0014
Create Date: 2026-05-22
Additive only. New table tracks each invocation of the FC-5 migration
tooling (backup, gs, ir, ml_queue, verify, rollback). kind/status are
plain String(32) — values validated at the API layer per the spec, not
a Postgres ENUM (so adding kinds later doesn't need a schema migration).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0015"
down_revision: Union[str, None] = "0014"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"migration_run",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kind", sa.String(32), nullable=False, index=True),
sa.Column("status", sa.String(32), nullable=False, index=True),
sa.Column(
"dry_run", sa.Boolean(), nullable=False, server_default=sa.false(),
),
sa.Column(
"started_at", sa.DateTime(timezone=True),
nullable=False, server_default=sa.func.now(),
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"counts", postgresql.JSONB,
nullable=False, server_default=sa.text("'{}'::jsonb"),
),
sa.Column("error", sa.Text(), nullable=True),
sa.Column(
"metadata", postgresql.JSONB,
nullable=False, server_default=sa.text("'{}'::jsonb"),
),
)
def downgrade() -> None:
op.drop_table("migration_run")
-872
View File
@@ -1,872 +0,0 @@
"""Collapsed baseline — the whole schema in one revision.
Replaces revisions 0001..0087, which narrated the build-out of this project
and were deleted in milestone 328 step 1. A new install creates the schema in
one step instead of replaying that history.
WHY THE REVISION ID IS "0087" AND NOT "0001"
--------------------------------------------
It is deliberately the id of the LAST revision this baseline collapses, so an
existing database needs no intervention at all:
* a fresh install finds current=none, head=0087, runs this file once, and
ends stamped at 0087.
* an existing install is ALREADY at 0087, so `alembic upgrade head` finds
current == head and does nothing.
The alternative — numbering this 0001 and stamping every existing database —
means running `alembic stamp` against live data, and stamp VALIDATES NOTHING.
It writes a version string whether or not the schema actually matches, so a
wrong baseline would be discovered later, by the next real migration, with no
clean way back. Keeping the id removes that operation instead of making it
safe. Future revisions continue at 0088.
The one case this makes worse, and it fails LOUDLY rather than silently: a
database still sitting between 0001 and 0086 (i.e. never upgraded to head)
cannot be located in this chain and errors out. Upgrade to 0087 on a
pre-squash build first, then take this one.
WHAT IS HAND-WRITTEN HERE
-------------------------
Most of this file is `alembic revision --autogenerate` output, but four
things are NOT in SQLAlchemy metadata and the generator cannot produce them.
Each fails differently, and none of them fail at generation time:
1. CREATE EXTENSION vector (was 0001) — without it the VECTOR
columns below cannot be created at all.
2. CREATE EXTENSION tsm_system_rows (was 0004) — used by the random-sample
query path; its absence surfaces only when that query runs.
3. The HNSW index on image_record.siglip_embedding (was 0036). Raw SQL
because alembic's create_index cannot express `USING hnsw (...
vector_cosine_ops)`. Its absence is the quietest failure of the four:
everything works, similarity search just stops using an index.
4. `import pgvector.sqlalchemy.vector`. Autogenerate EMITS references to
pgvector.sqlalchemy.vector.VECTOR but does not add the import, so the
generated file dies with NameError on first run.
The acceptance test for this file is not that it reads correctly — it is
`.forgejo/workflows/baseline.yml`, which builds a database from the old
0001..0087 chain (read out of git) and one from this file, and diffs
pg_dump --schema-only output. That is what proves nothing was missed.
Revision ID: 0087
Revises:
Create Date: 2026-08-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# Autogenerate references pgvector.sqlalchemy.vector.VECTOR without importing
# it. Item 4 above.
import pgvector.sqlalchemy.vector
revision: str = "0087"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Extensions FIRST: the VECTOR columns below cannot be created without
# `vector`, so ordering here is load-bearing, not tidiness.
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.execute("CREATE EXTENSION IF NOT EXISTS tsm_system_rows")
op.create_table('app_setting',
sa.Column('key', sa.String(length=64), nullable=False),
sa.Column('value', sa.Text(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('key', name=op.f('pk_app_setting'))
)
op.create_table('artist',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('is_subscription', sa.Boolean(), nullable=False),
sa.Column('auto_check', sa.Boolean(), nullable=False),
sa.Column('check_interval_seconds', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_artist')),
sa.UniqueConstraint('slug', name=op.f('uq_artist_slug'))
)
op.create_table('backup_run',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(length=16), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('tag', sa.String(length=64), nullable=True),
sa.Column('triggered_by', sa.String(length=32), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('sql_path', sa.Text(), nullable=True),
sa.Column('tar_path', sa.Text(), nullable=True),
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('manifest', sa.JSON(), server_default='{}', nullable=False),
sa.Column('restored_from_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['restored_from_id'], ['backup_run.id'], name=op.f('fk_backup_run_restored_from_id_backup_run'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_backup_run'))
)
op.create_index(op.f('ix_backup_run_finished_at'), 'backup_run', ['finished_at'], unique=False)
op.create_index(op.f('ix_backup_run_kind'), 'backup_run', ['kind'], unique=False)
op.create_index(op.f('ix_backup_run_started_at'), 'backup_run', ['started_at'], unique=False)
op.create_index(op.f('ix_backup_run_status'), 'backup_run', ['status'], unique=False)
op.create_index(op.f('ix_backup_run_tag'), 'backup_run', ['tag'], unique=False)
op.create_table('credential',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=64), nullable=False),
sa.Column('credential_type', sa.String(length=32), nullable=False),
sa.Column('encrypted_blob', sa.LargeBinary(), nullable=False),
sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_verified', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_credential')),
sa.UniqueConstraint('platform', name=op.f('uq_credential_platform'))
)
op.create_table('head_auto_apply_run',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dry_run', sa.Boolean(), nullable=False),
sa.Column('params', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('n_applied', sa.Integer(), nullable=True),
sa.Column('report', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('last_progress_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_head_auto_apply_run'))
)
op.create_index(op.f('ix_head_auto_apply_run_status'), 'head_auto_apply_run', ['status'], unique=False)
op.create_table('head_training_run',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('params', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('n_trained', sa.Integer(), nullable=True),
sa.Column('n_skipped', sa.Integer(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('last_progress_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_head_training_run'))
)
op.create_index(op.f('ix_head_training_run_status'), 'head_training_run', ['status'], unique=False)
op.create_table('import_batch',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('triggered_by', sa.String(length=32), nullable=False),
sa.Column('source_path', sa.Text(), nullable=False),
sa.Column('scan_mode', sa.String(length=16), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('total_files', sa.Integer(), nullable=False),
sa.Column('imported', sa.Integer(), nullable=False),
sa.Column('skipped', sa.Integer(), nullable=False),
sa.Column('failed', sa.Integer(), nullable=False),
sa.Column('attachments', sa.Integer(), nullable=False),
sa.Column('refreshed', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_import_batch'))
)
op.create_index(op.f('ix_import_batch_status'), 'import_batch', ['status'], unique=False)
op.create_table('import_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('import_scan_path', sa.Text(), nullable=False),
sa.Column('min_width', sa.Integer(), nullable=False),
sa.Column('min_height', sa.Integer(), nullable=False),
sa.Column('skip_transparent', sa.Boolean(), nullable=False),
sa.Column('transparency_threshold', sa.Float(), nullable=False),
sa.Column('skip_single_color', sa.Boolean(), nullable=False),
sa.Column('single_color_threshold', sa.Float(), nullable=False),
sa.Column('single_color_tolerance', sa.Integer(), nullable=False),
sa.Column('phash_threshold', sa.Integer(), nullable=False),
sa.Column('download_rate_limit_seconds', sa.Float(), nullable=False),
sa.Column('download_validate_files', sa.Boolean(), nullable=False),
sa.Column('download_schedule_default_seconds', sa.Integer(), nullable=False),
sa.Column('download_event_retention_days', sa.Integer(), nullable=False),
sa.Column('download_failure_warning_threshold', sa.Integer(), nullable=False),
sa.Column('backup_db_nightly_enabled', sa.Boolean(), nullable=False),
sa.Column('backup_db_nightly_hour_utc', sa.Integer(), nullable=False),
sa.Column('backup_db_keep_last_n', sa.Integer(), nullable=False),
sa.Column('backup_images_keep_last_n', sa.Integer(), nullable=False),
sa.Column('series_suggest_enabled', sa.Boolean(), nullable=False),
sa.Column('series_suggest_threshold', sa.Float(), nullable=False),
sa.Column('extdl_mega_enabled', sa.Boolean(), server_default='true', nullable=False),
sa.Column('extdl_gdrive_enabled', sa.Boolean(), server_default='true', nullable=False),
sa.Column('extdl_mediafire_enabled', sa.Boolean(), server_default='true', nullable=False),
sa.Column('extdl_dropbox_enabled', sa.Boolean(), server_default='true', nullable=False),
sa.Column('extdl_pixeldrain_enabled', sa.Boolean(), server_default='true', nullable=False),
sa.Column('translation_enabled', sa.Boolean(), server_default='false', nullable=False),
sa.Column('interpreter_base_url', sa.Text(), server_default='', nullable=False),
sa.Column('translation_target_lang', sa.Text(), server_default='en', nullable=False),
sa.Column('translation_min_confidence', sa.Float(), server_default='0.9', nullable=False),
sa.Column('wip_title_tagging_enabled', sa.Boolean(), server_default='true', nullable=False),
sa.Column('wip_soft_title_tagging_enabled', sa.Boolean(), server_default='false', nullable=False),
sa.CheckConstraint('id = 1', name=op.f('ck_import_settings_singleton')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_import_settings'))
)
op.create_table('library_audit_run',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('rule', sa.String(length=32), nullable=False),
sa.Column('params', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('scanned_count', sa.Integer(), nullable=False),
sa.Column('matched_count', sa.Integer(), nullable=False),
sa.Column('matched_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('resume_after_id', sa.Integer(), nullable=False),
sa.Column('last_progress_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_library_audit_run'))
)
op.create_index(op.f('ix_library_audit_run_rule'), 'library_audit_run', ['rule'], unique=False)
op.create_index(op.f('ix_library_audit_run_status'), 'library_audit_run', ['status'], unique=False)
op.create_table('ml_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('cpu_embed_enabled', sa.Boolean(), nullable=False),
sa.Column('video_frame_interval_seconds', sa.Float(), nullable=False),
sa.Column('video_max_frames', sa.Integer(), nullable=False),
sa.Column('head_min_positives', sa.Integer(), nullable=False),
sa.Column('head_auto_apply_precision', sa.Float(), nullable=False),
sa.Column('head_auto_apply_enabled', sa.Boolean(), nullable=False),
sa.Column('head_auto_apply_min_positives', sa.Integer(), nullable=False),
sa.Column('ccip_match_threshold', sa.Float(), nullable=False),
sa.Column('ccip_auto_apply_enabled', sa.Boolean(), nullable=False),
sa.Column('ccip_auto_apply_threshold', sa.Float(), nullable=False),
sa.Column('presentation_auto_apply_enabled', sa.Boolean(), nullable=False),
sa.Column('presentation_auto_apply_threshold', sa.Float(), nullable=False),
sa.Column('presentation_conflict_threshold', sa.Float(), nullable=False),
sa.Column('process_auto_apply_enabled', sa.Boolean(), nullable=False),
sa.Column('process_auto_apply_threshold', sa.Float(), nullable=False),
sa.Column('process_conflict_threshold', sa.Float(), nullable=False),
sa.Column('embedder_model_version', sa.String(length=128), nullable=False),
sa.Column('embedder_model_name', sa.String(length=128), nullable=False),
sa.Column('detector_person_enabled', sa.Boolean(), nullable=False),
sa.Column('detector_person_weights', sa.String(length=512), nullable=False),
sa.Column('detector_person_conf', sa.Float(), nullable=False),
sa.Column('detector_anatomy_enabled', sa.Boolean(), nullable=False),
sa.Column('detector_anatomy_weights', sa.String(length=512), nullable=False),
sa.Column('detector_anatomy_conf', sa.Float(), nullable=False),
sa.Column('detector_panel_enabled', sa.Boolean(), nullable=False),
sa.Column('detector_panel_weights', sa.String(length=512), nullable=False),
sa.Column('detector_panel_conf', sa.Float(), nullable=False),
sa.Column('detector_max_figures', sa.Integer(), nullable=False),
sa.Column('detector_max_components', sa.Integer(), nullable=False),
sa.Column('detector_max_panels', sa.Integer(), nullable=False),
sa.Column('detector_max_regions', sa.Integer(), nullable=False),
sa.Column('detector_dedupe_iou', sa.Float(), nullable=False),
sa.Column('ccip_ref_signature', sa.String(length=128), nullable=True),
sa.Column('ccip_prototype_cap', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('id = 1', name=op.f('ck_ml_settings_singleton')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_ml_settings'))
)
op.create_table('tag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('kind', sa.Enum('artist', 'character', 'fandom', 'general', 'series', 'archive', 'post', name='tag_kind'), nullable=False),
sa.Column('fandom_id', sa.Integer(), nullable=True),
sa.Column('is_system', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("(fandom_id IS NULL) OR (kind = 'character')", name=op.f('ck_tag_ck_tag_fandom_requires_character')),
sa.ForeignKeyConstraint(['fandom_id'], ['tag.id'], name=op.f('fk_tag_fandom_id_tag'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tag'))
)
op.create_index(op.f('ix_tag_fandom_id'), 'tag', ['fandom_id'], unique=False)
op.create_table('task_run',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('celery_task_id', sa.String(length=64), nullable=False),
sa.Column('queue', sa.String(length=32), nullable=False),
sa.Column('task_name', sa.String(length=128), nullable=False),
sa.Column('target_id', sa.Integer(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('error_type', sa.String(length=128), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('retry_count', sa.Integer(), nullable=True),
sa.Column('worker_hostname', sa.String(length=128), nullable=True),
sa.Column('args_summary', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_task_run'))
)
op.create_index(op.f('ix_task_run_celery_task_id'), 'task_run', ['celery_task_id'], unique=False)
op.create_index(op.f('ix_task_run_finished_at'), 'task_run', ['finished_at'], unique=False)
op.create_index(op.f('ix_task_run_queue'), 'task_run', ['queue'], unique=False)
op.create_index(op.f('ix_task_run_started_at'), 'task_run', ['started_at'], unique=False)
op.create_index(op.f('ix_task_run_status'), 'task_run', ['status'], unique=False)
op.create_index(op.f('ix_task_run_task_name'), 'task_run', ['task_name'], unique=False)
op.create_table('artist_visit',
sa.Column('artist_id', sa.Integer(), nullable=False),
sa.Column('last_viewed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_artist_visit_artist_id_artist'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('artist_id', name=op.f('pk_artist_visit'))
)
op.create_table('ccip_prototype_state',
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('fingerprint', sa.String(length=64), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_ccip_prototype_state_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('tag_id', name=op.f('pk_ccip_prototype_state'))
)
op.create_table('head_metric',
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('n_misfires', sa.Integer(), nullable=False),
sa.Column('n_underfires', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_head_metric_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('tag_id', name=op.f('pk_head_metric'))
)
op.create_table('head_metrics_snapshot',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('snapshot_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('n_auto_applied', sa.Integer(), nullable=False),
sa.Column('n_misfires', sa.Integer(), nullable=False),
sa.Column('n_underfires', sa.Integer(), nullable=False),
sa.Column('ap', sa.Float(), nullable=True),
sa.Column('precision_cv', sa.Float(), nullable=True),
sa.Column('recall', sa.Float(), nullable=True),
sa.Column('n_pos', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_head_metrics_snapshot_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_head_metrics_snapshot'))
)
op.create_index(op.f('ix_head_metrics_snapshot_snapshot_at'), 'head_metrics_snapshot', ['snapshot_at'], unique=False)
op.create_index(op.f('ix_head_metrics_snapshot_tag_id'), 'head_metrics_snapshot', ['tag_id'], unique=False)
op.create_table('source',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('artist_id', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=64), nullable=False),
sa.Column('url', sa.Text(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('config_overrides', sa.JSON(), nullable=True),
sa.Column('last_checked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('error_type', sa.String(length=32), nullable=True),
sa.Column('check_interval_override', sa.Integer(), nullable=True),
sa.Column('consecutive_failures', sa.Integer(), nullable=False),
sa.Column('backfill_runs_remaining', sa.Integer(), server_default='0', nullable=False),
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_source_artist_id_artist'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_source'))
)
op.create_index(op.f('ix_source_artist_id'), 'source', ['artist_id'], unique=False)
op.create_index(op.f('ix_source_error_type'), 'source', ['error_type'], unique=False)
op.create_table('tag_alias',
sa.Column('alias_string', sa.String(length=255), nullable=False),
sa.Column('alias_category', sa.String(length=32), nullable=False),
sa.Column('canonical_tag_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['canonical_tag_id'], ['tag.id'], name=op.f('fk_tag_alias_canonical_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('alias_string', 'alias_category', name=op.f('pk_tag_alias'))
)
op.create_index(op.f('ix_tag_alias_canonical_tag_id'), 'tag_alias', ['canonical_tag_id'], unique=False)
op.create_table('tag_head',
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('embedding_version', sa.String(length=128), nullable=False),
sa.Column('weights', pgvector.sqlalchemy.vector.VECTOR(dim=1152), nullable=False),
sa.Column('bias', sa.Float(), nullable=False),
sa.Column('suggest_threshold', sa.Float(), nullable=False),
sa.Column('auto_apply_threshold', sa.Float(), nullable=True),
sa.Column('n_pos', sa.Integer(), nullable=False),
sa.Column('n_neg', sa.Integer(), nullable=False),
sa.Column('ap', sa.Float(), nullable=False),
sa.Column('precision_cv', sa.Float(), nullable=False),
sa.Column('recall', sa.Float(), nullable=False),
sa.Column('trained_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('train_fingerprint', sa.String(length=128), nullable=True),
sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_tag_head_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('tag_id', name=op.f('pk_tag_head'))
)
op.create_table('patreon_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(), 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_patreon_failed_media_source_id_source'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_patreon_failed_media')),
sa.UniqueConstraint('source_id', 'filehash', name='uq_patreon_failed_media_source_id')
)
op.create_index(op.f('ix_patreon_failed_media_source_id'), 'patreon_failed_media', ['source_id'], unique=False)
op.create_table('patreon_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_patreon_seen_media_source_id_source'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_patreon_seen_media')),
sa.UniqueConstraint('source_id', 'filehash', name='uq_patreon_seen_media_source_id')
)
op.create_index(op.f('ix_patreon_seen_media_source_id'), 'patreon_seen_media', ['source_id'], unique=False)
op.create_table('pixiv_failed_media',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('source_id', sa.Integer(), nullable=False),
sa.Column('filehash', sa.String(length=128), nullable=False),
sa.Column('attempts', sa.Integer(), 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_pixiv_failed_media_source_id_source'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_pixiv_failed_media')),
sa.UniqueConstraint('source_id', 'filehash', name='uq_pixiv_failed_media_source_id')
)
op.create_index(op.f('ix_pixiv_failed_media_source_id'), 'pixiv_failed_media', ['source_id'], unique=False)
op.create_table('pixiv_seen_media',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('source_id', sa.Integer(), nullable=False),
sa.Column('filehash', sa.String(length=128), nullable=False),
sa.Column('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_pixiv_seen_media_source_id_source'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_pixiv_seen_media')),
sa.UniqueConstraint('source_id', 'filehash', name='uq_pixiv_seen_media_source_id')
)
op.create_index(op.f('ix_pixiv_seen_media_source_id'), 'pixiv_seen_media', ['source_id'], unique=False)
op.create_table('post',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('source_id', sa.Integer(), nullable=True),
sa.Column('artist_id', sa.Integer(), nullable=False),
sa.Column('external_post_id', sa.String(length=128), nullable=False),
sa.Column('post_url', sa.Text(), nullable=True),
sa.Column('post_title', sa.Text(), nullable=True),
sa.Column('post_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('raw_metadata', sa.JSON(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('attachment_count', sa.Integer(), nullable=True),
sa.Column('post_title_translated', sa.Text(), nullable=True),
sa.Column('description_translated', sa.Text(), nullable=True),
sa.Column('translated_source_lang', sa.String(length=8), nullable=True),
sa.Column('translation_engine_version', sa.String(length=128), nullable=True),
sa.Column('translated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('translation_override', sa.String(length=16), server_default='auto', nullable=False),
sa.Column('downloaded_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("translation_override IN ('auto', 'force', 'original')", name=op.f('ck_post_ck_post_translation_override')),
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_post_artist_id_artist'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_post_source_id_source'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_post')),
sa.UniqueConstraint('source_id', 'external_post_id', name='uq_post_source_external_id')
)
op.create_index(op.f('ix_post_artist_id'), 'post', ['artist_id'], unique=False)
op.create_index(op.f('ix_post_source_id'), 'post', ['source_id'], unique=False)
op.create_table('subscribestar_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(), 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_subscribestar_failed_media_source_id_source'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_subscribestar_failed_media')),
sa.UniqueConstraint('source_id', 'filehash', name='uq_subscribestar_failed_media_source_id')
)
op.create_index(op.f('ix_subscribestar_failed_media_source_id'), 'subscribestar_failed_media', ['source_id'], unique=False)
op.create_table('subscribestar_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_subscribestar_seen_media_source_id_source'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_subscribestar_seen_media')),
sa.UniqueConstraint('source_id', 'filehash', name='uq_subscribestar_seen_media_source_id')
)
op.create_index(op.f('ix_subscribestar_seen_media_source_id'), 'subscribestar_seen_media', ['source_id'], unique=False)
op.create_table('download_event',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('source_id', sa.Integer(), nullable=False),
sa.Column('post_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=32), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('bytes_downloaded', sa.BigInteger(), nullable=False),
sa.Column('files_count', sa.Integer(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_download_event_post_id_post'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_download_event_source_id_source'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_download_event'))
)
op.create_index(op.f('ix_download_event_post_id'), 'download_event', ['post_id'], unique=False)
op.create_index(op.f('ix_download_event_source_id'), 'download_event', ['source_id'], unique=False)
op.create_table('image_record',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('path', sa.Text(), nullable=False),
sa.Column('sha256', sa.String(length=64), nullable=False),
sa.Column('phash', sa.String(length=32), nullable=True),
sa.Column('size_bytes', sa.BigInteger(), nullable=False),
sa.Column('mime', sa.String(length=64), nullable=False),
sa.Column('width', sa.Integer(), nullable=True),
sa.Column('height', sa.Integer(), nullable=True),
sa.Column('duration_seconds', sa.Float(), nullable=True),
sa.Column('integrity_status', sa.String(length=24), nullable=False),
sa.Column('thumbnail_path', sa.Text(), nullable=True),
sa.Column('source_url', sa.Text(), nullable=True),
sa.Column('source_filehash', sa.String(length=32), nullable=True),
sa.Column('origin', sa.Enum('downloaded', 'imported_filesystem', 'uploaded', name='origin_enum'), nullable=False),
sa.Column('primary_post_id', sa.Integer(), nullable=True),
sa.Column('artist_id', sa.Integer(), nullable=True),
sa.Column('siglip_embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1152), nullable=True),
sa.Column('siglip_model_version', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('effective_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('earliest_post_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_image_record_artist_id_artist'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['primary_post_id'], ['post.id'], name=op.f('fk_image_record_primary_post_id_post'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_image_record')),
sa.UniqueConstraint('path', name=op.f('uq_image_record_path'))
)
op.create_index(op.f('ix_image_record_artist_id'), 'image_record', ['artist_id'], unique=False)
op.create_index(op.f('ix_image_record_integrity_status'), 'image_record', ['integrity_status'], unique=False)
op.create_index(op.f('ix_image_record_phash'), 'image_record', ['phash'], unique=False)
op.create_index(op.f('ix_image_record_primary_post_id'), 'image_record', ['primary_post_id'], unique=False)
op.create_index(op.f('ix_image_record_sha256'), 'image_record', ['sha256'], unique=True)
op.create_index(op.f('ix_image_record_source_filehash'), 'image_record', ['source_filehash'], unique=False)
op.create_table('post_attachment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('post_id', sa.Integer(), nullable=True),
sa.Column('artist_id', sa.Integer(), nullable=True),
sa.Column('sha256', sa.String(length=64), nullable=False),
sa.Column('path', sa.Text(), nullable=False),
sa.Column('original_filename', sa.Text(), nullable=False),
sa.Column('ext', sa.String(length=32), nullable=False),
sa.Column('mime', sa.String(length=128), nullable=True),
sa.Column('size_bytes', sa.BigInteger(), nullable=False),
sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_post_attachment_artist_id_artist'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_post_attachment_post_id_post'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_post_attachment'))
)
op.create_index(op.f('ix_post_attachment_artist_id'), 'post_attachment', ['artist_id'], unique=False)
op.create_index(op.f('ix_post_attachment_post_id'), 'post_attachment', ['post_id'], unique=False)
op.create_index(op.f('ix_post_attachment_sha256'), 'post_attachment', ['sha256'], unique=False)
op.create_index('uq_post_attachment_null_post_sha', 'post_attachment', ['sha256'], unique=True, postgresql_where=sa.text('post_id IS NULL'))
op.create_index('uq_post_attachment_post_sha', 'post_attachment', ['post_id', 'sha256'], unique=True, postgresql_where=sa.text('post_id IS NOT NULL'))
op.create_table('series_suggestion',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('post_id', sa.Integer(), nullable=False),
sa.Column('series_tag_id', sa.Integer(), nullable=False),
sa.Column('score', sa.Float(), nullable=False),
sa.Column('signals', sa.JSON(), nullable=True),
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_series_suggestion_post_id_post'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['series_tag_id'], ['tag.id'], name=op.f('fk_series_suggestion_series_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_series_suggestion')),
sa.UniqueConstraint('post_id', 'series_tag_id', name='uq_series_suggestion_post_series')
)
op.create_index(op.f('ix_series_suggestion_post_id'), 'series_suggestion', ['post_id'], unique=False)
op.create_index(op.f('ix_series_suggestion_series_tag_id'), 'series_suggestion', ['series_tag_id'], unique=False)
op.create_index(op.f('ix_series_suggestion_status'), 'series_suggestion', ['status'], unique=False)
op.create_table('external_link',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('post_id', sa.Integer(), nullable=False),
sa.Column('artist_id', sa.Integer(), nullable=True),
sa.Column('host', sa.String(length=16), nullable=False),
sa.Column('url', sa.Text(), nullable=False),
sa.Column('label', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
sa.Column('attempts', sa.Integer(), server_default=sa.text('0'), nullable=False),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('attachment_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Float(), nullable=True),
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_external_link_artist_id_artist'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['attachment_id'], ['post_attachment.id'], name=op.f('fk_external_link_attachment_id_post_attachment'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_external_link_post_id_post'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_external_link'))
)
op.create_index(op.f('ix_external_link_artist_id'), 'external_link', ['artist_id'], unique=False)
op.create_index(op.f('ix_external_link_post_id'), 'external_link', ['post_id'], unique=False)
op.create_index('ix_external_link_status', 'external_link', ['status'], unique=False)
op.create_index('uq_external_link_post_url', 'external_link', ['post_id', 'url'], unique=True)
op.create_table('gpu_job',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('image_record_id', sa.Integer(), nullable=False),
sa.Column('task', sa.String(length=32), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('lease_token', sa.String(length=64), nullable=True),
sa.Column('leased_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('attempts', sa.Integer(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('triage_status', sa.String(length=16), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_gpu_job_image_record_id_image_record'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_gpu_job'))
)
op.create_index(op.f('ix_gpu_job_image_record_id'), 'gpu_job', ['image_record_id'], unique=False)
op.create_index('ix_gpu_job_leased_expires', 'gpu_job', ['lease_expires_at'], unique=False, postgresql_where=sa.text("status = 'leased'"))
op.create_index('ix_gpu_job_pending', 'gpu_job', ['id'], unique=False, postgresql_where=sa.text("status = 'pending'"))
op.create_index(op.f('ix_gpu_job_status'), 'gpu_job', ['status'], unique=False)
op.create_table('image_provenance',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('image_record_id', sa.Integer(), nullable=False),
sa.Column('post_id', sa.Integer(), nullable=False),
sa.Column('source_id', sa.Integer(), nullable=True),
sa.Column('from_attachment_id', sa.Integer(), nullable=True),
sa.Column('captured_metadata', sa.JSON(), nullable=True),
sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['from_attachment_id'], ['post_attachment.id'], name=op.f('fk_image_provenance_from_attachment_id_post_attachment'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_image_provenance_image_record_id_image_record'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_image_provenance_post_id_post'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_image_provenance_source_id_source'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_image_provenance')),
sa.UniqueConstraint('image_record_id', 'post_id', name='uq_image_provenance_image_post')
)
op.create_index(op.f('ix_image_provenance_from_attachment_id'), 'image_provenance', ['from_attachment_id'], unique=False)
op.create_index(op.f('ix_image_provenance_image_record_id'), 'image_provenance', ['image_record_id'], unique=False)
op.create_index(op.f('ix_image_provenance_post_id'), 'image_provenance', ['post_id'], unique=False)
op.create_index(op.f('ix_image_provenance_source_id'), 'image_provenance', ['source_id'], unique=False)
op.create_table('image_region',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('image_record_id', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(length=16), nullable=False),
sa.Column('frame_time', sa.Float(), nullable=True),
sa.Column('rx', sa.Float(), nullable=False),
sa.Column('ry', sa.Float(), nullable=False),
sa.Column('rw', sa.Float(), nullable=False),
sa.Column('rh', sa.Float(), nullable=False),
sa.Column('score', sa.Float(), nullable=True),
sa.Column('detector_version', sa.String(length=64), nullable=True),
sa.Column('crop_version', sa.String(length=64), nullable=True),
sa.Column('embedding_version', sa.String(length=128), nullable=True),
sa.Column('ccip_embedding', pgvector.sqlalchemy.vector.VECTOR(dim=768), nullable=True),
sa.Column('siglip_embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1152), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_image_region_image_record_id_image_record'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_image_region'))
)
op.create_index(op.f('ix_image_region_image_record_id'), 'image_region', ['image_record_id'], unique=False)
op.create_table('image_tag',
sa.Column('image_record_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('source', sa.String(length=32), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_image_tag_image_record_id_image_record'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_image_tag_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('image_record_id', 'tag_id', name=op.f('pk_image_tag'))
)
op.create_table('import_task',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('batch_id', sa.Integer(), nullable=False),
sa.Column('source_path', sa.Text(), nullable=False),
sa.Column('task_type', sa.String(length=16), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('recovery_count', sa.Integer(), nullable=False),
sa.Column('refetched', sa.Boolean(), nullable=False),
sa.Column('result_image_id', sa.Integer(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['batch_id'], ['import_batch.id'], name=op.f('fk_import_task_batch_id_import_batch'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['result_image_id'], ['image_record.id'], name=op.f('fk_import_task_result_image_id_image_record'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_import_task'))
)
op.create_index(op.f('ix_import_task_batch_id'), 'import_task', ['batch_id'], unique=False)
op.create_index(op.f('ix_import_task_status'), 'import_task', ['status'], unique=False)
op.create_table('presentation_review',
sa.Column('image_record_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('conflict_tag_id', sa.Integer(), nullable=True),
sa.Column('conflict_score', sa.Float(), nullable=False),
sa.Column('mode', sa.String(length=16), server_default='chrome', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['conflict_tag_id'], ['tag.id'], name=op.f('fk_presentation_review_conflict_tag_id_tag'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_presentation_review_image_record_id_image_record'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_presentation_review_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('image_record_id', 'tag_id', name=op.f('pk_presentation_review'))
)
op.create_table('series_page',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('series_tag_id', sa.Integer(), nullable=False),
sa.Column('image_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), server_default='placed', nullable=False),
sa.Column('page_number', sa.Integer(), nullable=True),
sa.Column('stated_page', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], name=op.f('fk_series_page_image_id_image_record'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['series_tag_id'], ['tag.id'], name=op.f('fk_series_page_series_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_series_page')),
sa.UniqueConstraint('image_id', name=op.f('uq_series_page_image_id'))
)
op.create_index(op.f('ix_series_page_series_tag_id'), 'series_page', ['series_tag_id'], unique=False)
op.create_table('tag_positive_confirmation',
sa.Column('image_record_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('confirmed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_tag_positive_confirmation_image_record_id_image_record'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_tag_positive_confirmation_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('image_record_id', 'tag_id', name=op.f('pk_tag_positive_confirmation'))
)
op.create_index(op.f('ix_tag_positive_confirmation_tag_id'), 'tag_positive_confirmation', ['tag_id'], unique=False)
op.create_table('tag_suggestion_rejection',
sa.Column('image_record_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('rejected_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_tag_suggestion_rejection_image_record_id_image_record'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_tag_suggestion_rejection_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('image_record_id', 'tag_id', name=op.f('pk_tag_suggestion_rejection'))
)
op.create_index(op.f('ix_tag_suggestion_rejection_tag_id'), 'tag_suggestion_rejection', ['tag_id'], unique=False)
op.create_table('character_prototype',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('ccip_embedding', pgvector.sqlalchemy.vector.VECTOR(dim=768), nullable=False),
sa.Column('region_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['region_id'], ['image_region.id'], name=op.f('fk_character_prototype_region_id_image_region'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_character_prototype_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_character_prototype'))
)
op.create_index(op.f('ix_character_prototype_tag_id'), 'character_prototype', ['tag_id'], unique=False)
op.create_table('series_chapter',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('series_tag_id', sa.Integer(), nullable=False),
sa.Column('anchor_page_id', sa.Integer(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('stated_part', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['anchor_page_id'], ['series_page.id'], name=op.f('fk_series_chapter_anchor_page_id_series_page'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['series_tag_id'], ['tag.id'], name=op.f('fk_series_chapter_series_tag_id_tag'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_series_chapter')),
sa.UniqueConstraint('anchor_page_id', name=op.f('uq_series_chapter_anchor_page_id'))
)
op.create_index(op.f('ix_series_chapter_series_tag_id'), 'series_chapter', ['series_tag_id'], unique=False)
# The HNSW index, item 3 above. Must match the query's cosine-distance
# operator class or the planner will not use it.
op.execute(
"CREATE INDEX ix_image_record_siglip_hnsw "
"ON image_record USING hnsw (siglip_embedding vector_cosine_ops)"
)
def downgrade() -> None:
# Dropping image_record takes its indexes with it, so the HNSW index needs
# no separate drop. The extensions are deliberately left in place: they are
# database-scoped and something else may be using them.
op.drop_index(op.f('ix_series_chapter_series_tag_id'), table_name='series_chapter')
op.drop_table('series_chapter')
op.drop_index(op.f('ix_character_prototype_tag_id'), table_name='character_prototype')
op.drop_table('character_prototype')
op.drop_index(op.f('ix_tag_suggestion_rejection_tag_id'), table_name='tag_suggestion_rejection')
op.drop_table('tag_suggestion_rejection')
op.drop_index(op.f('ix_tag_positive_confirmation_tag_id'), table_name='tag_positive_confirmation')
op.drop_table('tag_positive_confirmation')
op.drop_index(op.f('ix_series_page_series_tag_id'), table_name='series_page')
op.drop_table('series_page')
op.drop_table('presentation_review')
op.drop_index(op.f('ix_import_task_status'), table_name='import_task')
op.drop_index(op.f('ix_import_task_batch_id'), table_name='import_task')
op.drop_table('import_task')
op.drop_table('image_tag')
op.drop_index(op.f('ix_image_region_image_record_id'), table_name='image_region')
op.drop_table('image_region')
op.drop_index(op.f('ix_image_provenance_source_id'), table_name='image_provenance')
op.drop_index(op.f('ix_image_provenance_post_id'), table_name='image_provenance')
op.drop_index(op.f('ix_image_provenance_image_record_id'), table_name='image_provenance')
op.drop_index(op.f('ix_image_provenance_from_attachment_id'), table_name='image_provenance')
op.drop_table('image_provenance')
op.drop_index(op.f('ix_gpu_job_status'), table_name='gpu_job')
op.drop_index('ix_gpu_job_pending', table_name='gpu_job', postgresql_where=sa.text("status = 'pending'"))
op.drop_index('ix_gpu_job_leased_expires', table_name='gpu_job', postgresql_where=sa.text("status = 'leased'"))
op.drop_index(op.f('ix_gpu_job_image_record_id'), table_name='gpu_job')
op.drop_table('gpu_job')
op.drop_index('uq_external_link_post_url', table_name='external_link')
op.drop_index('ix_external_link_status', table_name='external_link')
op.drop_index(op.f('ix_external_link_post_id'), table_name='external_link')
op.drop_index(op.f('ix_external_link_artist_id'), table_name='external_link')
op.drop_table('external_link')
op.drop_index(op.f('ix_series_suggestion_status'), table_name='series_suggestion')
op.drop_index(op.f('ix_series_suggestion_series_tag_id'), table_name='series_suggestion')
op.drop_index(op.f('ix_series_suggestion_post_id'), table_name='series_suggestion')
op.drop_table('series_suggestion')
op.drop_index('uq_post_attachment_post_sha', table_name='post_attachment', postgresql_where=sa.text('post_id IS NOT NULL'))
op.drop_index('uq_post_attachment_null_post_sha', table_name='post_attachment', postgresql_where=sa.text('post_id IS NULL'))
op.drop_index(op.f('ix_post_attachment_sha256'), table_name='post_attachment')
op.drop_index(op.f('ix_post_attachment_post_id'), table_name='post_attachment')
op.drop_index(op.f('ix_post_attachment_artist_id'), table_name='post_attachment')
op.drop_table('post_attachment')
op.drop_index(op.f('ix_image_record_source_filehash'), table_name='image_record')
op.drop_index(op.f('ix_image_record_sha256'), table_name='image_record')
op.drop_index(op.f('ix_image_record_primary_post_id'), table_name='image_record')
op.drop_index(op.f('ix_image_record_phash'), table_name='image_record')
op.drop_index(op.f('ix_image_record_integrity_status'), table_name='image_record')
op.drop_index(op.f('ix_image_record_artist_id'), table_name='image_record')
op.drop_table('image_record')
op.drop_index(op.f('ix_download_event_source_id'), table_name='download_event')
op.drop_index(op.f('ix_download_event_post_id'), table_name='download_event')
op.drop_table('download_event')
op.drop_index(op.f('ix_subscribestar_seen_media_source_id'), table_name='subscribestar_seen_media')
op.drop_table('subscribestar_seen_media')
op.drop_index(op.f('ix_subscribestar_failed_media_source_id'), table_name='subscribestar_failed_media')
op.drop_table('subscribestar_failed_media')
op.drop_index(op.f('ix_post_source_id'), table_name='post')
op.drop_index(op.f('ix_post_artist_id'), table_name='post')
op.drop_table('post')
op.drop_index(op.f('ix_pixiv_seen_media_source_id'), table_name='pixiv_seen_media')
op.drop_table('pixiv_seen_media')
op.drop_index(op.f('ix_pixiv_failed_media_source_id'), table_name='pixiv_failed_media')
op.drop_table('pixiv_failed_media')
op.drop_index(op.f('ix_patreon_seen_media_source_id'), table_name='patreon_seen_media')
op.drop_table('patreon_seen_media')
op.drop_index(op.f('ix_patreon_failed_media_source_id'), table_name='patreon_failed_media')
op.drop_table('patreon_failed_media')
op.drop_table('tag_head')
op.drop_index(op.f('ix_tag_alias_canonical_tag_id'), table_name='tag_alias')
op.drop_table('tag_alias')
op.drop_index(op.f('ix_source_error_type'), table_name='source')
op.drop_index(op.f('ix_source_artist_id'), table_name='source')
op.drop_table('source')
op.drop_index(op.f('ix_head_metrics_snapshot_tag_id'), table_name='head_metrics_snapshot')
op.drop_index(op.f('ix_head_metrics_snapshot_snapshot_at'), table_name='head_metrics_snapshot')
op.drop_table('head_metrics_snapshot')
op.drop_table('head_metric')
op.drop_table('ccip_prototype_state')
op.drop_table('artist_visit')
op.drop_index(op.f('ix_task_run_task_name'), table_name='task_run')
op.drop_index(op.f('ix_task_run_status'), table_name='task_run')
op.drop_index(op.f('ix_task_run_started_at'), table_name='task_run')
op.drop_index(op.f('ix_task_run_queue'), table_name='task_run')
op.drop_index(op.f('ix_task_run_finished_at'), table_name='task_run')
op.drop_index(op.f('ix_task_run_celery_task_id'), table_name='task_run')
op.drop_table('task_run')
op.drop_index(op.f('ix_tag_fandom_id'), table_name='tag')
op.drop_table('tag')
op.drop_table('ml_settings')
op.drop_index(op.f('ix_library_audit_run_status'), table_name='library_audit_run')
op.drop_index(op.f('ix_library_audit_run_rule'), table_name='library_audit_run')
op.drop_table('library_audit_run')
op.drop_table('import_settings')
op.drop_index(op.f('ix_import_batch_status'), table_name='import_batch')
op.drop_table('import_batch')
op.drop_index(op.f('ix_head_training_run_status'), table_name='head_training_run')
op.drop_table('head_training_run')
op.drop_index(op.f('ix_head_auto_apply_run_status'), table_name='head_auto_apply_run')
op.drop_table('head_auto_apply_run')
op.drop_table('credential')
op.drop_index(op.f('ix_backup_run_tag'), table_name='backup_run')
op.drop_index(op.f('ix_backup_run_status'), table_name='backup_run')
op.drop_index(op.f('ix_backup_run_started_at'), table_name='backup_run')
op.drop_index(op.f('ix_backup_run_kind'), table_name='backup_run')
op.drop_index(op.f('ix_backup_run_finished_at'), table_name='backup_run')
op.drop_table('backup_run')
op.drop_table('artist')
op.drop_table('app_setting')
+7 -55
View File
@@ -3,23 +3,13 @@
import logging
from pathlib import Path
from quart import Quart, request
from quart import Quart
from .api import all_blueprints
from .config import get_config
from .frontend import frontend_bp
from .services.credential_crypto import CredentialCrypto
# Browser-extension origins. The FabledCurator extension fetches from
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ on
# Chromium-based browsers. Operator-flagged 2026-05-26: extension's
# 'Test connection' returned `NetworkError` because the X-Extension-Key
# header on /api/credentials triggers a CORS preflight that our routes
# don't handle. Whitelisting only these two schemes (not opening CORS
# up generally) lets the extension talk to a plain-HTTP self-hosted FC
# without weakening the no-CORS posture for normal browser usage.
_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://")
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
@@ -33,56 +23,18 @@ def create_app() -> Quart:
app = Quart(__name__)
app.secret_key = cfg.secret_key
# Stream files in 4 MiB chunks instead of Quart's 8 KiB default. The image
# library lives on a CIFS/SMB share (mounted rsize=4 MiB), so 8 KiB reads
# meant ~19k network round-trips for one large original — 3058s downloads
# that starved both the GPU agent and the browser (operator-flagged
# 2026-07-01). 4 MiB matches the mount's read size → one round-trip per read,
# ~500× fewer. buffer_size is the MAX read, so small thumbnails still read in
# a single gulp, and Range/mime/ETag/conditional handling lives on Response,
# so this keeps all of it. Guarded so a future Quart-internal change can't
# break boot — worst case we fall back to the slow default.
try:
from quart.wrappers.response import FileBody
FileBody.buffer_size = 4 * 1024 * 1024
except Exception:
logging.getLogger(__name__).warning(
"could not raise FileBody.buffer_size — file serving stays on 8 KiB chunks"
)
# FC-5: legacy IR ingest JSON can run to tens of MB (hundreds of
# thousands of image_tag_associations). Werkzeug's default form
# memory cap is 500KB; raise both ceilings so the multipart upload
# for /api/migrate/ir_ingest doesn't 413.
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 * 1024 # 1 GB
app.config["MAX_FORM_MEMORY_SIZE"] = 1024 * 1024 * 1024 # 1 GB
for bp in all_blueprints():
app.register_blueprint(bp)
# Registered last so /api/* routes win over the SPA catch-all.
app.register_blueprint(frontend_bp)
@app.before_request
async def _extension_cors_preflight():
# Short-circuit OPTIONS preflight from the browser extension with a
# 204 + CORS headers (the after_request hook below adds them).
# Without this, OPTIONS lands on routes that only declared POST/GET
# methods and 405s before the after_request gets a chance.
if request.method != "OPTIONS":
return None
origin = request.headers.get("Origin", "")
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
return "", 204
return None
@app.after_request
async def _extension_cors_headers(response):
origin = request.headers.get("Origin", "")
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, PATCH, DELETE, OPTIONS"
)
response.headers["Access-Control-Allow-Headers"] = (
"Content-Type, X-Extension-Key"
)
response.headers["Access-Control-Max-Age"] = "86400"
return response
@app.after_serving
async def _dispose_db_engine() -> None:
from .extensions import dispose_engine
+4 -18
View File
@@ -14,20 +14,16 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
def all_blueprints() -> list[Blueprint]:
from .admin import admin_bp
from .aliases import aliases_bp
from .allowlist import allowlist_bp
from .artist import artist_bp
from .artists import artists_bp
from .attachments import attachments_bp
from .ccip import ccip_bp
from .cleanup import cleanup_bp
from .credentials import credentials_bp
from .downloads import downloads_bp
from .extension import extension_bp
from .gallery import gallery_bp
from .gpu import gpu_bp
from .heads import heads_bp
from .import_admin import import_admin_bp
from .migrate import migrate_bp
from .ml_admin import ml_admin_bp
from .platforms import platforms_bp
from .posts import posts_bp
@@ -36,10 +32,7 @@ def all_blueprints() -> list[Blueprint]:
from .showcase import showcase_bp
from .sources import sources_bp
from .suggestions import suggestions_bp
from .system_activity import system_activity_bp
from .system_backup import system_backup_bp
from .tags import tags_bp
from .thumbnails import thumbnails_bp
return [
api_bp,
attachments_bp,
@@ -50,22 +43,15 @@ def all_blueprints() -> list[Blueprint]:
artists_bp,
showcase_bp,
settings_bp,
system_activity_bp,
system_backup_bp,
admin_bp,
cleanup_bp,
import_admin_bp,
migrate_bp,
suggestions_bp,
allowlist_bp,
aliases_bp,
heads_bp,
gpu_bp,
ccip_bp,
ml_admin_bp,
thumbnails_bp,
sources_bp,
platforms_bp,
posts_bp,
credentials_bp,
extension_bp,
downloads_bp,
]
-16
View File
@@ -1,16 +0,0 @@
"""Shared API response helpers."""
from quart import jsonify
def error_response(
error: str, *, status: int = 400, detail: str | None = None, **extra,
):
"""JSON error body + HTTP status. `detail` is included only when given;
`extra` keys are merged into the body. Returns the (response, status)
tuple Quart expects. Imported as `_bad` by the blueprints."""
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
-522
View File
@@ -1,522 +0,0 @@
"""FC-3k: /api/admin — destructive admin actions.
Action surfaces:
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
POST /api/admin/images/bulk-delete (Tier C)
DELETE /api/admin/tags/<int:tag_id> (Tier B)
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
POST /api/admin/tags/prune-unused (Tier A)
POST /api/admin/posts/prune-bare (Tier A)
POST /api/admin/posts/refetch-external (Tier A)
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
Tier-C ops take a dry_run body flag (returns projection inline,
no dispatch) and a confirm body field (server-recomputed token).
Long-running ops dispatch a maintenance-queue Celery task; the UI
tails FC-3i's /api/system/activity/runs to surface progress.
"""
from __future__ import annotations
import hashlib
from quart import Blueprint, jsonify, request
from sqlalchemy import select, text
from ..extensions import get_session
from ..models import Artist, Post
from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete
from ._responses import error_response as _bad
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
def _bulk_image_confirm_token(image_ids: list[int]) -> str:
"""Stable 8-hex token derived from the sorted id list. Mutates
when the selection changes; stays the same across modal opens of
the same selection so the operator can paste without confusion."""
canon = ",".join(str(i) for i in sorted(image_ids))
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
return digest[:8]
async def _run_dry_run_op(service_fn, **service_kwargs):
"""Shared body for the Tier-A dry-run/apply endpoints: read the `dry_run`
flag, run the cleanup_service predicate under `run_sync`, and return its
result dict. The SAME `service_fn` drives both preview and apply (the flag
just toggles), so a handler physically can't let its preview diverge from
its delete (rule 93). Default False preserves the existing contract — the UI
always passes `dry_run` explicitly (true to preview, false to apply). Extra
service kwargs (e.g. `source_id`) pass straight through."""
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
async with get_session() as session:
result = await session.run_sync(
lambda sync_sess: service_fn(sync_sess, dry_run=dry_run, **service_kwargs)
)
return jsonify(result)
def _queued(async_result):
"""Standard 202 for an operator-triggered maintenance task: hand the UI the
Celery task id so it can tail /maintenance/task-result (or the activity
dashboard) for the summary. (trigger_vacuum stays bespoke — the UI doesn't
poll it, so it returns no task id.)"""
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
async def artist_cascade_delete(slug: str):
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
supplied_confirm = body.get("confirm", "")
async with get_session() as session:
artist = (await session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
return _bad("not_found", status=404)
artist_id = artist.id
projected = await session.run_sync(
lambda sync_sess: project_artist_cascade(sync_sess, slug=slug)
)
if dry_run:
return jsonify(projected)
expected = f"delete-artist-{artist_id}"
if supplied_confirm != expected:
return _bad(
"confirm_mismatch",
detail=f"confirm must equal {expected!r}",
expected=expected,
)
from ..tasks.admin import delete_artist_cascade_task
async_result = delete_artist_cascade_task.delay(artist_id=artist_id)
return jsonify({"task_id": async_result.id}), 202
@admin_bp.route("/images/bulk-delete", methods=["POST"])
async def images_bulk_delete():
body = await request.get_json(silent=True) or {}
image_ids = body.get("image_ids")
if not isinstance(image_ids, list) or not image_ids:
return _bad("invalid_image_ids", detail="image_ids must be non-empty list of int")
try:
image_ids = [int(i) for i in image_ids]
except (TypeError, ValueError):
return _bad("invalid_image_ids", detail="image_ids must contain only ints")
dry_run = bool(body.get("dry_run", False))
supplied_confirm = body.get("confirm", "")
async with get_session() as session:
projected = await session.run_sync(
lambda sync_sess: project_bulk_image_delete(
sync_sess, image_ids=image_ids,
)
)
sha8 = _bulk_image_confirm_token(image_ids)
expected = f"delete-images-{sha8}"
if dry_run:
# Hand the canonical Tier-C confirm token back with the
# projection so the frontend doesn't have to recompute SHA-256
# client-side via crypto.subtle (Secure-Context-gated,
# undefined on plain-HTTP origins per the homelab posture).
# Operator-flagged 2026-05-27.
projected["confirm_token"] = expected
return jsonify(projected)
if supplied_confirm != expected:
return _bad(
"confirm_mismatch",
detail=f"confirm must equal {expected!r}",
expected=expected,
)
from ..tasks.admin import bulk_delete_images_task
async_result = bulk_delete_images_task.delay(image_ids=image_ids)
return jsonify({"task_id": async_result.id}), 202
@admin_bp.route("/tags/<int:tag_id>", methods=["DELETE"])
async def tag_delete(tag_id: int):
"""Tier-B sync delete. UI yes/no modal is the only confirmation."""
from ..services.cleanup_service import delete_tag
async with get_session() as session:
try:
result = await session.run_sync(
lambda sync_sess: delete_tag(sync_sess, tag_id=tag_id)
)
except LookupError:
return _bad("not_found", status=404)
except ValueError as exc:
# System tags (#128) — the training-hygiene machinery keys on
# these rows.
return _bad("system_tag", detail=str(exc))
return jsonify(result)
@admin_bp.route("/tags/<int:dest_id>/merge", methods=["POST"])
async def tag_merge(dest_id: int):
"""Wraps TagService.merge. Source repoints to dest, dest survives,
source row deleted, protective alias auto-created if source was
ML-applied or allowlisted."""
from ..services.tag_service import TagMergeConflict, TagService, TagValidationError
body = await request.get_json(silent=True) or {}
source_id = body.get("source_id")
if not isinstance(source_id, int) or source_id == dest_id:
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
# dry_run: non-mutating preview (counts + sample) so the operator can
# confirm the target before the irreversible merge (#8, rule 93 parity).
if body.get("dry_run"):
async with get_session() as session:
try:
p = await TagService(session).merge_preview(
source_id=source_id, target_id=dest_id,
)
except TagValidationError as exc:
return _bad("tag_not_found", status=404, detail=str(exc))
return jsonify({
"preview": {
"source_id": p.source_id, "source_name": p.source_name,
"target_id": p.target_id, "target_name": p.target_name,
"compatible": p.compatible,
"images_moving": p.images_moving,
"images_already_on_target": p.images_already_on_target,
"source_total": p.source_total,
"series_pages": p.series_pages,
"will_alias": p.will_alias,
"sample_thumbnails": p.sample_thumbnails,
},
})
async with get_session() as session:
try:
result = await TagService(session).merge(
source_id=source_id, target_id=dest_id,
)
except TagMergeConflict as exc:
return _bad("merge_conflict", status=409, detail=str(exc))
except TagValidationError as exc:
return _bad("tag_kind_mismatch", detail=str(exc))
except LookupError:
return _bad("not_found", status=404)
# MergeResult is a frozen dataclass — flatten to dict.
return jsonify({
"result": {
"target_id": result.target_id,
"target_name": result.target_name,
"target_kind": result.target_kind,
"merged_count": result.merged_count,
"alias_created": result.alias_created,
"source_deleted": result.source_deleted,
},
})
@admin_bp.route("/tags/<int:tag_id>/usage-count", methods=["GET"])
async def tag_usage_count(tag_id: int):
"""Helper for the Tier-B yes/no prompt; surfaces "N associations"
in the dialog so the operator knows what they're nuking."""
from ..services.cleanup_service import count_tag_associations
async with get_session() as session:
count = await session.run_sync(
lambda sync_sess: count_tag_associations(
sync_sess, tag_id=tag_id,
)
)
return jsonify({"count": count})
@admin_bp.route("/tags/prune-unused", methods=["POST"])
async def tags_prune_unused():
"""Tier-A: dry-run preview list IS the prompt. UI calls with
dry_run=true first, shows the list, operator clicks button to
re-call with dry_run=false."""
from ..services.cleanup_service import prune_unused_tags
return await _run_dry_run_op(prune_unused_tags)
@admin_bp.route("/posts/prune-bare", methods=["POST"])
async def posts_prune_bare():
"""Tier-A: delete bare posts — Post rows with no linked images (primary OR
provenance) and no attachments. Dry-run preview list IS the prompt: UI calls
with dry_run=true first, shows the count + sample, operator confirms by
re-calling with dry_run=false. Same preview/apply-parity predicate as the
prune itself, so the preview can't diverge from the delete."""
from ..services.cleanup_service import prune_bare_posts
return await _run_dry_run_op(prune_bare_posts)
@admin_bp.route("/posts/reconcile-duplicates", methods=["POST"])
async def posts_reconcile_duplicates():
"""Tier-A: unify duplicate post rows for the same real post — the gallery-dl
(attachment-id) + native (post-id) duplicates — onto ONE post-id-keyed keeper,
moving image/provenance/attachment/link rows over. Images are untouched.
dry_run=true returns {groups, posts_to_merge, sample}; dry_run=false applies
and returns {groups, merged, sample}. Optional source_id scopes to one source.
Same find_duplicate_post_groups predicate drives preview + apply (rule 93)."""
from ..services.cleanup_service import reconcile_duplicate_posts
body = await request.get_json(silent=True) or {}
raw_source = body.get("source_id")
try:
source_id = int(raw_source) if raw_source is not None else None
except (TypeError, ValueError):
return _bad("invalid_source_id", detail="source_id must be an integer")
return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id)
@admin_bp.route("/posts/refetch-external", methods=["POST"])
async def posts_refetch_external():
"""Surgical re-fetch of a post's external file-host links (operator
2026-07-03): the normal cadence never re-walks deep back-catalogue posts,
so a deleted external file only comes back by resetting its ExternalLink
row(s) — this endpoint does that per post and dispatches the fetches.
Sha-dedupe discards payload files that still exist, so only what's
missing lands. Body: {external_post_id: str, source_id?: int (to
disambiguate the same external id across sources)}."""
from ..services.external_links import refetch_links_for_post
body = await request.get_json(silent=True) or {}
ext_id = str(body.get("external_post_id") or "").strip()
if not ext_id:
return _bad("missing_external_post_id",
detail="external_post_id is required")
raw_source = body.get("source_id")
try:
source_id = int(raw_source) if raw_source is not None else None
except (TypeError, ValueError):
return _bad("invalid_source_id", detail="source_id must be an integer")
async with get_session() as session:
stmt = select(Post.id).where(Post.external_post_id == ext_id)
if source_id is not None:
stmt = stmt.where(Post.source_id == source_id)
post_ids = (await session.execute(stmt)).scalars().all()
if not post_ids:
return _bad("post_not_found", status=404,
detail=f"no post with external_post_id {ext_id!r}")
results = {}
for pid in post_ids:
results[str(pid)] = await session.run_sync(
lambda s, p=pid: refetch_links_for_post(s, p)
)
return jsonify({"posts": results})
def _reset_content_confirm_token(projection: dict) -> str:
"""Stable 8-hex token derived from the live counts (mirrors the Tier-C
bulk-delete token): it changes whenever the data changes, so the apply can
only ever run against numbers the operator just previewed."""
canon = f"reset-content:{projection.get('count')}:{projection.get('applications')}"
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:8]
@admin_bp.route("/tags/reset-content", methods=["POST"])
async def tags_reset_content():
"""Full-instance reset of the CONTENT vocabulary: deletes ALL general +
character tags and their image applications — INCLUDING the examples the
tagging heads learned from. Suggestions do NOT repopulate on their own
(the Camie predictions that once did are long retired): the operator
re-tags from scratch and the heads retrain from the new signal. fandom +
series tags + series_page ordering are preserved.
Deliberately Tier-C-gated despite the Tier-A shape (operator 2026-07-02:
the full reset stays, but behind extra steps): dry_run returns the
projection + a `confirm` token derived from the live counts; the apply
must echo that token back or it is rejected."""
from ..services.cleanup_service import reset_content_tagging
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
async with get_session() as session:
projection = await session.run_sync(
lambda s: reset_content_tagging(s, dry_run=True)
)
token = _reset_content_confirm_token(projection)
if dry_run:
projection["confirm"] = token
return jsonify(projection)
if str(body.get("confirm", "")) != token:
return _bad(
"confirm_mismatch",
detail="run a fresh preview and echo its confirm token",
)
result = await session.run_sync(
lambda s: reset_content_tagging(s, dry_run=False)
)
return jsonify(result)
@admin_bp.route("/tags/normalize", methods=["POST"])
async def tags_normalize():
"""#714: retro-normalize existing tags to the #701 canonical form (Title
Case + collapsed whitespace) and merge case/whitespace-variant duplicates.
dry_run=true (default) returns a projection inline — group/collision/rename
counts + a sample of the changes — so the UI shows exactly what'll happen.
dry_run=false dispatches the long-running maintenance task (the merge FK
repoints can touch many tags); the UI tails the activity dashboard for the
summary. Idempotent; back up first (the merges are irreversible)."""
from ..services.tag_service import normalize_existing_tags
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True))
if dry_run:
async with get_session() as session:
result = await normalize_existing_tags(session, dry_run=True)
return jsonify(result)
from ..tasks.admin import normalize_tags_task
async_result = normalize_tags_task.delay()
return _queued(async_result)
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
async def db_stats():
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
so the operator can see when a VACUUM is worth running."""
from ..tasks.maintenance import VACUUM_TABLES
wanted = set(VACUUM_TABLES)
async with get_session() as session:
rows = (await session.execute(text(
"SELECT relname, n_live_tup, n_dead_tup, last_vacuum, "
"last_autovacuum, last_analyze FROM pg_stat_user_tables"
))).all()
def _iso(v):
return v.isoformat() if v is not None else None
out = []
for r in rows:
if r.relname not in wanted:
continue
live = r.n_live_tup or 0
dead = r.n_dead_tup or 0
total = live + dead
out.append({
"table": r.relname,
"live": live,
"dead": dead,
"dead_pct": round(100 * dead / total, 1) if total else 0.0,
"last_vacuum": _iso(r.last_vacuum),
"last_autovacuum": _iso(r.last_autovacuum),
"last_analyze": _iso(r.last_analyze),
})
out.sort(key=lambda t: t["dead"], reverse=True)
return jsonify({"tables": out})
@admin_bp.route("/maintenance/vacuum", methods=["POST"])
async def trigger_vacuum():
"""Operator-triggered VACUUM (ANALYZE) over the high-churn tables — the
same maintenance-queue task the weekly Beat schedule runs."""
from ..tasks.maintenance import vacuum_analyze
vacuum_analyze.delay()
return jsonify({"status": "queued"}), 202
@admin_bp.route("/maintenance/reextract-archives", methods=["POST"])
async def trigger_reextract_archives():
"""Operator-triggered re-extract (#713): PostAttachments that are actually
archives but were filed opaquely (pre magic-byte gate) get extracted and
their members linked to the post. Idempotent; runs on the maintenance queue."""
from ..tasks.admin import reextract_archive_attachments_task
async_result = reextract_archive_attachments_task.delay()
return _queued(async_result)
@admin_bp.route("/maintenance/prune-missing-files", methods=["POST"])
async def trigger_prune_missing_files():
"""Operator-triggered orphan repair (#859): delete ImageRecords whose backing
file is gone from disk (e.g. left by the external-attach unlink bug), so they
stop 404-ing on playback. The task aborts WITHOUT deleting if a large fraction
of files look missing (a filesystem/NFS stall). Maintenance queue;
operator-triggered only — never an unattended sweep."""
from ..tasks.admin import prune_missing_file_records_task
async_result = prune_missing_file_records_task.delay()
return _queued(async_result)
@admin_bp.route("/maintenance/reclaim-attachments", methods=["POST"])
async def trigger_reclaim_attachments():
"""Reclaim orphaned attachments (#3068). Body {"dry_run": bool}: dry_run
(the DEFAULT here) projects the orphan rows and unreferenced store blobs
without touching either; dry_run=false deletes the rows then unlinks every
blob no surviving row references. Maintenance queue; operator-triggered
only — never an unattended sweep, since the apply unlinks files. Returns the
Celery task id — poll /maintenance/task-result/<id> for the summary."""
from ..tasks.admin import reclaim_orphaned_attachments_task
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
async_result = reclaim_orphaned_attachments_task.delay(dry_run=dry_run)
return _queued(async_result)
@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
async def trigger_dedup_videos():
"""Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
what would be removed (groups / redundant count / reclaimable bytes) WITHOUT
deleting; dry_run=false applies it (re-link posts to the keeper, then delete
the redundant copies). Either way it first re-probes NULL-duration videos so
the existing library participates. Returns the Celery task id — poll
/maintenance/task-result/<id> for the summary."""
from ..tasks.admin import dedup_videos_task
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
async_result = dedup_videos_task.delay(dry_run=dry_run)
return _queued(async_result)
@admin_bp.route("/maintenance/purge-gated-previews", methods=["POST"])
async def trigger_purge_gated_previews():
"""Cleanup (#874 follow-up). Body {"dry_run": bool}: dry_run=true previews how
many blurred locked-preview images (grabbed from tier-gated Patreon posts
before the fix) would be removed WITHOUT deleting; dry_run=false applies it.
Re-walks every enabled Patreon source read-only and matches by content hash, so
real content downloaded when access existed is provably spared. Returns the
Celery task id — poll /maintenance/task-result/<id> for the summary."""
from ..tasks.admin import purge_gated_previews_task
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
async_result = purge_gated_previews_task.delay(dry_run=dry_run)
return _queued(async_result)
@admin_bp.route("/maintenance/task-result/<task_id>", methods=["GET"])
async def maintenance_task_result(task_id: str):
"""Poll a maintenance Celery task's result (the summary dict it returns).
Used by the video-dedup card to show the dry-run projection before apply."""
from ..celery_app import celery
res = celery.AsyncResult(task_id)
ready = res.ready()
return jsonify({
"ready": ready,
"successful": res.successful() if ready else None,
"result": res.result if (ready and res.successful()) else None,
})
+59
View File
@@ -0,0 +1,59 @@
"""Allowlist API: list, adjust threshold, remove."""
from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import TagAllowlist
from ..services.ml.allowlist import AllowlistService
allowlist_bp = Blueprint("allowlist", __name__, url_prefix="/api")
@allowlist_bp.route("/allowlist", methods=["GET"])
async def list_allowlist():
async with get_session() as session:
rows = await AllowlistService(session).list_all()
return jsonify(
[
{
"tag_id": r.tag_id,
"tag_name": r.tag_name,
"tag_kind": r.tag_kind,
"min_confidence": r.min_confidence,
}
for r in rows
]
)
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["GET"])
async def get_one(tag_id: int):
async with get_session() as session:
row = await session.get(TagAllowlist, tag_id)
if row is None:
return jsonify({"error": "not on allowlist"}), 404
return jsonify(
{"min_confidence": row.min_confidence, "added_at": row.added_at.isoformat()}
)
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["PATCH"])
async def patch_threshold(tag_id: int):
body = await request.get_json()
if not body or "min_confidence" not in body:
return jsonify({"error": "min_confidence required"}), 400
mc = float(body["min_confidence"])
if not (0 < mc <= 1):
return jsonify({"error": "min_confidence must be in (0, 1]"}), 400
async with get_session() as session:
await AllowlistService(session).update_threshold(tag_id, mc)
await session.commit()
return "", 204
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["DELETE"])
async def remove(tag_id: int):
async with get_session() as session:
await AllowlistService(session).remove(tag_id)
await session.commit()
return "", 204
-18
View File
@@ -31,24 +31,6 @@ async def create_or_get():
}), 201
@artists_bp.route("/<int:artist_id>", methods=["PATCH"])
async def rename(artist_id: int):
"""Rename an artist's DISPLAY NAME (#130). Name only — the slug and every
on-disk path stay put, so this is instant and safe. Name is non-unique."""
body = await request.get_json()
if not isinstance(body, dict) or not isinstance(body.get("name"), str):
return jsonify({"error": "invalid_body"}), 400
async with get_session() as session:
svc = ArtistService(session)
try:
artist = await svc.rename(artist_id, body["name"])
except ValueError as exc:
return jsonify({"error": "empty_name", "detail": str(exc)}), 400
if artist is None:
return jsonify({"error": "not_found"}), 404
return jsonify({"id": artist.id, "name": artist.name, "slug": artist.slug})
@artists_bp.route("/autocomplete", methods=["GET"])
async def autocomplete():
q = request.args.get("q") or ""
-124
View File
@@ -1,124 +0,0 @@
"""CCIP / region observability API (#114) — read-only, analysis-shaped.
So the work can be checked through an API as the agent fills in vectors: overall
coverage (regions by kind, how many images have figure CCIP vectors, which
characters have enough reference examples to match on) + a per-image drill-down
(its regions + the CCIP character matches it would get). Mirrors the heads
metrics endpoint; no GPU, just reads what's stored.
"""
from quart import Blueprint, jsonify
from sqlalchemy import distinct, func, select
from ..extensions import get_session
from ..models import ImageRegion, Tag, TagKind
from ..models.tag import image_tag
from ..services.ml.ccip import match_image
ccip_bp = Blueprint("ccip", __name__, url_prefix="/api/ccip")
_FIGURE_KINDS = ("face", "figure")
@ccip_bp.route("/overview", methods=["GET"])
async def overview():
async with get_session() as session:
by_kind = dict(
(
await session.execute(
select(ImageRegion.kind, func.count()).group_by(ImageRegion.kind)
)
).all()
)
images_with_figure_ccip = (
await session.execute(
select(func.count(distinct(ImageRegion.image_record_id)))
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None))
)
).scalar_one()
# Concept-crop (SigLIP bag) coverage — how far the back-catalogue embed
# has progressed, so the max-over-bag scorer's reach is checkable.
images_with_concept_siglip = (
await session.execute(
select(func.count(distinct(ImageRegion.image_record_id)))
.where(ImageRegion.kind == "concept")
.where(ImageRegion.siglip_embedding.is_not(None))
)
).scalar_one()
# Per-character reference counts (no vectors loaded) — which characters
# have enough examples to match on.
ref_rows = (
await session.execute(
select(image_tag.c.tag_id, Tag.name, func.count())
.select_from(ImageRegion)
.join(
image_tag,
image_tag.c.image_record_id == ImageRegion.image_record_id,
)
.join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.kind == TagKind.character)
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None))
.group_by(image_tag.c.tag_id, Tag.name)
.order_by(func.count().desc())
)
).all()
versions = [
v for (v,) in (
await session.execute(
select(distinct(ImageRegion.embedding_version))
)
).all() if v
]
auto_applied = (
await session.execute(
select(func.count()).select_from(image_tag).where(
image_tag.c.source == "ccip_auto"
)
)
).scalar_one()
return jsonify({
"regions_by_kind": by_kind,
"images_with_figure_ccip": images_with_figure_ccip,
"images_with_concept_siglip": images_with_concept_siglip,
"characters_with_references": len(ref_rows),
"character_references": [
{"tag_id": t, "name": n, "n_refs": c} for (t, n, c) in ref_rows
],
"embedding_versions": versions,
"auto_applied": auto_applied,
})
@ccip_bp.route("/images/<int:image_id>", methods=["GET"])
async def image_detail(image_id: int):
"""An image's stored regions + the CCIP character matches it would get —
for spot-checking the agent's output + the matcher."""
async with get_session() as session:
regions = (
await session.execute(
select(ImageRegion)
.where(ImageRegion.image_record_id == image_id)
.order_by(ImageRegion.id)
)
).scalars().all()
matches = await match_image(session, image_id)
return jsonify({
"image_id": image_id,
"regions": [
{
"id": r.id,
"kind": r.kind,
"bbox": [r.rx, r.ry, r.rw, r.rh],
"frame_time": r.frame_time,
"score": r.score,
"detector_version": r.detector_version,
"embedding_version": r.embedding_version,
"has_ccip": r.ccip_embedding is not None,
"has_siglip": r.siglip_embedding is not None,
}
for r in regions
],
"ccip_matches": matches,
})
-198
View File
@@ -1,198 +0,0 @@
"""FC-Cleanup: /api/cleanup/* — retroactive enforcement of import filters.
Endpoints:
POST /min-dimension/preview synchronous SQL audit
POST /min-dimension/delete synchronous SQL delete (Tier-C token)
POST /audit async transparency / single_color start
GET /audit list recent audit_run rows
GET /audit/<id> single audit_run row
POST /audit/<id>/apply apply matched_ids deletes (Tier-C token)
POST /audit/<id>/cancel flip running audit to cancelled
Unused-tags retroactive prune intentionally NOT in this namespace —
TagMaintenanceCard (Maintenance tab → moved to Cleanup tab in v26.05.25.7)
uses the existing /api/admin/tags/prune-unused endpoint via the admin
store. No duplicate route here.
Confirm-token format matches modal/DestructiveConfirmModal.vue convention:
`delete-min-dim-<sha8(w,h)>` for min-dim delete
`delete-audit-<id>` for audit apply
(Modal hardcodes action ∈ {'restore', 'delete'}; "apply audit" is semantically a delete of the matched images, so we use `delete-audit-<id>`.)
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from ..extensions import get_session
from ..models import LibraryAuditRun
from ..services import cleanup_service
from ._responses import error_response as _bad
cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")
IMAGES_ROOT = Path("/images")
def _min_dim_token(min_w: int, min_h: int) -> str:
# SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both
# sides use SHA-256 truncated to 8 hex chars.
canon = f"{min_w}x{min_h}"
return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"
def _serialize_audit_run(audit: LibraryAuditRun) -> dict:
return {
"id": audit.id,
"rule": audit.rule,
"params": audit.params,
"status": audit.status,
"started_at": audit.started_at.isoformat() if audit.started_at else None,
"finished_at": audit.finished_at.isoformat() if audit.finished_at else None,
"scanned_count": audit.scanned_count,
"matched_count": audit.matched_count,
"matched_ids": audit.matched_ids,
"error": audit.error,
}
@cleanup_bp.route("/min-dimension/preview", methods=["POST"])
async def min_dim_preview():
body = await request.get_json(silent=True) or {}
try:
min_w = int(body.get("min_width", 0))
min_h = int(body.get("min_height", 0))
except (TypeError, ValueError):
return _bad("invalid_dimensions")
if min_w < 0 or min_h < 0:
return _bad("invalid_dimensions")
async with get_session() as session:
projection = await session.run_sync(
lambda s: cleanup_service.project_min_dimension_violations(
s, min_width=min_w, min_height=min_h,
)
)
# Hand the canonical Tier-C delete token back with the preview so
# the frontend doesn't have to recompute SHA-256 client-side.
# window.crypto.subtle is Secure-Context-gated and undefined on
# plain-HTTP origins (homelab posture); without this the Delete
# button silently swallowed the TypeError and never opened the
# confirm modal. Operator-flagged 2026-05-27.
projection["confirm_token"] = _min_dim_token(min_w, min_h)
return jsonify(projection)
@cleanup_bp.route("/min-dimension/delete", methods=["POST"])
async def min_dim_delete():
body = await request.get_json(silent=True) or {}
try:
min_w = int(body.get("min_width", 0))
min_h = int(body.get("min_height", 0))
except (TypeError, ValueError):
return _bad("invalid_dimensions")
if min_w < 0 or min_h < 0:
return _bad("invalid_dimensions")
supplied = body.get("confirm", "")
expected = _min_dim_token(min_w, min_h)
if supplied != expected:
return _bad("confirm_mismatch", expected=expected)
async with get_session() as session:
deleted = await session.run_sync(
lambda s: cleanup_service.delete_min_dimension_violations(
s, min_width=min_w, min_height=min_h, images_root=IMAGES_ROOT,
)
)
await session.commit()
return jsonify({"deleted": deleted})
@cleanup_bp.route("/audit", methods=["POST"])
async def audit_create():
body = await request.get_json(silent=True) or {}
rule = body.get("rule")
params = body.get("params") or {}
if rule not in ("transparency", "single_color"):
return _bad("invalid_rule")
if not isinstance(params, dict):
return _bad("invalid_params")
async with get_session() as session:
try:
audit_id = await session.run_sync(
lambda s: cleanup_service.start_audit_run(
s, rule=rule, params=params,
)
)
except cleanup_service.AuditAlreadyRunning as running_id:
return _bad(
"audit_already_running", status=409,
running_id=int(str(running_id)),
)
except ValueError as exc:
return _bad(str(exc))
await session.commit()
return jsonify({"audit_id": audit_id, "status": "running"}), 202
@cleanup_bp.route("/audit/<int:audit_id>", methods=["GET"])
async def audit_get(audit_id: int):
async with get_session() as session:
audit = (await session.execute(
select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
)).scalar_one_or_none()
if audit is None:
return _bad("not_found", status=404)
return jsonify(_serialize_audit_run(audit))
@cleanup_bp.route("/audit", methods=["GET"])
async def audit_history():
try:
limit = min(int(request.args.get("limit", "20")), 100)
except ValueError:
return _bad("invalid_limit")
# Optional rule filter so a card can reconnect to ITS latest run on mount
# (?rule=transparency&limit=1) — the audit survives navigation; the UI
# rehydrates from this rather than losing the in-flight scan.
rule = request.args.get("rule") or None
async with get_session() as session:
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
if rule is not None:
stmt = stmt.where(LibraryAuditRun.rule == rule)
rows = (await session.execute(stmt.limit(limit))).scalars().all()
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
@cleanup_bp.route("/audit/<int:audit_id>/apply", methods=["POST"])
async def audit_apply(audit_id: int):
body = await request.get_json(silent=True) or {}
confirm = body.get("confirm", "")
async with get_session() as session:
try:
deleted = await session.run_sync(
lambda s: cleanup_service.apply_audit_run(
s, audit_id=audit_id, confirm_token=confirm,
images_root=IMAGES_ROOT,
)
)
except cleanup_service.AuditNotReady as exc:
return _bad("audit_not_ready", current_status=str(exc))
except cleanup_service.ConfirmTokenMismatch as exc:
return _bad("confirm_mismatch", expected=str(exc))
except ValueError as exc:
return _bad("not_found", status=404, detail=str(exc))
await session.commit()
return jsonify({"deleted": deleted})
@cleanup_bp.route("/audit/<int:audit_id>/cancel", methods=["POST"])
async def audit_cancel(audit_id: int):
async with get_session() as session:
await session.run_sync(
lambda s: cleanup_service.cancel_audit_run(s, audit_id=audit_id)
)
await session.commit()
return jsonify({"cancelled": True})
+8 -56
View File
@@ -20,7 +20,6 @@ from ..services.credential_service import (
UnknownPlatformError,
WrongAuthTypeError,
)
from ._responses import error_response as _bad
credentials_bp = Blueprint("credentials", __name__, url_prefix="/api/credentials")
@@ -39,6 +38,14 @@ def _get_crypto() -> CredentialCrypto:
return _crypto
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
async def _ext_key_ok(session) -> bool:
"""If X-Extension-Key is supplied, it must match the stored value.
Missing header → True (browser path; accepted per homelab posture).
@@ -117,58 +124,3 @@ async def delete_credential(platform: str):
except LookupError:
return _bad("not_found", status=404)
return "", 204
@credentials_bp.route("/<platform>/verify", methods=["POST"])
async def verify_credential(platform: str):
"""Test the stored credential against one of the platform's enabled sources,
WITHOUT downloading. Routes through the platform's backend
(download_backends.verify_credential) — native ingester for Patreon, an
authenticated API page; gallery-dl --simulate for the rest. On success
stamps last_verified. Returns {valid: bool|null, reason, last_verified?};
valid=null means "couldn't test" (no credential, no enabled source, or an
inconclusive network/drift result)."""
from ..models import Artist, Source
from ..services.download_backends import verify_source_credential
async with get_session() as session:
if not await _ext_key_ok(session):
return _bad("unauthorized", status=401)
svc = CredentialService(session, _get_crypto())
record = await svc.get(platform)
if record is None:
return jsonify({"valid": None, "reason": "No credential stored for this platform."})
# Pick an enabled source for this platform to point the probe at.
row = (await session.execute(
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.where(Source.platform == platform, Source.enabled.is_(True))
.order_by(Source.id.asc())
)).first()
if row is None:
return jsonify({
"valid": None,
"reason": "No enabled source for this platform to verify against — add a subscription first.",
})
source, artist = row
cookies_path = await svc.get_cookies_path(platform)
auth_token = await svc.get_token(platform)
ok, message = await verify_source_credential(
platform=platform,
url=source.url,
artist_slug=artist.slug,
config_overrides=source.config_overrides or {},
cookies_path=str(cookies_path) if cookies_path else None,
auth_token=auth_token,
images_root=Path("/images"),
)
last_verified = None
if ok:
async with get_session() as session:
ts = await CredentialService(session, _get_crypto()).mark_verified(platform)
last_verified = ts.isoformat() if ts else None
return jsonify({"valid": ok, "reason": message, "last_verified": last_verified})
+1 -100
View File
@@ -5,10 +5,8 @@ status/source/artist. Returns slim records.
Detail view: full DownloadEvent including the metadata JSONB.
"""
from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request
from sqlalchemy import func, select
from sqlalchemy import select
from ..extensions import get_session
from ..models import Artist, DownloadEvent, Source
@@ -44,9 +42,6 @@ def _list_record(event: DownloadEvent, source: Source | None, artist: Artist | N
"bytes_downloaded": event.bytes_downloaded,
"error": event.error,
"summary": _summary_from_metadata(event.metadata_),
# plan #709: mid-walk live counts for a RUNNING native-ingester event
# (None otherwise; phase 3 overwrites metadata with run_stats on finish).
"live": (event.metadata_ or {}).get("live"),
}
@@ -100,83 +95,6 @@ async def list_downloads():
return jsonify([_list_record(e, s, a) for e, s, a in rows])
@downloads_bp.route("/stats", methods=["GET"])
async def downloads_stats():
"""Status-grouped count over download_event for the dashboard stat chips.
`?window_hours=` (default 24) bounds by `started_at`. The full set of
statuses is always present in the response (zero for missing) so the
UI doesn't have to fill in defaults.
"""
try:
window_hours = int(request.args.get("window_hours", "24"))
except ValueError:
return jsonify({"error": "invalid_window_hours"}), 400
if window_hours < 1 or window_hours > 24 * 365:
return jsonify({"error": "invalid_window_hours"}), 400
since = datetime.now(UTC) - timedelta(hours=window_hours)
out = {"pending": 0, "running": 0, "ok": 0, "error": 0, "skipped": 0}
async with get_session() as session:
stmt = (
select(DownloadEvent.status, func.count())
.where(DownloadEvent.started_at >= since)
.group_by(DownloadEvent.status)
)
for status, n in (await session.execute(stmt)).all():
if status in out:
out[status] = int(n)
return jsonify(out)
@downloads_bp.route("/activity", methods=["GET"])
async def downloads_activity():
"""Hourly download-event counts over the last `?hours=` (default 24).
Returns a fixed-length, oldest-first bucket array so the UI can render
a sparkline directly. Bucketing is done in Python against UTC to dodge
session-timezone ambiguity in SQL date_trunc.
"""
try:
hours = int(request.args.get("hours", "24"))
except ValueError:
return jsonify({"error": "invalid_hours"}), 400
hours = max(1, min(168, hours))
now = datetime.now(UTC)
end = now.replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=hours - 1)
buckets = [
{"hour": (start + timedelta(hours=i)).isoformat(),
"ok": 0, "error": 0, "other": 0, "total": 0}
for i in range(hours)
]
async with get_session() as session:
rows = (await session.execute(
select(DownloadEvent.started_at, DownloadEvent.status)
.where(DownloadEvent.started_at >= start)
)).all()
for started_at, status in rows:
if started_at is None:
continue
sa = started_at if started_at.tzinfo else started_at.replace(tzinfo=UTC)
idx = int((sa - start).total_seconds() // 3600)
if not (0 <= idx < hours):
continue
b = buckets[idx]
if status == "ok":
b["ok"] += 1
elif status == "error":
b["error"] += 1
else:
b["other"] += 1
b["total"] += 1
return jsonify({"hours": hours, "buckets": buckets})
@downloads_bp.route("/<int:event_id>", methods=["GET"])
async def get_download(event_id: int):
async with get_session() as session:
@@ -190,20 +108,3 @@ async def get_download(event_id: int):
return jsonify({"error": "not_found"}), 404
event, source, artist = row
return jsonify(_detail_record(event, source, artist))
@downloads_bp.route("/recover-stalled", methods=["POST"])
async def recover_stalled():
"""Trigger the recover_stalled_download_events sweep on demand.
The same sweep runs every 5 min via Beat (see celery_app.beat_schedule);
this endpoint exists so the operator can force-clear stuck pending/
running download_events from the Subscriptions → Downloads maintenance
menu without waiting for the next scheduled tick.
"""
# Local import: avoids registering maintenance tasks during blueprint
# import (Celery task discovery races with the API import otherwise).
from ..tasks.maintenance import recover_stalled_download_events
recover_stalled_download_events.delay()
return jsonify({"queued": True}), 202
-177
View File
@@ -1,177 +0,0 @@
"""FC-3g: /api/extension — quick-add-source for the Firefox extension
+ install-time manifest for the Settings card.
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import re
from pathlib import Path
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from ..build_info import FC_CHANNEL as _FC_CHANNEL
from ..extensions import get_session
from ..models import AppSetting
from ..services.extension_service import (
ExtensionService,
InvalidUrlError,
UnknownPlatformError,
)
from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension")
# Default XPI directory; tests override via monkeypatching this module-
# level constant.
XPI_DIR = Path("/app/frontend/dist/extension")
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
# Which channel this image belongs to — "dev" or "main" — baked in at build
# time (milestone 271 step 7). Read from build_info rather than the environment
# a second time: /api/health reports the same value, and two independent
# `os.environ.get` calls are two things that can drift.
#
# Still bound as a module-level name here, so tests monkeypatch
# `extension.FC_CHANNEL` exactly as they did before, same as XPI_DIR above.
FC_CHANNEL = _FC_CHANNEL
async def _ext_key_required(session) -> bool:
"""Unlike /api/credentials (which accepts the browser path with no
header), quick-add-source writes server state and must be explicitly
authenticated."""
supplied = request.headers.get("X-Extension-Key")
if supplied is None:
return False
stored = (await session.execute(
select(AppSetting.value).where(AppSetting.key == "extension_api_key")
)).scalar_one_or_none()
if stored is None:
return False
# compare_digest, not `==`: the stored key is a shared secret, and a
# short-circuiting compare leaks its prefix through timing. Costs nothing
# here — it is not that this route is exposed (#3072). Compared as BYTES:
# compare_digest's str form rejects non-ASCII with TypeError, and this
# header is attacker-supplied, so a str compare would turn a junk key into
# a 500 instead of a 403.
return hmac.compare_digest(supplied.encode("utf-8"), stored.encode("utf-8"))
def _extract_version(xpi_name: str) -> str:
m = _XPI_VERSION_RE.search(xpi_name)
return m.group("version") if m else "unknown"
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fp:
for chunk in iter(lambda: fp.read(65536), b""):
h.update(chunk)
return h.hexdigest()
@extension_bp.route("/probe", methods=["GET"])
async def probe_source():
"""Read-only resolution of a creator-page URL: tells the extension
whether this URL is already a Source, is for an Artist that exists
but with a different URL, is brand new, or doesn't match any known
platform pattern. Drives the content-script chip's color/copy
BEFORE the operator clicks, so the button can show 'already added'
without requiring an add-attempt."""
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
result = await ExtensionService(session).probe(url)
return jsonify(result)
@extension_bp.route("/quick-add-source", methods=["POST"])
async def quick_add_source():
body = await request.get_json(silent=True)
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
url = body.get("url")
if not isinstance(url, str) or not url.strip():
return _bad("invalid_body", detail="url is required")
from .credentials import _get_crypto
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
try:
# crypto lets a pixiv add resolve the artist's display name via the
# stored OAuth token (else it falls back to the numeric id). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
except UnknownPlatformError as exc:
return _bad(
"unknown_platform",
detail=str(exc),
known=sorted(KNOWN_PLATFORMS),
)
except InvalidUrlError as exc:
return _bad("invalid_url", detail=str(exc))
return jsonify(result), (201 if result["created_source"] else 200)
def _read_manifest_sync() -> dict | None:
"""All the filesystem-touching work for /api/extension/manifest,
in a sync helper so the async route can dispatch it via
asyncio.to_thread (ASYNC240: no pathlib I/O in async functions)."""
if not XPI_DIR.is_dir():
return None
# Exclude the `fabledcurator-latest.xpi` alias when picking the file to
# extract a version from — it's a copy of the latest versioned XPI,
# written at the same mtime by build.yml, and would otherwise tie or
# win the sort (operator-flagged 2026-05-26: UI displayed "v latest"
# because `_extract_version("fabledcurator-latest.xpi")` returns
# the literal "latest"). The alias still serves as `latest_url`.
versioned = [
p for p in XPI_DIR.glob("fabledcurator-*.xpi")
if p.name != "fabledcurator-latest.xpi"
]
if not versioned:
return None
versioned.sort(key=lambda p: p.stat().st_mtime)
latest = versioned[-1]
info = {
"installed": True,
"version": _extract_version(latest.name),
"xpi_url": f"/extension/{latest.name}",
"latest_url": "/extension/fabledcurator-latest.xpi",
"sha256": _sha256(latest),
}
# The channel goes BESIDE the version, never inside it. A `-dev` suffix is
# what silently disabled the dev channel in the sibling project this design
# comes from: the comparator returned nothing for a non-integer segment, so
# every dev version compared equal and "no update available" became
# indistinguishable from "I cannot read this version".
#
# Omitted rather than defaulted when unset. Absence already has a meaning
# every reader must handle — an image built before this field existed says
# exactly the same thing by not having the key — so a blank channel reuses
# that path instead of inventing a second "unknown" spelling.
#
# Reported verbatim, not validated against {"dev", "main"}: if an image
# declares something else, showing what it actually claims is more useful
# to whoever is debugging it than dropping the value on the floor.
if FC_CHANNEL:
info["channel"] = FC_CHANNEL
return info
@extension_bp.route("/manifest", methods=["GET"])
async def extension_manifest():
info = await asyncio.to_thread(_read_manifest_sync)
if info is None:
return jsonify({"installed": False}), 404
return jsonify(info)
+41 -279
View File
@@ -1,131 +1,52 @@
"""Gallery API: cursor scroll, timeline, jump, image detail, facets."""
from datetime import UTC, datetime, timedelta
"""Gallery API: cursor scroll, timeline, jump, image detail."""
from quart import Blueprint, jsonify, request
from sqlalchemy import delete, select, update
from sqlalchemy.orm import aliased
from ..extensions import get_session
from ..models import (
ImageRecord,
PresentationReview,
Tag,
TagSuggestionRejection,
)
from ..models.tag import image_tag
from ..services.gallery_service import GalleryService, image_url, thumbnail_url
from ..services.gallery_service import GalleryService
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
def _image_json(i):
"""Serialize a GalleryImage for the scroll/similar list responses."""
return {
"id": i.id,
"sha256": i.sha256,
"mime": i.mime,
"width": i.width,
"height": i.height,
"created_at": i.created_at.isoformat(),
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
"thumbnail_url": i.thumbnail_url,
"artist": i.artist,
}
def _parse_date(raw):
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
Raises ValueError (→ 400) on a malformed value."""
if not raw:
return None
return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=UTC)
def _parse_filters():
"""Parse the composable gallery filters from query args, returning
``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
The structured tag filter (#6) is AND-of-OR plus exclusions:
- `tag_id` accepts a single id or a comma-separated list — all ANDed
(the include common case; back-compat).
- `tag_or` is REPEATABLE; each instance is a comma-separated OR-group, and
the image must match at least one tag from EACH group (groups ANDed).
- `tag_not` is a comma-separated exclude list (image must carry none).
`media` is image|video; `sort` is newest|oldest|posted_new|posted_old
(default posted_new); `platform` selects one
platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are
boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds
(date_to is widened by a day so the whole day is covered by the service's
half-open `< date_to`)."""
tag_raw = request.args.get("tag_id")
tag_ids = (
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
) or None
tag_or_groups = [
grp for raw in request.args.getlist("tag_or")
if (grp := [int(x) for x in raw.split(",") if x.strip()])
] or None
not_raw = request.args.get("tag_not")
tag_exclude = (
[int(x) for x in not_raw.split(",") if x.strip()] if not_raw else None
) or None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
media = request.args.get("media")
media_type = media if media in ("image", "video") else None
# newest/oldest key off effective_date (primary post / download); posted_new/
# posted_old off earliest_post_date (original publish across all posts). The
# default is posted_new so the grid leads with original publish date, not the
# download/repost the primary post points at (operator-flagged 2026-07-01).
sort = request.args.get("sort")
sort = sort if sort in ("newest", "oldest", "posted_new", "posted_old") else "posted_new"
platform = request.args.get("platform") or None
untagged = request.args.get("untagged") in ("1", "true", "yes")
no_artist = request.args.get("no_artist") in ("1", "true", "yes")
# Show the presentation chrome (banner / editor screenshot) that the default
# gallery hides — the Hidden view sets this (milestone 141).
include_hidden = request.args.get("include_hidden") in ("1", "true", "yes")
date_from = _parse_date(request.args.get("date_from"))
date_to = _parse_date(request.args.get("date_to"))
if date_to is not None:
date_to += timedelta(days=1) # inclusive of the date_to calendar day
filters = {
"tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id,
"media_type": media_type,
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
"platform": platform,
"untagged": untagged, "no_artist": no_artist,
"date_from": date_from, "date_to": date_to,
"include_hidden": include_hidden,
}
return filters, sort
@gallery_bp.route("/scroll", methods=["GET"])
async def scroll():
cursor = request.args.get("cursor") or None
try:
limit = int(request.args.get("limit", "50"))
filters, sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter or limit parameter"}), 400
return jsonify({"error": "limit must be an integer"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session:
svc = GalleryService(session)
try:
page = await svc.scroll(
cursor=cursor, limit=limit, sort=sort, **filters,
cursor=cursor, limit=limit, tag_id=tag_id,
post_id=post_id, artist_id=artist_id,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
{
"images": [_image_json(i) for i in page.images],
"images": [
{
"id": i.id,
"sha256": i.sha256,
"mime": i.mime,
"width": i.width,
"height": i.height,
"created_at": i.created_at.isoformat(),
"thumbnail_url": i.thumbnail_url,
"artist": i.artist,
}
for i in page.images
],
"next_cursor": page.next_cursor,
"date_groups": [
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
@@ -134,66 +55,20 @@ async def scroll():
)
@gallery_bp.route("/similar", methods=["GET"])
async def similar():
"""Visual "more like this": images ranked by cosine distance to the
`similar_to` image's embedding. Composes with the scope filters (AND) but
ignores post_id and sort. Bounded top-N, no cursor."""
try:
similar_to = int(request.args["similar_to"])
limit = int(request.args.get("limit", "100"))
filters, _sort = _parse_filters()
except (KeyError, ValueError):
return jsonify({"error": "similar_to query param required"}), 400
# Explore passes exclude_wip=1 to also drop work-in-progress from the
# rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
# Explore reach (#1476): 0 = nearest (gallery default), →1 reaches into farther
# distance bands so the walk can escape a dense cluster. exclude_ids = the
# breadcrumb, so already-walked images aren't re-served as neighbours.
try:
reach = max(0.0, min(1.0, float(request.args.get("reach", "0"))))
except ValueError:
reach = 0.0
exclude_ids = [
int(x) for x in request.args.get("exclude_ids", "").split(",")
if x.strip().isdigit()
] or None
# post_id is the exclusive post-detail view — not a similarity scope.
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
scope = {
k: v for k, v in filters.items() if k not in ("post_id", "include_hidden")
}
async with get_session() as session:
svc = GalleryService(session)
try:
images = await svc.similar(
image_id=similar_to, limit=limit, exclude_wip=exclude_wip,
reach=reach, exclude_ids=exclude_ids, **scope)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if images is None:
return jsonify({"error": "not found"}), 404
return jsonify(
{
"images": [_image_json(i) for i in images],
"next_cursor": None,
"date_groups": [],
}
)
@gallery_bp.route("/timeline", methods=["GET"])
async def timeline():
try:
filters, _sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session:
svc = GalleryService(session)
try:
buckets = await svc.timeline(**filters)
buckets = await svc.timeline(
tag_id=tag_id, post_id=post_id, artist_id=artist_id
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
@@ -201,144 +76,31 @@ async def timeline():
)
@gallery_bp.route("/facets", methods=["GET"])
async def facets():
try:
filters, _sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400
async with get_session() as session:
svc = GalleryService(session)
try:
f = await svc.facets(**filters)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
{
"total": f.total,
"platforms": f.platforms,
"untagged": f.untagged,
"no_artist": f.no_artist,
"date_min": f.date_min.isoformat() if f.date_min else None,
"date_max": f.date_max.isoformat() if f.date_max else None,
}
)
@gallery_bp.route("/jump", methods=["GET"])
async def jump():
try:
year = int(request.args["year"])
month = int(request.args["month"])
filters, sort = _parse_filters()
except (KeyError, ValueError):
return jsonify({"error": "year and month query params required"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session:
svc = GalleryService(session)
try:
cursor = await svc.jump_cursor(
year=year, month=month, sort=sort, **filters,
year=year, month=month, tag_id=tag_id,
post_id=post_id, artist_id=artist_id,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"cursor": cursor})
# -- Hidden-view review (#141): auto-hidden chrome flagged "also looks like
# content", surfaced in the gallery's Show-hidden review strip. -----------
@gallery_bp.route("/hidden-review", methods=["GET"])
async def hidden_review():
"""Unresolved system-tag auto-apply review flags (chrome + process, #1464),
most-concerning first (highest content score) — for the review strip. `mode`
tells the client whether the flagged tag hid the image ('chrome') or left it
visible ('process'), which decides the resolve labels (un-hide vs remove-tag)."""
ptag = aliased(Tag)
ctag = aliased(Tag)
async with get_session() as session:
rows = (await session.execute(
select(
PresentationReview.image_record_id,
PresentationReview.tag_id,
PresentationReview.conflict_tag_id,
PresentationReview.conflict_score,
PresentationReview.mode,
ImageRecord.path, ImageRecord.thumbnail_path,
ImageRecord.sha256, ImageRecord.mime,
ptag.name.label("tag_name"),
ctag.name.label("conflict_name"),
)
.join(ImageRecord, ImageRecord.id == PresentationReview.image_record_id)
.join(ptag, ptag.id == PresentationReview.tag_id)
.outerjoin(ctag, ctag.id == PresentationReview.conflict_tag_id)
.where(PresentationReview.resolved_at.is_(None))
.order_by(PresentationReview.conflict_score.desc())
)).all()
return jsonify({"items": [
{
"image_id": r.image_record_id,
"tag_id": r.tag_id,
"tag_name": r.tag_name,
"conflict_tag_id": r.conflict_tag_id,
"conflict_name": r.conflict_name,
"conflict_score": r.conflict_score,
"mode": r.mode,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": image_url(r.path),
}
for r in rows
]})
@gallery_bp.route(
"/hidden-review/<int:image_id>/<int:tag_id>/keep", methods=["POST"]
)
async def hidden_review_keep(image_id, tag_id):
"""Keep the auto-hide: resolve the flag; the tag stays applied (#141)."""
async with get_session() as session:
await session.execute(
update(PresentationReview)
.where(
PresentationReview.image_record_id == image_id,
PresentationReview.tag_id == tag_id,
)
.values(resolved_at=datetime.now(UTC))
)
await session.commit()
return "", 204
@gallery_bp.route(
"/hidden-review/<int:image_id>/<int:tag_id>/unhide", methods=["POST"]
)
async def hidden_review_unhide(image_id, tag_id):
"""Un-hide: remove the presentation tag (image returns to the gallery), record
a rejection so the head LEARNS it misfired, and resolve the flag (#141)."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
async with get_session() as session:
await session.execute(
delete(image_tag).where(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
)
await session.execute(
pg_insert(TagSuggestionRejection)
.values(image_record_id=image_id, tag_id=tag_id)
.on_conflict_do_nothing()
)
await session.execute(
update(PresentationReview)
.where(
PresentationReview.image_record_id == image_id,
PresentationReview.tag_id == tag_id,
)
.values(resolved_at=datetime.now(UTC))
)
await session.commit()
return "", 204
@gallery_bp.route("/image/<int:image_id>", methods=["GET"])
async def image_detail(image_id: int):
async with get_session() as session:
-420
View File
@@ -1,420 +0,0 @@
"""GPU-job API (#114): the HTTP surface the desktop agent pulls work from.
The agent stays HTTP-only — it leases jobs, fetches image pixels via the normal
FC image URLs, and submits embeddings/regions back, all over this API. Redis and
Postgres are never exposed. The agent endpoints are gated by a bearer token
(Authorization: Bearer <token>) stored in AppSetting; the admin endpoints
(token / backfill / status) ride the browser session like the rest of FC's
homelab admin.
"""
import secrets
from pathlib import Path
from quart import Blueprint, jsonify, request
from sqlalchemy import func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..extensions import get_session
from ..models import AppSetting, GpuJob, ImageRecord, MLSettings
from ..services.gallery_service import image_url
from ..services.ml.gpu_jobs import GpuJobService, error_dedupe_statements
from ..services.ml.gpu_triage import classify_reason, recover_defective_image
from ..services.ml.regions import RegionService
gpu_bp = Blueprint("gpu", __name__, url_prefix="/api/gpu")
# Same container mount the maintenance tasks use (tasks/admin.py) — recovery
# deletes the defective original + thumbnail under it.
_IMAGES_ROOT = Path("/images")
_TOKEN_KEY = "gpu_agent_token"
def _bearer() -> str | None:
h = request.headers.get("Authorization", "")
return h[7:].strip() if h.startswith("Bearer ") else None
async def _agent_authed(session) -> bool:
supplied = _bearer()
if not supplied:
return False
stored = (
await session.execute(
select(AppSetting.value).where(AppSetting.key == _TOKEN_KEY)
)
).scalar_one_or_none()
return stored is not None and secrets.compare_digest(supplied, stored)
# --- Admin (browser): token + backfill + status -------------------------
@gpu_bp.route("/token", methods=["GET"])
async def get_token():
async with get_session() as session:
tok = (
await session.execute(
select(AppSetting.value).where(AppSetting.key == _TOKEN_KEY)
)
).scalar_one_or_none()
return jsonify({"token": tok, "configured": tok is not None})
@gpu_bp.route("/token/rotate", methods=["POST"])
async def rotate_token():
token = secrets.token_urlsafe(32)
async with get_session() as session:
await session.execute(
pg_insert(AppSetting)
.values(key=_TOKEN_KEY, value=token)
.on_conflict_do_update(index_elements=["key"], set_={"value": token})
)
await session.commit()
return jsonify({"token": token})
@gpu_bp.route("/status", methods=["GET"])
async def status():
async with get_session() as session:
rows = (
await session.execute(
select(GpuJob.status, func.count()).group_by(GpuJob.status)
)
).all()
counts = dict(rows)
return jsonify({
"pending": counts.get("pending", 0),
"leased": counts.get("leased", 0),
"done": counts.get("done", 0),
"error": counts.get("error", 0),
})
@gpu_bp.route("/backfill", methods=["POST"])
async def backfill():
"""Enqueue a job for every image that doesn't already have one for `task`."""
body = await request.get_json(silent=True) or {}
task = str(body.get("task") or "ccip")
from ..tasks.gpu_queue import enqueue_gpu_backfill
r = enqueue_gpu_backfill.delay(task)
return jsonify({"celery_task_id": r.id, "task": task}), 202
@gpu_bp.route("/reprocess", methods=["POST"])
async def reprocess():
"""Reset every done/error job of `task` back to pending so the agent re-runs
the WHOLE library under the current pipeline (e.g. after adding crop
detectors). Heavy — the back-catalogue is otherwise skipped by the backfills."""
body = await request.get_json(silent=True) or {}
task = str(body.get("task") or "ccip")
from ..tasks.gpu_queue import reprocess_gpu_jobs
r = reprocess_gpu_jobs.delay(task)
return jsonify({"celery_task_id": r.id, "task": task}), 202
@gpu_bp.route("/retry_errors", methods=["POST"])
async def retry_errors():
"""Requeue every ERRORED job (all task types) back to pending — the scoped
recovery after an agent-side fix (e.g. the short-video sampler), where
/reprocess would needlessly re-run the whole done library too. Attempts and
the stored error reset so each job gets its full retry budget under the
fixed pipeline. Stale tombstones are pruned FIRST (loop-era duplicates and
rows a later success made moot — the same statements the backfills run), so
one failing file requeues as ONE job, never a fan-out of duplicates. Small
row count (errors only) → inline statements; the response carries the
counts for the UI toast. Triage-confirmed defects are NOT requeued (see
the WHERE below) — they stay on the recovery surface."""
async with get_session() as session:
pruned = 0
for stmt in error_dedupe_statements():
pruned += (await session.execute(stmt)).rowcount or 0
res = await session.execute(
update(GpuJob)
.where(
GpuJob.status == "error",
# Triage-confirmed DEFECTS stay errored: the integrity probe
# already proved the FILE is bad, so re-running the job just
# burns agent time re-minting the same tombstone — those go
# through /errors/<id>/recover instead.
or_(GpuJob.triage_status.is_(None),
GpuJob.triage_status != "defect"),
)
.values(
status="pending", attempts=0, error=None, lease_token=None,
leased_at=None, lease_expires_at=None, triage_status=None,
updated_at=func.now(),
)
)
kept = (
await session.execute(
select(func.count()).select_from(GpuJob)
.where(GpuJob.status == "error")
)
).scalar_one()
await session.commit()
return jsonify({
"requeued": res.rowcount or 0, "pruned": pruned, "defects_kept": kept,
})
# --- Failure triage + recovery (#125) ------------------------------------
@gpu_bp.route("/errors", methods=["GET"])
async def errors():
"""The triage view of the error tombstones: every errored job joined with
its image's integrity verdict, bucketed by reason for the overview. The
probe sweep (triage_gpu_errors, 15-min beat) fills triage_status; 'defect'
rows are the recovery surface's list."""
async with get_session() as session:
rows = (
await session.execute(
select(
GpuJob.id, GpuJob.image_record_id, GpuJob.task,
GpuJob.error, GpuJob.triage_status, GpuJob.updated_at,
ImageRecord.integrity_status, ImageRecord.mime,
ImageRecord.path, ImageRecord.thumbnail_path,
)
.join(ImageRecord, ImageRecord.id == GpuJob.image_record_id)
.where(GpuJob.status == "error")
.order_by(GpuJob.updated_at.desc())
.limit(500)
)
).all()
total = (
await session.execute(
select(func.count()).select_from(GpuJob)
.where(GpuJob.status == "error")
)
).scalar_one()
by_class: dict[str, int] = {}
triage = {"defect": 0, "file_ok": 0, "unclassified": 0}
items = []
for r in rows:
cls = classify_reason(r.error)
by_class[cls] = by_class.get(cls, 0) + 1
bucket = r.triage_status or "unclassified"
triage[bucket] = triage.get(bucket, 0) + 1
items.append({
"job_id": r.id,
"image_id": r.image_record_id,
"task": r.task,
"error": r.error,
"reason_class": cls,
"triage_status": r.triage_status,
"integrity_status": r.integrity_status,
"mime": r.mime,
"image_url": image_url(r.path),
"thumbnail_url": (
image_url(r.thumbnail_path) if r.thumbnail_path else None
),
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
})
return jsonify({
"total": total, "by_class": by_class, "triage": triage, "items": items,
})
@gpu_bp.route("/errors/triage", methods=["POST"])
async def errors_triage():
"""Run the probe sweep NOW (the card's button) instead of waiting out the
15-minute beat cadence."""
from ..tasks.maintenance import triage_gpu_errors
r = triage_gpu_errors.delay()
return jsonify({"celery_task_id": r.id}), 202
@gpu_bp.route("/errors/<int:image_id>/recover", methods=["POST"])
async def errors_recover(image_id: int):
"""Recover a defect-triaged original: delete the bad copy + record and
re-poll its subscription Source (a fresh fetch re-imports the file, which
re-enters the GPU pipeline). Returns status 'no_source' when nothing
pollable resolves — the file needs manual replacement there."""
async with get_session() as session:
result = await session.run_sync(
lambda s: recover_defective_image(
s, image_id, images_root=_IMAGES_ROOT,
)
)
return jsonify(result)
# --- Agent (bearer token): lease / submit / heartbeat / fail ------------
@gpu_bp.route("/jobs/lease", methods=["POST"])
async def lease():
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
try:
batch = min(max(int(body.get("batch_size", 8)), 1), 64)
except (TypeError, ValueError):
batch = 8
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
ml = await MLSettings.load(session)
# image rows for url/mime in one shot
ids = [j.image_record_id for j in jobs]
imgs = {
i.id: i for i in (
await session.execute(
select(ImageRecord).where(ImageRecord.id.in_(ids))
)
).scalars()
} if ids else {}
await session.commit()
# Crop-proposer config, announced FROM THE SETTING like embed_model_name
# (#134): the agent builds its detectors from this, rebuilding live when
# it changes — so tuning is a DB/UI edit, never an agent restart. Same
# block for every job in the batch (it's global), built once. An enabled
# toggle off is carried through so the agent skips that proposer.
detectors = {
"person": {
"enabled": ml.detector_person_enabled,
"weights": ml.detector_person_weights,
"conf": ml.detector_person_conf,
},
"anatomy": {
"enabled": ml.detector_anatomy_enabled,
"weights": ml.detector_anatomy_weights,
"conf": ml.detector_anatomy_conf,
},
"panel": {
"enabled": ml.detector_panel_enabled,
"weights": ml.detector_panel_weights,
"conf": ml.detector_panel_conf,
},
"max_figures": ml.detector_max_figures,
"max_components": ml.detector_max_components,
"max_panels": ml.detector_max_panels,
"max_regions": ml.detector_max_regions,
"dedupe_iou": ml.detector_dedupe_iou,
}
out = []
for j in jobs:
img = imgs.get(j.image_record_id)
if img is None:
continue
out.append({
"job_id": j.id,
"image_id": j.image_record_id,
"task": j.task,
"mime": img.mime,
"image_url": image_url(img.path),
# For video/animated: the agent samples at this cadence.
"frame_interval_seconds": ml.video_frame_interval_seconds,
"max_frames": ml.video_max_frames,
# The embedding model the agent must use for concept crops + the
# whole-image 'embed' task, so its vectors land in the SAME space
# the heads trained in. Server-announced FROM THE SETTING → the
# agent stays model-agnostic; an operator swap is a setting + a
# re-embed, never an agent change.
"embed_model_name": ml.embedder_model_name,
"embed_version": ml.embedder_model_version,
"detectors": detectors,
})
return jsonify({"jobs": out})
@gpu_bp.route("/jobs/heartbeat", methods=["POST"])
async def heartbeat():
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_ids = [int(x) for x in (body.get("job_ids") or [])]
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
n = await GpuJobService(session).heartbeat(agent_id, job_ids)
await session.commit()
return jsonify({"extended": n})
@gpu_bp.route("/jobs/submit", methods=["POST"])
async def submit():
"""Store a job's regions + close it. regions: [{kind, bbox:[x,y,w,h],
frame_time?, score?, *_version?, ccip_embedding?, siglip_embedding?}].
replace_kinds defaults to the kinds present in the submitted regions."""
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_id = body.get("job_id")
regions = body.get("regions") or []
if job_id is None:
return jsonify({"error": "job_id required"}), 400
kinds = body.get("replace_kinds") or sorted({r["kind"] for r in regions})
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
job = await session.get(GpuJob, int(job_id))
if job is None or job.status != "leased" or job.lease_token != agent_id:
return jsonify({"error": "lease_invalid"}), 409
if kinds:
await RegionService(session).replace_regions(
job.image_record_id, kinds, regions
)
await GpuJobService(session).complete(agent_id, int(job_id))
await session.commit()
return jsonify({"ok": True, "stored": len(regions)})
@gpu_bp.route("/jobs/submit_embedding", methods=["POST"])
async def submit_embedding():
"""Store a whole-image SigLIP embedding (the 'embed' task) on image_record +
close the job. Body: {agent_id, job_id, embedding:[...], embedding_version}.
This is how the GPU agent re-embeds the library under a new model (#1190) —
much faster than the CPU ml-worker at higher resolutions."""
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_id = body.get("job_id")
embedding = body.get("embedding")
version = body.get("embedding_version")
if job_id is None or not embedding or not version:
return jsonify({"error": "job_id, embedding, embedding_version required"}), 400
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
job = await session.get(GpuJob, int(job_id))
if job is None or job.status != "leased" or job.lease_token != agent_id:
return jsonify({"error": "lease_invalid"}), 409
img = await session.get(ImageRecord, job.image_record_id)
if img is not None:
img.siglip_embedding = embedding
img.siglip_model_version = version
await GpuJobService(session).complete(agent_id, int(job_id))
await session.commit()
return jsonify({"ok": True})
@gpu_bp.route("/jobs/fail", methods=["POST"])
async def fail():
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_id = body.get("job_id")
if job_id is None:
return jsonify({"error": "job_id required"}), 400
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
ok = await GpuJobService(session).fail(
agent_id, int(job_id), str(body.get("error") or "")
)
await session.commit()
return jsonify({"ok": ok})
@gpu_bp.route("/jobs/release", methods=["POST"])
async def release():
"""Graceful stop: the agent hands its still-leased jobs back to pending so
they're picked up immediately instead of waiting out the lease."""
body = await request.get_json(silent=True) or {}
agent_id = str(body.get("agent_id") or "agent")
job_ids = [int(x) for x in (body.get("job_ids") or [])]
async with get_session() as session:
if not await _agent_authed(session):
return jsonify({"error": "unauthorized"}), 401
n = await GpuJobService(session).release(agent_id, job_ids)
await session.commit()
return jsonify({"released": n})
-285
View File
@@ -1,285 +0,0 @@
"""Heads API (#114): train + inspect the per-concept heads that power
suggestions (replacing Camie + centroid).
POST /api/heads/train — (re)train all eligible heads (one run at a time).
GET /api/heads — status: head count, last-trained, running run, the
per-concept head table (strength + auto-apply ready),
and recent training runs. The card rehydrates from
here so status survives navigation.
"""
from quart import Blueprint, jsonify, request
from sqlalchemy import desc, func, select
from ..extensions import get_session
from ..models import (
HeadAutoApplyRun,
HeadMetric,
HeadMetricsSnapshot,
HeadTrainingRun,
Tag,
TagHead,
)
from ..models.tag import image_tag
from ..services.ml.heads import (
HeadAutoApplyAlreadyRunning,
HeadAutoApplyDisabled,
HeadTrainingAlreadyRunning,
start_head_auto_apply_run,
start_head_training_run,
)
heads_bp = Blueprint("heads", __name__, url_prefix="/api/heads")
def _serialize_run(run: HeadTrainingRun) -> dict:
return {
"id": run.id,
"params": run.params,
"status": run.status,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"n_trained": run.n_trained,
"n_skipped": run.n_skipped,
"error": run.error,
}
@heads_bp.route("/train", methods=["POST"])
async def train():
body = await request.get_json(silent=True) or {}
params = body.get("params") or body or {}
async with get_session() as session:
try:
run_id = await session.run_sync(
lambda s: start_head_training_run(s, params)
)
except HeadTrainingAlreadyRunning as running:
return jsonify({
"error": "training_already_running",
"running_id": int(running.args[0]),
}), 409
await session.commit()
return jsonify({"run_id": run_id, "status": "running"}), 202
@heads_bp.route("", methods=["GET"])
async def status():
async with get_session() as session:
count, last_trained = (
await session.execute(
select(func.count(), func.max(TagHead.trained_at))
)
).one()
graduated = (
await session.execute(
select(func.count()).where(
TagHead.auto_apply_threshold.is_not(None)
)
)
).scalar_one()
running = (
await session.execute(
select(HeadTrainingRun.id)
.where(HeadTrainingRun.status == "running")
.order_by(HeadTrainingRun.id.desc())
.limit(1)
)
).scalar_one_or_none()
runs = (
await session.execute(
select(HeadTrainingRun)
.order_by(HeadTrainingRun.id.desc())
.limit(10)
)
).scalars().all()
# The per-concept table: strongest first, capped for the admin card.
head_rows = (
await session.execute(
select(
TagHead.tag_id, Tag.name, Tag.kind,
TagHead.n_pos, TagHead.n_neg, TagHead.ap,
TagHead.precision_cv, TagHead.recall,
TagHead.auto_apply_threshold, TagHead.trained_at,
)
.join(Tag, Tag.id == TagHead.tag_id)
.order_by(desc(TagHead.ap))
.limit(500)
)
).all()
heads = [
{
"tag_id": r.tag_id,
"name": r.name,
"category": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
"n_pos": r.n_pos,
"n_neg": r.n_neg,
"ap": r.ap,
"precision": r.precision_cv,
"recall": r.recall,
"auto_apply": r.auto_apply_threshold is not None,
"trained_at": r.trained_at.isoformat() if r.trained_at else None,
}
for r in head_rows
]
return jsonify({
"head_count": count,
"graduated_count": graduated,
"last_trained_at": last_trained.isoformat() if last_trained else None,
"running_id": running,
"runs": [_serialize_run(r) for r in runs],
"heads": heads,
})
def _serialize_apply_run(run: HeadAutoApplyRun) -> dict:
return {
"id": run.id,
"dry_run": run.dry_run,
"status": run.status,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"n_applied": run.n_applied,
"report": run.report,
"error": run.error,
}
@heads_bp.route("/auto-apply", methods=["POST"])
async def auto_apply():
"""Trigger an earned-auto-apply sweep. {dry_run:true} previews (writes
nothing); a real sweep needs head_auto_apply_enabled on."""
body = await request.get_json(silent=True) or {}
params = {"dry_run": bool(body.get("dry_run", False))}
async with get_session() as session:
try:
run_id = await session.run_sync(
lambda s: start_head_auto_apply_run(s, params)
)
except HeadAutoApplyAlreadyRunning as running:
return jsonify({
"error": "auto_apply_already_running",
"running_id": int(running.args[0]),
}), 409
except HeadAutoApplyDisabled:
return jsonify({"error": "auto_apply_disabled"}), 400
await session.commit()
return jsonify({"run_id": run_id, "status": "running"}), 202
@heads_bp.route("/auto-apply", methods=["GET"])
async def auto_apply_status():
async with get_session() as session:
running = (
await session.execute(
select(HeadAutoApplyRun.id)
.where(HeadAutoApplyRun.status == "running")
.order_by(HeadAutoApplyRun.id.desc())
.limit(1)
)
).scalar_one_or_none()
runs = (
await session.execute(
select(HeadAutoApplyRun)
.order_by(HeadAutoApplyRun.id.desc())
.limit(10)
)
).scalars().all()
return jsonify({
"running_id": running,
"runs": [_serialize_apply_run(r) for r in runs],
})
@heads_bp.route("/metrics", methods=["GET"])
async def metrics():
"""Auto-apply observability: per-concept current counts (volume, misfires,
under-fires, realized misfire rate, head quality) + the daily time-series so
the operator can tune the precision target + support floor from real data."""
async with get_session() as session:
head_rows = (
await session.execute(
select(
TagHead.tag_id, Tag.name, TagHead.ap, TagHead.precision_cv,
TagHead.recall, TagHead.auto_apply_threshold, TagHead.n_pos,
).join(Tag, Tag.id == TagHead.tag_id)
)
).all()
heads = {r.tag_id: r for r in head_rows}
metric_rows = (
await session.execute(
select(
HeadMetric.tag_id, HeadMetric.n_misfires, HeadMetric.n_underfires
)
)
).all()
mets = {r.tag_id: r for r in metric_rows}
applied = dict(
(
await session.execute(
select(image_tag.c.tag_id, func.count())
.where(image_tag.c.source == "head_auto")
.group_by(image_tag.c.tag_id)
)
).all()
)
names = {r.tag_id: r.name for r in head_rows}
# Names for metric-only tags (head pruned but corrections recorded).
missing = [t for t in mets if t not in names]
if missing:
for tid, nm in (
await session.execute(
select(Tag.id, Tag.name).where(Tag.id.in_(missing))
)
).all():
names[tid] = nm
concepts = []
for tid in set(heads) | set(mets):
h = heads.get(tid)
m = mets.get(tid)
n_applied = applied.get(tid, 0)
n_mis = m.n_misfires if m else 0
denom = n_applied + n_mis
concepts.append({
"tag_id": tid,
"name": names.get(tid, str(tid)),
"n_auto_applied": n_applied,
"n_misfires": n_mis,
"n_underfires": m.n_underfires if m else 0,
# Of everything this head ever auto-applied, the fraction you
# removed — the misfire rate (null until something fired).
"misfire_rate": round(n_mis / denom, 4) if denom else None,
"ap": h.ap if h else None,
"precision_cv": h.precision_cv if h else None,
"recall": h.recall if h else None,
"auto_apply": bool(h and h.auto_apply_threshold is not None),
"n_pos": h.n_pos if h else None,
})
concepts.sort(key=lambda c: (c["n_misfires"], c["n_auto_applied"]), reverse=True)
snaps = (
await session.execute(
select(HeadMetricsSnapshot)
.order_by(HeadMetricsSnapshot.snapshot_at.desc())
.limit(1000)
)
).scalars().all()
return jsonify({
"concepts": concepts,
"snapshots": [
{
"tag_id": s.tag_id,
"name": s.name,
"snapshot_at": s.snapshot_at.isoformat() if s.snapshot_at else None,
"n_auto_applied": s.n_auto_applied,
"n_misfires": s.n_misfires,
"n_underfires": s.n_underfires,
"ap": s.ap,
"precision_cv": s.precision_cv,
"recall": s.recall,
"n_pos": s.n_pos,
}
for s in snaps
],
})
+2 -17
View File
@@ -1,20 +1,5 @@
"""Health endpoint — no DB or Redis touch; liveness, plus the build's identity.
The identity rides here rather than on a route of its own because it answers
at the same cost: two module constants, no I/O, nothing that can be slow or
fail. It is also already fetched app-wide — TopNav calls `refreshHealth` on
mount — so a separate endpoint would mean a second request for two strings.
Both fields are OMITTED when unset rather than sent empty. See build_info.
"""
from ..build_info import FC_CHANNEL, FC_VERSION
"""Health endpoint — no DB or Redis touch; just liveness."""
async def get_health():
body = {"status": "ok"}
if FC_VERSION:
body["version"] = FC_VERSION
if FC_CHANNEL:
body["channel"] = FC_CHANNEL
return body, 200
return {"status": "ok"}, 200
+13 -147
View File
@@ -35,26 +35,10 @@ async def trigger_scan():
@import_admin_bp.route("/status", methods=["GET"])
async def status():
async with get_session() as session:
# Active batch = running batch that still has outstanding work.
# Plain "most recent running" picks freshly-created scans that
# enqueued zero new files and hides the older batch that's
# actually being processed. Mirrors the EXISTS predicate
# /api/system/stats already uses (api/settings.py:145-160).
# Audit 2026-06-02 — /api/import/status and /api/system/stats
# used to disagree on the active-batch predicate; the UI banner
# said "Scanning…" indefinitely while the stats card said idle.
active = (
await session.execute(
select(ImportBatch)
.where(
ImportBatch.status == "running",
select(ImportTask.id)
.where(
ImportTask.batch_id == ImportBatch.id,
ImportTask.status.in_(["pending", "queued", "processing"]),
)
.exists(),
)
.where(ImportBatch.status == "running")
.order_by(ImportBatch.started_at.desc())
.limit(1)
)
@@ -63,13 +47,10 @@ async def status():
if active:
payload["active_batch"] = {
"id": active.id,
"source_path": active.source_path,
"scan_mode": active.scan_mode,
"total_files": active.total_files,
"imported": active.imported,
"skipped": active.skipped,
"failed": active.failed,
"refreshed": active.refreshed,
"started_at": active.started_at.isoformat(),
}
return jsonify(payload)
@@ -119,139 +100,24 @@ async def list_tasks():
@import_admin_bp.route("/retry-failed", methods=["POST"])
async def retry_failed():
# Fold SELECT into UPDATE…WHERE…RETURNING — the prior SELECT-then-
# UPDATE-WHERE-id-IN pattern blew past psycopg's 65535-parameter
# ceiling once failed_ids exceeded ~65k rows.
async with get_session() as session:
result = await session.execute(
update(ImportTask)
.where(ImportTask.status == "failed")
.values(
status="queued", error=None,
started_at=None, finished_at=None,
)
.returning(ImportTask.id, ImportTask.task_type)
)
failed = result.all()
if not failed:
return jsonify({"retried": 0})
await session.commit()
from ..tasks.import_file import enqueue_import
for tid, task_type in failed:
enqueue_import(tid, task_type)
return jsonify({"retried": len(failed)})
@import_admin_bp.route("/tasks/<int:task_id>/refetch", methods=["POST"])
async def refetch_task(task_id: int):
"""Layer-2 one-shot re-download: delete the (corrupt) file behind a
failed import task and re-run its source's downloader to fetch a
fresh copy. Only works for files that resolve to an enabled,
real-URL subscription Source; filesystem-only imports return
no_source.
Returns one of: refetch_queued (+source_id) / no_source /
already_refetched / not_found / not_failed.
"""
async with get_session() as session:
result = await session.run_sync(_refetch_task_sync, task_id)
if result["status"] == "not_found":
return jsonify(result), 404
if result["status"] == "not_failed":
return jsonify(result), 400
return jsonify(result)
def _refetch_task_sync(session, task_id: int) -> dict:
from pathlib import Path
from ..models import ImportSettings
from ..services.refetch_service import attempt_refetch
task = session.get(ImportTask, task_id)
if task is None:
return {"status": "not_found"}
if task.status != "failed":
return {"status": "not_failed"}
settings = ImportSettings.load_sync(session)
return attempt_refetch(session, task, Path(settings.import_scan_path))
@import_admin_bp.route("/clear-stuck", methods=["POST"])
async def clear_stuck():
"""Force any non-terminal ImportTask (status in pending/queued/
processing) to 'failed' AND finalize any ImportBatch that ends up
with no active children. Escape hatch for the operator when the
automatic recover_interrupted_tasks sweep keeps re-queueing the
same stuck row forever (e.g., underlying file is genuinely broken
and the import keeps OSError-looping at PIL load).
Idempotent + non-destructive: rows survive as 'failed' so the
Retry-Failed button can re-attempt them once whatever was broken
is fixed. Banked 2026-05-25 — operator hit 3 large PNGs that
autoretry-looped for 2 days after a corrupt-data PIL OSError.
"""
async with get_session() as session:
# Fold SELECT into UPDATE…WHERE — see /retry-failed for the
# 65535-parameter ceiling rationale. rowcount is enough here
# because we don't need the ids afterward (no .delay()).
clear_result = await session.execute(
update(ImportTask)
.where(
ImportTask.status.in_(["pending", "queued", "processing"])
)
.values(
status="failed",
finished_at=datetime.now(UTC),
error=(
"manually cleared via /api/import/clear-stuck "
"— stuck in non-terminal state; retry once "
"underlying cause (corrupt file, missing model, "
"etc.) is resolved"
),
)
)
tasks_failed = clear_result.rowcount or 0
# Finalize any 'running' ImportBatch that no longer has any
# active children. The "Scanning..." banner is driven by
# /api/import/status finding a running batch; left untouched,
# it would persist forever after the stuck-task clear.
running_batches = (
await session.execute(
select(ImportBatch.id).where(ImportBatch.status == "running")
)
failed_ids = (
await session.execute(select(ImportTask.id).where(ImportTask.status == "failed"))
).scalars().all()
finalized_batches = 0
for batch_id in running_batches:
still_active = (
if not failed_ids:
return jsonify({"retried": 0})
await session.execute(
select(ImportTask.id)
.where(ImportTask.batch_id == batch_id)
.where(ImportTask.status.in_(
["pending", "queued", "processing"]
))
.limit(1)
update(ImportTask)
.where(ImportTask.id.in_(failed_ids))
.values(status="queued", error=None, started_at=None, finished_at=None)
)
).scalar_one_or_none()
if still_active is None:
await session.execute(
update(ImportBatch)
.where(ImportBatch.id == batch_id)
.values(
status="complete",
finished_at=datetime.now(UTC),
)
)
finalized_batches += 1
await session.commit()
return jsonify({
"tasks_failed": tasks_failed,
"batches_finalized": finalized_batches,
})
from ..tasks.import_file import import_media_file
for tid in failed_ids:
import_media_file.delay(tid)
return jsonify({"retried": len(failed_ids)})
@import_admin_bp.route("/clear-completed", methods=["POST"])
+141
View File
@@ -0,0 +1,141 @@
"""FC-5: /api/migrate — trigger and poll migration runs.
Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an
`export_file` field. All other kinds accept JSON. Apply-without-backup
guard rejects non-dry-run ingests unless a pre_migration-tagged backup
exists in the last 24h (override with body.force=true).
"""
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from ..extensions import get_session
from ..models import MigrationRun
from ..tasks.migration import run_migration
migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate")
_VALID_KINDS = frozenset({
"backup", "gs_ingest", "ir_ingest", "tag_apply",
"ml_queue", "verify", "rollback",
})
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
_APPLY_KINDS = frozenset({"gs_ingest", "ir_ingest", "tag_apply", "rollback"})
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _has_recent_pre_migration_backup() -> bool:
from ..services.migrators import backup as backup_mod
images_root = Path("/images")
manifest = backup_mod.find_latest_backup(images_root, tag="pre_migration")
if manifest is None:
return False
created_at_str = manifest.get("created_at")
if not created_at_str:
return False
created_at = datetime.fromisoformat(created_at_str)
return (datetime.now(UTC) - created_at) < timedelta(hours=24)
def _run_to_dict(run: MigrationRun) -> dict:
return {
"id": run.id,
"kind": run.kind,
"status": run.status,
"dry_run": run.dry_run,
"started_at": run.started_at.isoformat(),
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"counts": run.counts or {},
"error": run.error,
"metadata": run.metadata_ or {},
}
@migrate_bp.route("/<kind>", methods=["POST"])
async def create_run(kind: str):
if kind not in _VALID_KINDS:
return _bad("unknown_kind", detail=f"kind must be one of {sorted(_VALID_KINDS)}")
# Ingest kinds accept multipart/form-data; everything else takes JSON.
if kind in _INGEST_KINDS:
form = await request.form
files = await request.files
if "export_file" not in files:
return _bad("missing_export_file", detail="multipart export_file required")
export_file = files["export_file"]
try:
raw = export_file.read()
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return _bad("invalid_export_file", detail=str(exc))
dry_run = str(form.get("dry_run", "false")).lower() in ("true", "1", "yes")
force = str(form.get("force", "false")).lower() in ("true", "1", "yes")
params: dict = {"data": data, "dry_run": dry_run}
else:
body = await request.get_json()
if body is None:
body = {}
if not isinstance(body, dict):
return _bad("invalid_body")
dry_run = bool(body.get("dry_run", False))
force = bool(body.get("force", False))
params = dict(body)
is_apply = (kind in _APPLY_KINDS) and not dry_run
if is_apply and not force and not _has_recent_pre_migration_backup():
return _bad(
"no_backup",
detail="apply action requires a pre_migration-tagged backup "
"in the last 24h (or force=true).",
)
if kind == "backup":
params.setdefault("tag", "pre_migration")
async with get_session() as session:
run = MigrationRun(kind=kind, status="pending", dry_run=dry_run)
session.add(run)
await session.commit()
await session.refresh(run)
run_id = run.id
run_migration.delay(run_id, kind, params)
return jsonify({"run_id": run_id, "status": "pending"}), 202
@migrate_bp.route("/runs/<int:run_id>", methods=["GET"])
async def get_run(run_id: int):
async with get_session() as session:
run = (await session.execute(
select(MigrationRun).where(MigrationRun.id == run_id)
)).scalar_one_or_none()
if run is None:
return _bad("not_found", status=404)
return jsonify(_run_to_dict(run))
@migrate_bp.route("/runs", methods=["GET"])
async def list_runs():
try:
limit = int(request.args.get("limit", "10"))
except ValueError:
return _bad("invalid_limit")
if limit < 1 or limit > 100:
return _bad("invalid_limit")
async with get_session() as session:
rows = (await session.execute(
select(MigrationRun)
.order_by(MigrationRun.id.desc())
.limit(limit)
)).scalars().all()
return jsonify([_run_to_dict(r) for r in rows])
+38 -141
View File
@@ -1,177 +1,74 @@
"""ML admin API: settings + backfill trigger."""
"""ML admin API: settings, backfill trigger, centroid recompute trigger."""
from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import MLSettings
from ..services.ml.heads import AUTO_APPLY_THRESHOLD_MAX, AUTO_APPLY_THRESHOLD_MIN
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
# Crop-proposer / detector config (#134). Announced to the GPU agent in the lease
# → tunable here with no restart. weights = ultralytics name | URL | hf_repo::file
# (empty, or enabled off, skips that proposer).
_DETECTOR_FIELDS = (
"detector_person_enabled",
"detector_person_weights",
"detector_person_conf",
"detector_anatomy_enabled",
"detector_anatomy_weights",
"detector_anatomy_conf",
"detector_panel_enabled",
"detector_panel_weights",
"detector_panel_conf",
"detector_max_figures",
"detector_max_components",
"detector_max_panels",
"detector_max_regions",
"detector_dedupe_iou",
)
_EDITABLE = (
"cpu_embed_enabled",
"video_frame_interval_seconds",
"video_max_frames",
"head_min_positives",
"head_auto_apply_precision",
"head_auto_apply_enabled",
"head_auto_apply_min_positives",
"ccip_match_threshold",
"ccip_auto_apply_enabled",
"ccip_auto_apply_threshold",
"presentation_auto_apply_enabled",
"presentation_auto_apply_threshold",
"presentation_conflict_threshold",
"process_auto_apply_enabled",
"process_auto_apply_threshold",
"process_conflict_threshold",
"embedder_model_name",
"embedder_model_version",
*_DETECTOR_FIELDS,
"suggestion_threshold_artist",
"suggestion_threshold_character",
"suggestion_threshold_copyright",
"suggestion_threshold_general",
"centroid_similarity_threshold",
"min_reference_images",
)
# Supported embedders for the Settings dropdown — all 1152-d so a swap is a
# drop-in (re-embed + retrain, no schema change). Server-authoritative so the UI
# never free-types a model name.
SUPPORTED_EMBEDDERS = (
{
"name": "google/siglip2-so400m-patch16-512",
"version": "siglip2-so400m-patch16-512",
"label": "SigLIP 2 · so400m · 512px (recommended)",
"dim": 1152,
},
{
"name": "google/siglip2-so400m-patch16-384",
"version": "siglip2-so400m-patch16-384",
"label": "SigLIP 2 · so400m · 384px (faster)",
"dim": 1152,
},
{
"name": "google/siglip-so400m-patch14-384",
"version": "siglip-so400m-patch14-384",
"label": "SigLIP 1 · so400m · 384px (original)",
"dim": 1152,
},
)
@ml_admin_bp.route("/embedder-models", methods=["GET"])
async def embedder_models():
return jsonify({"models": list(SUPPORTED_EMBEDDERS)})
@ml_admin_bp.route("/settings", methods=["GET"])
async def get_settings():
from sqlalchemy import select
async with get_session() as session:
s = await MLSettings.load(session)
# Table-driven off _EDITABLE (which PATCH also writes) so a new settings field
# can never be silently absent from GET — the split that historically dropped
# fields. _EDITABLE already includes *_DETECTOR_FIELDS.
return jsonify({f: getattr(s, f) for f in _EDITABLE})
s = (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
return jsonify(
{
"suggestion_threshold_artist": s.suggestion_threshold_artist,
"suggestion_threshold_character": s.suggestion_threshold_character,
"suggestion_threshold_copyright": s.suggestion_threshold_copyright,
"suggestion_threshold_general": s.suggestion_threshold_general,
"centroid_similarity_threshold": s.centroid_similarity_threshold,
"min_reference_images": s.min_reference_images,
"tagger_model_version": s.tagger_model_version,
"embedder_model_version": s.embedder_model_version,
}
)
@ml_admin_bp.route("/settings", methods=["PATCH"])
async def patch_settings():
from sqlalchemy import select
body = await request.get_json()
if not isinstance(body, dict):
return jsonify({"error": "body must be an object"}), 400
async with get_session() as session:
s = await MLSettings.load(session)
# Merge the patch over current values, then validate the result as a
# whole — the store-floor invariant couples three fields, so they
# can't be checked one at a time.
proposed = {f: getattr(s, f) for f in _EDITABLE}
s = (
await session.execute(select(MLSettings).where(MLSettings.id == 1))
).scalar_one()
for field in _EDITABLE:
if field in body:
proposed[field] = body[field]
err = _validate(proposed)
if err is not None:
return jsonify({"error": err}), 400
for field in _EDITABLE:
setattr(s, field, proposed[field])
setattr(s, field, body[field])
await session.commit()
return await get_settings()
def _validate(p: dict) -> str | None:
"""Returns an error string if the proposed settings are invalid, else None."""
# Video embedding (#747).
if p["video_frame_interval_seconds"] <= 0:
return "video_frame_interval_seconds must be > 0"
if p["video_max_frames"] < 1:
return "video_max_frames must be >= 1"
# Head training (#114).
if int(p["head_min_positives"]) < 1:
return "head_min_positives must be >= 1"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["head_auto_apply_precision"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"head_auto_apply_precision must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if int(p["head_auto_apply_min_positives"]) < 1:
return "head_auto_apply_min_positives must be >= 1"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_match_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"ccip_match_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"ccip_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
# Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
# consequential); the conflict cut is a plain probability [0,1].
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["presentation_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"presentation_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
return "presentation_conflict_threshold must be between 0 and 1"
# Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
# low-harm (excludes-from-training + a review flag), but keep the same bar.
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["process_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
return "process_conflict_threshold must be between 0 and 1"
# Embedder model swap (#1190): both must be non-empty. Changing them means a
# different embedding space — the operator must re-embed + retrain after.
for key in ("embedder_model_name", "embedder_model_version"):
if not str(p[key]).strip():
return f"{key} must not be empty"
# Crop proposers (#134). Weights may be empty (that proposer is just off);
# confidences are probabilities; caps are positive counts; IoU is [0,1].
for key in ("detector_person_conf", "detector_anatomy_conf", "detector_panel_conf"):
if not (0.0 <= float(p[key]) <= 1.0):
return f"{key} must be between 0 and 1"
for key in (
"detector_max_figures", "detector_max_components",
"detector_max_panels", "detector_max_regions",
):
if int(p[key]) < 1:
return f"{key} must be >= 1"
if not (0.0 <= float(p["detector_dedupe_iou"]) <= 1.0):
return "detector_dedupe_iou must be between 0 and 1"
return None
@ml_admin_bp.route("/backfill", methods=["POST"])
async def trigger_backfill():
from ..tasks.ml import backfill
r = backfill.delay()
return jsonify({"celery_task_id": r.id}), 202
@ml_admin_bp.route("/recompute-centroids", methods=["POST"])
async def trigger_recompute():
from ..tasks.ml import recompute_centroids
r = recompute_centroids.delay()
return jsonify({"celery_task_id": r.id}), 202
+8 -106
View File
@@ -3,27 +3,18 @@
from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import ImportSettings, Post
from ..services import interpreter_client as ic
from ..services.post_feed_service import PostFeedService
from ..services.source_service import KNOWN_PLATFORMS
from ..utils.text import html_to_plain
from ._responses import error_response as _bad
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
_TRANSLATION_OVERRIDES = ("auto", "force", "original")
def _queue_for_sweep(post: Post) -> None:
"""Mark a post untranslated (all translation columns NULL) so the periodic
sweep re-runs it under its new override — used when Interpreter is down and we
can't translate inline."""
post.post_title_translated = None
post.description_translated = None
post.translated_source_lang = None
post.translation_engine_version = None
post.translated_at = None
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
@posts_bp.route("", methods=["GET"])
@@ -33,10 +24,7 @@ async def list_posts():
cursor = args.get("cursor") or None
artist_id_raw = args.get("artist_id")
platform = args.get("platform") or None
q = (args.get("q") or "").strip() or None
limit_raw = args.get("limit", "24")
direction = args.get("direction", "older")
around_raw = args.get("around")
try:
limit = int(limit_raw)
@@ -45,16 +33,6 @@ async def list_posts():
if limit < 1 or limit > 100:
return _bad("invalid_limit", detail="limit must be between 1 and 100")
if direction not in ("older", "newer"):
return _bad("invalid_direction", detail="direction must be 'older' or 'newer'")
around_id = None
if around_raw is not None:
try:
around_id = int(around_raw)
except ValueError:
return _bad("invalid_around", detail="around must be an integer post id")
artist_id = None
if artist_id_raw is not None:
try:
@@ -69,19 +47,10 @@ async def list_posts():
)
async with get_session() as session:
svc = PostFeedService(session)
if around_id is not None:
result = await svc.around(
post_id=around_id, artist_id=artist_id,
platform=platform, q=q, limit=limit,
)
if result is None:
return _bad("not_found", status=404, detail=f"post id={around_id}")
return jsonify(result)
try:
page = await svc.scroll(
page = await PostFeedService(session).scroll(
cursor=cursor, artist_id=artist_id,
platform=platform, q=q, limit=limit, direction=direction,
platform=platform, limit=limit,
)
except ValueError as exc:
# Service raises ValueError for malformed cursors only;
@@ -98,70 +67,3 @@ async def get_post(post_id: int):
if item is None:
return _bad("not_found", status=404, detail=f"post id={post_id}")
return jsonify(item)
@posts_bp.route("/<int:post_id>/translation-override", methods=["POST"])
async def set_translation_override(post_id: int):
"""Sticky per-post translation override (milestone 155). Body:
``{"override": "auto" | "force" | "original"}``.
'original' keeps the original (clears any stored translation now — no
Interpreter needed). 'force'/'auto' translate the post immediately if the
service is up (force bypasses the acceptance floor; auto re-runs the gate);
if it's down we save the flag and mark the post untranslated so the next sweep
applies it. The override persists, so the sweep + Re-translate-all keep
honoring it. Returns the updated translation fields + an ``applied`` status."""
body = await request.get_json(silent=True) or {}
override = body.get("override")
if override not in _TRANSLATION_OVERRIDES:
return _bad(
"invalid_override",
detail=f"override must be one of {list(_TRANSLATION_OVERRIDES)}",
)
# Lazy import (mirrors settings.py) so the API module doesn't pull the celery
# task graph at import time.
from ..tasks.translation import _store_translation, _translate_field
async with get_session() as session:
post = await session.get(Post, post_id)
if post is None:
return _bad("not_found", status=404, detail=f"post id={post_id}")
post.translation_override = override
cfg = await ImportSettings.load(session)
target = (cfg.translation_target_lang or "en").strip() or "en"
if override == "original":
_store_translation(post, (None, None, None), (None, None, None), target)
applied = "cleared"
else:
base_url = (cfg.interpreter_base_url or "").strip()
if cfg.translation_enabled and base_url and ic.health(base_url):
force = override == "force"
title = (post.post_title or "").strip()
desc = (html_to_plain(post.description) if post.description else "") or ""
desc = desc.strip()
mc = cfg.translation_min_confidence
try:
title_res = _translate_field(title, base_url, target, mc, force=force)
desc_res = _translate_field(desc, base_url, target, mc, force=force)
except ic.InterpreterUnavailable:
_queue_for_sweep(post)
applied = "queued"
else:
_store_translation(post, title_res, desc_res, target)
applied = "translated"
else:
# Disabled / no URL / unhealthy → let the sweep apply it later.
_queue_for_sweep(post)
applied = "queued"
await session.commit()
return jsonify({
"id": post.id,
"translation_override": post.translation_override,
"post_title_translated": post.post_title_translated,
"description_translated": post.description_translated,
"translated_source_lang": post.translated_source_lang,
"applied": applied,
})
+25 -272
View File
@@ -1,24 +1,12 @@
"""Settings API: import filters, system stats."""
import asyncio
import secrets
from quart import Blueprint, jsonify, request
from sqlalchemy import func, or_, select
from sqlalchemy import func, select
from ..extensions import get_session
from ..models import (
AppSetting,
Artist,
ImageRecord,
ImportBatch,
ImportSettings,
ImportTask,
Post,
Tag,
TaskRun,
)
from ..services import interpreter_client as ic
from ..models import AppSetting, ImageRecord, ImportBatch, ImportSettings, ImportTask, Tag
settings_bp = Blueprint("settings", __name__, url_prefix="/api")
@@ -37,38 +25,30 @@ _EDITABLE_FIELDS = (
"download_schedule_default_seconds",
"download_event_retention_days",
"download_failure_warning_threshold",
"series_suggest_enabled",
"series_suggest_threshold",
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
"extdl_dropbox_enabled",
"extdl_pixeldrain_enabled",
"translation_enabled",
"interpreter_base_url",
"translation_target_lang",
"translation_min_confidence",
"wip_title_tagging_enabled",
"wip_soft_title_tagging_enabled",
)
# Per-host external-download toggles — all plain booleans, validated uniformly.
_EXTDL_TOGGLE_FIELDS = (
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
"extdl_dropbox_enabled",
"extdl_pixeldrain_enabled",
)
@settings_bp.route("/settings/import", methods=["GET"])
async def get_import_settings():
async with get_session() as session:
row = await ImportSettings.load(session)
# Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field
# can't be silently absent from GET.
return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS})
row = (
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
).scalar_one()
return jsonify({
"min_width": row.min_width,
"min_height": row.min_height,
"skip_transparent": row.skip_transparent,
"transparency_threshold": row.transparency_threshold,
"skip_single_color": row.skip_single_color,
"single_color_threshold": row.single_color_threshold,
"single_color_tolerance": row.single_color_tolerance,
"phash_threshold": row.phash_threshold,
"download_rate_limit_seconds": row.download_rate_limit_seconds,
"download_validate_files": row.download_validate_files,
"download_schedule_default_seconds": row.download_schedule_default_seconds,
"download_event_retention_days": row.download_event_retention_days,
"download_failure_warning_threshold": row.download_failure_warning_threshold,
})
@settings_bp.route("/settings/import", methods=["PATCH"])
@@ -118,53 +98,10 @@ async def update_import_settings():
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 100:
return _bad_int("download_failure_warning_threshold", 1, 100)
if "series_suggest_enabled" in body and not isinstance(
body["series_suggest_enabled"], bool
):
return jsonify(
{"error": "series_suggest_enabled must be a boolean"}
), 400
for tog in _EXTDL_TOGGLE_FIELDS:
if tog in body and not isinstance(body[tog], bool):
return jsonify({"error": f"{tog} must be a boolean"}), 400
# Translation (#143): base URL may be empty (feature off until set — no
# default host; the operator points it at their own Interpreter proxy).
if "translation_enabled" in body and not isinstance(
body["translation_enabled"], bool
):
return jsonify({"error": "translation_enabled must be a boolean"}), 400
for key in ("interpreter_base_url", "translation_target_lang"):
if key in body and not isinstance(body[key], str):
return jsonify({"error": f"{key} must be a string"}), 400
# Acceptance floor (milestone 155): latin-script translations below this
# Interpreter confidence are kept as the original.
if "translation_min_confidence" in body:
v = body["translation_min_confidence"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify(
{"error": "translation_min_confidence must be a number in [0, 1]"}
), 400
if "series_suggest_threshold" in body:
v = body["series_suggest_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify(
{"error": "series_suggest_threshold must be a number in [0, 1]"}
), 400
if "wip_title_tagging_enabled" in body and not isinstance(
body["wip_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_title_tagging_enabled must be a boolean"}
), 400
if "wip_soft_title_tagging_enabled" in body and not isinstance(
body["wip_soft_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
), 400
async with get_session() as session:
row = await ImportSettings.load(session)
row = (
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
).scalar_one()
for field in _EDITABLE_FIELDS:
if field in body:
setattr(row, field, body[field])
@@ -173,18 +110,6 @@ async def update_import_settings():
return await get_import_settings()
@settings_bp.route("/settings/wip-title/scan", methods=["POST"])
async def wip_title_scan():
"""Enqueue the back-catalogue WIP-title scan (task #1458 Settings button):
apply the `wip` system tag to EXISTING posts whose title declares
work-in-progress. New imports are tagged live by the importer; this catches
the existing library. Returns the Celery task id (202)."""
from ..tasks.maintenance import backfill_wip_title_tags
r = backfill_wip_title_tags.delay()
return jsonify({"celery_task_id": r.id}), 202
@settings_bp.route("/system/stats", methods=["GET"])
async def system_stats():
async with get_session() as session:
@@ -193,9 +118,6 @@ async def system_stats():
storage_bytes = (
(await session.execute(select(func.coalesce(func.sum(ImageRecord.size_bytes), 0)))).scalar_one()
)
subscription_count = (await session.execute(
select(func.count(Artist.id)).where(Artist.is_subscription.is_(True))
)).scalar_one()
# Task counts grouped by status
status_rows = (
@@ -216,23 +138,11 @@ async def system_stats():
).all()
integrity_counts = {row[0]: row[1] for row in integrity_rows}
# Active batch = running batch that still has outstanding work.
# Plain "most recent running" picks a freshly-created scan that
# enqueued zero new files and hides the older batch that's
# actually being processed; the EXISTS clause filters those
# empty batches out.
# Active batch (most recent running)
active_batch_row = (
await session.execute(
select(ImportBatch)
.where(
ImportBatch.status == "running",
select(ImportTask.id)
.where(
ImportTask.batch_id == ImportBatch.id,
ImportTask.status.in_(["pending", "queued", "processing"]),
)
.exists(),
)
.where(ImportBatch.status == "running")
.order_by(ImportBatch.started_at.desc())
.limit(1)
)
@@ -253,7 +163,6 @@ async def system_stats():
"total_images": total_images,
"total_tags": total_tags,
"storage_bytes": storage_bytes,
"subscription_count": int(subscription_count),
"tasks": {
"pending": status_counts.get("pending", 0),
"queued": status_counts.get("queued", 0),
@@ -310,159 +219,3 @@ async def rotate_extension_api_key():
row.value = new_value
await session.commit()
return jsonify({"key": new_value})
# --- Translation (#143): live status + manual "Translate now" --------------
@settings_bp.route("/settings/translation/status", methods=["GET"])
async def translation_status():
"""For the Settings card: is it on, is a URL set, is the service reachable,
and how many posts still await translation. Health runs the sync client in a
thread so the event loop isn't blocked."""
translation_tasks = (
"backend.app.tasks.translation.translate_posts",
"backend.app.tasks.translation.retranslate_posts",
)
async with get_session() as session:
cfg = await ImportSettings.load(session)
untranslated = (await session.execute(
select(func.count(Post.id))
.where(Post.translated_source_lang.is_(None))
.where(or_(
Post.post_title.is_not(None), Post.description.is_not(None),
))
)).scalar_one()
# Live progress: is a sweep running now, and what did the last one do?
# (run-until-done re-enqueues itself, so `active` stays true across a
# bulk re-translate; `last_run` surfaces a completed run's outcome.)
active = (await session.execute(
select(func.count(TaskRun.id))
.where(TaskRun.task_name.in_(translation_tasks))
.where(TaskRun.status == "running")
)).scalar_one()
last = (await session.execute(
select(TaskRun.task_name, TaskRun.status, TaskRun.finished_at)
.where(TaskRun.task_name.in_(translation_tasks))
.where(TaskRun.finished_at.is_not(None))
.order_by(TaskRun.finished_at.desc())
.limit(1)
)).first()
base_url = (cfg.interpreter_base_url or "").strip()
healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
return jsonify({
"enabled": cfg.translation_enabled,
"base_url_set": bool(base_url),
"healthy": healthy,
"untranslated_count": int(untranslated),
"active": int(active) > 0,
"last_run": {
"task": last[0].rsplit(".", 1)[-1],
"status": last[1],
"finished_at": last[2].isoformat() if last[2] else None,
} if last else None,
})
@settings_bp.route("/settings/translation/test", methods=["POST"])
async def translation_test():
"""On-demand reachability check for a GIVEN Interpreter base URL (the Settings
'Test connection' button) — pings /v1/health without saving, so the operator
can verify a URL before enabling. Health runs in a thread (sync client)."""
body = await request.get_json()
base_url = ""
if isinstance(body, dict):
base_url = (body.get("base_url") or "").strip()
healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
return jsonify({"healthy": healthy})
@settings_bp.route("/settings/translation/probe", methods=["POST"])
async def translation_probe():
"""Diagnostic for the Settings 'Test translation' box: translate a pasted
snippet WITHOUT saving anything, returning what Interpreter *detected*
(language + confidence) alongside the result. Lets the operator see why a
given string was (mis-)detected — e.g. a short English title flagged as
another language — so a detection guard can be tuned from real numbers.
Read-only: no post is touched. Uses the currently-saved base URL + target."""
body = await request.get_json(silent=True)
body = body if isinstance(body, dict) else {}
text = (body.get("text") or "").strip()
if not text:
return jsonify({"error": "provide text to translate"}), 400
async with get_session() as session:
cfg = await ImportSettings.load(session)
base_url = (cfg.interpreter_base_url or "").strip()
if not base_url:
return jsonify({"error": "no Interpreter base URL is set"}), 400
target = (cfg.translation_target_lang or "en").strip() or "en"
try:
res = await asyncio.to_thread(
ic.translate, [text], base_url=base_url, target=target,
)
except ic.InterpreterUnavailable as e:
return jsonify({"error": f"Interpreter unavailable: {e}"}), 503
except ic.InterpreterBadRequest as e:
return jsonify({"error": f"Interpreter rejected the request: {e}"}), 400
translations = res.get("translations") or []
return jsonify({
"target": target,
"detected_lang": res.get("detected_lang"),
"detected_confidence": res.get("detected_confidence"),
"engine": res.get("engine"),
"engine_version": res.get("engine_version"),
"translated": translations[0] if translations else None,
})
@settings_bp.route("/settings/translation/run", methods=["POST"])
async def translation_run():
"""Enqueue the translate sweep now (the Settings 'Translate now' button).
Runs in drain mode — run-until-done — so one press chases the whole
untranslated backlog to zero rather than a single 300-post chunk."""
async with get_session() as session:
cfg = await ImportSettings.load(session)
if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
return jsonify(
{"error": "translation is disabled or no base URL is set"}
), 400
from ..tasks.translation import translate_posts
r = translate_posts.delay(drain=True)
return jsonify({"celery_task_id": r.id}), 202
@settings_bp.route("/settings/translation/retranslate", methods=["POST"])
async def translation_retranslate():
"""Re-translate stored translations after a model change (m146). Body:
``{"artist_id": <int>}`` aims at one artist; ``{"all": true}`` re-runs every
artist. ``all`` must be explicit so an empty/typo body can't wipe everything.
Clears the scoped translations and enqueues the run-until-done retranslate
sweep (the Interpreter cache re-translates on a changed model, is cache-fast
otherwise). Same enabled + base-URL guard as 'Translate now'."""
body = await request.get_json(silent=True)
body = body if isinstance(body, dict) else {}
artist_id = body.get("artist_id")
do_all = bool(body.get("all"))
if artist_id is None and not do_all:
return jsonify(
{"error": "provide artist_id, or all=true to re-translate everything"}
), 400
if artist_id is not None:
try:
artist_id = int(artist_id)
except (TypeError, ValueError):
return jsonify({"error": "artist_id must be an integer"}), 400
async with get_session() as session:
cfg = await ImportSettings.load(session)
if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
return jsonify(
{"error": "translation is disabled or no base URL is set"}
), 400
from ..tasks.translation import retranslate_posts
# artist_id wins when both are sent; otherwise all=true → None (every artist).
artist_ids = [artist_id] if artist_id is not None else None
r = retranslate_posts.delay(artist_ids=artist_ids)
return jsonify({"celery_task_id": r.id}), 202
+10 -144
View File
@@ -5,7 +5,6 @@ from sqlalchemy import select
from ..extensions import get_session
from ..models import DownloadEvent, Source
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
from ..services.source_service import (
KNOWN_PLATFORMS,
ArtistNotFoundError,
@@ -15,11 +14,18 @@ from ..services.source_service import (
SourceService,
UnknownPlatformError,
)
from ._responses import error_response as _bad
sources_bp = Blueprint("sources", __name__, url_prefix="/api/sources")
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
@sources_bp.route("", methods=["GET"])
async def list_sources():
artist_id_raw = request.args.get("artist_id")
@@ -29,19 +35,11 @@ async def list_sources():
artist_id = int(artist_id_raw)
except ValueError:
return _bad("invalid_artist_id", detail="artist_id must be an integer")
failing = request.args.get("failing", "").lower() in ("1", "true", "yes")
async with get_session() as session:
records = await SourceService(session).list(artist_id=artist_id, failing=failing)
records = await SourceService(session).list(artist_id=artist_id)
return jsonify([r.to_dict() for r in records])
@sources_bp.route("/schedule-status", methods=["GET"])
async def schedule_status():
"""FC-dashboards: scheduler health for the Subscriptions hub."""
async with get_session() as session:
return jsonify(await scheduler_status(session))
@sources_bp.route("/<int:source_id>", methods=["GET"])
async def get_source(source_id: int):
async with get_session() as session:
@@ -85,22 +83,6 @@ async def create_source():
return _bad("empty_url", detail=str(exc))
except DuplicateSourceError as exc:
return _bad("duplicate", status=409, existing_id=exc.existing_id)
# Immediate kickoff: a new enabled source is armed for backfill (#693)
# but would otherwise sit idle until the next scheduler tick (~60s).
# Enqueue the first walk now, skipping only if the platform is in a
# rate-limit cooldown (the scheduler picks it up when that clears).
dispatch_id = None
if record.enabled:
cooldowns = await active_platform_cooldowns(session)
if record.platform not in cooldowns:
session.add(DownloadEvent(source_id=record.id, status="pending"))
await session.commit()
dispatch_id = record.id
if dispatch_id is not None:
from ..tasks.download import download_source
download_source.delay(dispatch_id)
return jsonify(record.to_dict()), 201
@@ -136,115 +118,12 @@ async def delete_source(source_id: int):
return "", 204
@sources_bp.route("/<int:source_id>/reassign", methods=["POST"])
async def reassign_source(source_id: int):
"""Move this source (and the content it brought in) to another artist
(#130). Files don't move — the slug is immutable — so this just re-attributes
the source, its posts, and its images. Body: {target_artist_id}."""
body = await request.get_json(silent=True) or {}
target = body.get("target_artist_id")
if not isinstance(target, int):
return _bad("invalid_body", detail="target_artist_id (int) required")
async with get_session() as session:
try:
record = await SourceService(session).reassign(source_id, target)
except LookupError:
return _bad("not_found", status=404)
except ArtistNotFoundError:
return _bad("artist_not_found", detail="target artist not found", status=404)
return jsonify(record.to_dict())
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
async def set_backfill(source_id: int):
"""Plan #693/#697 + #830: start/stop a backfill, or start a recovery /
recapture. Body: `{"action": "start" | "stop" | "recover" | "recapture"}`
(default "start"). 'start' walks the full post history in time-boxed chunks
until it reaches the bottom (then the source shows 'complete'); 'recover' is
the same walk but bypasses the Patreon seen-ledger to re-fetch
dropped-and-deleted near-dups under the current pHash threshold; 'recapture'
re-grabs EVERY post's body + external links and localizes on-disk inline
images WITHOUT re-downloading media; 'stop' cancels any back to tick mode.
Returns the updated source dict (incl. backfill_state / backfill_chunks /
backfill_bypass_seen / backfill_recapture)."""
from pathlib import Path
from ..services.credential_service import CredentialService
from ..services.download_backends import (
uses_native_ingester,
verify_source_credential,
)
from .credentials import _get_crypto
payload = await request.get_json(silent=True) or {}
action = payload.get("action", "start")
if action not in ("start", "stop", "recover", "recapture"):
return _bad(
"invalid_action",
detail="action must be 'start', 'stop', 'recover', or 'recapture'",
)
# Pre-flight (plan #703 #2): before arming a deep walk on a native-ingester
# platform (where verify is one cheap API page), refuse if the credential is
# DEFINITIVELY rejected — don't burn chunks against expired cookies. Proceed
# on valid OR inconclusive (a network blip shouldn't block). Gated to native
# platforms: gallery-dl verify is a slow --simulate subprocess, too heavy for
# an arm action. The credential read happens in a session that's CLOSED
# before the verify network call (don't hold a DB conn across the request).
if action in ("start", "recover", "recapture"):
async with get_session() as session:
rec = await SourceService(session).get(source_id)
if rec is None:
return _bad("not_found", status=404)
native = uses_native_ingester(rec.platform)
if native:
cred = CredentialService(session, _get_crypto())
cookies_path = await cred.get_cookies_path(rec.platform)
auth_token = await cred.get_token(rec.platform)
if native:
ok, message = await verify_source_credential(
platform=rec.platform,
url=rec.url,
artist_slug=rec.artist_slug,
config_overrides=rec.config_overrides or {},
cookies_path=str(cookies_path) if cookies_path else None,
auth_token=auth_token,
images_root=Path("/images"),
)
if ok is False:
return _bad("credential_rejected", detail=message, status=409)
async with get_session() as session:
try:
svc = SourceService(session)
if action == "start":
record = await svc.start_backfill(source_id)
elif action == "recover":
record = await svc.start_recovery(source_id)
elif action == "recapture":
record = await svc.start_recapture(source_id)
else:
record = await svc.stop_backfill(source_id)
except LookupError:
return _bad("not_found", status=404)
return jsonify(record.to_dict())
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
async def check_source(source_id: int):
"""FC-3c: enqueue a download for this source.
Returns 202 with the new DownloadEvent id. If a pending/running
event already exists for this source, returns 409 with that id. If
the source's platform is currently in a rate-limit cooldown, returns
**202 with `{status: "deferred", cooldown_until, platform}`** and
does NOT create an event or dispatch — the bulk retry path uses this
to avoid bowling N sources right back into the rate limit the
cooldown is preventing. Single-click "retry this one source" passes
`?force=true` to override the cooldown (operator-explicit, useful
for rapid auth-fix testing). The in-flight guard always applies.
"""
force = (request.args.get("force") or "").lower() in ("1", "true", "yes")
event already exists for this source, returns 409 with that id."""
async with get_session() as session:
source = (await session.execute(
select(Source).where(Source.id == source_id)
@@ -254,19 +133,6 @@ async def check_source(source_id: int):
if not source.enabled:
return _bad("source_disabled", detail="enable the source first")
# Cooldown gate (unless explicitly overridden). Checked before the
# in-flight guard because a deferred retry doesn't need to create
# or check for an event at all.
if not force:
cooldowns = await active_platform_cooldowns(session)
expires_at = cooldowns.get(source.platform)
if expires_at is not None:
return jsonify({
"status": "deferred",
"platform": source.platform,
"cooldown_until": expires_at.isoformat(),
}), 202
in_flight = (await session.execute(
select(DownloadEvent.id).where(
DownloadEvent.source_id == source_id,
+31 -45
View File
@@ -11,22 +11,8 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
async def get_suggestions(image_id: int):
# ?min=<float> overrides the per-head suggest thresholds for INCLUSION. The
# rail sends min=0 in its single per-image fetch to get EVERY head (each row
# still carries above_threshold vs its natural cut), then derives the panel
# (above_threshold) and the typed dropdown (all, filtered by text) client-side
# — no second request. Omitted → only above-threshold rows.
override = None
raw_min = request.args.get("min")
if raw_min is not None:
try:
override = min(1.0, max(0.0, float(raw_min)))
except ValueError:
return jsonify({"error": "min must be a float in [0,1]"}), 400
async with get_session() as session:
sl = await SuggestionService(session).for_image(
image_id, threshold_override=override
)
sl = await SuggestionService(session).for_image(image_id)
return jsonify(
{
"by_category": {
@@ -37,19 +23,7 @@ async def get_suggestions(image_id: int):
"category": s.category,
"score": round(s.score, 4),
"source": s.source,
# whether the score cleared the head's own suggest cut.
# The single min=0 fetch returns every head; the panel
# shows above_threshold, the typed dropdown shows all and
# annotates each match with its score.
"above_threshold": s.above_threshold,
# operator dismissed this tag for this image — surfaced
# (not dropped) so the rail can show it rejected + offer
# one-click un-reject.
"rejected": s.rejected,
# the crop region that produced this tag (#1206) —
# {bbox,kind,detector} or null (whole-image won). Drives
# the hover→overlay highlight.
"grounding": s.grounding,
"creates_new_tag": s.creates_new_tag,
}
for s in items
]
@@ -68,9 +42,36 @@ async def accept_suggestion(image_id: int):
return jsonify({"error": "tag_id required"}), 400
tag_id = body["tag_id"]
async with get_session() as session:
await AllowlistService(session).accept(image_id, tag_id)
newly_added = await AllowlistService(session).accept(image_id, tag_id)
await session.commit()
return jsonify({"accepted": True, "tag_id": tag_id})
if newly_added:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=tag_id)
return "", 204
@suggestions_bp.route(
"/images/<int:image_id>/suggestions/alias", methods=["POST"]
)
async def alias_suggestion(image_id: int):
body = await request.get_json()
required = {"alias_string", "alias_category", "canonical_tag_id"}
if not body or not required.issubset(body):
return jsonify({"error": f"required: {sorted(required)}"}), 400
async with get_session() as session:
newly_added = await AllowlistService(session).add_alias_and_accept(
image_id,
body["alias_string"],
body["alias_category"],
body["canonical_tag_id"],
)
await session.commit()
if newly_added:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=body["canonical_tag_id"])
return "", 204
@suggestions_bp.route(
@@ -86,21 +87,6 @@ async def dismiss_suggestion(image_id: int):
return "", 204
@suggestions_bp.route(
"/images/<int:image_id>/suggestions/undismiss", methods=["POST"]
)
async def undismiss_suggestion(image_id: int):
"""Reverse a per-image dismissal (reject-recovery). Idempotent — undoing a
tag that isn't rejected is a no-op delete."""
body = await request.get_json()
if not body or "tag_id" not in body:
return jsonify({"error": "tag_id required"}), 400
async with get_session() as session:
await AllowlistService(session).undismiss(image_id, body["tag_id"])
await session.commit()
return "", 204
@suggestions_bp.route("/suggestions/bulk", methods=["POST"])
async def bulk_suggestions():
body = await request.get_json()
-257
View File
@@ -1,257 +0,0 @@
"""FC-3i: system activity dashboard endpoints.
Read-only. Combines Redis-broker queue depths (LLEN per queue),
Celery worker introspection (celery inspect), and the task_run DB
history into the surfaces the SystemActivityTab UI consumes.
All filesystem/sync-client work goes through asyncio.to_thread per
ASYNC230/240 (mirrors backend.app.api.extension's pattern).
"""
from __future__ import annotations
import asyncio
import time
from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request
from sqlalchemy import desc, func, select
from ..config import get_config
from ..extensions import get_session
from ..models import TaskRun
from ..services.scheduler_service import scheduler_status
system_activity_bp = Blueprint(
"system_activity", __name__, url_prefix="/api/system/activity",
)
# Canonical queue order — must match celery_app.task_routes. UI renders
# in this order; queues with no LLEN response show as null rather than
# absent.
_QUEUE_NAMES = (
"default", "import", "thumbnail", "ml",
"download", "scan", "maintenance", "maintenance_long",
)
# Cache module-level so all requests share the cache between polls.
# Tests can reset via direct dict mutation if needed.
_QUEUE_CACHE: dict = {"ts": 0.0, "data": None}
_WORKER_CACHE: dict = {"ts": 0.0, "data": None}
_QUEUE_CACHE_TTL = 2.0
_WORKER_CACHE_TTL = 5.0
def _read_queues_sync() -> dict:
"""Reads each queue's LLEN from the broker. Sync — caller wraps in
asyncio.to_thread. Per-queue try/except returns None on failure so
one bad queue doesn't break the whole response."""
import redis # local import; only this endpoint needs it
cfg = get_config()
client = redis.Redis.from_url(cfg.celery_broker_url)
out: dict = {}
for name in _QUEUE_NAMES:
try:
out[name] = int(client.llen(name))
except Exception: # noqa: BLE001 — broker hiccup shouldn't break UI
out[name] = None
return {
"queues": out,
"fetched_at": datetime.now(UTC).isoformat(),
}
def _read_workers_sync() -> dict:
"""celery inspect active_queues + active. Returns per-worker info."""
from ..celery_app import celery as celery_app
insp = celery_app.control.inspect(timeout=2.0)
active_queues = insp.active_queues() or {}
active_tasks = insp.active() or {}
workers: dict = {}
for hostname, queues in active_queues.items():
workers[hostname] = {
"queues": sorted({q["name"] for q in queues}),
"active_count": len(active_tasks.get(hostname, [])),
}
return {
"workers": workers,
"fetched_at": datetime.now(UTC).isoformat(),
}
async def _queues_cached() -> dict:
"""Per-queue Redis LLEN, cached 2s. Shared by /queues and /summary."""
now = time.time()
if _QUEUE_CACHE["data"] is None or (now - _QUEUE_CACHE["ts"]) > _QUEUE_CACHE_TTL:
_QUEUE_CACHE["data"] = await asyncio.to_thread(_read_queues_sync)
_QUEUE_CACHE["ts"] = now
return _QUEUE_CACHE["data"]
@system_activity_bp.route("/queues", methods=["GET"])
async def get_queues():
"""Per-queue Redis LLEN. Cached 2s.
Response: {queues: {name: depth_or_null}, fetched_at: iso8601}
"""
return jsonify(await _queues_cached())
@system_activity_bp.route("/workers", methods=["GET"])
async def get_workers():
"""Live celery inspect. Cached 5s.
Response: {workers: {hostname: {queues, active_count}}, fetched_at}
"""
now = time.time()
if _WORKER_CACHE["data"] is None or (now - _WORKER_CACHE["ts"]) > _WORKER_CACHE_TTL:
_WORKER_CACHE["data"] = await asyncio.to_thread(_read_workers_sync)
_WORKER_CACHE["ts"] = now
return jsonify(_WORKER_CACHE["data"])
@system_activity_bp.route("/summary", methods=["GET"])
async def get_summary():
"""One-call rollup for the always-on TopNav pipeline indicator:
scheduler health, per-queue pending depths, currently-running count, and
recent (24h) failure count. Cheap — cached queue LLENs + two TaskRun
counts — so it's safe to poll app-wide."""
queues_data = await _queues_cached()
depths = queues_data.get("queues", {})
queued_total = sum(v for v in depths.values() if isinstance(v, int))
since = datetime.now(UTC) - timedelta(hours=24)
async with get_session() as session:
scheduler = await scheduler_status(session)
running = (await session.execute(
select(func.count(TaskRun.id)).where(TaskRun.status == "running")
)).scalar_one()
failing = (await session.execute(
select(func.count(TaskRun.id))
.where(TaskRun.status.in_(["error", "timeout"]))
.where(TaskRun.finished_at >= since)
)).scalar_one()
return jsonify({
"scheduler": scheduler,
"queues": depths,
"queued_total": queued_total,
"running": int(running),
"failing": int(failing),
})
@system_activity_bp.route("/runs", methods=["GET"])
async def list_runs():
"""Paginated task_run history. Query params:
queue=<name> filter to one queue
status=<status> filter to one status (running/ok/error/timeout/retry)
task=<substr> case-insensitive substring match on task_name
limit=<int> default 50, max 200
before_id=<int> cursor for keyset pagination
Response: {runs: [...], next_cursor: id|null}
"""
try:
limit = min(int(request.args.get("limit", "50")), 200)
except ValueError:
return jsonify({"error": "invalid_limit"}), 400
if limit < 1:
return jsonify({"error": "invalid_limit"}), 400
queue = request.args.get("queue")
status = request.args.get("status")
task = request.args.get("task")
before_id_raw = request.args.get("before_id")
before_id = int(before_id_raw) if before_id_raw else None
async with get_session() as session:
stmt = select(TaskRun).order_by(desc(TaskRun.id))
if queue:
stmt = stmt.where(TaskRun.queue == queue)
if status:
stmt = stmt.where(TaskRun.status == status)
if task:
# Task names contain literal underscores (download_source,
# vacuum_analyze) — escape LIKE wildcards so a search for
# "vacuum_analyze" doesn't treat "_" as a single-char match.
stmt = stmt.where(TaskRun.task_name.ilike(f"%{_escape_like(task)}%", escape="\\"))
if before_id is not None:
stmt = stmt.where(TaskRun.id < before_id)
stmt = stmt.limit(limit + 1)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
return jsonify({
"runs": [_row_to_dict(r) for r in rows],
"next_cursor": rows[-1].id if has_more and rows else None,
})
@system_activity_bp.route("/failures", methods=["GET"])
async def list_failures():
"""Recent failures across all lanes (24h window).
Response: {recent: [...], count_by_type: {ErrorClass: n}, since}
"""
try:
limit = min(int(request.args.get("limit", "50")), 200)
except ValueError:
return jsonify({"error": "invalid_limit"}), 400
since = datetime.now(UTC) - timedelta(hours=24)
async with get_session() as session:
recent_stmt = (
select(TaskRun)
.where(TaskRun.status.in_(["error", "timeout"]))
.where(TaskRun.finished_at >= since)
.order_by(desc(TaskRun.finished_at))
.limit(limit)
)
recent = (await session.execute(recent_stmt)).scalars().all()
count_stmt = (
select(TaskRun.error_type, func.count(TaskRun.id))
.where(TaskRun.status.in_(["error", "timeout"]))
.where(TaskRun.finished_at >= since)
.group_by(TaskRun.error_type)
.order_by(desc(func.count(TaskRun.id)))
)
counts = (await session.execute(count_stmt)).all()
return jsonify({
"recent": [_row_to_dict(r) for r in recent],
"count_by_type": {
(row[0] or "Unknown"): row[1]
for row in counts
},
"since": since.isoformat(),
})
def _escape_like(value: str) -> str:
"""Escape SQL LIKE/ILIKE metacharacters so user search text is matched
literally. Pairs with `escape="\\"` on the .ilike() call."""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _row_to_dict(r: TaskRun) -> dict:
return {
"id": r.id,
"queue": r.queue,
"task_name": r.task_name,
"target_id": r.target_id,
"celery_task_id": r.celery_task_id,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"duration_ms": r.duration_ms,
"status": r.status,
"error_type": r.error_type,
"error_message": r.error_message,
"retry_count": r.retry_count,
"worker_hostname": r.worker_hostname,
"args_summary": r.args_summary,
}
-255
View File
@@ -1,255 +0,0 @@
"""FC-3h: /api/system/backup — create/list/restore/delete/tag for
DB + image backups.
Read endpoints are public on FC (operator-facing internal API; same
posture as /api/system/activity). Write endpoints take a typed
`confirm` body field that must match a server-generated token for
that backup row, to prevent click-to-destroy by stale browser tabs
or accidental cURL.
"""
from __future__ import annotations
from quart import Blueprint, jsonify, request
from sqlalchemy import desc, select
from ..extensions import get_session
from ..models import BackupRun, ImportSettings
from ._responses import error_response as _bad
system_backup_bp = Blueprint(
"system_backup", __name__, url_prefix="/api/system/backup",
)
_KINDS = frozenset({"db", "images"})
_TAG_MAX_LEN = 64
_BACKUP_SETTINGS_FIELDS = (
"backup_db_nightly_enabled",
"backup_db_nightly_hour_utc",
"backup_db_keep_last_n",
"backup_images_keep_last_n",
)
def _row_to_dict(r: BackupRun) -> dict:
return {
"id": r.id,
"kind": r.kind,
"status": r.status,
"tag": r.tag,
"triggered_by": r.triggered_by,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"duration_seconds": (
int((r.finished_at - r.started_at).total_seconds())
if r.finished_at and r.started_at else None
),
"sql_path": r.sql_path,
"tar_path": r.tar_path,
"size_bytes": r.size_bytes,
"error": r.error,
"restored_from_id": r.restored_from_id,
"manifest": r.manifest or {},
}
def _validate_tag(tag):
if tag is None:
return None
if not isinstance(tag, str):
return _bad("invalid_tag", detail="tag must be string or null")
tag = tag.strip()
if not tag:
return None
if len(tag) > _TAG_MAX_LEN:
return _bad("invalid_tag", detail=f"tag too long (max {_TAG_MAX_LEN})")
return tag
def _validate_backup_settings_patch(body: dict):
if "backup_db_nightly_enabled" in body and not isinstance(
body["backup_db_nightly_enabled"], bool,
):
return _bad("invalid_value", detail="backup_db_nightly_enabled must be bool")
if "backup_db_nightly_hour_utc" in body:
v = body["backup_db_nightly_hour_utc"]
if not isinstance(v, int) or isinstance(v, bool) or not (0 <= v <= 23):
return _bad("invalid_value", detail="backup_db_nightly_hour_utc must be 0..23")
if "backup_db_keep_last_n" in body:
v = body["backup_db_keep_last_n"]
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 365):
return _bad("invalid_value", detail="backup_db_keep_last_n must be 1..365")
if "backup_images_keep_last_n" in body:
v = body["backup_images_keep_last_n"]
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 100):
return _bad("invalid_value", detail="backup_images_keep_last_n must be 1..100")
return None
@system_backup_bp.route("/db", methods=["POST"])
async def trigger_db_backup():
body = await request.get_json(silent=True) or {}
tag = _validate_tag(body.get("tag"))
if isinstance(tag, tuple):
return tag
from ..tasks.backup import backup_db_task
backup_db_task.delay(tag=tag, triggered_by="manual")
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/images", methods=["POST"])
async def trigger_images_backup():
body = await request.get_json(silent=True) or {}
tag = _validate_tag(body.get("tag"))
if isinstance(tag, tuple):
return tag
from ..tasks.backup import backup_images_task
backup_images_task.delay(tag=tag, triggered_by="manual")
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/runs", methods=["GET"])
async def list_runs():
try:
limit = min(int(request.args.get("limit", "50")), 200)
except ValueError:
return _bad("invalid_limit")
if limit < 1:
return _bad("invalid_limit")
kind = request.args.get("kind")
if kind is not None and kind not in _KINDS:
return _bad("invalid_kind", detail=f"kind must be one of {sorted(_KINDS)}")
before_id_raw = request.args.get("before_id")
before_id = int(before_id_raw) if before_id_raw else None
async with get_session() as session:
stmt = select(BackupRun).order_by(desc(BackupRun.id))
if kind:
stmt = stmt.where(BackupRun.kind == kind)
if before_id is not None:
stmt = stmt.where(BackupRun.id < before_id)
stmt = stmt.limit(limit + 1)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
return jsonify({
"runs": [_row_to_dict(r) for r in rows],
"next_cursor": rows[-1].id if has_more and rows else None,
})
@system_backup_bp.route("/runs/<int:run_id>", methods=["GET"])
async def get_run(run_id: int):
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
return jsonify(_row_to_dict(row))
@system_backup_bp.route("/runs/<int:run_id>", methods=["PATCH"])
async def patch_run(run_id: int):
body = await request.get_json(silent=True) or {}
if "tag" not in body:
return _bad("invalid_body", detail="tag required")
tag = _validate_tag(body["tag"])
if isinstance(tag, tuple):
return tag
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
row.tag = tag
await session.commit()
await session.refresh(row)
return jsonify(_row_to_dict(row))
@system_backup_bp.route("/runs/<int:run_id>/restore", methods=["POST"])
async def trigger_restore(run_id: int):
body = await request.get_json(silent=True) or {}
supplied = body.get("confirm", "")
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
if row.status != "ok":
return _bad(
"not_restorable",
detail=f"source backup status={row.status!r}; only 'ok' rows are restorable",
)
expected = f"restore-{row.kind}-{row.id}"
if supplied != expected:
return _bad(
"confirm_mismatch",
detail=f"confirm must equal {expected!r}",
expected=expected,
)
kind = row.kind
if kind == "db":
from ..tasks.backup import restore_db_task
restore_db_task.delay(source_backup_run_id=run_id)
else: # 'images' (the only other value _KINDS allows via the trigger path)
from ..tasks.backup import restore_images_task
restore_images_task.delay(source_backup_run_id=run_id)
return jsonify({"status": "dispatched", "kind": kind}), 202
@system_backup_bp.route("/runs/<int:run_id>", methods=["DELETE"])
async def delete_run(run_id: int):
body = await request.get_json(silent=True) or {}
supplied = body.get("confirm", "")
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
expected = f"delete-{row.kind}-{row.id}"
if supplied != expected:
return _bad(
"confirm_mismatch",
detail=f"confirm must equal {expected!r}",
expected=expected,
)
from ..services import backup_service
backup_service.unlink_artifact_files(
sql_path=row.sql_path, tar_path=row.tar_path,
manifest_path=(row.manifest or {}).get("manifest_path"),
)
await session.delete(row)
await session.commit()
return "", 204
@system_backup_bp.route("/settings", methods=["GET"])
async def get_settings():
async with get_session() as session:
row = await ImportSettings.load(session)
return jsonify({
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
"backup_db_keep_last_n": row.backup_db_keep_last_n,
"backup_images_keep_last_n": row.backup_images_keep_last_n,
})
@system_backup_bp.route("/settings", methods=["PATCH"])
async def patch_settings():
body = await request.get_json(silent=True)
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
err = _validate_backup_settings_patch(body)
if err is not None:
return err
async with get_session() as session:
row = await ImportSettings.load(session)
for field in _BACKUP_SETTINGS_FIELDS:
if field in body:
setattr(row, field, body[field])
await session.commit()
return await get_settings()
+48 -491
View File
@@ -1,28 +1,20 @@
"""Tags API: autocomplete, create, list/add/remove for an image."""
from quart import Blueprint, jsonify, request
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy import exists, select
from sqlalchemy.exc import IntegrityError
from ..extensions import get_session
from ..models import Tag, TagHead, TagKind, TagPositiveConfirmation
from ..models.tag import image_tag
from ..models.tag_suggestion_rejection import TagSuggestionRejection
from ..models import Tag, TagKind
from ..models.tag_allowlist import TagAllowlist
from ..services.bulk_tag_service import BulkTagService
from ..services.ml.aliases import AliasService
from ..services.ml.heads import ground_applied_tag
from ..services.series_match_service import SeriesMatchService
from ..services.series_service import SeriesError, SeriesService
from ..services.tag_directory_service import TagDirectoryService
from ..services.tag_query import serialize_tag
from ..services.tag_service import (
TagMergeConflict,
TagService,
TagValidationError,
normalize_tag_name,
)
from ..utils.tag_prefix import parse_kind_prefix
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
@@ -63,117 +55,6 @@ def _parse_bulk_ids(
return ids, None
# Application-source groupings (image_tag.source). HUMAN = operator signal;
# AUTO = machine-applied (heads/CCIP, + legacy Camie ml_auto).
_SOURCE_GROUPS = {
"human": ("manual", "ml_accepted"),
"manual": ("manual",),
"accepted": ("ml_accepted",),
"auto": ("head_auto", "ccip_auto", "ml_auto"),
}
@tags_bp.route("/tags/top", methods=["GET"])
async def tags_top():
"""Top tags by image count — a fast indexed aggregate for ANALYSIS (not the
paged UI directory, which is alphabetical + builds previews). Params:
?kind=general|character|fandom|… ?source=all|human|manual|accepted|auto
?limit=50 (cap 500) ?min_count=N. → {tags:[{tag_id,name,kind,count}]} desc."""
kind = _coerce_kind(request.args.get("kind"))
try:
limit = min(max(int(request.args.get("limit", "50")), 1), 500)
except ValueError:
return jsonify({"error": "limit must be an integer"}), 400
min_count = None
if "min_count" in request.args:
try:
min_count = int(request.args["min_count"])
except ValueError:
return jsonify({"error": "min_count must be an integer"}), 400
src_vals = _SOURCE_GROUPS.get((request.args.get("source") or "all").lower())
cnt = func.count(image_tag.c.image_record_id)
stmt = (
select(Tag.id, Tag.name, Tag.kind, cnt.label("count"))
.select_from(Tag)
.join(image_tag, image_tag.c.tag_id == Tag.id)
.group_by(Tag.id, Tag.name, Tag.kind)
.order_by(cnt.desc(), Tag.name.asc())
.limit(limit)
)
if kind is not None:
stmt = stmt.where(Tag.kind == kind)
if src_vals is not None:
stmt = stmt.where(image_tag.c.source.in_(src_vals))
if min_count is not None:
stmt = stmt.having(cnt >= min_count)
async with get_session() as session:
rows = (await session.execute(stmt)).all()
return jsonify({"tags": [
{
"tag_id": r.id, "name": r.name,
"kind": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
"count": r.count,
}
for r in rows
]})
@tags_bp.route("/tags/<int:tag_id>/stats", methods=["GET"])
async def tag_stats(tag_id: int):
"""Per-tag dataset health: total + per-source application counts (human vs
machine), rejection count, and whether a trained head exists. Read-only,
analysis-shaped — backs concept-readiness + source-split decisions."""
async with get_session() as session:
tag = await session.get(Tag, tag_id)
if tag is None:
return jsonify({"error": "not found"}), 404
by_source = dict(
(
await session.execute(
select(image_tag.c.source, func.count())
.where(image_tag.c.tag_id == tag_id)
.group_by(image_tag.c.source)
)
).all()
)
rejected = (
await session.execute(
select(func.count())
.select_from(TagSuggestionRejection)
.where(TagSuggestionRejection.tag_id == tag_id)
)
).scalar_one()
has_head = (
await session.execute(
select(func.count())
.select_from(TagHead)
.where(TagHead.tag_id == tag_id)
)
).scalar_one() > 0
human = by_source.get("manual", 0) + by_source.get("ml_accepted", 0)
auto = (
by_source.get("head_auto", 0)
+ by_source.get("ccip_auto", 0)
+ by_source.get("ml_auto", 0)
)
return jsonify({
"tag_id": tag_id,
"name": tag.name,
"kind": tag.kind.value if hasattr(tag.kind, "value") else str(tag.kind),
"count_total": sum(by_source.values()),
"count_human": human,
"count_manual": by_source.get("manual", 0),
"count_accepted": by_source.get("ml_accepted", 0),
"count_auto": auto,
"count_head_auto": by_source.get("head_auto", 0),
"count_ccip_auto": by_source.get("ccip_auto", 0),
"count_rejected": rejected,
"by_source": by_source,
"has_head": has_head,
})
@tags_bp.route("/tags/autocomplete", methods=["GET"])
async def autocomplete():
q = request.args.get("q", "")
@@ -188,7 +69,17 @@ async def autocomplete():
hits = await svc.autocomplete(q, kind=kind, limit=limit)
return jsonify(
[{**serialize_tag(h), "image_count": h.image_count} for h in hits]
[
{
"id": h.id,
"name": h.name,
"kind": h.kind,
"fandom_id": h.fandom_id,
"fandom_name": h.fandom_name,
"image_count": h.image_count,
}
for h in hits
]
)
@@ -214,46 +105,15 @@ async def directory():
@tags_bp.route("/tags", methods=["POST"])
async def create_tag():
"""Create a tag. Two input shapes accepted:
1. Explicit: {name, kind, fandom_id?} — caller already split, kind wins.
2. IR-suffix: {name} where name = "kind:Name" (e.g. "artist:Eric").
The server runs parse_kind_prefix(name) to derive kind; the colon
and prefix are stripped from the stored tag name. If no recognized
prefix is present, the kind defaults to `general`.
Explicit kind ALWAYS wins (backward-compat for existing callers).
"""
body = await request.get_json()
if not body or "name" not in body:
return jsonify({"error": "name required"}), 400
if not body or "name" not in body or "kind" not in body:
return jsonify({"error": "name and kind required"}), 400
name = body["name"]
explicit_kind_raw = body.get("kind")
if explicit_kind_raw is not None:
# Caller provided kind — honor it; don't re-parse.
kind = _coerce_kind(explicit_kind_raw)
kind = _coerce_kind(body["kind"])
if kind is None:
return jsonify({"error": f"invalid kind {explicit_kind_raw!r}"}), 400
else:
# IR-style: parse "kind:Name" from the raw name.
parsed_kind, parsed_name = parse_kind_prefix(name)
if parsed_kind is not None:
name = parsed_name
kind = _coerce_kind(parsed_kind)
# parse_kind_prefix only returns kinds from KNOWN_KINDS which
# are all valid TagKind members, so _coerce_kind can't return
# None here — but defensive.
if kind is None:
return jsonify({"error": f"invalid kind {parsed_kind!r}"}), 400
else:
kind = TagKind.general
return jsonify({"error": f"invalid kind {body['kind']!r}"}), 400
fandom_id = body.get("fandom_id")
# #701: Title-Case operator-entered tags. Only here (the explicit create
# endpoint), NOT in the shared find_or_create — the ML tagger uses that path
# and must keep the booru vocabulary's casing for allowlist matching.
name = normalize_tag_name(name)
async with get_session() as session:
svc = TagService(session)
try:
@@ -271,7 +131,17 @@ async def list_tags_for_image(image_id: int):
async with get_session() as session:
svc = TagService(session)
tags = await svc.list_for_image(image_id)
return jsonify([serialize_tag(t) for t in tags])
return jsonify(
[
{
"id": t.id,
"name": t.name,
"kind": t.kind.value,
"fandom_id": t.fandom_id,
}
for t in tags
]
)
@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
@@ -297,95 +167,15 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
return "", 204
@tags_bp.route("/images/<int:image_id>/tags/<int:tag_id>/confirm", methods=["POST"])
async def confirm_tag_on_image(image_id: int, tag_id: int):
"""Operator affirmed an applied tag is correct ("keep" on a doubted positive).
Idempotent; recorded so the eval's doubts list stops resurfacing it (#1130)."""
async with get_session() as session:
await session.execute(
pg_insert(TagPositiveConfirmation)
.values(image_record_id=image_id, tag_id=tag_id)
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
)
await session.commit()
return "", 204
@tags_bp.route(
"/images/<int:image_id>/tags/<int:tag_id>/grounding", methods=["GET"]
)
async def tag_grounding(image_id: int, tag_id: int):
"""Which crop region best explains an ALREADY-APPLIED tag on this image
(#1206 Step 4). Powers the hover→overlay highlight on applied tag chips,
mirroring the suggestion rail's live grounding. Computed on demand (applied
tags aren't scored live). → {grounding: {bbox,kind,detector}|null,
has_head: bool}; has_head False means the tag has no head to localize with,
so the chip shows no overlay."""
async with get_session() as session:
grounding, has_head = await ground_applied_tag(session, image_id, tag_id)
return jsonify({"grounding": grounding, "has_head": has_head})
@tags_bp.route("/tags/<int:tag_id>", methods=["GET"])
async def get_tag(tag_id: int):
"""Resolve a single tag (used by the gallery to label its active
tag-filter chip)."""
async with get_session() as session:
tag = await session.get(Tag, tag_id)
if tag is None:
return jsonify({"error": "tag not found"}), 404
return jsonify(
{
"id": tag.id,
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
"is_system": tag.is_system,
}
)
@tags_bp.route("/tags/<int:tag_id>/aliases", methods=["GET"])
async def list_tag_aliases(tag_id: int):
"""Model keys that fold into this tag (tag-side alias view). Remove via the
shared DELETE /api/aliases/<string>/<category>."""
async with get_session() as session:
if await session.get(Tag, tag_id) is None:
return jsonify({"error": "tag not found"}), 404
rows = await AliasService(session).list_for_tag(tag_id)
return jsonify(
[
{
"alias_string": r.alias_string,
"alias_category": r.alias_category,
}
for r in rows
]
)
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
async def update_tag(tag_id: int):
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
`fandom_id` (a fandom tag id, or null to clear — character tags only).
`merge: true` resolves a collision by merging into the existing tag.
"""
body = await request.get_json() or {}
has_name = "name" in body
has_fandom = "fandom_id" in body
if not has_name and not has_fandom:
return jsonify({"error": "name or fandom_id required"}), 400
do_merge = bool(body.get("merge"))
async def rename_tag(tag_id: int):
body = await request.get_json()
if not body or "name" not in body:
return jsonify({"error": "name required"}), 400
async with get_session() as session:
svc = TagService(session)
try:
tag = None
if has_name:
tag = await svc.rename(tag_id, body["name"])
if has_fandom:
tag = await svc.set_fandom(
tag_id, body["fandom_id"], merge=do_merge
)
except TagMergeConflict as exc:
return jsonify(
{
@@ -402,13 +192,7 @@ async def update_tag(tag_id: int):
return jsonify({"error": str(exc)}), 400
await session.commit()
return jsonify(
{
"id": tag.id,
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
"is_system": tag.is_system,
}
{"id": tag.id, "name": tag.name, "kind": tag.kind.value}
)
@@ -427,6 +211,13 @@ async def merge_tag(source_id: int):
status = 404 if "not found" in msg else 400
return jsonify({"error": msg}), status
await session.commit()
target_allowlisted = await session.scalar(
select(exists().where(TagAllowlist.tag_id == result.target_id))
)
if target_allowlisted:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=result.target_id)
return jsonify(
{
"target": {
@@ -502,31 +293,6 @@ def _series_err(exc: SeriesError):
return jsonify({"error": msg}), status
def _opt_int(body, key: str):
"""(value, error) — value is None when absent, error is (json, status)."""
if not body or body.get(key) is None:
return None, None
try:
return int(body[key]), None
except (TypeError, ValueError):
return None, (jsonify({"error": f"{key} must be an integer"}), 400)
def _parse_int_list(body, key: str, *, max_ids: int = 500):
"""(list, error) for a required list of ints under `key`."""
if not body or key not in body:
return None, (jsonify({"error": f"{key} required"}), 400)
raw = body[key]
if not isinstance(raw, list) or not raw:
return None, (jsonify({"error": f"{key} must be a non-empty list"}), 400)
if len(raw) > max_ids:
return None, (jsonify({"error": f"too many ids (max {max_ids})"}), 400)
try:
return [int(x) for x in raw], None
except (TypeError, ValueError):
return None, (jsonify({"error": f"{key} must be integers"}), 400)
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
async def series_pages(tag_id: int):
async with get_session() as session:
@@ -567,26 +333,15 @@ async def series_remove(tag_id: int):
return jsonify({"removed_count": n})
@tags_bp.route("/series/<int:tag_id>/pages/number", methods=["POST"])
async def series_set_page_number(tag_id: int):
"""Set one placed page's number — the operator's value (sparse, gaps
allowed); pass page_number: null to leave it unnumbered."""
body = await request.get_json() or {}
image_id, ierr = _opt_int(body, "image_id")
if ierr:
return ierr
if image_id is None:
return jsonify({"error": "image_id required"}), 400
if "page_number" not in body:
return jsonify({"error": "page_number required (may be null)"}), 400
page_number, perr = _opt_int(body, "page_number")
if perr:
return perr
@tags_bp.route("/series/<int:tag_id>/reorder", methods=["POST"])
async def series_reorder(tag_id: int):
body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).set_page_number(
tag_id, image_id, page_number
)
await SeriesService(session).reorder(tag_id, ids)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
@@ -609,201 +364,3 @@ async def series_cover(tag_id: int):
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
# ---- chapter dividers (FC-6.x) -------------------------------------------
# A chapter is a cosmetic divider anchored to the page that begins it; it owns
# no pages. Page ordering follows each page's operator-set number (the
# /pages/number endpoint), so there is no per-chapter reorder/merge — those are
# gone.
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
async def series_chapter_create(tag_id: int):
body = await request.get_json() or {}
anchor, aerr = _opt_int(body, "anchor_image_id")
if aerr:
return aerr
if anchor is None:
return jsonify({"error": "anchor_image_id required"}), 400
title = body.get("title")
if title is not None and not isinstance(title, str):
return jsonify({"error": "title must be a string"}), 400
part, perr = _opt_int(body, "stated_part")
if perr:
return perr
async with get_session() as session:
try:
ch = await SeriesService(session).create_divider(
tag_id, anchor, title=title, stated_part=part,
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(ch)
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["PATCH"]
)
async def series_chapter_update(tag_id: int, chapter_id: int):
body = await request.get_json() or {}
kwargs: dict = {}
if "title" in body:
if body["title"] is not None and not isinstance(body["title"], str):
return jsonify({"error": "title must be a string"}), 400
kwargs.update(set_title=True, title=body["title"])
if "stated_part" in body:
part, perr = _opt_int(body, "stated_part")
if perr:
return perr
kwargs.update(set_part=True, stated_part=part)
if "anchor_image_id" in body:
anchor, aerr = _opt_int(body, "anchor_image_id")
if aerr:
return aerr
if anchor is None:
return jsonify(
{"error": "anchor_image_id must be an integer"}
), 400
kwargs.update(set_anchor=True, anchor_image_id=anchor)
async with get_session() as session:
try:
await SeriesService(session).update_divider(
tag_id, chapter_id, **kwargs
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["DELETE"]
)
async def series_chapter_delete(tag_id: int, chapter_id: int):
async with get_session() as session:
try:
await SeriesService(session).delete_divider(tag_id, chapter_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
# ---- browse list + post→series flows (FC-6.2) -----------------------------
@tags_bp.route("/series", methods=["GET"])
async def series_list():
args = request.args
sort = args.get("sort", "recent")
if sort not in ("recent", "name", "size"):
return jsonify({"error": "sort must be recent|name|size"}), 400
artist_id = None
if args.get("artist_id") is not None:
try:
artist_id = int(args["artist_id"])
except ValueError:
return jsonify({"error": "artist_id must be an integer"}), 400
async with get_session() as session:
rows = await SeriesService(session).list_series(
sort=sort, artist_id=artist_id
)
return jsonify({"series": rows})
@tags_bp.route("/series/from-post", methods=["POST"])
async def series_from_post():
body = await request.get_json()
post_id, err = _opt_int(body, "post_id")
if err:
return err
if post_id is None:
return jsonify({"error": "post_id required"}), 400
async with get_session() as session:
try:
out = await SeriesService(session).promote_post_to_series(post_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(out)
@tags_bp.route("/series/<int:tag_id>/add-post", methods=["POST"])
async def series_add_post(tag_id: int):
body = await request.get_json()
post_id, err = _opt_int(body, "post_id")
if err:
return err
if post_id is None:
return jsonify({"error": "post_id required"}), 400
async with get_session() as session:
try:
out = await SeriesService(session).add_post(tag_id, post_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(out)
@tags_bp.route("/series/<int:tag_id>/pending/place", methods=["POST"])
async def series_place_pending(tag_id: int):
"""Place staged (pending) pages into the run, numbered sequentially from
`start_page` in the given order (#789). start_page null → unnumbered."""
body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
start, serr = _opt_int(body, "start_page")
if serr:
return serr
async with get_session() as session:
try:
n = await SeriesService(session).place_pending(
tag_id, ids, start_page=start
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"placed_count": n})
# ---- suggestion queue (FC-6.3) --------------------------------------------
@tags_bp.route("/series/suggestions", methods=["GET"])
async def series_suggestions_list():
async with get_session() as session:
rows = await SeriesMatchService(session).list_pending()
return jsonify({"suggestions": rows})
@tags_bp.route("/series/suggestions/<int:sid>/accept", methods=["POST"])
async def series_suggestion_accept(sid: int):
async with get_session() as session:
try:
out = await SeriesMatchService(session).accept(sid)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(out)
@tags_bp.route("/series/suggestions/<int:sid>/dismiss", methods=["POST"])
async def series_suggestion_dismiss(sid: int):
async with get_session() as session:
try:
await SeriesMatchService(session).dismiss(sid)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"ok": True})
@tags_bp.route("/series/suggestions/rescan", methods=["POST"])
async def series_suggestions_rescan():
from ..tasks.admin import rescan_series_suggestions_task
res = rescan_series_suggestions_task.delay()
return jsonify({"task_id": res.id})
-28
View File
@@ -1,28 +0,0 @@
"""Thumbnail admin API: backfill trigger."""
import asyncio
from quart import Blueprint, jsonify
thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
@thumbnails_bp.route("/backfill", methods=["POST"])
async def trigger_backfill():
"""Run the backfill scan synchronously, return the counts. The actual
thumbnail generation work is still off-loaded to the thumbnail Celery
queue via `generate_thumbnail.delay()` per missing row — so this
handler is fast even on a 100k-image library (a scan is just SELECT
id, thumbnail_path + a file.stat() per row, no heavy work).
Operator-flagged 2026-06-01: the previous fire-and-forget shape
returned `{celery_task_id}` only, so the admin UI had no idea whether
backfill found 0 or 5000 candidates — \"found nothing\" was
indistinguishable from \"the worker isn't picking up the task.\""""
from ..tasks.thumbnail import _run_backfill_scan
# Sync scan inside an executor so we don't block the event loop.
counts = await asyncio.get_running_loop().run_in_executor(
None, _run_backfill_scan,
)
return jsonify(counts), 200
-30
View File
@@ -1,30 +0,0 @@
"""What this build IS — stamped at image build time, not configurable.
Deliberately separate from `config.py`. Those are operator settings, read from
the environment and meant to be changed. These describe the artifact itself and
are baked in by CI (the `FC_VERSION` / `FC_CHANNEL` build args); an operator
setting them by hand is not a supported thing to do, it is just how a value
gets from the build into the running process.
**Absent rather than empty when unknown.** A locally-built image has no version,
and neither did any image predating the field — one spelling of "cannot say",
which every reader already has to handle, instead of a second one to
special-case (note #3127 §7).
**Why this matters more than it used to.** Milestone 318 stopped publishing
version image tags, so a running instance's self-report is now the *only*
answer to "which build is this?" — there is no registry name left to check it
against. A wrong value here has nothing to contradict it. That is why the UI
renders `unknown` rather than a blank or a plausible default: an empty footer
reads as "no version", which is a different and false claim.
The channel lives BESIDE the version and is never folded into it (rule 149).
A `-dev` suffix would be parsed by the extension's comparator as a segment
worth 0, making every dev build compare equal to every other — issue #2993's
exact failure.
"""
import os
FC_VERSION = os.environ.get("FC_VERSION", "").strip()
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
+11 -227
View File
@@ -7,7 +7,7 @@ Queues:
download — gallery-dl tasks (FC-3)
scan — periodic source checks (FC-3) — kept separate so long imports
don't starve the scheduler
maintenance — recovery sweeps, pHash backfill, GPU-queue coordination, etc.
maintenance — pHash recomputation, centroid rebuild, etc. (FC-2/FC-3)
default — anything not explicitly routed
"""
@@ -28,14 +28,9 @@ def make_celery() -> Celery:
"backend.app.tasks.import_file",
"backend.app.tasks.thumbnail",
"backend.app.tasks.maintenance",
"backend.app.tasks.migration",
"backend.app.tasks.ml",
"backend.app.tasks.gpu_queue",
"backend.app.tasks.download",
"backend.app.tasks.external",
"backend.app.tasks.backup",
"backend.app.tasks.admin",
"backend.app.tasks.library_audit",
"backend.app.tasks.translation",
],
)
app.conf.update(
@@ -43,69 +38,16 @@ def make_celery() -> Celery:
task_routes={
"backend.app.tasks.import_file.*": {"queue": "import"},
"backend.app.tasks.ml.*": {"queue": "ml"},
# GPU-queue coordination (backfill enqueues, orphan recovery,
# reprocess) is pure DB work — it rides the maintenance quick lane
# so the GPU agent pipeline works even on stacks that drop the
# (now-optional, B3) ml-worker container entirely.
"backend.app.tasks.gpu_queue.*": {"queue": "maintenance"},
"backend.app.tasks.thumbnail.*": {"queue": "thumbnail"},
"backend.app.tasks.download.*": {"queue": "download"},
# External file-host fetches are downloads — same lane (they can run
# long, but the download worker already tolerates long backfills).
"backend.app.tasks.external.*": {"queue": "download"},
"backend.app.tasks.scan.*": {"queue": "scan"},
# `maintenance` is the QUICK lane — recovery sweeps, vacuum, cleanup
# (concurrency-1 on the scheduler). The long one-shots (DB backups,
# library audits, admin maintenance: normalize/re-extract/cascade-
# delete) run on a SEPARATE `maintenance_long` lane + worker so they
# can never starve the quick self-healing sweeps (operator-flagged
# 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours).
"backend.app.tasks.maintenance.*": {"queue": "maintenance"},
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
# Translation backfill hits the LLM (~16s/item) → the long lane so it
# never starves the quick self-healing sweeps (#143).
"backend.app.tasks.translation.*": {"queue": "maintenance_long"},
"backend.app.tasks.migration.*": {"queue": "maintenance"},
},
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
task_acks_late=True,
# Deploy graceful-shutdown safety: with acks_late, a task killed because
# it outran the container's stop-grace window (SIGKILL) is re-queued
# rather than silently lost. Safe because our long tasks are idempotent +
# chunked (translation per-post commit, downloads terminal-status, audits
# chunk) and the 5-min recovery sweeps re-drive anything left non-terminal
# — a re-run resumes cleanly and never corrupts. No redeliver-loop risk:
# heavy GPU work is tombstoned via gpu_queue, not run inline in a worker.
task_reject_on_worker_lost=True,
worker_prefetch_multiplier=1,
# Broker resilience (2026-06-24): a swarm overlay-network blip after a
# redeploy left Redis healthy but transiently unreachable, and a worker
# starting in that window crash-looped on the initial broker connect
# (kombu OperationalError) instead of waiting it out — needing a manual
# Redis reset to recover. Retry the broker FOREVER (None) on startup and
# at runtime so a transient outage self-heals when routing returns,
# rather than the worker exiting.
broker_connection_retry_on_startup=True,
broker_connection_retry=True,
broker_connection_max_retries=None,
# Redis-transport socket options (apply to the BROKER connection): a
# short connect timeout + TCP keepalive so a dead/blocked socket is
# noticed and retried, and a periodic health check that proactively
# reconnects a live worker through a network hiccup.
broker_transport_options={
"socket_connect_timeout": 5,
"socket_timeout": 30,
"socket_keepalive": True,
"retry_on_timeout": True,
"health_check_interval": 30,
},
# Same hardening for the Redis RESULT backend (separate connection pool).
redis_socket_connect_timeout=5,
redis_socket_timeout=30,
redis_socket_keepalive=True,
redis_retry_on_timeout=True,
redis_backend_health_check_interval=30,
beat_schedule={
"recover-interrupted-tasks": {
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
@@ -115,89 +57,16 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
"schedule": 86400.0, # daily
},
"cleanup-orphaned-temp-files": {
"task": "backend.app.tasks.maintenance.cleanup_orphaned_temp_files",
"schedule": 86400.0, # daily — sweep .part/.partial left by a
# download/import killed mid-write (graceful-shutdown fallout)
"ml-backfill-daily": {
"task": "backend.app.tasks.ml.backfill",
"schedule": 86400.0,
},
"train-heads-nightly": {
"task": "backend.app.tasks.ml.scheduled_train_heads",
"schedule": 86400.0, # passive cadence; manual retrain stays available
"recompute-centroids-daily": {
"task": "backend.app.tasks.ml.recompute_centroids",
"schedule": 86400.0,
},
"refresh-character-prototypes": {
"task": "backend.app.tasks.ml.refresh_character_prototypes",
"schedule": 900.0, # ~15 min; cheap global-gate no-op when idle (#1317)
},
"reconcile-character-prototypes-nightly": {
"task": "backend.app.tasks.ml.refresh_character_prototypes",
"schedule": 86400.0, # nightly FULL reconcile (belt-and-suspenders)
"args": (True,), # full=True
},
"apply-head-tags-daily": {
"task": "backend.app.tasks.ml.scheduled_apply_head_tags",
"schedule": 86400.0, # no-op unless head_auto_apply_enabled
},
"recover-orphaned-gpu-jobs": {
"task": "backend.app.tasks.gpu_queue.recover_orphaned_gpu_jobs",
"schedule": 60.0, # quick pickup of work a dead agent orphaned
},
"triage-gpu-errors": {
"task": "backend.app.tasks.maintenance.triage_gpu_errors",
"schedule": 900.0, # probe errored jobs' files → defect/file_ok
},
"enqueue-ccip-backfill-hourly": {
"task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
"schedule": 3600.0, # auto-feed NEW images; errored are
"args": ("ccip",), # tombstoned — retry is the button only
},
"enqueue-siglip-backfill-daily": {
"task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
"schedule": 86400.0, # drain the concept-crop back-catalogue
"args": ("siglip",), # (errored are tombstoned, not retried)
},
"enqueue-embed-backfill-daily": {
"task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
"schedule": 86400.0, # whole-image re-embed under the current
"args": ("embed",), # model (an operator swap) drains via agent
},
"ccip-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_ccip_auto_apply",
"schedule": 86400.0, # no-op unless ccip_auto_apply_enabled
},
"retract-auto-tags-daily": {
"task": "backend.app.tasks.ml.scheduled_retract_auto_tags",
"schedule": 86400.0, # soft auto-apply: drop auto-tags now below
# their threshold (m139); no-op unless the auto-apply switch is on
},
"presentation-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_presentation_auto_apply",
"schedule": 86400.0, # auto-hide banner chrome (#141);
# no-op unless presentation_auto_apply_enabled
},
"process-auto-apply-daily": {
"task": "backend.app.tasks.ml.scheduled_process_auto_apply",
"schedule": 86400.0, # auto-tag wip/editor process art (#1464);
# no-op unless process_auto_apply_enabled (opt-in)
},
"soft-wip-conflict-audit-daily": {
"task": "backend.app.tasks.ml.scheduled_soft_wip_conflict_audit",
"schedule": 86400.0, # flag ring-loud soft-WIP (sketch/doodle) tags
# for review (#1474); no-op with no content heads
},
"prune-presentation-reviews-daily": {
"task": "backend.app.tasks.ml.prune_presentation_reviews",
"schedule": 86400.0, # retention: drop resolved review flags >30d
},
"translate-posts-8h": {
"task": "backend.app.tasks.translation.translate_posts",
"schedule": 28800.0, # every 8h: steady-state cadence for the
# trickle of newly-imported posts (no-op unless translation
# configured + healthy). One bounded 300-chunk per fire — the
# one-time backlog drains via the "Translate now" button
# (drain=True, run-until-done), not this sweep.
},
"snapshot-head-metrics-daily": {
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
"apply-allowlist-sweep-daily": {
"task": "backend.app.tasks.ml.apply_allowlist_tags",
"schedule": 86400.0,
},
"integrity-verify-weekly": {
@@ -212,94 +81,9 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.cleanup_old_download_events",
"schedule": 86400.0, # daily
},
"recover-stalled-download-events": {
"task": "backend.app.tasks.maintenance.recover_stalled_download_events",
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
},
"recover-stalled-task-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_task_runs",
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
},
"prune-task-runs": {
"task": "backend.app.tasks.maintenance.prune_task_runs",
"schedule": 86400.0, # daily
},
"vacuum-analyze": {
"task": "backend.app.tasks.maintenance.vacuum_analyze",
"schedule": 604800.0, # weekly — reclaim dead-tuple bloat + refresh stats
},
"fc3h-backup-db-nightly": {
"task": "backend.app.tasks.backup.backup_db_nightly",
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
},
"fc3h-prune-backups": {
"task": "backend.app.tasks.backup.prune_backups",
"schedule": 86400.0, # daily
},
# Audit 2026-06-02 — three new per-entity recovery sweeps.
# Each runs every 5 min like the other recover_stalled_*
# sweeps; each is a no-op when nothing is stuck.
"recover-stalled-backup-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_backup_runs",
"schedule": 300.0,
},
"recover-stalled-library-audit-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_library_audit_runs",
"schedule": 300.0,
},
"recover-stalled-head-training-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_head_training_runs",
"schedule": 300.0,
},
"recover-stalled-head-auto-apply-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs",
"schedule": 300.0,
},
"recover-stalled-import-batches": {
"task": "backend.app.tasks.maintenance.recover_stalled_import_batches",
"schedule": 300.0,
},
# Audit 2026-06-02 — daily retention for two entities
# whose terminal rows otherwise accumulate forever.
"prune-library-audit-runs": {
"task": "backend.app.tasks.maintenance.prune_library_audit_runs",
"schedule": 86400.0,
},
"prune-import-batches": {
"task": "backend.app.tasks.maintenance.prune_import_batches",
"schedule": 86400.0,
},
# Audit 2026-06-02 — backfill_thumbnails's docstring claimed
# "periodic Beat" but the entry was never registered, so the
# library got no self-healing thumbnail repair; only the
# manual admin-UI button fired it. Daily cadence is gentle
# (the task is idempotent and only enqueues regen for rows
# whose stored thumbnails are missing or corrupt).
"backfill-thumbnails-daily": {
"task": "backend.app.tasks.thumbnail.backfill_thumbnails",
"schedule": 86400.0,
},
# External file-host downloads (#830): a steady sweep catches links
# the post-download hook missed (worker down, etc.); recovery re-tries
# dead links daily; retention prunes long-dead rows.
"extdl-sweep": {
"task": "backend.app.tasks.external.sweep_external_links",
"schedule": 600.0, # every 10 min
},
"extdl-recover-daily": {
"task": "backend.app.tasks.external.recover_external_links",
"schedule": 86400.0,
},
"extdl-prune-daily": {
"task": "backend.app.tasks.external.prune_external_links",
"schedule": 86400.0,
},
},
timezone="UTC",
)
# FC-3i: register task_run signal handlers (side-effect import).
from . import celery_signals # noqa: F401
return app
-227
View File
@@ -1,227 +0,0 @@
"""FC-3i: task_run lifecycle via Celery signals.
Subscribes to task_prerun / task_postrun / task_failure / task_retry
and persists one task_run row per task attempt. Drop-in for every
existing and future Celery task — no per-task instrumentation.
Signal handlers run inside the worker process (sync context); DB
writes go through the existing shared sync engine
(backend.app.tasks._sync_engine.sync_session_factory) — one engine
per worker process, not per-task, so we don't blow Postgres
max_connections under load (the reason FC-3g shared-engine fix
existed).
Failure-mode discipline (operator-pressed point): every handler is
wrapped in try/except that swallows + logs. If the DB is down or the
handler has a bug, the real task still runs — the dashboard goes
dark for that interval. Monitoring NEVER breaks the thing it's
monitoring.
"""
import logging
from datetime import UTC, datetime
from celery.exceptions import SoftTimeLimitExceeded
from celery.signals import task_failure, task_postrun, task_prerun, task_retry
from .models import TaskRun
from .tasks._sync_engine import sync_session_factory
log = logging.getLogger(__name__)
# Celery-internal tasks that would generate dashboard noise without
# operational value. Conservative list; extend only when a specific
# task proves noisy.
_UNTRACKED_TASK_NAMES = frozenset({
"celery.chord_unlock",
"celery.backend_cleanup",
"celery.chunks",
})
_MAX_ERROR_MESSAGE_LEN = 2000
_MAX_ARGS_SUMMARY_LEN = 255
_MAX_WORKER_HOSTNAME_LEN = 128
# PostgreSQL Integer is signed 32-bit. Tasks called with a first-arg
# int outside this range (e.g. an absurdly large mock value, or a
# string-of-digits coercible to int but bigger than 2^31-1) would crash
# the INSERT with NumericValueOutOfRange. Bound the recorded value to
# the column's range; values outside become None (target_id is
# nullable, so this is safe).
_INT32_MAX = 2_147_483_647
_INT32_MIN = -2_147_483_648
def _queue_for(task) -> str:
"""Reverse the task→queue routing from celery_app.task_routes.
Keep in sync if task_routes is reordered.
Audit 2026-06-02: backup/admin/library_audit prefixes were
missing here even though task_routes sent all three to
'maintenance'. The TaskRun.queue column then lied for those
rows (claimed 'default') so per-queue dashboard filters and
per-queue threshold overrides silently missed them.
"""
name = getattr(task, "name", "") or ""
if name.startswith("backend.app.tasks.import_file."):
return "import"
if name.startswith("backend.app.tasks.ml."):
return "ml"
if name.startswith("backend.app.tasks.thumbnail."):
return "thumbnail"
if name.startswith((
"backend.app.tasks.download.",
# External file-host fetches share the download lane (celery_app
# routes external.* → download). Mirror it here or TaskRun.queue
# lies 'default' for them, so per-queue dashboard filters and the
# per-queue threshold override miss them — the same gap the
# 2026-06-02 audit fixed for backup/admin/library_audit.
"backend.app.tasks.external.",
)):
return "download"
if name.startswith("backend.app.tasks.scan."):
return "scan"
if name.startswith((
"backend.app.tasks.maintenance.",
"backend.app.tasks.backup.",
"backend.app.tasks.admin.",
"backend.app.tasks.library_audit.",
)):
return "maintenance"
return "default"
def _target_id_from_args(args) -> int | None:
"""Best-effort: if the first positional arg parses as int AND fits
in the column's signed-32-bit range, record it as target_id
(image_id, source_id, etc.). Never raises."""
if not args:
return None
try:
value = int(args[0])
except (TypeError, ValueError):
return None
if value < _INT32_MIN or value > _INT32_MAX:
return None
return value
def _truncate(s, limit: int) -> str | None:
if s is None:
return None
text = str(s)
return text if len(text) <= limit else text[:limit]
def _is_tracked(task_name: str | None) -> bool:
return bool(task_name) and task_name not in _UNTRACKED_TASK_NAMES
@task_prerun.connect
def _on_prerun(sender=None, task_id=None, task=None, args=None,
kwargs=None, **_):
if not _is_tracked(getattr(task, "name", None)):
return
try:
Session = sync_session_factory()
with Session() as session:
session.add(TaskRun(
celery_task_id=task_id or "",
queue=_queue_for(task),
task_name=task.name,
target_id=_target_id_from_args(args),
started_at=datetime.now(UTC),
status="running",
args_summary=_truncate(repr(args), _MAX_ARGS_SUMMARY_LEN),
worker_hostname=_truncate(
getattr(sender, "hostname", None),
_MAX_WORKER_HOSTNAME_LEN,
),
))
session.commit()
except Exception: # noqa: BLE001 — never break the worker
log.exception("task_run prerun insert failed (task=%s)",
getattr(task, "name", "?"))
def _finalize(task_id: str, *, status: str,
error_type: str | None = None,
error_message: str | None = None,
retry_count: int | None = None) -> None:
"""Shared write path for postrun/failure/retry. Picks the most-
recent task_run row for this celery_task_id that's still 'running'
(retries reuse the same celery_task_id; each new attempt's prerun
inserts a fresh row, so finalize targets the latest running row)."""
try:
from sqlalchemy import select
Session = sync_session_factory()
now = datetime.now(UTC)
with Session() as session:
row = session.execute(
select(TaskRun)
.where(TaskRun.celery_task_id == task_id)
.where(TaskRun.status == "running")
.order_by(TaskRun.id.desc())
.limit(1)
).scalar_one_or_none()
if row is None:
return # no prerun row (untracked, insert failed, or already finalized)
row.finished_at = now
row.duration_ms = int(
(now - row.started_at).total_seconds() * 1000
)
row.status = status
if error_type is not None:
row.error_type = _truncate(error_type, 128)
if error_message is not None:
row.error_message = _truncate(error_message, _MAX_ERROR_MESSAGE_LEN)
if retry_count is not None:
row.retry_count = retry_count
session.commit()
except Exception: # noqa: BLE001
log.exception("task_run finalize failed (task_id=%s)", task_id)
@task_postrun.connect
def _on_postrun(sender=None, task_id=None, task=None, args=None,
kwargs=None, retval=None, state=None, **_):
if not _is_tracked(getattr(task, "name", None)):
return
# state is one of SUCCESS/FAILURE/RETRY/etc. Only handle SUCCESS;
# task_failure handles FAILURE explicitly (with the exception).
if state != "SUCCESS":
return
_finalize(task_id, status="ok")
@task_failure.connect
def _on_failure(sender=None, task_id=None, exception=None,
args=None, kwargs=None, einfo=None, **_):
if not _is_tracked(getattr(sender, "name", None)):
return
status = ("timeout"
if isinstance(exception, SoftTimeLimitExceeded)
else "error")
_finalize(
task_id, status=status,
error_type=type(exception).__name__ if exception else "Unknown",
error_message=str(exception) if exception else None,
)
@task_retry.connect
def _on_retry(sender=None, request=None, reason=None, einfo=None, **_):
if not _is_tracked(getattr(sender, "name", None)):
return
task_id = getattr(request, "id", None)
if not task_id:
return
# Mark current attempt's row as 'retry' (terminal for this row).
# The next attempt's task_prerun inserts a fresh row.
_finalize(
task_id, status="retry",
error_type=type(reason).__name__ if reason else "Retry",
error_message=str(reason) if reason else None,
retry_count=getattr(request, "retries", 0),
)
+2 -66
View File
@@ -1,78 +1,14 @@
"""Serves the built Vue SPA from frontend/dist/ with history-mode fallback,
the on-disk image library + thumbnails from /images, and the signed
Firefox extension XPI from frontend/dist/extension/.
"""
"""Serves the built Vue SPA from frontend/dist/ with history-mode fallback."""
import re
from pathlib import Path
from quart import Blueprint, abort, send_file, send_from_directory
from quart import Blueprint, send_from_directory
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
IMAGES_ROOT = Path("/images")
XPI_DIR = FRONTEND_DIST / "extension"
_XPI_NAME_RE = re.compile(r"^fabledcurator-[\w.-]+\.xpi$")
frontend_bp = Blueprint("frontend", __name__)
@frontend_bp.route("/images/<path:subpath>")
async def serve_image(subpath: str):
"""Serve a file from the /images volume (originals + thumbnails).
Without this route the SPA catch-all below would swallow image
requests and return index.html, leaving the browser to render the
aspect-ratio-shaped grey placeholder.
"""
target = (IMAGES_ROOT / subpath).resolve()
# Defend against path-traversal: refuse anything that escapes /images.
try:
target.relative_to(IMAGES_ROOT)
except ValueError:
abort(404)
if not target.is_file():
abort(404)
return await send_from_directory(IMAGES_ROOT, subpath)
@frontend_bp.route("/extension/<filename>")
async def serve_extension(filename: str):
"""Serve the signed FC Firefox extension XPI.
Path whitelist: filename must match fabledcurator-*.xpi. The special
name fabledcurator-latest.xpi serves the most-recently-modified XPI
in the directory.
The application/x-xpinstall MIME tells Firefox to show its native
install prompt instead of downloading the file as a blob.
"""
if not _XPI_NAME_RE.fullmatch(filename):
abort(404)
if not XPI_DIR.is_dir():
abort(404)
if filename == "fabledcurator-latest.xpi":
xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime)
if not xpis:
abort(404)
latest = xpis[-1]
return await send_file(
latest, mimetype="application/x-xpinstall",
attachment_filename=latest.name,
)
target = (XPI_DIR / filename).resolve()
try:
target.relative_to(XPI_DIR)
except ValueError:
abort(404)
if not target.is_file():
abort(404)
return await send_file(
target, mimetype="application/x-xpinstall",
attachment_filename=filename,
)
@frontend_bp.route("/")
@frontend_bp.route("/<path:subpath>")
async def serve_spa(subpath: str = ""):
+7 -49
View File
@@ -2,90 +2,48 @@
from .app_setting import AppSetting
from .artist import Artist
from .artist_visit import ArtistVisit
from .backup_run import BackupRun
from .base import Base
from .character_prototype import CcipPrototypeState, CharacterPrototype
from .credential import Credential
from .download_event import DownloadEvent
from .external_link import ExternalLink
from .gpu_job import GpuJob
from .head_auto_apply_run import HeadAutoApplyRun
from .head_metric import HeadMetric
from .head_metrics_snapshot import HeadMetricsSnapshot
from .head_training_run import HeadTrainingRun
from .image_provenance import ImageProvenance
from .image_record import ImageRecord
from .image_region import ImageRegion
from .import_batch import ImportBatch
from .import_settings import ImportSettings
from .import_task import ImportTask
from .library_audit_run import LibraryAuditRun
from .migration_run import MigrationRun
from .ml_settings import MLSettings
from .patreon_failed_media import PatreonFailedMedia
from .patreon_seen_media import PatreonSeenMedia
from .pixiv_failed_media import PixivFailedMedia
from .pixiv_seen_media import PixivSeenMedia
from .post import Post
from .post_attachment import PostAttachment, attachment_download_url
from .presentation_review import PresentationReview
from .series_chapter import SeriesChapter
from .post_attachment import PostAttachment
from .series_page import SeriesPage
from .series_suggestion import SeriesSuggestion
from .source import Source
from .subscribestar_failed_media import SubscribeStarFailedMedia
from .subscribestar_seen_media import SubscribeStarSeenMedia
from .tag import Tag, TagKind, image_tag
from .tag_alias import TagAlias
from .tag_head import TagHead
from .tag_positive_confirmation import TagPositiveConfirmation
from .tag_allowlist import TagAllowlist
from .tag_reference_embedding import TagReferenceEmbedding
from .tag_suggestion_rejection import TagSuggestionRejection
from .task_run import TaskRun
__all__ = [
"Base",
"AppSetting",
"Artist",
"ArtistVisit",
"BackupRun",
"Source",
"Credential",
"PatreonFailedMedia",
"PatreonSeenMedia",
"PixivFailedMedia",
"PixivSeenMedia",
"SubscribeStarFailedMedia",
"SubscribeStarSeenMedia",
"Post",
"PostAttachment",
"attachment_download_url",
"PresentationReview",
"SeriesChapter",
"SeriesPage",
"SeriesSuggestion",
"ImageRecord",
"ImageProvenance",
"ImageRegion",
"Tag",
"TagKind",
"image_tag",
"DownloadEvent",
"ExternalLink",
"GpuJob",
"ImportBatch",
"ImportTask",
"ImportSettings",
"LibraryAuditRun",
"MLSettings",
"HeadAutoApplyRun",
"HeadMetric",
"HeadMetricsSnapshot",
"HeadTrainingRun",
"MigrationRun",
"TagAlias",
"TagHead",
"CharacterPrototype",
"CcipPrototypeState",
"TagPositiveConfirmation",
"TagAllowlist",
"TagReferenceEmbedding",
"TagSuggestionRejection",
"TaskRun",
]
+1 -8
View File
@@ -15,14 +15,7 @@ class Artist(Base):
__tablename__ = "artist"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Display name: freely editable, NON-unique (two real creators can share a
# name). Decoupled from identity/storage in migration 0077 (#130) — renaming
# touches ONLY this. Was unique until then.
name: Mapped[str] = mapped_column(String(255), nullable=False)
# Storage/identity key: IMMUTABLE + unique. This is the on-disk path
# component (download_service artist_slug = artist.slug → images_root/<slug>/
# <platform>/…), so it is set once at creation (collision-suffixed) and NEVER
# changes — a rename must not move files. Existing artists keep their slug.
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
-36
View File
@@ -1,36 +0,0 @@
"""ArtistVisit — per-artist 'last viewed' timestamp.
Powers the "+N new since last visit" badge on the artists directory and
the matching banner on `ArtistView`. One row per artist, single global
operator. When the multi-user model lands, the PK widens to
`(user_id, artist_id)` — currently aspirational only (no User model,
no services/access.py); operator approved skipping `user_id` for now
under rule #22 (breaking changes welcome).
Seed at migration time: every existing artist gets `last_viewed_at = NOW()`
so the badge starts at 0 across the board (no noisy "5000 unseen" on
first deploy). New artists also auto-get a row via
`ArtistService.find_or_create`.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ArtistVisit(Base):
__tablename__ = "artist_visit"
artist_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("artist.id", ondelete="CASCADE"),
primary_key=True,
)
last_viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
-55
View File
@@ -1,55 +0,0 @@
"""FC-3h: backup_run — operator-facing artifact record for a backup run.
One row per backup attempt (kind='db' or 'images'). Lifecycle
tracking (started_at/finished_at/duration_ms/exception text) lives
in task_run from FC-3i — this row records artifact metadata: file
paths, sizes, tag (retention protection), and restore lineage via
restored_from_id.
Status values (String, not Postgres ENUM — per
feedback_check_existing_enums):
pending — created but task hasn't started yet (rare; usually
status starts as 'running' from the task body).
running — backup task is in flight.
ok — artifact successfully written.
error — task raised; error column populated.
restoring — this row represents a restore attempt (kind = restored
kind); linked to source via restored_from_id.
restored — restore completed successfully.
"""
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class BackupRun(Base):
__tablename__ = "backup_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending", index=True,
)
tag: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
triggered_by: Mapped[str] = mapped_column(String(32), nullable=False)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True,
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True,
)
sql_path: Mapped[str | None] = mapped_column(Text, nullable=True)
tar_path: Mapped[str | None] = mapped_column(Text, nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
manifest: Mapped[dict] = mapped_column(
JSON, nullable=False, default=dict, server_default="{}",
)
restored_from_id: Mapped[int | None] = mapped_column(
ForeignKey("backup_run.id", ondelete="SET NULL"),
nullable=True,
)
-62
View File
@@ -1,62 +0,0 @@
"""Precomputed CCIP character prototypes (#1317, milestone 138).
The live matcher (ccip.match_image) needs each character's reference figure
vectors. Building that on the request path reloaded EVERY figure CCIP vector in
the library on any change (~4s, invalidated by every character accept). These
tables make the references a PRECOMPUTED, INCREMENTAL artifact refreshed off the
request path (services.ml.character_prototypes):
- CharacterPrototype: a character's reference vectors, capped to
MLSettings.ccip_prototype_cap so MATCH cost doesn't grow with a character's
popularity. The async matcher only READS these.
- CcipPrototypeState: a per-character fingerprint (reference count + max region
id) so a refresh rebuilds ONLY the characters whose references changed, and
its updated_at lets the matcher's cache reload just the advanced characters.
"""
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .image_region import CCIP_DIM
class CharacterPrototype(Base):
__tablename__ = "character_prototype"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# The character tag these vectors identify. CASCADE: deleting the tag drops
# its prototypes.
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
# A reference figure/face CCIP vector (same space as
# ImageRegion.ccip_embedding).
ccip_embedding: Mapped[list[float]] = mapped_column(
Vector(CCIP_DIM), nullable=False
)
# Provenance: the region this vector was copied from. SET NULL so pruning a
# region doesn't delete the prototype mid-cycle (the next refresh reconciles).
region_id: Mapped[int | None] = mapped_column(
ForeignKey("image_region.id", ondelete="SET NULL"), nullable=True
)
class CcipPrototypeState(Base):
__tablename__ = "ccip_prototype_state"
# One row per character that currently has prototypes.
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
)
# count(reference regions) + max(region id) at last build — the cheap
# per-character change detector that drives incremental rebuilds.
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
# Bumped when this character's prototypes are rebuilt; the matcher cache
# reloads only characters whose updated_at advanced.
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-73
View File
@@ -1,73 +0,0 @@
"""ExternalLink — an off-platform file-host link found in a post body.
Creators host the actual files (films, packs) on mega.nz / Google Drive /
MediaFire / Dropbox / Pixeldrain and drop the link in the post text. This row
is the record that the link existed (so nothing is silently dropped), the
dedup + dead-letter ledger for fetching it, and the driver the download worker
walks. `url` keeps the FULL link including the `#fragment` (mega's decryption
key) — truncating it makes the file undownloadable.
status lifecycle: pending → downloading → downloaded | failed | dead
(too many attempts) | skipped (host disabled). `attachment_id` links the
captured file once a download lands (SET NULL so deleting the attachment
doesn't delete the link record).
"""
from datetime import datetime
from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
# Kept in sync with link_extract.SUPPORTED_HOSTS and the CHECK in migration 0049.
HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
STATUSES = ("pending", "downloading", "downloaded", "failed", "skipped", "dead")
class ExternalLink(Base):
__tablename__ = "external_link"
__table_args__ = (
# One row per (post, url). The full url (incl. #fragment) is the identity
# — the same file linked twice in a post collapses to one row.
Index("uq_external_link_post_url", "post_id", "url", unique=True),
Index("ix_external_link_status", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
artist_id: Mapped[int | None] = mapped_column(
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
)
host: Mapped[str] = mapped_column(String(16), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
label: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(16), nullable=False, server_default="pending"
)
attempts: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("0")
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
attachment_id: Mapped[int | None] = mapped_column(
ForeignKey("post_attachment.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
-75
View File
@@ -1,75 +0,0 @@
"""GpuJob — a unit of GPU work the desktop agent pulls over HTTP (#114).
The durable work list that lets the agent stay HTTP-only: the server enqueues a
job per (image, task) — e.g. detect figures + CCIP-embed — and the agent LEASES a
batch, computes on its GPU, then SUBMITS results, all over the already-exposed web
API. Redis/Postgres stay private. A lease has an expiry; the lease query itself
re-claims expired leases (agent died / stopped mid-batch), so the queue is
self-healing without a separate sweep. One job is per ITEM; the agent fans a
VIDEO out into per-frame instances internally (see image_region.frame_time).
State: pending → leased → done | error (a failure under the attempt cap returns to
pending for another agent).
"""
from datetime import datetime
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class GpuJob(Base):
__tablename__ = "gpu_job"
# Partial indexes over just the live slice (see migration 0070): the lease
# reads the lowest-id pending jobs on the hot path, and reclaims expired
# leases as a backstop — both stay O(batch) as done/error history grows.
__table_args__ = (
Index("ix_gpu_job_pending", "id", postgresql_where=text("status = 'pending'")),
Index(
"ix_gpu_job_leased_expires", "lease_expires_at",
postgresql_where=text("status = 'leased'"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
image_record_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"), index=True
)
# What to compute, e.g. 'ccip' (detect figures + CCIP-embed) or 'siglip_region'.
task: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending", index=True
)
# pending | leased | done | error
lease_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
leased_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
lease_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Triage verdict for an ERRORED job (#125): NULL = not yet probed;
# 'defect' = the integrity probe says the FILE itself is bad (surfaced for
# recovery, excluded from /retry_errors); 'file_ok' = the file passes —
# the failure was operational (timeout/transient), safe to retry.
triage_status: Mapped[str | None] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-46
View File
@@ -1,46 +0,0 @@
"""HeadAutoApplyRun — persisted lifecycle of an earned-auto-apply sweep (#114).
A graduated head can apply its tag to images it scores above the head's
auto-apply threshold, without a human. This row tracks one such sweep (or a
dry-run PREVIEW of it) so the result survives navigation and the admin card can
show what fired / what would fire. Mirrors HeadTrainingRun. State machine:
running → ready / error. The `report` JSONB holds per-concept counts
(applied / projected / scanned).
"""
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class HeadAutoApplyRun(Base):
__tablename__ = "head_auto_apply_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# dry_run=True is a PREVIEW: scores + counts what WOULD apply, writes nothing
# (preview/apply parity, rule 93).
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", index=True
)
# running | ready | error
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Total tags applied across all heads this sweep (0 for a clean dry-run).
n_applied: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Per-concept breakdown: [{tag_id, name, applied, scanned, threshold}, ...].
report: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
last_progress_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
-32
View File
@@ -1,32 +0,0 @@
"""HeadMetric — running correction counters per concept (#114 observability).
Earned auto-apply fires graduated heads; to TUNE it we need to know how often a
head's auto-applied tag was wrong (the operator removed it = a MISFIRE) and how
often the operator had to add a tag a head exists for by hand (an UNDER-FIRE,
the head missed it). image_tag.source is lost when a row is deleted, so these
are captured as durable cumulative counters at correction time — they survive
head retrain/prune (keyed by tag, not by the head row). The daily snapshot reads
them into the time-series.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class HeadMetric(Base):
__tablename__ = "head_metric"
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
)
# An auto-applied (source='head_auto') tag the operator later REMOVED.
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# A tag with a head that the operator added by HAND (the head missed it).
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -1,38 +0,0 @@
"""HeadMetricsSnapshot — a daily per-concept time-series point (#114).
The "amount of change over time" reporting the operator asked for: once a day,
record each concept's auto-applied VOLUME (current head_auto tags), cumulative
misfires/under-fires, and the head's measured quality. Plotting these rows over
time shows whether auto-apply is landing better/worse and whether tagging more is
sharpening a concept — the signal for tuning the precision target + support floor.
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class HeadMetricsSnapshot(Base):
__tablename__ = "head_metrics_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), index=True
)
# Denormalized so a snapshot stays readable even if the tag is later renamed.
name: Mapped[str] = mapped_column(String(255), nullable=False)
snapshot_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
)
# Current count of source='head_auto' applications still standing.
n_auto_applied: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# The head's measured quality at snapshot time (null if no head exists).
ap: Mapped[float | None] = mapped_column(Float, nullable=True)
precision_cv: Mapped[float | None] = mapped_column(Float, nullable=True)
recall: Mapped[float | None] = mapped_column(Float, nullable=True)
n_pos: Mapped[int | None] = mapped_column(Integer, nullable=True)
-44
View File
@@ -1,44 +0,0 @@
"""HeadTrainingRun — persisted lifecycle of a head-training batch (#114).
A persisted run row (not transient frontend state) so the run SURVIVES
navigation and the admin card can show live + historical status.
Training is idempotent (it upserts tag_head rows), so a SIGKILL'd run is harmless
— a maintenance recovery sweep flips a stalled `running` row to `error`, and the
next run re-trains. State machine: running → ready / error.
"""
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class HeadTrainingRun(Base):
__tablename__ = "head_training_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Training parameters: {min_positives, neg_ratio, precision_target, ...}.
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", index=True
)
# running | ready | error
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# How many concepts got a (re)trained head vs were skipped (too few labels).
n_trained: Mapped[int | None] = mapped_column(Integer, nullable=True)
n_skipped: Mapped[int | None] = mapped_column(Integer, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Last time the task made progress — the recovery sweep tells a live run
# from a SIGKILL'd one by this.
last_progress_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+7 -31
View File
@@ -1,18 +1,14 @@
"""ImageProvenance — links an ImageRecord to a Post.
One image can have many provenance rows — different posts each contribute
metadata (enrich-on-duplicate rule, spec §3: a downloaded image that is a
pHash dupe of an existing record gets a NEW provenance row for the new post
appended, rather than the metadata being dropped). But the (image, post)
pair is unique — alembic 0021 enforces uq_image_provenance_image_post
after operator-flagged 2026-05-26 saw _apply_sidecar's existence-check +
INSERT race plant duplicates that then broke .scalar_one_or_none() on
every later deep-scan rederive (MultipleResultsFound).
Many-to-one (one image, many provenance rows) enables the enrich-on-duplicate
rule (spec §3): when a downloaded image is a pHash dupe of an existing
record, we append a new provenance row to the existing record rather than
dropping the metadata.
"""
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -20,12 +16,6 @@ from .base import Base
class ImageProvenance(Base):
__tablename__ = "image_provenance"
__table_args__ = (
UniqueConstraint(
"image_record_id", "post_id",
name="uq_image_provenance_image_post",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
image_record_id: Mapped[int] = mapped_column(
@@ -34,22 +24,8 @@ class ImageProvenance(Base):
post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
# Nullable since alembic 0030 — provenance rows for filesystem-imported
# content with no subscription have NULL source_id. FK ondelete SET
# NULL so deleting a Source detaches its provenance rows instead of
# destroying the linkage between image and post.
source_id: Mapped[int | None] = mapped_column(
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True
)
# The archive PostAttachment this image was extracted FROM, when it came
# out of a .zip/.rar rather than as a loose file (milestone #87). Lets the
# provenance UI show the exact archive a file lives inside instead of every
# attachment on the post. NULL for loose downloads and pre-backfill rows.
# SET NULL so deleting the archive attachment never destroys the (image,
# post) edge — it just forgets which archive it came from.
from_attachment_id: Mapped[int | None] = mapped_column(
ForeignKey("post_attachment.id", ondelete="SET NULL"),
nullable=True, index=True,
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
captured_at: Mapped[datetime] = mapped_column(
+9 -42
View File
@@ -9,10 +9,10 @@ from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
JSON,
BigInteger,
DateTime,
Enum,
Float,
ForeignKey,
Integer,
String,
@@ -39,10 +39,6 @@ class ImageRecord(Base):
mime: Mapped[str] = mapped_column(String(64), nullable=False)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Video container duration (seconds); NULL for images. The Tier-1 video
# near-dup key (#871): two videos of the same artist with matching duration
# (+ aspect) are the same content across re-encodes — dedup like image pHash.
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
@@ -53,18 +49,6 @@ class ImageRecord(Base):
# Thumbnail (populated by FC-2)
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True)
# Source provenance for downloaded media (#830 Phase 2). `source_url` is the
# CDN/origin URL the file was fetched from (debugging + future re-fetch).
# `source_filehash` is the URL's 32-hex CDN identity segment
# (utils.paths.filehash_from_url) — the JOIN KEY that maps a post body's
# inline `<img src=CDN>` back to this local copy so the rendered body serves
# our stored image instead of hotlinking the public source. Indexed for the
# render-time lookup. NULL for filesystem-imported / pre-Phase-2 rows.
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_filehash: Mapped[str | None] = mapped_column(
String(32), nullable=True, index=True
)
# Origin / provenance pointers
origin: Mapped[str] = mapped_column(Enum(*ORIGIN_CHOICES, name="origin_enum"), nullable=False)
primary_post_id: Mapped[int | None] = mapped_column(
@@ -76,37 +60,20 @@ class ImageRecord(Base):
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
)
# ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
# embedding dim; siglip_model_version stamps which model produced it (so an
# operator model swap, #1190, can re-embed the stale rows). A different-dim
# model would need a column-width migration.
# ML fields (populated by FC-2's ml-worker)
tagger_predictions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
tagger_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 1152 = SigLIP-so400m embedding dim. Swapping models in FC-2 may require
# a column-width migration.
siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
siglip_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Centroid score cache (populated post-tagging)
centroid_scores: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Denormalized gallery sort key = COALESCE(primary post's post_date,
# created_at) (alembic 0035). The gallery used to compute this as a
# COALESCE across the Post outer join on every /scroll, which can't use
# an index and re-sorted a large slice of the library per page (×10 with
# the old serial batching). Materializing it lets the cursor scroll read
# ix_image_record_effective_date directly. Maintained by the importer
# (services/importer.py _apply_sidecar) when a primary post with a date
# is linked; plain inserts keep the created_at-equivalent server default.
effective_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Denormalized ORIGINAL-publish sort key (alembic 0071) = MIN(post_date)
# across ALL of the image's provenance posts, else created_at. effective_date
# above keys off the PRIMARY post (often the repost/download the file came
# from); this keys off the earliest publish across EVERY post the image
# appears in, so the gallery can sort by when content was first posted rather
# than when it was downloaded (operator-flagged 2026-07-01). Maintained by
# services/importer.py, recomputed whenever a dated post is linked.
earliest_post_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
-65
View File
@@ -1,65 +0,0 @@
"""ImageRegion — a detected/proposed sub-region of an image + its crop embedding.
The storage backbone of the crop pipeline (#114). A region is a normalized bbox
plus the embedding of its crop:
- kind='face' / 'figure' → embedded by CCIP for cross-artist character identity.
- kind='concept' → embedded by SigLIP, a localized instance for a concept head's
bag-of-embeddings (a concept is "present if ANY instance matches").
One row carries the embedding appropriate to its kind (the other is null). The
bbox doubles as grounded-tag provenance (hover a tag → highlight its region; a
wrong box is a precise negative). The GPU agent writes these via the job API;
the few-shot character matcher + bag scorer read them — both server-side, no GPU.
"""
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
CCIP_DIM = 768 # deepghs/imgutils CCIP character embedding
SIGLIP_DIM = 1152 # matches image_record.siglip_embedding
class ImageRegion(Base):
__tablename__ = "image_region"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
image_record_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"), index=True
)
# 'frame' (a whole video frame → SigLIP bag) | 'face' | 'figure' (→ CCIP
# character id) | 'concept' (→ SigLIP head bag) | 'panel' (a comic panel crop,
# also SigLIP → the bag). Free String, not an enum — proposers can add kinds
# without a migration; the bag scorer keys on a non-null siglip_embedding, not
# the kind, so any SigLIP-embedded region joins the bag.
kind: Mapped[str] = mapped_column(String(16), nullable=False)
# For video/animated media: the source frame's timestamp in SECONDS. NULL for
# static images. Lets a video be a BAG of per-frame instances (fixes the
# mean-embedding muddle) + grounds a tag to "appears at 0:42".
frame_time: Mapped[float | None] = mapped_column(Float, nullable=True)
# Normalized bbox in [0,1]: top-left (rx, ry) + size (rw, rh). Named rx/ry/…
# rather than x/y/by to dodge SQL keyword ambiguity ('by').
rx: Mapped[float] = mapped_column(Float, nullable=False)
ry: Mapped[float] = mapped_column(Float, nullable=False)
rw: Mapped[float] = mapped_column(Float, nullable=False)
rh: Mapped[float] = mapped_column(Float, nullable=False)
# Proposer/detector confidence (null for deterministic proposers).
score: Mapped[float | None] = mapped_column(Float, nullable=True)
# Version stamps so a re-detect / re-crop / re-embed can be gated (compute
# once; only redo when the producing model version changes).
detector_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
crop_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
embedding_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Exactly one is set, per kind.
ccip_embedding: Mapped[list[float] | None] = mapped_column(
Vector(CCIP_DIM), nullable=True
)
siglip_embedding: Mapped[list[float] | None] = mapped_column(
Vector(SIGLIP_DIM), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-4
View File
@@ -26,10 +26,6 @@ class ImportBatch(Base):
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Deep-scan only: count of already-imported files whose sidecar metadata
# got re-applied this run (post/source/provenance upsert). Stays 0 on
# quick-scan batches. See `Importer.import_one(deep_scan=True)`.
refreshed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
# running | complete | cancelled
+1 -95
View File
@@ -4,7 +4,7 @@ Enforced as a single row via a CHECK (id = 1) constraint. The application
always SELECTs id=1 and never inserts/deletes after the initial migration.
"""
from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text, select
from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -49,97 +49,3 @@ class ImportSettings(Base):
download_failure_warning_threshold: Mapped[int] = mapped_column(
Integer, nullable=False, default=5
)
# FC-3h backup knobs.
backup_db_nightly_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
)
backup_db_nightly_hour_utc: Mapped[int] = mapped_column(
Integer, nullable=False, default=3,
)
backup_db_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=14,
)
backup_images_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=3,
)
# FC-6.3 series continuation matcher. enabled gates the rescan; threshold is
# the weighted-score cut-off (0..1) above which a pending suggestion is made.
series_suggest_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
)
series_suggest_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.5,
)
# #830 off-platform file-host downloads — per-host enable lever (default on,
# rule #26). Column names are extdl_<host>_enabled so the worker reads them
# via getattr(settings, f"extdl_{host}_enabled", True).
extdl_mega_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_gdrive_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_mediafire_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_dropbox_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
extdl_pixeldrain_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
# -- Post-text translation via the Interpreter LAN service (milestone 143).
# Off by default with NO default host — it needs a reachable Interpreter
# service (the operator's, behind a reverse proxy), which not every install
# has; the operator sets the URL and flips it on. Empty base_url OR disabled
# → the translate sweep no-ops.
translation_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
)
interpreter_base_url: Mapped[str] = mapped_column(
Text, nullable=False, default="", server_default="",
)
translation_target_lang: Mapped[str] = mapped_column(
Text, nullable=False, default="en", server_default="en",
)
# The latin-script acceptance floor for the translation gate: a translation
# whose Interpreter-reported confidence is below this is kept as the original
# (operator-tunable, milestone 155). Default 0.9 — stricter than the old
# hardcoded 0.8, because Interpreter confidently mis-detects short ASCII
# English (e.g. "… WIP Part 1") as a European language at ~0.86. CJK stays
# trusted regardless (script-detected). Per-post overrides handle the misses.
translation_min_confidence: Mapped[float] = mapped_column(
Float, nullable=False, default=0.9, server_default="0.9",
)
# Title-based WIP auto-tagging (task #1458). When a freshly-imported post's
# TITLE explicitly declares work-in-progress ("WIP" / "work in progress"),
# the importer applies the `wip` system tag to its images — the artist's own
# label, used to keep unfinished pieces out of the Explore/gallery browse. ON
# by default (rule 26 — the feature works out of the box). Gates only the
# LIVE import hook; the existing catalogue is caught by the operator-triggered
# "Scan existing posts" backfill (which runs regardless of this flag).
wip_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true",
)
# Soft WIP title tier (#1474): also tag sketch/doodle/scribble titles, but with
# a PROVISIONAL source (`wip_title_soft`) that never trains the head, since these
# are lower-precision (a finished "sketch" isn't WIP). OFF by default — a lower-
# precision tier is opt-in (the ring-loud audit surfaces false positives).
wip_soft_title_tagging_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
)
@classmethod
async def load(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via an async session."""
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
@classmethod
def load_sync(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via a sync session."""
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
+1 -17
View File
@@ -8,16 +8,7 @@ been processing longer than the stuck-task threshold.
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
func,
)
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
@@ -35,13 +26,6 @@ class ImportTask(Base):
task_type: Mapped[str] = mapped_column(String(16), nullable=False) # media|archive
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
# Poison-pill circuit breaker (alembic 0026). recovery_count tracks
# how many times the stuck-task sweep has re-queued this row; after
# the cap it's failed with a diagnostic instead of looping. refetched
# bounds the one-shot re-download remediation to a single attempt.
recovery_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
refetched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
result_image_id: Mapped[int | None] = mapped_column(
ForeignKey("image_record.id", ondelete="SET NULL"), nullable=True
)
-44
View File
@@ -1,44 +0,0 @@
"""LibraryAuditRun — async transparency / single_color audit lifecycle.
State machine: running → ready → applied / cancelled / error.
matched_ids JSONB is appended-to by scan_library_for_rule; apply_audit_run
reads it and routes through cleanup_service.delete_images.
"""
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class LibraryAuditRun(Base):
__tablename__ = "library_audit_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rule: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", index=True,
)
# running | ready | applied | cancelled | error
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Chunked-scan state (alembic 0039): keyset cursor the next chunk resumes
# from, and the last time a chunk made progress (so the recovery sweep can
# tell a progressing multi-chunk audit from a stuck one).
resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_progress_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
+37
View File
@@ -0,0 +1,37 @@
"""MigrationRun — tracks each FC-5 migration invocation (backup/gs/ir/etc).
kind/status are String(32) not Postgres ENUM so adding kinds later
doesn't need a schema migration. The API layer validates values.
"""
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class MigrationRun(Base):
__tablename__ = "migration_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
counts: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default=sa.text("'{}'::jsonb"),
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_: Mapped[dict] = mapped_column(
"metadata", JSONB, nullable=False, default=dict,
server_default=sa.text("'{}'::jsonb"),
)
+15 -197
View File
@@ -2,16 +2,7 @@
from datetime import datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
Float,
Integer,
String,
func,
select,
)
from sqlalchemy import CheckConstraint, DateTime, Float, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -24,203 +15,30 @@ class MLSettings(Base):
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# CPU whole-image embedding (B3, operator 2026-07-02). The ml-worker's ONLY
# processing role is the embed fallback for stacks WITHOUT a GPU agent — ON
# by default so a fresh install works with no agent. Stacks that run the
# agent and drop the ml-worker container turn this OFF so import hooks stop
# queueing embed work nothing will consume (the daily GPU 'embed' backfill
# covers those images instead).
cpu_embed_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
suggestion_threshold_artist: Mapped[float] = mapped_column(
Float, nullable=False, default=0.30
)
# Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
# a fixed count) so coverage reflects real screen time regardless of length;
# cap the total so a long video can't explode into hundreds of embeds. The
# per-frame SigLIP embeddings are mean-pooled. Operator-tunable.
video_frame_interval_seconds: Mapped[float] = mapped_column(
Float, nullable=False, default=4.0
suggestion_threshold_character: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
)
video_max_frames: Mapped[int] = mapped_column(
Integer, nullable=False, default=64
suggestion_threshold_copyright: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
)
# Tagging-v2 head training (#114). The head is the suggestion source that
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
# needs >= head_min_positives labelled images before a head is trained;
# head_auto_apply_precision is the precision bar a head must clear (at some
# operating point) to "graduate" into earned auto-apply. Operator-tunable.
head_min_positives: Mapped[int] = mapped_column(
Integer, nullable=False, default=8
)
head_auto_apply_precision: Mapped[float] = mapped_column(
Float, nullable=False, default=0.97
)
# Earned auto-apply (#114). A graduated head fires (tags images without a
# human) when this master switch is on AND the head has at least
# head_auto_apply_min_positives clean labels — so a precise-looking but
# under-supported low-N head can't spray tags across the library. ON by
# default (operator-asked 2026-06-29: opt-OUT, not opt-in); the support +
# measured-precision gates keep it safe, and every auto-tag is reversible.
head_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
head_auto_apply_min_positives: Mapped[int] = mapped_column(
# Support floor raised 30→50 (operator-asked 2026-07-06): a head needs
# more human labels before it may fire without a human.
Integer, nullable=False, default=50
)
# CCIP character-match cosine cut (#114). 0.85 default — the v1 flat 0.75
# over-fired (high-reference characters matched a scatter of images); 0.85
# keeps the confident single-character matches. Tunable from the agent card.
ccip_match_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.85
)
# CCIP auto-apply (#114). Confident matches (>= ccip_auto_apply_threshold,
# above the suggest cut) auto-tag on a daily sweep. ON by default (opt-out);
# single-character references + the high bar keep it safe, every tag reversible.
ccip_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
# Raised 0.92→0.95 (operator-asked 2026-07-06) so only very confident
# character matches auto-tag.
suggestion_threshold_general: Mapped[float] = mapped_column(
Float, nullable=False, default=0.95
)
# -- Presentation chrome auto-hide (#141) -------------------------------
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
# with its OWN flat threshold (decoupled from content-head graduation) and is
# HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an
# image would be auto-hidden but ALSO scores >= presentation_conflict_threshold
# on a content head, it's still hidden but flagged for review
# (PresentationReview, mode='chrome') instead of buried silently. ON by default
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
# screenshot` are no longer chrome — they went to the PROCESS path below.
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
centroid_similarity_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.55
)
presentation_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.90
min_reference_images: Mapped[int] = mapped_column(
Integer, nullable=False, default=5
)
presentation_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
tagger_model_version: Mapped[str] = mapped_column(
String(128), nullable=False, default="camie-tagger-v2"
)
# -- Process auto-apply (#1464) ----------------------------------------
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
# screenshots that must stay OUT of head/CCIP training but, unlike chrome,
# remain VISIBLE in the gallery (operator 2026-07-12). They auto-apply on the
# sweep with their OWN flat threshold and a PROVISIONAL source (`process_auto`,
# in training_data._AUTO_SOURCES) so the head NEVER trains on its own output —
# it learns only from title (`wip_title`) + manual labels, which breaks the
# runaway loop. When a process tag would be applied but the image ALSO scores
# >= process_conflict_threshold on a content head, it's flagged for review
# (PresentationReview, mode='process') rather than silently marked. OFF by
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
process_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
process_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.90
)
process_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
)
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
# existing libraries keep their stored value until the operator re-embeds.
embedder_model_version: Mapped[str] = mapped_column(
String(128), nullable=False, default="siglip2-so400m-patch16-512"
)
# The HF model NAME the embedder loads (server CPU embed + announced to the
# GPU agent in the lease). Operator-settable so the embedder is a choice, not
# a hardcode (#1190): set name + version together, then re-embed + retrain.
embedder_model_name: Mapped[str] = mapped_column(
String(128), nullable=False, default="google/siglip2-so400m-patch16-512"
)
# -- Crop proposers / detectors (#1202, #134) --------------------------
# WHERE-to-crop YOLO detectors feeding the crop→SigLIP bag + CCIP. Config
# lives HERE (DB) and is announced to the GPU agent in the lease — same as
# the embedder model — so it is UI-tunable with NO restart, and the agent's
# env is bootstrap-only. Each weights spec is an ultralytics builtin name,
# an http(s) URL, or "hf_repo::file" (agent's _resolve). enabled off (or an
# empty weights) skips that proposer. All ON by default (operator 2026-07-05)
# so a fresh install crops out-of-the-box.
# person: general COCO figure detector for Western/realistic art the anime
# person-detector misses → NMS-merged with imgutils → CCIP + concept.
detector_person_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
detector_person_weights: Mapped[str] = mapped_column(
String(512), nullable=False, default="yolo11n.pt"
)
detector_person_conf: Mapped[float] = mapped_column(
Float, nullable=False, default=0.35
)
# anatomy: booru_yolo anime/furry/NSFW torso components → concept crops.
# Default = yolov11m_aa22 (26 classes, best mAP50-95 0.96), committed in the
# upstream repo so the URL resolves. License UNSTATED — fine for a private
# homelab (operator accepted #1202).
detector_anatomy_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
detector_anatomy_weights: Mapped[str] = mapped_column(
String(512), nullable=False,
default=(
"https://github.com/aperveyev/booru_yolo/raw/main/models/"
"yolov11m_aa22.pt"
),
)
detector_anatomy_conf: Mapped[float] = mapped_column(
Float, nullable=False, default=0.30
)
# panel: comic page → panel regions → concept crops (Apache-2.0, YOLOv12x).
detector_panel_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
detector_panel_weights: Mapped[str] = mapped_column(
String(512), nullable=False,
default="mosesb/best-comic-panel-detection::best.pt",
)
detector_panel_conf: Mapped[float] = mapped_column(
Float, nullable=False, default=0.30
)
# Per-frame caps bound the crop→embed explosion; max_regions is the hard
# per-job backstop; dedupe_iou drops near-duplicate crops before the embed.
detector_max_figures: Mapped[int] = mapped_column(
Integer, nullable=False, default=8
)
detector_max_components: Mapped[int] = mapped_column(
Integer, nullable=False, default=8
)
detector_max_panels: Mapped[int] = mapped_column(
Integer, nullable=False, default=8
)
detector_max_regions: Mapped[int] = mapped_column(
Integer, nullable=False, default=128
)
detector_dedupe_iou: Mapped[float] = mapped_column(
Float, nullable=False, default=0.85
)
# -- CCIP character prototypes (#1317) ---------------------------------
# The per-character reference set is precomputed + refreshed INCREMENTALLY
# (services.ml.character_prototypes) instead of rebuilt on the request path.
# ccip_ref_signature is the cheap GLOBAL gate — when it's unchanged the
# refresh no-ops; ccip_prototype_cap bounds the reference vectors kept per
# character so MATCH cost doesn't grow with a character's popularity.
ccip_ref_signature: Mapped[str | None] = mapped_column(
String(128), nullable=True
)
ccip_prototype_cap: Mapped[int] = mapped_column(
Integer, nullable=False, default=64
String(128), nullable=False, default="siglip-so400m-patch14-384"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@classmethod
async def load(cls, session) -> MLSettings:
"""The singleton settings row (id=1), via an async session. Mirrors
ImportSettings.load — the shared singleton-loader pattern."""
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
@classmethod
def load_sync(cls, session) -> MLSettings:
"""The singleton settings row (id=1), via a sync session."""
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
@@ -1,45 +0,0 @@
"""PatreonFailedMedia — per-source dead-letter ledger of Patreon media that
keeps failing to download/validate.
Plan #705 (#7). A media that fails every walk (404'd CDN URL, deleted post,
geo-blocked Mux stream, persistently-corrupt bytes) would otherwise re-error
forever and re-burn backfill chunks. After ``attempts`` reaches the dead-letter
threshold the ingester skips it on routine tick/backfill walks (recovery still
re-attempts it — the operator's "try everything again"). A later clean download
clears the row (the media recovered).
`filehash` is the same per-media key the seen-ledger uses (32-hex CDN MD5, or a
``video:`` / ``post:filename`` synthesized key) — hence String(128). UNIQUE
(source_id, filehash) is the upsert 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 PatreonFailedMedia(Base):
__tablename__ = "patreon_failed_media"
__table_args__ = (
UniqueConstraint(
"source_id", "filehash", name="uq_patreon_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)
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()
)
-38
View File
@@ -1,38 +0,0 @@
"""PatreonSeenMedia — per-source ledger of Patreon media already
downloaded+processed.
Replaces gallery-dl's archive.sqlite3 with our own queryable table so
routine walks can skip media we've already ingested (and a future
"recovery" mode can deliberately bypass the ledger to re-walk).
`filehash` is normally a Patreon CDN MD5 (32 hex chars), but videos —
which have no stable content hash at discovery time — use a sentinel of
the form ``video:<post_id>:<media_id>``, hence String(128) rather than 32.
"""
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 PatreonSeenMedia(Base):
__tablename__ = "patreon_seen_media"
__table_args__ = (
# Dedup key the downloader upserts against: one ledger row per
# (source, media). A second sighting of the same media is a no-op.
UniqueConstraint("source_id", "filehash", name="uq_patreon_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()
)
-45
View File
@@ -1,45 +0,0 @@
"""PixivFailedMedia — per-source dead-letter ledger of Pixiv media that keeps
failing to download/validate.
Mirror of PatreonFailedMedia/SubscribeStarFailedMedia. Media that fails every
walk (404'd pximg URL, deleted work, persistently-corrupt bytes) would
otherwise re-error forever and re-burn backfill chunks. After ``attempts``
reaches the dead-letter threshold the ingester skips it on routine
tick/backfill walks (recovery still re-attempts). A later clean download
clears the row.
`filehash` is the same synthesized ``<illust_id>:p<num>`` /
``<illust_id>:ugoira`` key the seen-ledger uses. UNIQUE (source_id, filehash)
is the upsert key.
"""
from datetime import datetime
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 PixivFailedMedia(Base):
__tablename__ = "pixiv_failed_media"
__table_args__ = (
UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_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)
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()
)
-42
View File
@@ -1,42 +0,0 @@
"""PixivSeenMedia — per-source ledger of Pixiv media already
downloaded+processed.
Mirror of PatreonSeenMedia/SubscribeStarSeenMedia for the Pixiv native
ingester (replacing gallery-dl). One queryable row per (source, media) so
routine walks skip media we've already ingested; recovery mode bypasses the
ledger to re-walk.
Pixiv original URLs carry no content hash, so `filehash` is always the
synthesized ``<illust_id>:p<num>`` (page) / ``<illust_id>:ugoira`` (frame
zip) key — stable across any URL-shape drift. String(128) matches the sibling
ledgers.
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import DateTime
from .base import Base
class PixivSeenMedia(Base):
__tablename__ = "pixiv_seen_media"
__table_args__ = (
# Dedup key the downloader upserts against: one ledger row per
# (source, media). A second sighting of the same media is a no-op.
UniqueConstraint(
"source_id", "filehash", name="uq_pixiv_seen_media_source_id"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+5 -63
View File
@@ -1,24 +1,11 @@
"""Post — provenance anchor for one creator post (may contain many images).
"""Post — provenance anchor for content downloaded from a Source.
`source_id` is nullable since alembic 0030 — filesystem-imported posts
with no live subscription have NULL source_id. `artist_id` is the
denormalized always-present link to the creator (added in 0030 so
artist-filter queries don't depend on the Source detour).
A Post is one creator post; it may contain many images/videos.
"""
from datetime import datetime
from sqlalchemy import (
JSON,
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -27,29 +14,12 @@ from .base import Base
class Post(Base):
__tablename__ = "post"
__table_args__ = (
# Source-bound dedup. Postgres treats NULL != NULL so rows
# with source_id IS NULL aren't deduped by this constraint;
# the partial unique index `uq_post_artist_external_id_null_source`
# (created in alembic 0030) covers that case via
# (artist_id, external_post_id).
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
CheckConstraint(
"translation_override IN ('auto', 'force', 'original')",
name="ck_post_translation_override",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int | None] = mapped_column(
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True
)
# Denormalized; always equals source.artist_id when source_id is set
# (the importer is responsible for keeping them consistent on insert).
# Filter queries (artist detail, artist-scoped posts feed) use this
# directly instead of joining through Source.
artist_id: Mapped[int] = mapped_column(
ForeignKey("artist.id", ondelete="CASCADE"),
nullable=False, index=True,
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
external_post_id: Mapped[str] = mapped_column(String(128), nullable=False)
post_url: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -61,34 +31,6 @@ class Post(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- Post-text translation (milestone 143). Filled by the translate_posts
# sweep via the Interpreter LAN service so viewing is instant.
# translated_source_lang is the DETECTED original language; "en" (or a
# passthrough) means nothing to translate and the *_translated columns stay
# NULL. engine_version keys the Interpreter cache — re-runs are ~1ms and a
# model upgrade re-translates instead of serving stale.
post_title_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
description_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
translated_source_lang: Mapped[str | None] = mapped_column(
String(8), nullable=True
)
translation_engine_version: Mapped[str | None] = mapped_column(
String(128), nullable=True
)
translated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Sticky per-post override of the translation decision (milestone 155):
# 'auto' = the acceptance gate decides; 'force' = always store Interpreter's
# translation even below the confidence floor (rescue a skipped legit-foreign
# title); 'original' = never translate, keep the original (kill a confidently
# mis-flagged one the floor can't catch). The sweep reads this on every run,
# and re-translate leaves 'original' posts alone, so the choice survives a
# Re-translate-all.
translation_override: Mapped[str] = mapped_column(
String(16), nullable=False, default="auto", server_default="auto",
)
downloaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+1 -33
View File
@@ -14,12 +14,10 @@ from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
@@ -28,24 +26,6 @@ from .base import Base
class PostAttachment(Base):
__tablename__ = "post_attachment"
# Dedup is PER-POST, not global (2026-06-08): the same non-art file attached
# to many posts gets one row per post over a single sha-addressed blob, so no
# post is left a bare shell. Partial uniques: (post_id, sha256) for real posts;
# (sha256) alone for the NULL-post filesystem case (one row per file there).
__table_args__ = (
Index(
"uq_post_attachment_post_sha",
"post_id", "sha256",
unique=True,
postgresql_where=text("post_id IS NOT NULL"),
),
Index(
"uq_post_attachment_null_post_sha",
"sha256",
unique=True,
postgresql_where=text("post_id IS NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
post_id: Mapped[int | None] = mapped_column(
@@ -55,7 +35,7 @@ class PostAttachment(Base):
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
)
sha256: Mapped[str] = mapped_column(
String(64), nullable=False, index=True
String(64), nullable=False, unique=True, index=True
)
path: Mapped[str] = mapped_column(Text, nullable=False)
original_filename: Mapped[str] = mapped_column(Text, nullable=False)
@@ -65,15 +45,3 @@ class PostAttachment(Base):
captured_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
def attachment_download_url(attachment_id: int) -> str:
"""The path that streams this attachment's bytes.
Both serializers that expose an attachment to the frontend
(`provenance_service`, `post_feed_service`) built this literal themselves,
so changing the route in `api/attachments.py` meant two edits and only one
would be remembered (#3072). `test_attachment_download_url` pins it against
the app's registered rule, so the drift is caught rather than trusted to.
"""
return f"/api/attachments/{attachment_id}/download"

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