Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9075d8eadd | ||
|
|
88e53e5b86 | ||
|
|
37e8b796a1 | ||
|
|
4e82208926 | ||
|
|
52fff00353 | ||
|
|
c14338cbce | ||
|
|
8c36dd28b0 | ||
|
|
88cfb3dd02 | ||
|
|
5d4f223b71 | ||
|
|
05090c6e85 | ||
|
|
3a577d5ade | ||
|
|
f4fe02e346 | ||
|
|
e766197d99 | ||
|
|
3872e1dda9 | ||
|
|
9814f3dbaf | ||
|
|
b214460fdb | ||
|
|
ac55d0e8d8 | ||
|
|
89a89e0ded | ||
|
|
4e9aac2c05 | ||
|
|
2879ac6f2b | ||
|
|
b8dce6c483 | ||
|
|
d1c0b82a22 | ||
|
|
5526b8dc78 | ||
|
|
16eb7075c4 | ||
|
|
885dcf64f3 | ||
|
|
f2f6b6d25e | ||
|
|
0822240fde | ||
|
|
27f7f3fd01 | ||
|
|
c5bf564f53 | ||
|
|
602c7d275d |
@@ -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
|
||||
+102
-1467
File diff suppressed because it is too large
Load Diff
+161
-155
@@ -1,126 +1,19 @@
|
||||
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]
|
||||
# pull_request trigger intentionally absent — with branches: [dev, main]
|
||||
# above, every PR commit already fires CI via the push event on dev. Adding
|
||||
# pull_request would duplicate runs on dev→main PRs. FC has no fork PRs
|
||||
# (single-operator Forgejo repo) so push coverage is complete.
|
||||
|
||||
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,13 +25,6 @@ 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-
|
||||
@@ -165,8 +51,9 @@ jobs:
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
|
||||
# Ruff moved to the dedicated fast `lint` job above (fails in seconds,
|
||||
# no dep install). This job is now unit tests only.
|
||||
- name: Ruff lint
|
||||
run: ruff check backend/ tests/ alembic/
|
||||
|
||||
- name: Pytest (unit only — integration runs in the integration job)
|
||||
run: pytest tests/ -v -m "not integration"
|
||||
|
||||
@@ -184,32 +71,35 @@ 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.
|
||||
# Integration suite split into THREE parallel shards (2026-05-25, runner
|
||||
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service
|
||||
# set and runs alembic + a disjoint subset of integration tests. Shards
|
||||
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
|
||||
# stays single-threaded per shard but multiple shards run in parallel
|
||||
# wall-clock. Approximate split — rebalance once --durations=15 output
|
||||
# reveals which shard is the long pole.
|
||||
#
|
||||
# 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.
|
||||
# Each shard's docker-ps filter uses its own unique job name to scope
|
||||
# service-container resolution. act_runner appears to strip underscores
|
||||
# from job names when building container labels — `int_api` yielded
|
||||
# zero matches on 2026-05-25 — so shards use no-separator names
|
||||
# (`intapi`, `intimp`, `intcore`) instead. Each step prints
|
||||
# `docker ps -a` first so a future naming-convention shift surfaces in
|
||||
# the log without another 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:
|
||||
# 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 project's "add deps to image when used by >1
|
||||
# project" rule keeps the install per-job.
|
||||
|
||||
intapi:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -240,14 +130,14 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Integration suite (resolve service IPs, migrate, test)
|
||||
- name: API integration shard (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)
|
||||
PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intapi" --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")
|
||||
@@ -264,14 +154,130 @@ jobs:
|
||||
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/test_api_*.py -v -m integration --durations=15
|
||||
|
||||
intimp:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
DB_USER: fabledcurator
|
||||
DB_PASSWORD: ci_integration
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: fabledcurator_test
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: fabledcurator
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: fabledcurator_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U fabledcurator"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Importer integration shard (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=intimp" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intimp" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
test -n "$PG" && test -n "$RD"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||
test -n "$PG_IP" && test -n "$RD_IP"
|
||||
export DB_HOST="$PG_IP"
|
||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
||||
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
|
||||
alembic upgrade head
|
||||
pytest tests/test_importer*.py tests/test_import_*.py tests/test_migration_*.py tests/test_phash_*.py tests/test_sidecar_*.py tests/test_scan_*.py tests/test_archive_extractor.py tests/test_backfill_phash.py -v -m integration --durations=15
|
||||
|
||||
intcore:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
DB_USER: fabledcurator
|
||||
DB_PASSWORD: ci_integration
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: fabledcurator_test
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: fabledcurator
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: fabledcurator_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U fabledcurator"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
||||
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=intcore" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intcore" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
test -n "$PG" && test -n "$RD"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||
test -n "$PG_IP" && test -n "$RD_IP"
|
||||
export DB_HOST="$PG_IP"
|
||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
||||
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
|
||||
alembic upgrade head
|
||||
pytest tests/ -v -m integration --durations=15 \
|
||||
--ignore-glob='tests/test_api_*.py' \
|
||||
--ignore-glob='tests/test_importer*.py' \
|
||||
--ignore-glob='tests/test_import_*.py' \
|
||||
--ignore-glob='tests/test_migration_*.py' \
|
||||
--ignore-glob='tests/test_phash_*.py' \
|
||||
--ignore-glob='tests/test_sidecar_*.py' \
|
||||
--ignore-glob='tests/test_scan_*.py' \
|
||||
--ignore-glob='tests/test_archive_extractor.py' \
|
||||
--ignore-glob='tests/test_backfill_phash.py'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: extension
|
||||
# Lint + unit tests. The sign-and-publish dance moved into build.yml's
|
||||
# Lint-only workflow. 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.
|
||||
@@ -10,78 +10,20 @@ on:
|
||||
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
|
||||
image: node:22-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: Install web-ext
|
||||
run: cd extension && npm install --no-save --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."
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.25
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM python:3.14-slim
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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>"""
|
||||
@@ -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()
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 90–95M 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 "?"
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""fc3i: task_run table
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
Create Date: 2026-05-24
|
||||
|
||||
Additive only. New table records every Celery task attempt via signal
|
||||
handlers (backend.app.celery_signals). Status is plain String(16) not
|
||||
Postgres ENUM (per feedback_check_existing_enums: ENUM columns hard-
|
||||
fail at INSERT, String columns extend cleanly).
|
||||
|
||||
Composite indexes anticipate the three dashboard panes:
|
||||
- (queue, started_at desc) — per-lane recent activity
|
||||
- (status, started_at desc) — recent failures pane
|
||||
- (task_name, started_at desc) — drill-down by task
|
||||
|
||||
Indexed columns get individual indexes via `index=True` on the model;
|
||||
the composites below cover the multi-column lookups.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0016"
|
||||
down_revision: Union[str, None] = "0015"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"task_run",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
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,
|
||||
server_default="running",
|
||||
),
|
||||
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),
|
||||
)
|
||||
|
||||
# Single-column indexes (matches Mapped[...].index=True on model).
|
||||
op.create_index("ix_task_run_celery_task_id", "task_run", ["celery_task_id"])
|
||||
op.create_index("ix_task_run_queue", "task_run", ["queue"])
|
||||
op.create_index("ix_task_run_task_name", "task_run", ["task_name"])
|
||||
op.create_index("ix_task_run_started_at", "task_run", ["started_at"])
|
||||
op.create_index("ix_task_run_finished_at", "task_run", ["finished_at"])
|
||||
op.create_index("ix_task_run_status", "task_run", ["status"])
|
||||
|
||||
# Composite indexes for dashboard query patterns.
|
||||
op.create_index(
|
||||
"ix_task_run_queue_started",
|
||||
"task_run", ["queue", sa.text("started_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_task_run_status_started",
|
||||
"task_run", ["status", sa.text("started_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_task_run_name_started",
|
||||
"task_run", ["task_name", sa.text("started_at DESC")],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_task_run_name_started", table_name="task_run")
|
||||
op.drop_index("ix_task_run_status_started", table_name="task_run")
|
||||
op.drop_index("ix_task_run_queue_started", table_name="task_run")
|
||||
op.drop_index("ix_task_run_status", table_name="task_run")
|
||||
op.drop_index("ix_task_run_finished_at", table_name="task_run")
|
||||
op.drop_index("ix_task_run_started_at", table_name="task_run")
|
||||
op.drop_index("ix_task_run_task_name", table_name="task_run")
|
||||
op.drop_index("ix_task_run_queue", table_name="task_run")
|
||||
op.drop_index("ix_task_run_celery_task_id", table_name="task_run")
|
||||
op.drop_table("task_run")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""fc3h: backup_run table
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
Create Date: 2026-05-24
|
||||
|
||||
Additive. New table records every backup/restore attempt with artifact
|
||||
metadata. Lifecycle tracking lives in task_run from FC-3i; this is
|
||||
artifact-only (paths, sizes, tag, restore lineage).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0017"
|
||||
down_revision: Union[str, None] = "0016"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"backup_run",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), nullable=False,
|
||||
server_default="pending",
|
||||
),
|
||||
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(), nullable=False, server_default="{}",
|
||||
),
|
||||
sa.Column(
|
||||
"restored_from_id", sa.Integer(),
|
||||
sa.ForeignKey("backup_run.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Single-column indexes (matches Mapped[...].index=True).
|
||||
op.create_index("ix_backup_run_kind", "backup_run", ["kind"])
|
||||
op.create_index("ix_backup_run_status", "backup_run", ["status"])
|
||||
op.create_index("ix_backup_run_tag", "backup_run", ["tag"])
|
||||
op.create_index("ix_backup_run_started_at", "backup_run", ["started_at"])
|
||||
op.create_index("ix_backup_run_finished_at", "backup_run", ["finished_at"])
|
||||
|
||||
# Composite indexes for dashboard query patterns.
|
||||
op.create_index(
|
||||
"ix_backup_run_kind_started",
|
||||
"backup_run", ["kind", sa.text("started_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_backup_run_status_finished",
|
||||
"backup_run", ["status", sa.text("finished_at DESC")],
|
||||
)
|
||||
# Partial index: only tagged rows participate in retention-exempt query.
|
||||
op.create_index(
|
||||
"ix_backup_run_tag_partial",
|
||||
"backup_run", ["tag"],
|
||||
postgresql_where=sa.text("tag IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_backup_run_tag_partial", table_name="backup_run")
|
||||
op.drop_index("ix_backup_run_status_finished", table_name="backup_run")
|
||||
op.drop_index("ix_backup_run_kind_started", table_name="backup_run")
|
||||
op.drop_index("ix_backup_run_finished_at", table_name="backup_run")
|
||||
op.drop_index("ix_backup_run_started_at", table_name="backup_run")
|
||||
op.drop_index("ix_backup_run_tag", table_name="backup_run")
|
||||
op.drop_index("ix_backup_run_status", table_name="backup_run")
|
||||
op.drop_index("ix_backup_run_kind", table_name="backup_run")
|
||||
op.drop_table("backup_run")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""fc3h: backup_* knobs on import_settings
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
Create Date: 2026-05-24
|
||||
|
||||
Adds four columns to the singleton import_settings row:
|
||||
- backup_db_nightly_enabled (default False — opt-in)
|
||||
- backup_db_nightly_hour_utc (default 3)
|
||||
- backup_db_keep_last_n (default 14)
|
||||
- backup_images_keep_last_n (default 3)
|
||||
|
||||
server_default ensures the singleton row is backfilled in place
|
||||
without an UPDATE statement.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0018"
|
||||
down_revision: Union[str, None] = "0017"
|
||||
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(
|
||||
"backup_db_nightly_enabled", sa.Boolean(),
|
||||
nullable=False, server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"backup_db_nightly_hour_utc", sa.Integer(),
|
||||
nullable=False, server_default="3",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"backup_db_keep_last_n", sa.Integer(),
|
||||
nullable=False, server_default="14",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"backup_images_keep_last_n", sa.Integer(),
|
||||
nullable=False, server_default="3",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("import_settings", "backup_images_keep_last_n")
|
||||
op.drop_column("import_settings", "backup_db_keep_last_n")
|
||||
op.drop_column("import_settings", "backup_db_nightly_hour_utc")
|
||||
op.drop_column("import_settings", "backup_db_nightly_enabled")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""import_batch.refreshed counter for deep-scan sidecar re-application
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
Create Date: 2026-05-25
|
||||
|
||||
Adds a `refreshed` counter to `import_batch`, mirroring the existing
|
||||
`imported`/`skipped`/`failed`/`attachments` columns. Deep scan now
|
||||
re-applies sidecar metadata to already-imported files (the IR feature
|
||||
that didn't make the FC port the first time); a "refreshed" outcome
|
||||
increments this counter so the UI can surface "X new, Y refreshed"
|
||||
instead of the misleading "Scan complete — no new files" message.
|
||||
|
||||
server_default=0 backfills existing rows in place — no UPDATE needed.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0019"
|
||||
down_revision: Union[str, None] = "0018"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"import_batch",
|
||||
sa.Column(
|
||||
"refreshed", sa.Integer(),
|
||||
nullable=False, server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("import_batch", "refreshed")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""fc-cleanup: library_audit_run table for async transparency/single_color audits
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
Create Date: 2026-05-26
|
||||
|
||||
The table backs the async audit lifecycle: rule + params snapshot, status
|
||||
state machine ('running' → 'ready' → 'applied'/'cancelled'/'error'), and
|
||||
the matched_ids JSONB array that the apply step deletes. Capped at 50k IDs
|
||||
per row by the scan task (oversize = rule too aggressive, operator narrows
|
||||
before re-running).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0020"
|
||||
down_revision: Union[str, None] = "0019"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"library_audit_run",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rule", sa.String(32), nullable=False),
|
||||
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column(
|
||||
"status", sa.String(16),
|
||||
nullable=False, server_default="running",
|
||||
),
|
||||
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(
|
||||
"scanned_count", sa.Integer(),
|
||||
nullable=False, server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"matched_count", sa.Integer(),
|
||||
nullable=False, server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"matched_ids", postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False, server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_library_audit_run_rule", "library_audit_run", ["rule"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_library_audit_run_status", "library_audit_run", ["status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_library_audit_run_status", table_name="library_audit_run")
|
||||
op.drop_index("ix_library_audit_run_rule", table_name="library_audit_run")
|
||||
op.drop_table("library_audit_run")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""provenance-race: dedupe + UNIQUE(image_record_id, post_id) on image_provenance
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-05-26
|
||||
|
||||
Closes the race in Importer._apply_sidecar's existence-check + INSERT pattern.
|
||||
Two workers writing for the same (image, post) pair both saw no existing row
|
||||
and both inserted, leaving duplicates that then broke .scalar_one_or_none()
|
||||
on every subsequent deep-scan rederive against those images
|
||||
(MultipleResultsFound). Most plausibly seeded when the 5-min recovery sweep
|
||||
re-enqueued a still-running long-import task and the second worker collided
|
||||
with the first inside _apply_sidecar.
|
||||
|
||||
Migration steps:
|
||||
1. DELETE all but min(id) per (image_record_id, post_id) pair. Operator's
|
||||
DB had 2 affected pairs at write-time; harmless no-op if zero.
|
||||
2. Add UNIQUE constraint so the importer's new savepoint+IntegrityError
|
||||
recovery path can trip on collision and re-select, mirroring
|
||||
uq_source_artist_platform_url and uq_post_source_external_id.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0021"
|
||||
down_revision: Union[str, None] = "0020"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM image_provenance ip1
|
||||
USING image_provenance ip2
|
||||
WHERE ip1.image_record_id = ip2.image_record_id
|
||||
AND ip1.post_id = ip2.post_id
|
||||
AND ip1.id > ip2.id
|
||||
"""
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_image_provenance_image_post",
|
||||
"image_provenance",
|
||||
["image_record_id", "post_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"uq_image_provenance_image_post",
|
||||
"image_provenance",
|
||||
type_="unique",
|
||||
)
|
||||
@@ -0,0 +1,223 @@
|
||||
"""source-collapse: one Source per (artist, platform) — consolidate junk per-post Sources
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
Create Date: 2026-05-26
|
||||
|
||||
Closes the operator-flagged 2026-05-26 issue where the filesystem importer
|
||||
called _find_or_create_source(url=sd.post_url), creating one Source row per
|
||||
imported post URL. Operator's Atole artist had 406 Source rows where there
|
||||
should have been 1 (the /cw/Atole subscription Source).
|
||||
|
||||
Source represents a subscription feed (one per artist+platform — the
|
||||
gallery-dl URL polled by the FC-3 downloader). Posts hang off it. The
|
||||
filesystem importer was misusing Source as a per-post key.
|
||||
|
||||
Migration steps per (artist_id, platform) group with >1 Source:
|
||||
1. Pick canonical — prefer a URL NOT matching '/posts/<id>$' (real
|
||||
campaign URL like /cw/Atole); else min(id).
|
||||
2. PRE-merge any Posts under non-canonical sources whose
|
||||
external_post_id ALREADY exists under the canonical source. (Same
|
||||
gallery-dl post imported via two different sidecar paths can plant
|
||||
two Post rows with identical external_post_id under different
|
||||
Sources for the same artist.) Repoint ImageProvenance +
|
||||
ImageRecord.primary_post_id to the canonical-side Post, dedupe
|
||||
ImageProvenance against alembic 0021's uq, then delete the
|
||||
non-canonical-side Post. This MUST happen before step 3 — Postgres
|
||||
fires uq_post_source_external_id row-by-row during the bulk UPDATE
|
||||
and the merge-after-reparent ordering 500s on first collision
|
||||
(operator-hit during v26.05.26.1 deploy, 2026-05-26).
|
||||
3. Reparent remaining Posts onto canonical (no collisions possible now).
|
||||
4. Reparent ImageProvenance.source_id off the non-canonical sources.
|
||||
5. Delete the orphan Source rows.
|
||||
6. If the canonical Source's URL still looks like a per-post URL (no
|
||||
campaign URL existed among candidates), rewrite it to
|
||||
'sidecar:<platform>:<artist_slug>' so the artist detail page shows
|
||||
something readable.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision: str = "0022"
|
||||
down_revision: Union[str, None] = "0021"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_POST_URL_RE = r"/posts/[^/]+$"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Find (artist_id, platform) groups with > 1 Source row.
|
||||
groups = conn.execute(text("""
|
||||
SELECT artist_id, platform
|
||||
FROM source
|
||||
GROUP BY artist_id, platform
|
||||
HAVING COUNT(*) > 1
|
||||
""")).fetchall()
|
||||
|
||||
for artist_id, platform in groups:
|
||||
rows = conn.execute(
|
||||
text("""
|
||||
SELECT id, url FROM source
|
||||
WHERE artist_id = :a AND platform = :p
|
||||
ORDER BY id ASC
|
||||
"""),
|
||||
{"a": artist_id, "p": platform},
|
||||
).fetchall()
|
||||
|
||||
# Canonical: first row whose URL doesn't look like a per-post URL;
|
||||
# else min(id).
|
||||
canonical_id = None
|
||||
for sid, url in rows:
|
||||
if not _matches_post_url(url):
|
||||
canonical_id = sid
|
||||
break
|
||||
if canonical_id is None:
|
||||
canonical_id = rows[0][0]
|
||||
|
||||
other_ids = [sid for sid, _ in rows if sid != canonical_id]
|
||||
if not other_ids:
|
||||
continue
|
||||
|
||||
# STEP 2: PRE-merge ALL Posts with duplicate external_post_id
|
||||
# across the entire (canonical + others) group, BEFORE the bulk
|
||||
# reparent. Two cases must both be handled:
|
||||
# (A) canonical has Post X with epid=N; an "other" source has
|
||||
# Post Y with epid=N → after bulk UPDATE, (canonical, N)
|
||||
# collides with itself.
|
||||
# (B) two different "other" sources each have a Post with
|
||||
# epid=N; canonical has none → after bulk UPDATE, both
|
||||
# are repointed to (canonical, N) and the second collides.
|
||||
# The earlier version of this migration only handled (A); the
|
||||
# operator's deploy 2026-05-26 tripped (B) at line 139.
|
||||
# Fix: group ALL Posts in the (artist, platform) by epid; for
|
||||
# any group with count>1, pick the keep (prefer one already
|
||||
# under canonical; else lowest id) and merge the rest into it.
|
||||
all_posts = conn.execute(
|
||||
text("""
|
||||
SELECT external_post_id, id, source_id
|
||||
FROM post
|
||||
WHERE source_id = :canonical OR source_id = ANY(:others)
|
||||
ORDER BY external_post_id, id
|
||||
"""),
|
||||
{"canonical": canonical_id, "others": other_ids},
|
||||
).fetchall()
|
||||
by_epid: dict = {}
|
||||
for epid, post_id, src_id in all_posts:
|
||||
by_epid.setdefault(epid, []).append((post_id, src_id))
|
||||
for _epid, posts in by_epid.items():
|
||||
if len(posts) <= 1:
|
||||
continue
|
||||
# Prefer a Post already under canonical as the keep.
|
||||
canonical_posts = [p for p in posts if p[1] == canonical_id]
|
||||
if canonical_posts:
|
||||
keep_id = canonical_posts[0][0]
|
||||
else:
|
||||
keep_id = posts[0][0] # already sorted by id ASC
|
||||
drop_ids = [p[0] for p in posts if p[0] != keep_id]
|
||||
for drop_id in drop_ids:
|
||||
# Pre-delete image_provenance rows under drop_ whose
|
||||
# image_record_id ALREADY has a provenance under keep —
|
||||
# the UPDATE below would otherwise repoint them and
|
||||
# trip uq_image_provenance_image_post (alembic 0021)
|
||||
# row-by-row before any after-the-fact dedupe could
|
||||
# run. Operator's v26.05.26.3 deploy 2026-05-26 tripped
|
||||
# this at line 123.
|
||||
conn.execute(
|
||||
text("""
|
||||
DELETE FROM image_provenance
|
||||
WHERE post_id = :drop_
|
||||
AND image_record_id IN (
|
||||
SELECT image_record_id FROM image_provenance
|
||||
WHERE post_id = :keep
|
||||
)
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
# Now safe to repoint the survivors.
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_provenance SET post_id = :keep
|
||||
WHERE post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_record SET primary_post_id = :keep
|
||||
WHERE primary_post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("DELETE FROM post WHERE id = :drop_"),
|
||||
{"drop_": drop_id},
|
||||
)
|
||||
|
||||
# STEP 3: Bulk reparent the remaining Posts off the other
|
||||
# Sources. After step 2, no collisions on
|
||||
# (canonical, external_post_id) are possible.
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE post SET source_id = :canonical
|
||||
WHERE source_id = ANY(:others)
|
||||
"""),
|
||||
{"canonical": canonical_id, "others": other_ids},
|
||||
)
|
||||
|
||||
# STEP 4: Reparent ImageProvenance.source_id (denormalized FK).
|
||||
# No UNIQUE on source_id; safe bulk update.
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_provenance SET source_id = :canonical
|
||||
WHERE source_id = ANY(:others)
|
||||
"""),
|
||||
{"canonical": canonical_id, "others": other_ids},
|
||||
)
|
||||
|
||||
# STEP 5: Drop the orphan Sources.
|
||||
conn.execute(
|
||||
text("DELETE FROM source WHERE id = ANY(:others)"),
|
||||
{"others": other_ids},
|
||||
)
|
||||
|
||||
# If the canonical's URL still looks per-post (no campaign URL
|
||||
# existed among the candidates), rewrite to a synthetic anchor so
|
||||
# the artist detail page renders something readable.
|
||||
canonical_url = conn.execute(
|
||||
text("SELECT url FROM source WHERE id = :id"),
|
||||
{"id": canonical_id},
|
||||
).scalar_one()
|
||||
if _matches_post_url(canonical_url):
|
||||
slug = conn.execute(
|
||||
text("SELECT slug FROM artist WHERE id = :id"),
|
||||
{"id": artist_id},
|
||||
).scalar_one()
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE source
|
||||
SET url = :new_url, enabled = false
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": canonical_id,
|
||||
"new_url": f"sidecar:{platform}:{slug}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Lossy migration — orphan Sources deleted, Posts reparented, Posts
|
||||
# merged. No safe downgrade. If you need to roll back the schema
|
||||
# invariant, fork from 0021 and re-run filesystem imports.
|
||||
pass
|
||||
|
||||
|
||||
def _matches_post_url(url: str) -> bool:
|
||||
"""True if url ends with /posts/<token> (gallery-dl-style per-post URL)."""
|
||||
import re
|
||||
return bool(re.search(_POST_URL_RE, url or ""))
|
||||
@@ -0,0 +1,99 @@
|
||||
"""drop meta + rating tag kinds — operator-retired 2026-05-26
|
||||
|
||||
Revision ID: 0023
|
||||
Revises: 0022
|
||||
Create Date: 2026-05-26
|
||||
|
||||
Operator decided meta + rating aren't valid tag kinds for FC. Per-row
|
||||
behavior: DELETE existing rows (operator chose "clean break" over
|
||||
"convert to general"). All cascading FKs (image_tag, tag_alias,
|
||||
tag_allowlist, tag_reference_embedding, tag_suggestion_rejection,
|
||||
series_page) use ondelete="CASCADE" so a single DELETE on tag cleans
|
||||
the related rows in one go.
|
||||
|
||||
After the data cleanup, recreate the tag_kind ENUM without 'meta' /
|
||||
'rating' (Postgres has no `ALTER TYPE ... DROP VALUE`; standard
|
||||
rename-create-cast-drop dance). The server default 'general' is
|
||||
dropped before the type swap and restored after.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0023"
|
||||
down_revision: Union[str, None] = "0022"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Delete tags of the retired kinds. CASCADE handles related tables.
|
||||
op.execute("DELETE FROM tag WHERE kind IN ('meta', 'rating')")
|
||||
|
||||
# 2. Drop the CHECK constraint that references the enum's literal
|
||||
# values. Postgres can't resolve `kind = 'character'` across the
|
||||
# type swap below — the literal would bind to the new tag_kind
|
||||
# but the column is on tag_kind_old, producing
|
||||
# "operator does not exist: tag_kind = tag_kind_old".
|
||||
# (Operator-hit during the v26.05.26.5 deploy attempt; ck was
|
||||
# originally added by alembic 0002.) Recreated post-swap.
|
||||
op.drop_constraint(
|
||||
"ck_tag_fandom_requires_character", "tag", type_="check"
|
||||
)
|
||||
|
||||
# 3. Drop the server default — ALTER COLUMN TYPE can't carry it
|
||||
# across the type swap below.
|
||||
op.execute("ALTER TABLE tag ALTER COLUMN kind DROP DEFAULT")
|
||||
|
||||
# 4. Recreate the tag_kind enum without meta/rating.
|
||||
op.execute("ALTER TYPE tag_kind RENAME TO tag_kind_old")
|
||||
op.execute(
|
||||
"CREATE TYPE tag_kind AS ENUM ("
|
||||
"'artist', 'character', 'fandom', 'general', "
|
||||
"'series', 'archive', 'post'"
|
||||
")"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE tag "
|
||||
"ALTER COLUMN kind TYPE tag_kind "
|
||||
"USING kind::text::tag_kind"
|
||||
)
|
||||
op.execute("DROP TYPE tag_kind_old")
|
||||
|
||||
# 5. Restore the server default.
|
||||
op.execute("ALTER TABLE tag ALTER COLUMN kind SET DEFAULT 'general'")
|
||||
|
||||
# 6. Restore the CHECK constraint (now bound to the new tag_kind).
|
||||
op.create_check_constraint(
|
||||
"ck_tag_fandom_requires_character",
|
||||
"tag",
|
||||
"(fandom_id IS NULL) OR (kind = 'character')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Add the values back to the enum so old code can boot. The deleted
|
||||
# tag rows are gone permanently — no safe restore.
|
||||
op.drop_constraint(
|
||||
"ck_tag_fandom_requires_character", "tag", type_="check"
|
||||
)
|
||||
op.execute("ALTER TABLE tag ALTER COLUMN kind DROP DEFAULT")
|
||||
op.execute("ALTER TYPE tag_kind RENAME TO tag_kind_old")
|
||||
op.execute(
|
||||
"CREATE TYPE tag_kind AS ENUM ("
|
||||
"'artist', 'character', 'fandom', 'general', "
|
||||
"'series', 'archive', 'post', 'meta', 'rating'"
|
||||
")"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE tag "
|
||||
"ALTER COLUMN kind TYPE tag_kind "
|
||||
"USING kind::text::tag_kind"
|
||||
)
|
||||
op.execute("DROP TYPE tag_kind_old")
|
||||
op.execute("ALTER TABLE tag ALTER COLUMN kind SET DEFAULT 'general'")
|
||||
op.create_check_constraint(
|
||||
"ck_tag_fandom_requires_character",
|
||||
"tag",
|
||||
"(fandom_id IS NULL) OR (kind = 'character')",
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""backfill post.post_title from description first-line — 2026-05-27
|
||||
|
||||
Revision ID: 0024
|
||||
Revises: 0023
|
||||
Create Date: 2026-05-27
|
||||
|
||||
SubscribeStar gallery-dl always writes `title: ""` and embeds the leading
|
||||
sentence inside `content` HTML. FC's sidecar parser was leaving
|
||||
post_title NULL for every SubscribeStar post since FC-3 shipped. The
|
||||
parser fix (sidecar._first_line_text fallback) now synthesizes a title
|
||||
at parse time; this migration applies the same logic retroactively to
|
||||
existing rows.
|
||||
|
||||
Operator-flagged 2026-05-27 after inspecting
|
||||
/mnt/Data/Patreon/Cheunart/subscribestar/ sidecars.
|
||||
|
||||
Idempotent: only touches rows where post_title IS NULL or empty AND
|
||||
description IS NOT NULL. Re-running the migration is a no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision: str = "0024"
|
||||
down_revision: Union[str, None] = "0023"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _first_line_text(body: str, limit: int = 120) -> str | None:
|
||||
"""Mirror of sidecar._first_line_text. Kept inline so the migration
|
||||
doesn't carry a runtime import dependency from app code that may
|
||||
have moved by the time the migration is replayed years from now."""
|
||||
if not body:
|
||||
return None
|
||||
text_ = _TAG_RE.sub(" ", body)
|
||||
text_ = text_.replace("\xa0", " ")
|
||||
for line in text_.splitlines():
|
||||
line = _WS_RE.sub(" ", line).strip()
|
||||
if line:
|
||||
if len(line) > limit:
|
||||
return line[: limit - 1].rstrip() + "…"
|
||||
return line
|
||||
return None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
text(
|
||||
"SELECT id, description FROM post "
|
||||
"WHERE (post_title IS NULL OR post_title = '') "
|
||||
"AND description IS NOT NULL AND description <> ''"
|
||||
)
|
||||
).fetchall()
|
||||
updated = 0
|
||||
for row in rows:
|
||||
derived = _first_line_text(row.description)
|
||||
if not derived:
|
||||
continue
|
||||
bind.execute(
|
||||
text("UPDATE post SET post_title = :t WHERE id = :id"),
|
||||
{"t": derived, "id": row.id},
|
||||
)
|
||||
updated += 1
|
||||
print(f"0024: backfilled post_title on {updated} row(s)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No safe restore — we can't tell which post_titles were derived vs
|
||||
# genuinely present. Leave the column alone on rollback.
|
||||
pass
|
||||
@@ -0,0 +1,288 @@
|
||||
"""sidecar-audit followup: correct external_post_id + post_url across all platforms
|
||||
|
||||
Revision ID: 0025
|
||||
Revises: 0024
|
||||
Create Date: 2026-05-27
|
||||
|
||||
Closes the operator-flagged 2026-05-27 sidecar audit findings. Three
|
||||
data-correctness bugs across non-Patreon platforms had been silently
|
||||
corrupting Posts since FC-3 shipped; the parser fix (sidecar.py, same
|
||||
commit) addresses new imports. This migration cleans up existing rows.
|
||||
|
||||
Per-platform actions:
|
||||
|
||||
subscribestar — gallery-dl wrote the per-attachment id in `id` and
|
||||
the actual post id in `post_id`. FC's parser picked `id`, so every
|
||||
multi-image SubscribeStar post was fragmented into N Post rows.
|
||||
1. For each SubscribeStar Post, read its sidecar (via the related
|
||||
ImageRecord's on-disk path), pull `post_id`, overwrite
|
||||
external_post_id and post_url.
|
||||
2. Merge groups of Posts under one source that now share an
|
||||
external_post_id (fragments of the same actual post). Same
|
||||
ImageProvenance pre-delete + repoint dance as alembic 0022.
|
||||
|
||||
hentaifoundry — sidecars have NO `url` field; `src` is the image
|
||||
URL. FC's parser stored post_url=NULL. Read each HF Post's sidecar
|
||||
for `user` + `index`, derive the canonical /pictures/user/<u>/<i>
|
||||
permalink. external_post_id (= `index`) was already correct.
|
||||
|
||||
discord — gallery-dl wrote the CDN attachment URL in `url`. FC's
|
||||
parser stored that as post_url. Read each Discord Post's sidecar
|
||||
for the server/channel/message triple, derive the proper
|
||||
discord.com/channels/.../<message> permalink. external_post_id (=
|
||||
`message_id`) was already correct.
|
||||
|
||||
pixiv — pure-SQL backfill: replace any `i.pximg.net`-style URL on
|
||||
Post.post_url with the derived `/artworks/<id>` permalink. Pixiv
|
||||
external_post_id (= `id`) was already correct; no sidecar IO
|
||||
needed.
|
||||
|
||||
Idempotent: re-running on already-corrected data is a no-op (skips
|
||||
rows whose derived value matches what's already stored).
|
||||
|
||||
Posts whose related ImageRecord paths don't resolve on disk (orphaned
|
||||
filesystem state) are skipped with a count in the migration output —
|
||||
those will be picked up by a future deep-scan.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision: str = "0025"
|
||||
down_revision: Union[str, None] = "0024"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# Mirror of sidecar._NUMBERING_PREFIX. Kept inline so the migration is
|
||||
# self-contained (the operator's banked rule:
|
||||
# reference_postgres_enum_swap_drop_checks.md says migrations shouldn't
|
||||
# import from runtime app code).
|
||||
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")
|
||||
|
||||
|
||||
def _find_sidecar(media_path: Path) -> Path | None:
|
||||
"""gallery-dl writes the sidecar under the unprefixed stem
|
||||
(`HOLLOW-ICHIGO.json`) while the media file gets a NN_ ordering
|
||||
prefix (`01_HOLLOW-ICHIGO.png`). Try in order:
|
||||
1. <stem>.json next to the media
|
||||
2. <media>.json next to the media (full-name variant)
|
||||
3. strip the NN_ prefix from the stem, then <stripped>.json
|
||||
"""
|
||||
if not media_path:
|
||||
return None
|
||||
cand = media_path.with_suffix(".json")
|
||||
if cand.is_file():
|
||||
return cand
|
||||
cand = media_path.parent / f"{media_path.name}.json"
|
||||
if cand.is_file():
|
||||
return cand
|
||||
m = _NUMBERING_PREFIX.match(media_path.stem)
|
||||
if m:
|
||||
cand = media_path.parent / f"{m.group(1)}.json"
|
||||
if cand.is_file():
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def _str_id(v) -> str | None:
|
||||
"""str() a JSON scalar id; reject bool (JSON booleans are ints in
|
||||
Python's eyes but they aren't valid sidecar ids)."""
|
||||
if isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, (str, int)) and str(v).strip():
|
||||
return str(v).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _str_field(v) -> str | None:
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── PART 1: Per-platform corrections requiring filesystem IO ─────
|
||||
# SubscribeStar, HentaiFoundry, Discord all need fields from the
|
||||
# sidecar to construct the right post_url. We walk each Post's
|
||||
# related ImageRecord.path to find the sidecar, read it, derive,
|
||||
# and update.
|
||||
targets = conn.execute(text("""
|
||||
SELECT p.id, p.external_post_id, p.post_url, s.platform
|
||||
FROM post p
|
||||
JOIN source s ON s.id = p.source_id
|
||||
WHERE s.platform IN ('subscribestar', 'hentaifoundry', 'discord')
|
||||
""")).fetchall()
|
||||
|
||||
stats: dict[str, dict[str, int]] = {
|
||||
plat: {"read": 0, "updated": 0, "no_sidecar": 0}
|
||||
for plat in ("subscribestar", "hentaifoundry", "discord")
|
||||
}
|
||||
for post_row in targets:
|
||||
plat = post_row.platform
|
||||
path = _first_attachment_path(conn, post_row.id)
|
||||
if not path:
|
||||
stats[plat]["no_sidecar"] += 1
|
||||
continue
|
||||
sidecar = _find_sidecar(Path(path))
|
||||
if sidecar is None:
|
||||
stats[plat]["no_sidecar"] += 1
|
||||
continue
|
||||
try:
|
||||
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
stats[plat]["no_sidecar"] += 1
|
||||
continue
|
||||
stats[plat]["read"] += 1
|
||||
|
||||
new_epid = post_row.external_post_id
|
||||
new_url = None
|
||||
if plat == "subscribestar":
|
||||
pid = _str_id(data.get("post_id"))
|
||||
if pid:
|
||||
new_epid = pid
|
||||
new_url = f"https://www.subscribestar.com/posts/{pid}"
|
||||
elif plat == "hentaifoundry":
|
||||
user = _str_field(data.get("user")) or _str_field(data.get("artist"))
|
||||
idx = _str_id(data.get("index"))
|
||||
if user and idx:
|
||||
new_url = f"https://www.hentai-foundry.com/pictures/user/{user}/{idx}"
|
||||
elif plat == "discord":
|
||||
sid = _str_id(data.get("server_id"))
|
||||
cid = _str_id(data.get("channel_id"))
|
||||
mid = _str_id(data.get("message_id"))
|
||||
if sid and cid and mid:
|
||||
new_url = f"https://discord.com/channels/{sid}/{cid}/{mid}"
|
||||
|
||||
# Idempotent: skip if nothing changed.
|
||||
if new_epid == post_row.external_post_id and new_url == post_row.post_url:
|
||||
continue
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE post
|
||||
SET external_post_id = :epid, post_url = :url
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"epid": new_epid, "url": new_url, "id": post_row.id},
|
||||
)
|
||||
stats[plat]["updated"] += 1
|
||||
|
||||
for plat, s in stats.items():
|
||||
print(
|
||||
f"0025: {plat} — read {s['read']} sidecars, "
|
||||
f"updated {s['updated']} Posts, "
|
||||
f"{s['no_sidecar']} Posts had no resolvable sidecar"
|
||||
)
|
||||
|
||||
# ── PART 2: Merge SubscribeStar fragments now sharing epid ───────
|
||||
# After Part 1, each group of Posts under one source with the SAME
|
||||
# new external_post_id is a fragment-set of the same actual post.
|
||||
# Merge to one canonical row. Pre-handle the same ImageProvenance
|
||||
# collision pattern as alembic 0022 (uq_image_provenance_image_post).
|
||||
fragment_groups = conn.execute(text("""
|
||||
SELECT p.source_id, p.external_post_id,
|
||||
ARRAY_AGG(p.id ORDER BY p.id ASC) AS post_ids
|
||||
FROM post p
|
||||
JOIN source s ON s.id = p.source_id
|
||||
WHERE s.platform = 'subscribestar'
|
||||
AND p.external_post_id IS NOT NULL
|
||||
GROUP BY p.source_id, p.external_post_id
|
||||
HAVING COUNT(*) > 1
|
||||
""")).fetchall()
|
||||
|
||||
merged = 0
|
||||
for grp in fragment_groups:
|
||||
post_ids = list(grp.post_ids)
|
||||
keep_id, *drop_ids = post_ids
|
||||
for drop_id in drop_ids:
|
||||
# Pre-DELETE colliding ImageProvenance under drop_ that
|
||||
# already exist under keep (alembic 0022 banked the pattern).
|
||||
conn.execute(
|
||||
text("""
|
||||
DELETE FROM image_provenance
|
||||
WHERE post_id = :drop_
|
||||
AND image_record_id IN (
|
||||
SELECT image_record_id FROM image_provenance
|
||||
WHERE post_id = :keep
|
||||
)
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_provenance SET post_id = :keep
|
||||
WHERE post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_record SET primary_post_id = :keep
|
||||
WHERE primary_post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE post_attachment SET post_id = :keep
|
||||
WHERE post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("DELETE FROM post WHERE id = :drop_"),
|
||||
{"drop_": drop_id},
|
||||
)
|
||||
merged += 1
|
||||
print(f"0025: subscribestar — merged {merged} duplicate Post fragments")
|
||||
|
||||
# ── PART 3: Pixiv post_url backfill (pure SQL) ───────────────────
|
||||
# Pixiv's external_post_id is already correct (gallery-dl's `id` is
|
||||
# the post id). Only post_url needs derivation: replace anything
|
||||
# under i.pximg.net (the file URL) with the /artworks/<id> permalink.
|
||||
pixiv_updated = conn.execute(text("""
|
||||
UPDATE post p
|
||||
SET post_url = 'https://www.pixiv.net/artworks/' || p.external_post_id
|
||||
FROM source s
|
||||
WHERE p.source_id = s.id
|
||||
AND s.platform = 'pixiv'
|
||||
AND p.external_post_id IS NOT NULL
|
||||
AND (p.post_url IS NULL
|
||||
OR p.post_url LIKE 'https://i.pximg.net/%'
|
||||
OR p.post_url LIKE 'http://i.pximg.net/%')
|
||||
""")).rowcount
|
||||
print(f"0025: pixiv — backfilled post_url on {pixiv_updated} Posts")
|
||||
|
||||
|
||||
def _first_attachment_path(conn, post_id: int) -> str | None:
|
||||
"""Return any ImageRecord.path attached to this post (via
|
||||
ImageProvenance). Lowest-id row keeps the migration deterministic
|
||||
so re-running on the same DB picks the same sidecar."""
|
||||
row = conn.execute(
|
||||
text("""
|
||||
SELECT ir.path
|
||||
FROM image_provenance ip
|
||||
JOIN image_record ir ON ir.id = ip.image_record_id
|
||||
WHERE ip.post_id = :pid
|
||||
ORDER BY ip.id ASC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"pid": post_id},
|
||||
).first()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Lossy: external_post_id values were overwritten with the correct
|
||||
# post_id; original per-attachment ids weren't preserved. Post-merge
|
||||
# also deleted drop rows. No safe restore. To roll back the schema
|
||||
# invariant, fork from 0024 and re-run sidecar imports.
|
||||
pass
|
||||
@@ -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')
|
||||
+6
-17
@@ -33,23 +33,12 @@ 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 — 30–58s 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)
|
||||
|
||||
@@ -16,18 +16,17 @@ 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
|
||||
@@ -55,11 +54,10 @@ def all_blueprints() -> list[Blueprint]:
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
+13
-327
@@ -1,13 +1,11 @@
|
||||
"""FC-3k: /api/admin — destructive admin actions.
|
||||
|
||||
Action surfaces:
|
||||
Five 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,
|
||||
@@ -20,16 +18,21 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import Artist, Post
|
||||
from ..models import Artist
|
||||
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 _bad(error: str, *, status: int = 400, **extra):
|
||||
body = {"error": error}
|
||||
body.update(extra)
|
||||
return jsonify(body), status
|
||||
|
||||
|
||||
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
|
||||
@@ -39,31 +42,6 @@ def _bulk_image_confirm_token(image_ids: list[int]) -> str:
|
||||
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 {}
|
||||
@@ -156,10 +134,6 @@ async def tag_delete(tag_id: int):
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@@ -175,30 +149,6 @@ async def tag_merge(dest_id: int):
|
||||
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(
|
||||
@@ -246,277 +196,13 @@ async def tags_prune_unused():
|
||||
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)
|
||||
lambda sync_sess: prune_unused_tags(
|
||||
sync_sess, dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -31,13 +31,18 @@ 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 _bad(error: str, *, status: int = 400, **extra):
|
||||
body = {"error": error}
|
||||
body.update(extra)
|
||||
return jsonify(body), status
|
||||
|
||||
|
||||
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.
|
||||
@@ -154,15 +159,12 @@ async def audit_history():
|
||||
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()
|
||||
rows = (await session.execute(
|
||||
select(LibraryAuditRun)
|
||||
.order_by(LibraryAuditRun.id.desc())
|
||||
.limit(limit)
|
||||
)).scalars().all()
|
||||
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
||||
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -44,9 +44,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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -129,54 +126,6 @@ async def downloads_stats():
|
||||
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 +139,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
|
||||
|
||||
@@ -6,14 +6,12 @@ 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 (
|
||||
@@ -22,7 +20,6 @@ from ..services.extension_service import (
|
||||
UnknownPlatformError,
|
||||
)
|
||||
from ..services.source_service import KNOWN_PLATFORMS
|
||||
from ._responses import error_response as _bad
|
||||
|
||||
extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension")
|
||||
|
||||
@@ -32,14 +29,11 @@ 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
|
||||
|
||||
def _bad(error: str, *, status: int = 400, **extra):
|
||||
body = {"error": error}
|
||||
body.update(extra)
|
||||
return jsonify(body), status
|
||||
|
||||
|
||||
async def _ext_key_required(session) -> bool:
|
||||
@@ -52,15 +46,7 @@ async def _ext_key_required(session) -> bool:
|
||||
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"))
|
||||
return stored is not None and supplied == stored
|
||||
|
||||
|
||||
def _extract_version(xpi_name: str) -> str:
|
||||
@@ -76,24 +62,6 @@ def _sha256(path: Path) -> str:
|
||||
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)
|
||||
@@ -103,15 +71,11 @@ async def quick_add_source():
|
||||
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)
|
||||
result = await ExtensionService(session).quick_add_source(url)
|
||||
except UnknownPlatformError as exc:
|
||||
return _bad(
|
||||
"unknown_platform",
|
||||
@@ -143,30 +107,13 @@ def _read_manifest_sync() -> dict | None:
|
||||
return None
|
||||
versioned.sort(key=lambda p: p.stat().st_mtime)
|
||||
latest = versioned[-1]
|
||||
info = {
|
||||
return {
|
||||
"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"])
|
||||
|
||||
+43
-279
@@ -1,131 +1,54 @@
|
||||
"""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(),
|
||||
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
||||
"effective_date": i.effective_date.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 +57,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 +78,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:
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
],
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -130,53 +114,18 @@ async def retry_failed():
|
||||
status="queued", error=None,
|
||||
started_at=None, finished_at=None,
|
||||
)
|
||||
.returning(ImportTask.id, ImportTask.task_type)
|
||||
.returning(ImportTask.id)
|
||||
)
|
||||
failed = result.all()
|
||||
if not failed:
|
||||
failed_ids = [row[0] for row in result.all()]
|
||||
if not failed_ids:
|
||||
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)
|
||||
from ..tasks.import_file import import_media_file
|
||||
for tid in failed_ids:
|
||||
import_media_file.delay(tid)
|
||||
|
||||
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))
|
||||
return jsonify({"retried": len(failed_ids)})
|
||||
|
||||
|
||||
@import_admin_bp.route("/clear-stuck", methods=["POST"])
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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. Backup + rollback
|
||||
were retired in FC-3h (2026-05-24); use /api/system/backup/* instead.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
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")
|
||||
|
||||
# 'backup' + 'rollback' retired 2026-05-24 (FC-3h); see /api/system/backup/*.
|
||||
_VALID_KINDS = frozenset({
|
||||
"gs_ingest", "ir_ingest", "tag_apply",
|
||||
"ml_queue", "verify", "cleanup",
|
||||
})
|
||||
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
|
||||
|
||||
|
||||
def _bad(error: str, *, status: int = 400, **extra):
|
||||
body = {"error": error}
|
||||
body.update(extra)
|
||||
return jsonify(body), status
|
||||
|
||||
|
||||
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")
|
||||
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))
|
||||
params = dict(body)
|
||||
|
||||
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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
+23
-254
@@ -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, Artist, 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:
|
||||
@@ -310,159 +235,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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -20,7 +20,6 @@ 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",
|
||||
@@ -31,7 +30,7 @@ system_activity_bp = Blueprint(
|
||||
# absent.
|
||||
_QUEUE_NAMES = (
|
||||
"default", "import", "thumbnail", "ml",
|
||||
"download", "scan", "maintenance", "maintenance_long",
|
||||
"download", "scan", "maintenance",
|
||||
)
|
||||
|
||||
# Cache module-level so all requests share the cache between polls.
|
||||
@@ -82,22 +81,17 @@ def _read_workers_sync() -> dict:
|
||||
}
|
||||
|
||||
|
||||
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())
|
||||
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 jsonify(_QUEUE_CACHE["data"])
|
||||
|
||||
|
||||
@system_activity_bp.route("/workers", methods=["GET"])
|
||||
@@ -113,41 +107,11 @@ async def get_workers():
|
||||
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
|
||||
|
||||
@@ -162,7 +126,6 @@ async def list_runs():
|
||||
|
||||
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
|
||||
|
||||
@@ -172,11 +135,6 @@ async def list_runs():
|
||||
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)
|
||||
@@ -232,12 +190,6 @@ async def list_failures():
|
||||
})
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -14,7 +14,6 @@ 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",
|
||||
@@ -30,6 +29,12 @@ _BACKUP_SETTINGS_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
def _bad(error: str, *, status: int = 400, **extra):
|
||||
body = {"error": error}
|
||||
body.update(extra)
|
||||
return jsonify(body), status
|
||||
|
||||
|
||||
def _row_to_dict(r: BackupRun) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
@@ -227,7 +232,9 @@ async def delete_run(run_id: int):
|
||||
@system_backup_bp.route("/settings", methods=["GET"])
|
||||
async def get_settings():
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
row = (await session.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
)).scalar_one()
|
||||
return jsonify({
|
||||
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
|
||||
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
|
||||
@@ -247,7 +254,9 @@ async def patch_settings():
|
||||
return err
|
||||
|
||||
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 _BACKUP_SETTINGS_FIELDS:
|
||||
if field in body:
|
||||
setattr(row, field, body[field])
|
||||
|
||||
+45
-461
@@ -1,26 +1,19 @@
|
||||
"""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
|
||||
|
||||
@@ -63,117 +56,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 +70,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
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -249,11 +141,6 @@ async def create_tag():
|
||||
|
||||
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 +158,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 +194,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
|
||||
)
|
||||
tag = await svc.rename(tag_id, body["name"])
|
||||
except TagMergeConflict as exc:
|
||||
return jsonify(
|
||||
{
|
||||
@@ -402,13 +219,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 +238,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 +320,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 +360,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 +391,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})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Thumbnail admin API: backfill trigger."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
|
||||
@@ -9,20 +7,7 @@ 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).
|
||||
from ..tasks.thumbnail import backfill_thumbnails
|
||||
|
||||
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
|
||||
r = backfill_thumbnails.delay()
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
@@ -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()
|
||||
+14
-205
@@ -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,12 @@ 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 +41,19 @@ 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 (~1–6s/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"},
|
||||
"backend.app.tasks.backup.*": {"queue": "maintenance"},
|
||||
"backend.app.tasks.admin.*": {"queue": "maintenance"},
|
||||
"backend.app.tasks.library_audit.*": {"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 +63,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,10 +87,6 @@ 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
|
||||
@@ -224,10 +95,6 @@ def make_celery() -> Celery:
|
||||
"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
|
||||
@@ -236,64 +103,6 @@ def make_celery() -> Celery:
|
||||
"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",
|
||||
)
|
||||
|
||||
@@ -54,14 +54,7 @@ _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.
|
||||
"""
|
||||
Keep in sync if task_routes is reordered."""
|
||||
name = getattr(task, "name", "") or ""
|
||||
if name.startswith("backend.app.tasks.import_file."):
|
||||
return "import"
|
||||
@@ -69,23 +62,13 @@ def _queue_for(task) -> str:
|
||||
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.",
|
||||
)):
|
||||
if name.startswith("backend.app.tasks.download."):
|
||||
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.",
|
||||
"backend.app.tasks.migration.",
|
||||
)):
|
||||
return "maintenance"
|
||||
return "default"
|
||||
|
||||
@@ -2,43 +2,26 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -46,46 +29,27 @@ __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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -34,22 +34,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,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,
|
||||
|
||||
@@ -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,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
|
||||
@@ -63,83 +63,3 @@ class ImportSettings(Base):
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -35,10 +35,3 @@ class LibraryAuditRun(Base):
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user