Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
|
||||||
+59
-1498
File diff suppressed because it is too large
Load Diff
+186
-165
@@ -1,126 +1,17 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
|
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
|
||||||
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
# - backend-lint-and-test: ruff + `pytest -m "not integration"`, no service containers.
|
||||||
# - extension-version: the derived version resolves and is a shape AMO takes.
|
|
||||||
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
|
|
||||||
# - frontend-build: vitest unit + vite build.
|
# - frontend-build: vitest unit + vite build.
|
||||||
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
|
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
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:
|
pull_request:
|
||||||
branches: [dev]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
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:
|
backend-lint-and-test:
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
@@ -132,25 +23,14 @@ jobs:
|
|||||||
SECRET_KEY: ci_unit_test_placeholder
|
SECRET_KEY: ci_unit_test_placeholder
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Cache pip wheels
|
||||||
# broken on this homelab runner since 2026-05-15 (first as request-
|
uses: actions/cache@v4
|
||||||
# timeout warnings, then as hard "Cannot find module .../dist/restore/
|
with:
|
||||||
# index.js" failures that tank the whole job). The cache step targeted
|
path: ~/.cache/pip
|
||||||
# ~/.cache/pip but the install below uses `uv pip install` primarily,
|
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||||
# whose own cache lives at ~/.cache/uv — so the cache step's real
|
restore-keys: |
|
||||||
# benefit was marginal even when working. Cost of removal: ~30s of
|
pip-${{ runner.os }}-py314-
|
||||||
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
|
|
||||||
# docker volume at the runner level (skips actions/cache entirely),
|
|
||||||
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
|
|
||||||
# pin act_runner version, etc.).
|
|
||||||
|
|
||||||
- name: Install Python deps
|
- name: Install Python deps
|
||||||
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
||||||
@@ -165,8 +45,9 @@ jobs:
|
|||||||
pip install -r requirements.txt pytest pytest-asyncio
|
pip install -r requirements.txt pytest pytest-asyncio
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ruff moved to the dedicated fast `lint` job above (fails in seconds,
|
- name: Ruff lint
|
||||||
# no dep install). This job is now unit tests only.
|
run: ruff check backend/ tests/ alembic/
|
||||||
|
|
||||||
- name: Pytest (unit only — integration runs in the integration job)
|
- name: Pytest (unit only — integration runs in the integration job)
|
||||||
run: pytest tests/ -v -m "not integration"
|
run: pytest tests/ -v -m "not integration"
|
||||||
|
|
||||||
@@ -184,32 +65,35 @@ jobs:
|
|||||||
# If we want strict lockfile-based reproducibility later, commit a
|
# If we want strict lockfile-based reproducibility later, commit a
|
||||||
# package-lock.json and flip this back to `npm ci`.
|
# package-lock.json and flip this back to `npm ci`.
|
||||||
- run: npm install --no-audit --no-fund
|
- run: npm install --no-audit --no-fund
|
||||||
# No type-check step: the frontend is pure JS (no .ts files, no JSDoc),
|
# `npm run check` (vue-tsc --noEmit) skipped: the frontend is pure JS
|
||||||
# so a type-checker has nothing to do. The vue-tsc devDep + its `check`
|
# with no .ts files and no JSDoc annotations, so vue-tsc has nothing
|
||||||
# script were dropped 2026-07-11 rather than bumped to v3. If we add
|
# to type-check. Re-enable once we add a tsconfig.json and either
|
||||||
# TS/JSDoc later, re-add a tsconfig.json + vue-tsc + a type-check step.
|
# convert to TS or add JSDoc.
|
||||||
- run: npm run test:unit
|
- run: npm run test:unit
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
|
# Integration suite split into THREE parallel shards (2026-05-25, runner
|
||||||
# The shards existed to parallelize ~8.5min of integration tests; once the
|
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service
|
||||||
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
|
# set and runs alembic + a disjoint subset of integration tests. Shards
|
||||||
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
|
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
|
||||||
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
|
# stays single-threaded per shard but multiple shards run in parallel
|
||||||
# runner slots for no wall-clock gain. One job now: spin up once, install
|
# wall-clock. Approximate split — rebalance once --durations=15 output
|
||||||
# once, migrate once, run every integration test.
|
# reveals which shard is the long pole.
|
||||||
#
|
#
|
||||||
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
|
# Each shard's docker-ps filter uses its own unique job name to scope
|
||||||
# containers by job name. act_runner strips underscores from job names when
|
# service-container resolution. act_runner appears to strip underscores
|
||||||
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
|
# from job names when building container labels — `int_api` yielded
|
||||||
# stays separator-free (`integration`). The step prints `docker ps -a` first
|
# zero matches on 2026-05-25 — so shards use no-separator names
|
||||||
# so a future naming-convention shift surfaces in the log without a
|
# (`intapi`, `intimp`, `intcore`) instead. Each step prints
|
||||||
# guess-and-push cycle.
|
# `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 —
|
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT
|
||||||
# per ci-requirements.md, FC is the only Python consumer of that image and the
|
# done — per ci-requirements.md, FC is the only Python consumer of that
|
||||||
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
|
# image and the CI-Runner project's "add deps to image when used by >1
|
||||||
integration:
|
# project" rule keeps the install per-job.
|
||||||
|
|
||||||
|
intapi:
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -240,14 +124,21 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Integration suite (resolve service IPs, migrate, test)
|
- name: Cache pip wheels
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pip
|
||||||
|
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||||
|
restore-keys: |
|
||||||
|
pip-${{ runner.os }}-py314-
|
||||||
|
- name: API integration shard (resolve service IPs, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
echo "=== end landscape ==="
|
echo "=== end landscape ==="
|
||||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||||
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||||
test -n "$PG" && test -n "$RD"
|
test -n "$PG" && test -n "$RD"
|
||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||||
@@ -264,14 +155,144 @@ jobs:
|
|||||||
else
|
else
|
||||||
pip install -r requirements.txt pytest pytest-asyncio
|
pip install -r requirements.txt pytest pytest-asyncio
|
||||||
fi
|
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
|
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: Cache pip wheels
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pip
|
||||||
|
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||||
|
restore-keys: |
|
||||||
|
pip-${{ runner.os }}-py314-
|
||||||
|
- 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: Cache pip wheels
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pip
|
||||||
|
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||||
|
restore-keys: |
|
||||||
|
pip-${{ runner.os }}-py314-
|
||||||
|
- 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
|
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
|
# `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,
|
# 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.
|
# eliminating the prior race between build.yml and a separate extension.yml.
|
||||||
@@ -10,78 +10,20 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'extension/**'
|
- 'extension/**'
|
||||||
- '.forgejo/workflows/extension.yml'
|
- '.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:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'extension/**'
|
- 'extension/**'
|
||||||
- '.forgejo/workflows/ci.yml'
|
|
||||||
- '.forgejo/workflows/build.yml'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: node:24-bookworm-slim
|
image: node:22-bookworm-slim
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
# Not --no-save: vitest and web-ext are both real devDependencies now,
|
- name: Install web-ext
|
||||||
# and the suite needs vitest resolvable from node_modules.
|
run: cd extension && npm install --no-save --no-audit --no-fund
|
||||||
- name: Install dev dependencies
|
|
||||||
run: cd extension && npm install --no-audit --no-fund
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: cd extension && npm run 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 Code per-user local overrides (shared .claude/settings.json is OK to commit)
|
||||||
.claude/settings.local.json
|
.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 / DB scratch
|
||||||
alembic/versions/__pycache__/
|
alembic/versions/__pycache__/
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-journal
|
*.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
|
WORKDIR /build
|
||||||
COPY frontend/package.json frontend/package-lock.json* ./
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
# No package-lock.json is tracked yet (we don't run npm locally per
|
# 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),
|
# System deps: ffmpeg (transcode + thumbnails, FC-2), unar (archives, FC-2),
|
||||||
# libpq for psycopg, postgresql-client + zstd for FC-5 backup/restore
|
# libpq for psycopg, postgresql-client + zstd for FC-5 backup/restore
|
||||||
# (pg_dump + tar --zstd), image libs, megatools (mega.nz public-link downloads
|
# (pg_dump + tar --zstd), image libs.
|
||||||
# for off-platform file-host links, #830 — `megatools dl`; Debian-native, no
|
|
||||||
# external MEGA apt repo needed).
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
unar \
|
unar \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
zstd \
|
zstd \
|
||||||
megatools \
|
|
||||||
libjpeg62-turbo \
|
libjpeg62-turbo \
|
||||||
libwebp7 \
|
libwebp7 \
|
||||||
libpng16-16 \
|
libpng16-16 \
|
||||||
@@ -47,29 +44,6 @@ RUN chmod +x entrypoint.sh
|
|||||||
|
|
||||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
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
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# syntax=docker/dockerfile:1.25
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
FROM python:3.14-slim
|
FROM python:3.14-slim
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
|||||||
@@ -6,50 +6,7 @@ Combines what was [ImageRepo](https://git.fabledsword.com/bvandeusen/ImageRepo)
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
In production. `main` is continuously deployed — every merge to `main` builds
|
Pre-v1. Not yet functional.
|
||||||
and publishes `:latest` images, so whatever is on `main` is what is running.
|
|
||||||
Day-to-day work happens on `dev`, which publishes `:dev` images.
|
|
||||||
|
|
||||||
## Versions and tags
|
|
||||||
|
|
||||||
Three image tags exist, and no others:
|
|
||||||
|
|
||||||
| Tag | Branch | Meaning |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `:latest` | `main` | Production. Moves on every merge. |
|
|
||||||
| `:c-<sha>` | `main` | Immutable — the rollback unit, all three images together. |
|
|
||||||
| `:dev` | `dev` | The rolling test channel. Moves on every push. |
|
|
||||||
|
|
||||||
There are deliberately **no version tags**. Nothing pins one, and a per-build
|
|
||||||
name nobody reads is upkeep for a model FC does not run (family rule 145; the
|
|
||||||
reasoning is note #3127 §5). Rolling back is `docker pull …:c-<sha>`.
|
|
||||||
|
|
||||||
Each artifact still has a version, derived rather than chosen: the commit time
|
|
||||||
of the newest change to that artifact's *own* shipped files, as
|
|
||||||
`YYYY.MM.DD.HHMM` UTC (rule 148). Four artifacts, four independent versions —
|
|
||||||
a push touching only `agent/` re-versions the agent and leaves web and ml
|
|
||||||
alone, and CI skips the builds whose content did not move.
|
|
||||||
|
|
||||||
Because no registry name carries it, the running instance's own report is the
|
|
||||||
only answer to "which build is this?". The foot of Settings shows
|
|
||||||
`FabledCurator 2026.08.29.0201 · dev`, and `/api/health` returns the same two
|
|
||||||
fields.
|
|
||||||
|
|
||||||
Release tags are optional bookmarks — FC went twelve weeks without one and
|
|
||||||
nothing was wrong. Pushing `v<version>` publishes a Forgejo release listing the
|
|
||||||
commits since the previous tag; it builds no image.
|
|
||||||
|
|
||||||
## What's in here
|
|
||||||
|
|
||||||
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
|
|
||||||
|
|
||||||
| Piece | Built from | Image | Role |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`. The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
|
|
||||||
| **ML worker** | `Dockerfile.ml` | `fabledcurator-ml` | Same app, plus `requirements-ml.txt` — tagging and embedding models that run in-container. |
|
|
||||||
| **GPU agent** | `agent/Dockerfile` | `fabledcurator-agent` | Optional desktop-GPU worker (`agent/`). Leases jobs over **HTTP only** — never touches the database or Redis. Run it for a burst, stop it to reclaim the card. See `agent/README.md`. |
|
|
||||||
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both `dev` and `main` (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See `extension/README.md`. |
|
|
||||||
| **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -72,42 +29,22 @@ docker compose -f docker-compose.yml up -d
|
|||||||
# (skips the override so containers pull registry images)
|
# (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
|
## 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.
|
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
|
## CI / Forgejo setup
|
||||||
|
|
||||||
Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
|
The repo's workflows expect:
|
||||||
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
|
|
||||||
content verification), `build.yml` (sign + publish), and `release.yml`, which
|
|
||||||
runs only on a `v*` tag and publishes a changelog without building anything.
|
|
||||||
|
|
||||||
**The toolchain each job runs in is its `container.image`, not its `runs-on`
|
- **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.
|
||||||
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
|
- **Repo secret `RELEASE_TOKEN`** — a Forgejo PAT with the following scopes:
|
||||||
then names the image it actually wants. `ci-requirements.md` is the current,
|
|
||||||
authoritative list of images and per-job installs — read that rather than a
|
|
||||||
copy here, so the two can't drift.
|
|
||||||
|
|
||||||
The repo expects one secret:
|
|
||||||
|
|
||||||
- **`RELEASE_TOKEN`** — a Forgejo PAT with:
|
|
||||||
- `write:package` + `read:package` — for `docker push` to `git.fabledsword.com`
|
- `write:package` + `read:package` — for `docker push` to `git.fabledsword.com`
|
||||||
- `write:release` — for the `ext-<version>` releases that cache the signed XPI
|
- `write:release` — for future release-cutting workflows
|
||||||
- `write:issue` — for issue-management automation
|
- `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`.
|
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
|
## License
|
||||||
|
|
||||||
Personal project; use at your own discretion.
|
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."""
|
"""Alembic environment — reads DATABASE_URL from app config."""
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from logging.config import fileConfig
|
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 alembic import context
|
||||||
from backend.app.config import get_config
|
from backend.app.config import get_config
|
||||||
from backend.app.models import Base
|
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
|
config = context.config
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
@@ -53,15 +38,6 @@ def run_migrations_online() -> None:
|
|||||||
poolclass=pool.NullPool,
|
poolclass=pool.NullPool,
|
||||||
)
|
)
|
||||||
with connectable.connect() as connection:
|
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(
|
context.configure(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
target_metadata=target_metadata,
|
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")
|
||||||
@@ -1,872 +0,0 @@
|
|||||||
"""Collapsed baseline — the whole schema in one revision.
|
|
||||||
|
|
||||||
Replaces revisions 0001..0087, which narrated the build-out of this project
|
|
||||||
and were deleted in milestone 328 step 1. A new install creates the schema in
|
|
||||||
one step instead of replaying that history.
|
|
||||||
|
|
||||||
WHY THE REVISION ID IS "0087" AND NOT "0001"
|
|
||||||
--------------------------------------------
|
|
||||||
It is deliberately the id of the LAST revision this baseline collapses, so an
|
|
||||||
existing database needs no intervention at all:
|
|
||||||
|
|
||||||
* a fresh install finds current=none, head=0087, runs this file once, and
|
|
||||||
ends stamped at 0087.
|
|
||||||
* an existing install is ALREADY at 0087, so `alembic upgrade head` finds
|
|
||||||
current == head and does nothing.
|
|
||||||
|
|
||||||
The alternative — numbering this 0001 and stamping every existing database —
|
|
||||||
means running `alembic stamp` against live data, and stamp VALIDATES NOTHING.
|
|
||||||
It writes a version string whether or not the schema actually matches, so a
|
|
||||||
wrong baseline would be discovered later, by the next real migration, with no
|
|
||||||
clean way back. Keeping the id removes that operation instead of making it
|
|
||||||
safe. Future revisions continue at 0088.
|
|
||||||
|
|
||||||
The one case this makes worse, and it fails LOUDLY rather than silently: a
|
|
||||||
database still sitting between 0001 and 0086 (i.e. never upgraded to head)
|
|
||||||
cannot be located in this chain and errors out. Upgrade to 0087 on a
|
|
||||||
pre-squash build first, then take this one.
|
|
||||||
|
|
||||||
WHAT IS HAND-WRITTEN HERE
|
|
||||||
-------------------------
|
|
||||||
Most of this file is `alembic revision --autogenerate` output, but four
|
|
||||||
things are NOT in SQLAlchemy metadata and the generator cannot produce them.
|
|
||||||
Each fails differently, and none of them fail at generation time:
|
|
||||||
|
|
||||||
1. CREATE EXTENSION vector (was 0001) — without it the VECTOR
|
|
||||||
columns below cannot be created at all.
|
|
||||||
2. CREATE EXTENSION tsm_system_rows (was 0004) — used by the random-sample
|
|
||||||
query path; its absence surfaces only when that query runs.
|
|
||||||
3. The HNSW index on image_record.siglip_embedding (was 0036). Raw SQL
|
|
||||||
because alembic's create_index cannot express `USING hnsw (...
|
|
||||||
vector_cosine_ops)`. Its absence is the quietest failure of the four:
|
|
||||||
everything works, similarity search just stops using an index.
|
|
||||||
4. `import pgvector.sqlalchemy.vector`. Autogenerate EMITS references to
|
|
||||||
pgvector.sqlalchemy.vector.VECTOR but does not add the import, so the
|
|
||||||
generated file dies with NameError on first run.
|
|
||||||
|
|
||||||
The acceptance test for this file is not that it reads correctly — it is
|
|
||||||
`.forgejo/workflows/baseline.yml`, which builds a database from the old
|
|
||||||
0001..0087 chain (read out of git) and one from this file, and diffs
|
|
||||||
pg_dump --schema-only output. That is what proves nothing was missed.
|
|
||||||
|
|
||||||
Revision ID: 0087
|
|
||||||
Revises:
|
|
||||||
Create Date: 2026-08-30
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# Autogenerate references pgvector.sqlalchemy.vector.VECTOR without importing
|
|
||||||
# it. Item 4 above.
|
|
||||||
import pgvector.sqlalchemy.vector
|
|
||||||
|
|
||||||
revision: str = "0087"
|
|
||||||
down_revision: Union[str, None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Extensions FIRST: the VECTOR columns below cannot be created without
|
|
||||||
# `vector`, so ordering here is load-bearing, not tidiness.
|
|
||||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
|
||||||
op.execute("CREATE EXTENSION IF NOT EXISTS tsm_system_rows")
|
|
||||||
|
|
||||||
op.create_table('app_setting',
|
|
||||||
sa.Column('key', sa.String(length=64), nullable=False),
|
|
||||||
sa.Column('value', sa.Text(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('key', name=op.f('pk_app_setting'))
|
|
||||||
)
|
|
||||||
op.create_table('artist',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('slug', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('notes', sa.Text(), nullable=True),
|
|
||||||
sa.Column('is_subscription', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('auto_check', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('check_interval_seconds', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_artist')),
|
|
||||||
sa.UniqueConstraint('slug', name=op.f('uq_artist_slug'))
|
|
||||||
)
|
|
||||||
op.create_table('backup_run',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('kind', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('tag', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('triggered_by', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('sql_path', sa.Text(), nullable=True),
|
|
||||||
sa.Column('tar_path', sa.Text(), nullable=True),
|
|
||||||
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
|
|
||||||
sa.Column('error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('manifest', sa.JSON(), server_default='{}', nullable=False),
|
|
||||||
sa.Column('restored_from_id', sa.Integer(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['restored_from_id'], ['backup_run.id'], name=op.f('fk_backup_run_restored_from_id_backup_run'), ondelete='SET NULL'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_backup_run'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_backup_run_finished_at'), 'backup_run', ['finished_at'], unique=False)
|
|
||||||
op.create_index(op.f('ix_backup_run_kind'), 'backup_run', ['kind'], unique=False)
|
|
||||||
op.create_index(op.f('ix_backup_run_started_at'), 'backup_run', ['started_at'], unique=False)
|
|
||||||
op.create_index(op.f('ix_backup_run_status'), 'backup_run', ['status'], unique=False)
|
|
||||||
op.create_index(op.f('ix_backup_run_tag'), 'backup_run', ['tag'], unique=False)
|
|
||||||
op.create_table('credential',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('platform', sa.String(length=64), nullable=False),
|
|
||||||
sa.Column('credential_type', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('encrypted_blob', sa.LargeBinary(), nullable=False),
|
|
||||||
sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('last_verified', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_credential')),
|
|
||||||
sa.UniqueConstraint('platform', name=op.f('uq_credential_platform'))
|
|
||||||
)
|
|
||||||
op.create_table('head_auto_apply_run',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('dry_run', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('params', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('n_applied', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('report', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
||||||
sa.Column('error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('last_progress_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_head_auto_apply_run'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_head_auto_apply_run_status'), 'head_auto_apply_run', ['status'], unique=False)
|
|
||||||
op.create_table('head_training_run',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('params', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('n_trained', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('n_skipped', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('last_progress_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_head_training_run'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_head_training_run_status'), 'head_training_run', ['status'], unique=False)
|
|
||||||
op.create_table('import_batch',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('triggered_by', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('source_path', sa.Text(), nullable=False),
|
|
||||||
sa.Column('scan_mode', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('total_files', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('imported', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('skipped', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('failed', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('attachments', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('refreshed', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=16), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_import_batch'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_import_batch_status'), 'import_batch', ['status'], unique=False)
|
|
||||||
op.create_table('import_settings',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('import_scan_path', sa.Text(), nullable=False),
|
|
||||||
sa.Column('min_width', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('min_height', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('skip_transparent', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('transparency_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('skip_single_color', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('single_color_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('single_color_tolerance', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('phash_threshold', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('download_rate_limit_seconds', sa.Float(), nullable=False),
|
|
||||||
sa.Column('download_validate_files', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('download_schedule_default_seconds', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('download_event_retention_days', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('download_failure_warning_threshold', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('backup_db_nightly_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('backup_db_nightly_hour_utc', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('backup_db_keep_last_n', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('backup_images_keep_last_n', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('series_suggest_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('series_suggest_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('extdl_mega_enabled', sa.Boolean(), server_default='true', nullable=False),
|
|
||||||
sa.Column('extdl_gdrive_enabled', sa.Boolean(), server_default='true', nullable=False),
|
|
||||||
sa.Column('extdl_mediafire_enabled', sa.Boolean(), server_default='true', nullable=False),
|
|
||||||
sa.Column('extdl_dropbox_enabled', sa.Boolean(), server_default='true', nullable=False),
|
|
||||||
sa.Column('extdl_pixeldrain_enabled', sa.Boolean(), server_default='true', nullable=False),
|
|
||||||
sa.Column('translation_enabled', sa.Boolean(), server_default='false', nullable=False),
|
|
||||||
sa.Column('interpreter_base_url', sa.Text(), server_default='', nullable=False),
|
|
||||||
sa.Column('translation_target_lang', sa.Text(), server_default='en', nullable=False),
|
|
||||||
sa.Column('translation_min_confidence', sa.Float(), server_default='0.9', nullable=False),
|
|
||||||
sa.Column('wip_title_tagging_enabled', sa.Boolean(), server_default='true', nullable=False),
|
|
||||||
sa.Column('wip_soft_title_tagging_enabled', sa.Boolean(), server_default='false', nullable=False),
|
|
||||||
sa.CheckConstraint('id = 1', name=op.f('ck_import_settings_singleton')),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_import_settings'))
|
|
||||||
)
|
|
||||||
op.create_table('library_audit_run',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('rule', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('params', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('scanned_count', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('matched_count', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('matched_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
||||||
sa.Column('error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('resume_after_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('last_progress_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_library_audit_run'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_library_audit_run_rule'), 'library_audit_run', ['rule'], unique=False)
|
|
||||||
op.create_index(op.f('ix_library_audit_run_status'), 'library_audit_run', ['status'], unique=False)
|
|
||||||
op.create_table('ml_settings',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('cpu_embed_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('video_frame_interval_seconds', sa.Float(), nullable=False),
|
|
||||||
sa.Column('video_max_frames', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('head_min_positives', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('head_auto_apply_precision', sa.Float(), nullable=False),
|
|
||||||
sa.Column('head_auto_apply_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('head_auto_apply_min_positives', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('ccip_match_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('ccip_auto_apply_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('ccip_auto_apply_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('presentation_auto_apply_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('presentation_auto_apply_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('presentation_conflict_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('process_auto_apply_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('process_auto_apply_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('process_conflict_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('embedder_model_version', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('embedder_model_name', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('detector_person_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('detector_person_weights', sa.String(length=512), nullable=False),
|
|
||||||
sa.Column('detector_person_conf', sa.Float(), nullable=False),
|
|
||||||
sa.Column('detector_anatomy_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('detector_anatomy_weights', sa.String(length=512), nullable=False),
|
|
||||||
sa.Column('detector_anatomy_conf', sa.Float(), nullable=False),
|
|
||||||
sa.Column('detector_panel_enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('detector_panel_weights', sa.String(length=512), nullable=False),
|
|
||||||
sa.Column('detector_panel_conf', sa.Float(), nullable=False),
|
|
||||||
sa.Column('detector_max_figures', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('detector_max_components', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('detector_max_panels', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('detector_max_regions', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('detector_dedupe_iou', sa.Float(), nullable=False),
|
|
||||||
sa.Column('ccip_ref_signature', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('ccip_prototype_cap', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.CheckConstraint('id = 1', name=op.f('ck_ml_settings_singleton')),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_ml_settings'))
|
|
||||||
)
|
|
||||||
op.create_table('tag',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('kind', sa.Enum('artist', 'character', 'fandom', 'general', 'series', 'archive', 'post', name='tag_kind'), nullable=False),
|
|
||||||
sa.Column('fandom_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('is_system', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.CheckConstraint("(fandom_id IS NULL) OR (kind = 'character')", name=op.f('ck_tag_ck_tag_fandom_requires_character')),
|
|
||||||
sa.ForeignKeyConstraint(['fandom_id'], ['tag.id'], name=op.f('fk_tag_fandom_id_tag'), ondelete='SET NULL'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_tag'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_tag_fandom_id'), 'tag', ['fandom_id'], unique=False)
|
|
||||||
op.create_table('task_run',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('celery_task_id', sa.String(length=64), nullable=False),
|
|
||||||
sa.Column('queue', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('task_name', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('target_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('status', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('error_type', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('error_message', sa.Text(), nullable=True),
|
|
||||||
sa.Column('retry_count', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('worker_hostname', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('args_summary', sa.String(length=255), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_task_run'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_task_run_celery_task_id'), 'task_run', ['celery_task_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_task_run_finished_at'), 'task_run', ['finished_at'], unique=False)
|
|
||||||
op.create_index(op.f('ix_task_run_queue'), 'task_run', ['queue'], unique=False)
|
|
||||||
op.create_index(op.f('ix_task_run_started_at'), 'task_run', ['started_at'], unique=False)
|
|
||||||
op.create_index(op.f('ix_task_run_status'), 'task_run', ['status'], unique=False)
|
|
||||||
op.create_index(op.f('ix_task_run_task_name'), 'task_run', ['task_name'], unique=False)
|
|
||||||
op.create_table('artist_visit',
|
|
||||||
sa.Column('artist_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('last_viewed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_artist_visit_artist_id_artist'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('artist_id', name=op.f('pk_artist_visit'))
|
|
||||||
)
|
|
||||||
op.create_table('ccip_prototype_state',
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('fingerprint', sa.String(length=64), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_ccip_prototype_state_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('tag_id', name=op.f('pk_ccip_prototype_state'))
|
|
||||||
)
|
|
||||||
op.create_table('head_metric',
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('n_misfires', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('n_underfires', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_head_metric_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('tag_id', name=op.f('pk_head_metric'))
|
|
||||||
)
|
|
||||||
op.create_table('head_metrics_snapshot',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('snapshot_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('n_auto_applied', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('n_misfires', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('n_underfires', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('ap', sa.Float(), nullable=True),
|
|
||||||
sa.Column('precision_cv', sa.Float(), nullable=True),
|
|
||||||
sa.Column('recall', sa.Float(), nullable=True),
|
|
||||||
sa.Column('n_pos', sa.Integer(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_head_metrics_snapshot_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_head_metrics_snapshot'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_head_metrics_snapshot_snapshot_at'), 'head_metrics_snapshot', ['snapshot_at'], unique=False)
|
|
||||||
op.create_index(op.f('ix_head_metrics_snapshot_tag_id'), 'head_metrics_snapshot', ['tag_id'], unique=False)
|
|
||||||
op.create_table('source',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('artist_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('platform', sa.String(length=64), nullable=False),
|
|
||||||
sa.Column('url', sa.Text(), nullable=False),
|
|
||||||
sa.Column('enabled', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('config_overrides', sa.JSON(), nullable=True),
|
|
||||||
sa.Column('last_checked_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('last_error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('error_type', sa.String(length=32), nullable=True),
|
|
||||||
sa.Column('check_interval_override', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('consecutive_failures', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('backfill_runs_remaining', sa.Integer(), server_default='0', nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_source_artist_id_artist'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_source'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_source_artist_id'), 'source', ['artist_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_source_error_type'), 'source', ['error_type'], unique=False)
|
|
||||||
op.create_table('tag_alias',
|
|
||||||
sa.Column('alias_string', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('alias_category', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('canonical_tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['canonical_tag_id'], ['tag.id'], name=op.f('fk_tag_alias_canonical_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('alias_string', 'alias_category', name=op.f('pk_tag_alias'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_tag_alias_canonical_tag_id'), 'tag_alias', ['canonical_tag_id'], unique=False)
|
|
||||||
op.create_table('tag_head',
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('embedding_version', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('weights', pgvector.sqlalchemy.vector.VECTOR(dim=1152), nullable=False),
|
|
||||||
sa.Column('bias', sa.Float(), nullable=False),
|
|
||||||
sa.Column('suggest_threshold', sa.Float(), nullable=False),
|
|
||||||
sa.Column('auto_apply_threshold', sa.Float(), nullable=True),
|
|
||||||
sa.Column('n_pos', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('n_neg', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('ap', sa.Float(), nullable=False),
|
|
||||||
sa.Column('precision_cv', sa.Float(), nullable=False),
|
|
||||||
sa.Column('recall', sa.Float(), nullable=False),
|
|
||||||
sa.Column('trained_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('train_fingerprint', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_tag_head_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('tag_id', name=op.f('pk_tag_head'))
|
|
||||||
)
|
|
||||||
op.create_table('patreon_failed_media',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('filehash', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('attempts', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('last_error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('first_failed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('last_failed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_patreon_failed_media_source_id_source'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_patreon_failed_media')),
|
|
||||||
sa.UniqueConstraint('source_id', 'filehash', name='uq_patreon_failed_media_source_id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_patreon_failed_media_source_id'), 'patreon_failed_media', ['source_id'], unique=False)
|
|
||||||
op.create_table('patreon_seen_media',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('filehash', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('post_id', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_patreon_seen_media_source_id_source'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_patreon_seen_media')),
|
|
||||||
sa.UniqueConstraint('source_id', 'filehash', name='uq_patreon_seen_media_source_id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_patreon_seen_media_source_id'), 'patreon_seen_media', ['source_id'], unique=False)
|
|
||||||
op.create_table('pixiv_failed_media',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('filehash', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('attempts', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('last_error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('first_failed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('last_failed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_pixiv_failed_media_source_id_source'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_pixiv_failed_media')),
|
|
||||||
sa.UniqueConstraint('source_id', 'filehash', name='uq_pixiv_failed_media_source_id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_pixiv_failed_media_source_id'), 'pixiv_failed_media', ['source_id'], unique=False)
|
|
||||||
op.create_table('pixiv_seen_media',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('filehash', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('post_id', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_pixiv_seen_media_source_id_source'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_pixiv_seen_media')),
|
|
||||||
sa.UniqueConstraint('source_id', 'filehash', name='uq_pixiv_seen_media_source_id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_pixiv_seen_media_source_id'), 'pixiv_seen_media', ['source_id'], unique=False)
|
|
||||||
op.create_table('post',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('artist_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('external_post_id', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('post_url', sa.Text(), nullable=True),
|
|
||||||
sa.Column('post_title', sa.Text(), nullable=True),
|
|
||||||
sa.Column('post_date', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('raw_metadata', sa.JSON(), nullable=True),
|
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
|
||||||
sa.Column('attachment_count', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('post_title_translated', sa.Text(), nullable=True),
|
|
||||||
sa.Column('description_translated', sa.Text(), nullable=True),
|
|
||||||
sa.Column('translated_source_lang', sa.String(length=8), nullable=True),
|
|
||||||
sa.Column('translation_engine_version', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('translated_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('translation_override', sa.String(length=16), server_default='auto', nullable=False),
|
|
||||||
sa.Column('downloaded_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.CheckConstraint("translation_override IN ('auto', 'force', 'original')", name=op.f('ck_post_ck_post_translation_override')),
|
|
||||||
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_post_artist_id_artist'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_post_source_id_source'), ondelete='SET NULL'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_post')),
|
|
||||||
sa.UniqueConstraint('source_id', 'external_post_id', name='uq_post_source_external_id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_post_artist_id'), 'post', ['artist_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_post_source_id'), 'post', ['source_id'], unique=False)
|
|
||||||
op.create_table('subscribestar_failed_media',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('filehash', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('attempts', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('last_error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('first_failed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('last_failed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_subscribestar_failed_media_source_id_source'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_subscribestar_failed_media')),
|
|
||||||
sa.UniqueConstraint('source_id', 'filehash', name='uq_subscribestar_failed_media_source_id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_subscribestar_failed_media_source_id'), 'subscribestar_failed_media', ['source_id'], unique=False)
|
|
||||||
op.create_table('subscribestar_seen_media',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('filehash', sa.String(length=128), nullable=False),
|
|
||||||
sa.Column('post_id', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_subscribestar_seen_media_source_id_source'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_subscribestar_seen_media')),
|
|
||||||
sa.UniqueConstraint('source_id', 'filehash', name='uq_subscribestar_seen_media_source_id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_subscribestar_seen_media_source_id'), 'subscribestar_seen_media', ['source_id'], unique=False)
|
|
||||||
op.create_table('download_event',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('post_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('status', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('bytes_downloaded', sa.BigInteger(), nullable=False),
|
|
||||||
sa.Column('files_count', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_download_event_post_id_post'), ondelete='SET NULL'),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_download_event_source_id_source'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_download_event'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_download_event_post_id'), 'download_event', ['post_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_download_event_source_id'), 'download_event', ['source_id'], unique=False)
|
|
||||||
op.create_table('image_record',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('path', sa.Text(), nullable=False),
|
|
||||||
sa.Column('sha256', sa.String(length=64), nullable=False),
|
|
||||||
sa.Column('phash', sa.String(length=32), nullable=True),
|
|
||||||
sa.Column('size_bytes', sa.BigInteger(), nullable=False),
|
|
||||||
sa.Column('mime', sa.String(length=64), nullable=False),
|
|
||||||
sa.Column('width', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('height', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('duration_seconds', sa.Float(), nullable=True),
|
|
||||||
sa.Column('integrity_status', sa.String(length=24), nullable=False),
|
|
||||||
sa.Column('thumbnail_path', sa.Text(), nullable=True),
|
|
||||||
sa.Column('source_url', sa.Text(), nullable=True),
|
|
||||||
sa.Column('source_filehash', sa.String(length=32), nullable=True),
|
|
||||||
sa.Column('origin', sa.Enum('downloaded', 'imported_filesystem', 'uploaded', name='origin_enum'), nullable=False),
|
|
||||||
sa.Column('primary_post_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('artist_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('siglip_embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1152), nullable=True),
|
|
||||||
sa.Column('siglip_model_version', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('effective_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('earliest_post_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_image_record_artist_id_artist'), ondelete='SET NULL'),
|
|
||||||
sa.ForeignKeyConstraint(['primary_post_id'], ['post.id'], name=op.f('fk_image_record_primary_post_id_post'), ondelete='SET NULL'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_image_record')),
|
|
||||||
sa.UniqueConstraint('path', name=op.f('uq_image_record_path'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_image_record_artist_id'), 'image_record', ['artist_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_image_record_integrity_status'), 'image_record', ['integrity_status'], unique=False)
|
|
||||||
op.create_index(op.f('ix_image_record_phash'), 'image_record', ['phash'], unique=False)
|
|
||||||
op.create_index(op.f('ix_image_record_primary_post_id'), 'image_record', ['primary_post_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_image_record_sha256'), 'image_record', ['sha256'], unique=True)
|
|
||||||
op.create_index(op.f('ix_image_record_source_filehash'), 'image_record', ['source_filehash'], unique=False)
|
|
||||||
op.create_table('post_attachment',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('post_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('artist_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('sha256', sa.String(length=64), nullable=False),
|
|
||||||
sa.Column('path', sa.Text(), nullable=False),
|
|
||||||
sa.Column('original_filename', sa.Text(), nullable=False),
|
|
||||||
sa.Column('ext', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('mime', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('size_bytes', sa.BigInteger(), nullable=False),
|
|
||||||
sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_post_attachment_artist_id_artist'), ondelete='SET NULL'),
|
|
||||||
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_post_attachment_post_id_post'), ondelete='SET NULL'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_post_attachment'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_post_attachment_artist_id'), 'post_attachment', ['artist_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_post_attachment_post_id'), 'post_attachment', ['post_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_post_attachment_sha256'), 'post_attachment', ['sha256'], unique=False)
|
|
||||||
op.create_index('uq_post_attachment_null_post_sha', 'post_attachment', ['sha256'], unique=True, postgresql_where=sa.text('post_id IS NULL'))
|
|
||||||
op.create_index('uq_post_attachment_post_sha', 'post_attachment', ['post_id', 'sha256'], unique=True, postgresql_where=sa.text('post_id IS NOT NULL'))
|
|
||||||
op.create_table('series_suggestion',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('post_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('series_tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('score', sa.Float(), nullable=False),
|
|
||||||
sa.Column('signals', sa.JSON(), nullable=True),
|
|
||||||
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_series_suggestion_post_id_post'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['series_tag_id'], ['tag.id'], name=op.f('fk_series_suggestion_series_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_series_suggestion')),
|
|
||||||
sa.UniqueConstraint('post_id', 'series_tag_id', name='uq_series_suggestion_post_series')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_series_suggestion_post_id'), 'series_suggestion', ['post_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_series_suggestion_series_tag_id'), 'series_suggestion', ['series_tag_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_series_suggestion_status'), 'series_suggestion', ['status'], unique=False)
|
|
||||||
op.create_table('external_link',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('post_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('artist_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('host', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('url', sa.Text(), nullable=False),
|
|
||||||
sa.Column('label', sa.Text(), nullable=True),
|
|
||||||
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
|
|
||||||
sa.Column('attempts', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
|
||||||
sa.Column('last_error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('attachment_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('duration_seconds', sa.Float(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], name=op.f('fk_external_link_artist_id_artist'), ondelete='SET NULL'),
|
|
||||||
sa.ForeignKeyConstraint(['attachment_id'], ['post_attachment.id'], name=op.f('fk_external_link_attachment_id_post_attachment'), ondelete='SET NULL'),
|
|
||||||
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_external_link_post_id_post'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_external_link'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_external_link_artist_id'), 'external_link', ['artist_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_external_link_post_id'), 'external_link', ['post_id'], unique=False)
|
|
||||||
op.create_index('ix_external_link_status', 'external_link', ['status'], unique=False)
|
|
||||||
op.create_index('uq_external_link_post_url', 'external_link', ['post_id', 'url'], unique=True)
|
|
||||||
op.create_table('gpu_job',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('image_record_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('task', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('lease_token', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('leased_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('attempts', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('triage_status', sa.String(length=16), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_gpu_job_image_record_id_image_record'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_gpu_job'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_gpu_job_image_record_id'), 'gpu_job', ['image_record_id'], unique=False)
|
|
||||||
op.create_index('ix_gpu_job_leased_expires', 'gpu_job', ['lease_expires_at'], unique=False, postgresql_where=sa.text("status = 'leased'"))
|
|
||||||
op.create_index('ix_gpu_job_pending', 'gpu_job', ['id'], unique=False, postgresql_where=sa.text("status = 'pending'"))
|
|
||||||
op.create_index(op.f('ix_gpu_job_status'), 'gpu_job', ['status'], unique=False)
|
|
||||||
op.create_table('image_provenance',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('image_record_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('post_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('from_attachment_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('captured_metadata', sa.JSON(), nullable=True),
|
|
||||||
sa.Column('captured_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['from_attachment_id'], ['post_attachment.id'], name=op.f('fk_image_provenance_from_attachment_id_post_attachment'), ondelete='SET NULL'),
|
|
||||||
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_image_provenance_image_record_id_image_record'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['post_id'], ['post.id'], name=op.f('fk_image_provenance_post_id_post'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['source_id'], ['source.id'], name=op.f('fk_image_provenance_source_id_source'), ondelete='SET NULL'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_image_provenance')),
|
|
||||||
sa.UniqueConstraint('image_record_id', 'post_id', name='uq_image_provenance_image_post')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_image_provenance_from_attachment_id'), 'image_provenance', ['from_attachment_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_image_provenance_image_record_id'), 'image_provenance', ['image_record_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_image_provenance_post_id'), 'image_provenance', ['post_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_image_provenance_source_id'), 'image_provenance', ['source_id'], unique=False)
|
|
||||||
op.create_table('image_region',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('image_record_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('kind', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('frame_time', sa.Float(), nullable=True),
|
|
||||||
sa.Column('rx', sa.Float(), nullable=False),
|
|
||||||
sa.Column('ry', sa.Float(), nullable=False),
|
|
||||||
sa.Column('rw', sa.Float(), nullable=False),
|
|
||||||
sa.Column('rh', sa.Float(), nullable=False),
|
|
||||||
sa.Column('score', sa.Float(), nullable=True),
|
|
||||||
sa.Column('detector_version', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('crop_version', sa.String(length=64), nullable=True),
|
|
||||||
sa.Column('embedding_version', sa.String(length=128), nullable=True),
|
|
||||||
sa.Column('ccip_embedding', pgvector.sqlalchemy.vector.VECTOR(dim=768), nullable=True),
|
|
||||||
sa.Column('siglip_embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1152), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_image_region_image_record_id_image_record'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_image_region'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_image_region_image_record_id'), 'image_region', ['image_record_id'], unique=False)
|
|
||||||
op.create_table('image_tag',
|
|
||||||
sa.Column('image_record_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source', sa.String(length=32), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_image_tag_image_record_id_image_record'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_image_tag_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('image_record_id', 'tag_id', name=op.f('pk_image_tag'))
|
|
||||||
)
|
|
||||||
op.create_table('import_task',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('batch_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('source_path', sa.Text(), nullable=False),
|
|
||||||
sa.Column('task_type', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('recovery_count', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('refetched', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('result_image_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('error', sa.Text(), nullable=True),
|
|
||||||
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['batch_id'], ['import_batch.id'], name=op.f('fk_import_task_batch_id_import_batch'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['result_image_id'], ['image_record.id'], name=op.f('fk_import_task_result_image_id_image_record'), ondelete='SET NULL'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_import_task'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_import_task_batch_id'), 'import_task', ['batch_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_import_task_status'), 'import_task', ['status'], unique=False)
|
|
||||||
op.create_table('presentation_review',
|
|
||||||
sa.Column('image_record_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('conflict_tag_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('conflict_score', sa.Float(), nullable=False),
|
|
||||||
sa.Column('mode', sa.String(length=16), server_default='chrome', nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['conflict_tag_id'], ['tag.id'], name=op.f('fk_presentation_review_conflict_tag_id_tag'), ondelete='SET NULL'),
|
|
||||||
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_presentation_review_image_record_id_image_record'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_presentation_review_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('image_record_id', 'tag_id', name=op.f('pk_presentation_review'))
|
|
||||||
)
|
|
||||||
op.create_table('series_page',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('series_tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('image_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=16), server_default='placed', nullable=False),
|
|
||||||
sa.Column('page_number', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('stated_page', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['image_id'], ['image_record.id'], name=op.f('fk_series_page_image_id_image_record'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['series_tag_id'], ['tag.id'], name=op.f('fk_series_page_series_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_series_page')),
|
|
||||||
sa.UniqueConstraint('image_id', name=op.f('uq_series_page_image_id'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_series_page_series_tag_id'), 'series_page', ['series_tag_id'], unique=False)
|
|
||||||
op.create_table('tag_positive_confirmation',
|
|
||||||
sa.Column('image_record_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('confirmed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_tag_positive_confirmation_image_record_id_image_record'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_tag_positive_confirmation_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('image_record_id', 'tag_id', name=op.f('pk_tag_positive_confirmation'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_tag_positive_confirmation_tag_id'), 'tag_positive_confirmation', ['tag_id'], unique=False)
|
|
||||||
op.create_table('tag_suggestion_rejection',
|
|
||||||
sa.Column('image_record_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('rejected_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['image_record_id'], ['image_record.id'], name=op.f('fk_tag_suggestion_rejection_image_record_id_image_record'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_tag_suggestion_rejection_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('image_record_id', 'tag_id', name=op.f('pk_tag_suggestion_rejection'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_tag_suggestion_rejection_tag_id'), 'tag_suggestion_rejection', ['tag_id'], unique=False)
|
|
||||||
op.create_table('character_prototype',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('ccip_embedding', pgvector.sqlalchemy.vector.VECTOR(dim=768), nullable=False),
|
|
||||||
sa.Column('region_id', sa.Integer(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['region_id'], ['image_region.id'], name=op.f('fk_character_prototype_region_id_image_region'), ondelete='SET NULL'),
|
|
||||||
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name=op.f('fk_character_prototype_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_character_prototype'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_character_prototype_tag_id'), 'character_prototype', ['tag_id'], unique=False)
|
|
||||||
op.create_table('series_chapter',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('series_tag_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('anchor_page_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('title', sa.Text(), nullable=True),
|
|
||||||
sa.Column('stated_part', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['anchor_page_id'], ['series_page.id'], name=op.f('fk_series_chapter_anchor_page_id_series_page'), ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['series_tag_id'], ['tag.id'], name=op.f('fk_series_chapter_series_tag_id_tag'), ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_series_chapter')),
|
|
||||||
sa.UniqueConstraint('anchor_page_id', name=op.f('uq_series_chapter_anchor_page_id'))
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_series_chapter_series_tag_id'), 'series_chapter', ['series_tag_id'], unique=False)
|
|
||||||
|
|
||||||
# The HNSW index, item 3 above. Must match the query's cosine-distance
|
|
||||||
# operator class or the planner will not use it.
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX ix_image_record_siglip_hnsw "
|
|
||||||
"ON image_record USING hnsw (siglip_embedding vector_cosine_ops)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Dropping image_record takes its indexes with it, so the HNSW index needs
|
|
||||||
# no separate drop. The extensions are deliberately left in place: they are
|
|
||||||
# database-scoped and something else may be using them.
|
|
||||||
op.drop_index(op.f('ix_series_chapter_series_tag_id'), table_name='series_chapter')
|
|
||||||
op.drop_table('series_chapter')
|
|
||||||
op.drop_index(op.f('ix_character_prototype_tag_id'), table_name='character_prototype')
|
|
||||||
op.drop_table('character_prototype')
|
|
||||||
op.drop_index(op.f('ix_tag_suggestion_rejection_tag_id'), table_name='tag_suggestion_rejection')
|
|
||||||
op.drop_table('tag_suggestion_rejection')
|
|
||||||
op.drop_index(op.f('ix_tag_positive_confirmation_tag_id'), table_name='tag_positive_confirmation')
|
|
||||||
op.drop_table('tag_positive_confirmation')
|
|
||||||
op.drop_index(op.f('ix_series_page_series_tag_id'), table_name='series_page')
|
|
||||||
op.drop_table('series_page')
|
|
||||||
op.drop_table('presentation_review')
|
|
||||||
op.drop_index(op.f('ix_import_task_status'), table_name='import_task')
|
|
||||||
op.drop_index(op.f('ix_import_task_batch_id'), table_name='import_task')
|
|
||||||
op.drop_table('import_task')
|
|
||||||
op.drop_table('image_tag')
|
|
||||||
op.drop_index(op.f('ix_image_region_image_record_id'), table_name='image_region')
|
|
||||||
op.drop_table('image_region')
|
|
||||||
op.drop_index(op.f('ix_image_provenance_source_id'), table_name='image_provenance')
|
|
||||||
op.drop_index(op.f('ix_image_provenance_post_id'), table_name='image_provenance')
|
|
||||||
op.drop_index(op.f('ix_image_provenance_image_record_id'), table_name='image_provenance')
|
|
||||||
op.drop_index(op.f('ix_image_provenance_from_attachment_id'), table_name='image_provenance')
|
|
||||||
op.drop_table('image_provenance')
|
|
||||||
op.drop_index(op.f('ix_gpu_job_status'), table_name='gpu_job')
|
|
||||||
op.drop_index('ix_gpu_job_pending', table_name='gpu_job', postgresql_where=sa.text("status = 'pending'"))
|
|
||||||
op.drop_index('ix_gpu_job_leased_expires', table_name='gpu_job', postgresql_where=sa.text("status = 'leased'"))
|
|
||||||
op.drop_index(op.f('ix_gpu_job_image_record_id'), table_name='gpu_job')
|
|
||||||
op.drop_table('gpu_job')
|
|
||||||
op.drop_index('uq_external_link_post_url', table_name='external_link')
|
|
||||||
op.drop_index('ix_external_link_status', table_name='external_link')
|
|
||||||
op.drop_index(op.f('ix_external_link_post_id'), table_name='external_link')
|
|
||||||
op.drop_index(op.f('ix_external_link_artist_id'), table_name='external_link')
|
|
||||||
op.drop_table('external_link')
|
|
||||||
op.drop_index(op.f('ix_series_suggestion_status'), table_name='series_suggestion')
|
|
||||||
op.drop_index(op.f('ix_series_suggestion_series_tag_id'), table_name='series_suggestion')
|
|
||||||
op.drop_index(op.f('ix_series_suggestion_post_id'), table_name='series_suggestion')
|
|
||||||
op.drop_table('series_suggestion')
|
|
||||||
op.drop_index('uq_post_attachment_post_sha', table_name='post_attachment', postgresql_where=sa.text('post_id IS NOT NULL'))
|
|
||||||
op.drop_index('uq_post_attachment_null_post_sha', table_name='post_attachment', postgresql_where=sa.text('post_id IS NULL'))
|
|
||||||
op.drop_index(op.f('ix_post_attachment_sha256'), table_name='post_attachment')
|
|
||||||
op.drop_index(op.f('ix_post_attachment_post_id'), table_name='post_attachment')
|
|
||||||
op.drop_index(op.f('ix_post_attachment_artist_id'), table_name='post_attachment')
|
|
||||||
op.drop_table('post_attachment')
|
|
||||||
op.drop_index(op.f('ix_image_record_source_filehash'), table_name='image_record')
|
|
||||||
op.drop_index(op.f('ix_image_record_sha256'), table_name='image_record')
|
|
||||||
op.drop_index(op.f('ix_image_record_primary_post_id'), table_name='image_record')
|
|
||||||
op.drop_index(op.f('ix_image_record_phash'), table_name='image_record')
|
|
||||||
op.drop_index(op.f('ix_image_record_integrity_status'), table_name='image_record')
|
|
||||||
op.drop_index(op.f('ix_image_record_artist_id'), table_name='image_record')
|
|
||||||
op.drop_table('image_record')
|
|
||||||
op.drop_index(op.f('ix_download_event_source_id'), table_name='download_event')
|
|
||||||
op.drop_index(op.f('ix_download_event_post_id'), table_name='download_event')
|
|
||||||
op.drop_table('download_event')
|
|
||||||
op.drop_index(op.f('ix_subscribestar_seen_media_source_id'), table_name='subscribestar_seen_media')
|
|
||||||
op.drop_table('subscribestar_seen_media')
|
|
||||||
op.drop_index(op.f('ix_subscribestar_failed_media_source_id'), table_name='subscribestar_failed_media')
|
|
||||||
op.drop_table('subscribestar_failed_media')
|
|
||||||
op.drop_index(op.f('ix_post_source_id'), table_name='post')
|
|
||||||
op.drop_index(op.f('ix_post_artist_id'), table_name='post')
|
|
||||||
op.drop_table('post')
|
|
||||||
op.drop_index(op.f('ix_pixiv_seen_media_source_id'), table_name='pixiv_seen_media')
|
|
||||||
op.drop_table('pixiv_seen_media')
|
|
||||||
op.drop_index(op.f('ix_pixiv_failed_media_source_id'), table_name='pixiv_failed_media')
|
|
||||||
op.drop_table('pixiv_failed_media')
|
|
||||||
op.drop_index(op.f('ix_patreon_seen_media_source_id'), table_name='patreon_seen_media')
|
|
||||||
op.drop_table('patreon_seen_media')
|
|
||||||
op.drop_index(op.f('ix_patreon_failed_media_source_id'), table_name='patreon_failed_media')
|
|
||||||
op.drop_table('patreon_failed_media')
|
|
||||||
op.drop_table('tag_head')
|
|
||||||
op.drop_index(op.f('ix_tag_alias_canonical_tag_id'), table_name='tag_alias')
|
|
||||||
op.drop_table('tag_alias')
|
|
||||||
op.drop_index(op.f('ix_source_error_type'), table_name='source')
|
|
||||||
op.drop_index(op.f('ix_source_artist_id'), table_name='source')
|
|
||||||
op.drop_table('source')
|
|
||||||
op.drop_index(op.f('ix_head_metrics_snapshot_tag_id'), table_name='head_metrics_snapshot')
|
|
||||||
op.drop_index(op.f('ix_head_metrics_snapshot_snapshot_at'), table_name='head_metrics_snapshot')
|
|
||||||
op.drop_table('head_metrics_snapshot')
|
|
||||||
op.drop_table('head_metric')
|
|
||||||
op.drop_table('ccip_prototype_state')
|
|
||||||
op.drop_table('artist_visit')
|
|
||||||
op.drop_index(op.f('ix_task_run_task_name'), table_name='task_run')
|
|
||||||
op.drop_index(op.f('ix_task_run_status'), table_name='task_run')
|
|
||||||
op.drop_index(op.f('ix_task_run_started_at'), table_name='task_run')
|
|
||||||
op.drop_index(op.f('ix_task_run_queue'), table_name='task_run')
|
|
||||||
op.drop_index(op.f('ix_task_run_finished_at'), table_name='task_run')
|
|
||||||
op.drop_index(op.f('ix_task_run_celery_task_id'), table_name='task_run')
|
|
||||||
op.drop_table('task_run')
|
|
||||||
op.drop_index(op.f('ix_tag_fandom_id'), table_name='tag')
|
|
||||||
op.drop_table('tag')
|
|
||||||
op.drop_table('ml_settings')
|
|
||||||
op.drop_index(op.f('ix_library_audit_run_status'), table_name='library_audit_run')
|
|
||||||
op.drop_index(op.f('ix_library_audit_run_rule'), table_name='library_audit_run')
|
|
||||||
op.drop_table('library_audit_run')
|
|
||||||
op.drop_table('import_settings')
|
|
||||||
op.drop_index(op.f('ix_import_batch_status'), table_name='import_batch')
|
|
||||||
op.drop_table('import_batch')
|
|
||||||
op.drop_index(op.f('ix_head_training_run_status'), table_name='head_training_run')
|
|
||||||
op.drop_table('head_training_run')
|
|
||||||
op.drop_index(op.f('ix_head_auto_apply_run_status'), table_name='head_auto_apply_run')
|
|
||||||
op.drop_table('head_auto_apply_run')
|
|
||||||
op.drop_table('credential')
|
|
||||||
op.drop_index(op.f('ix_backup_run_tag'), table_name='backup_run')
|
|
||||||
op.drop_index(op.f('ix_backup_run_status'), table_name='backup_run')
|
|
||||||
op.drop_index(op.f('ix_backup_run_started_at'), table_name='backup_run')
|
|
||||||
op.drop_index(op.f('ix_backup_run_kind'), table_name='backup_run')
|
|
||||||
op.drop_index(op.f('ix_backup_run_finished_at'), table_name='backup_run')
|
|
||||||
op.drop_table('backup_run')
|
|
||||||
op.drop_table('artist')
|
|
||||||
op.drop_table('app_setting')
|
|
||||||
+7
-55
@@ -3,23 +3,13 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Quart, request
|
from quart import Quart
|
||||||
|
|
||||||
from .api import all_blueprints
|
from .api import all_blueprints
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
from .frontend import frontend_bp
|
from .frontend import frontend_bp
|
||||||
from .services.credential_crypto import CredentialCrypto
|
from .services.credential_crypto import CredentialCrypto
|
||||||
|
|
||||||
# Browser-extension origins. The FabledCurator extension fetches from
|
|
||||||
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ on
|
|
||||||
# Chromium-based browsers. Operator-flagged 2026-05-26: extension's
|
|
||||||
# 'Test connection' returned `NetworkError` because the X-Extension-Key
|
|
||||||
# header on /api/credentials triggers a CORS preflight that our routes
|
|
||||||
# don't handle. Whitelisting only these two schemes (not opening CORS
|
|
||||||
# up generally) lets the extension talk to a plain-HTTP self-hosted FC
|
|
||||||
# without weakening the no-CORS posture for normal browser usage.
|
|
||||||
_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://")
|
|
||||||
|
|
||||||
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
||||||
|
|
||||||
|
|
||||||
@@ -33,56 +23,18 @@ def create_app() -> Quart:
|
|||||||
|
|
||||||
app = Quart(__name__)
|
app = Quart(__name__)
|
||||||
app.secret_key = cfg.secret_key
|
app.secret_key = cfg.secret_key
|
||||||
|
# FC-5: legacy IR ingest JSON can run to tens of MB (hundreds of
|
||||||
# Stream files in 4 MiB chunks instead of Quart's 8 KiB default. The image
|
# thousands of image_tag_associations). Werkzeug's default form
|
||||||
# library lives on a CIFS/SMB share (mounted rsize=4 MiB), so 8 KiB reads
|
# memory cap is 500KB; raise both ceilings so the multipart upload
|
||||||
# meant ~19k network round-trips for one large original — 30–58s downloads
|
# for /api/migrate/ir_ingest doesn't 413.
|
||||||
# that starved both the GPU agent and the browser (operator-flagged
|
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 * 1024 # 1 GB
|
||||||
# 2026-07-01). 4 MiB matches the mount's read size → one round-trip per read,
|
app.config["MAX_FORM_MEMORY_SIZE"] = 1024 * 1024 * 1024 # 1 GB
|
||||||
# ~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"
|
|
||||||
)
|
|
||||||
|
|
||||||
for bp in all_blueprints():
|
for bp in all_blueprints():
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
# Registered last so /api/* routes win over the SPA catch-all.
|
# Registered last so /api/* routes win over the SPA catch-all.
|
||||||
app.register_blueprint(frontend_bp)
|
app.register_blueprint(frontend_bp)
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
async def _extension_cors_preflight():
|
|
||||||
# Short-circuit OPTIONS preflight from the browser extension with a
|
|
||||||
# 204 + CORS headers (the after_request hook below adds them).
|
|
||||||
# Without this, OPTIONS lands on routes that only declared POST/GET
|
|
||||||
# methods and 405s before the after_request gets a chance.
|
|
||||||
if request.method != "OPTIONS":
|
|
||||||
return None
|
|
||||||
origin = request.headers.get("Origin", "")
|
|
||||||
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
|
||||||
return "", 204
|
|
||||||
return None
|
|
||||||
|
|
||||||
@app.after_request
|
|
||||||
async def _extension_cors_headers(response):
|
|
||||||
origin = request.headers.get("Origin", "")
|
|
||||||
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
|
||||||
response.headers["Access-Control-Allow-Origin"] = origin
|
|
||||||
response.headers["Access-Control-Allow-Methods"] = (
|
|
||||||
"GET, POST, PATCH, DELETE, OPTIONS"
|
|
||||||
)
|
|
||||||
response.headers["Access-Control-Allow-Headers"] = (
|
|
||||||
"Content-Type, X-Extension-Key"
|
|
||||||
)
|
|
||||||
response.headers["Access-Control-Max-Age"] = "86400"
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.after_serving
|
@app.after_serving
|
||||||
async def _dispose_db_engine() -> None:
|
async def _dispose_db_engine() -> None:
|
||||||
from .extensions import dispose_engine
|
from .extensions import dispose_engine
|
||||||
|
|||||||
@@ -16,18 +16,16 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
|
|||||||
def all_blueprints() -> list[Blueprint]:
|
def all_blueprints() -> list[Blueprint]:
|
||||||
from .admin import admin_bp
|
from .admin import admin_bp
|
||||||
from .aliases import aliases_bp
|
from .aliases import aliases_bp
|
||||||
|
from .allowlist import allowlist_bp
|
||||||
from .artist import artist_bp
|
from .artist import artist_bp
|
||||||
from .artists import artists_bp
|
from .artists import artists_bp
|
||||||
from .attachments import attachments_bp
|
from .attachments import attachments_bp
|
||||||
from .ccip import ccip_bp
|
|
||||||
from .cleanup import cleanup_bp
|
|
||||||
from .credentials import credentials_bp
|
from .credentials import credentials_bp
|
||||||
from .downloads import downloads_bp
|
from .downloads import downloads_bp
|
||||||
from .extension import extension_bp
|
from .extension import extension_bp
|
||||||
from .gallery import gallery_bp
|
from .gallery import gallery_bp
|
||||||
from .gpu import gpu_bp
|
|
||||||
from .heads import heads_bp
|
|
||||||
from .import_admin import import_admin_bp
|
from .import_admin import import_admin_bp
|
||||||
|
from .migrate import migrate_bp
|
||||||
from .ml_admin import ml_admin_bp
|
from .ml_admin import ml_admin_bp
|
||||||
from .platforms import platforms_bp
|
from .platforms import platforms_bp
|
||||||
from .posts import posts_bp
|
from .posts import posts_bp
|
||||||
@@ -39,7 +37,6 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
from .system_activity import system_activity_bp
|
from .system_activity import system_activity_bp
|
||||||
from .system_backup import system_backup_bp
|
from .system_backup import system_backup_bp
|
||||||
from .tags import tags_bp
|
from .tags import tags_bp
|
||||||
from .thumbnails import thumbnails_bp
|
|
||||||
return [
|
return [
|
||||||
api_bp,
|
api_bp,
|
||||||
attachments_bp,
|
attachments_bp,
|
||||||
@@ -53,15 +50,12 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
system_activity_bp,
|
system_activity_bp,
|
||||||
system_backup_bp,
|
system_backup_bp,
|
||||||
admin_bp,
|
admin_bp,
|
||||||
cleanup_bp,
|
|
||||||
import_admin_bp,
|
import_admin_bp,
|
||||||
|
migrate_bp,
|
||||||
suggestions_bp,
|
suggestions_bp,
|
||||||
|
allowlist_bp,
|
||||||
aliases_bp,
|
aliases_bp,
|
||||||
heads_bp,
|
|
||||||
gpu_bp,
|
|
||||||
ccip_bp,
|
|
||||||
ml_admin_bp,
|
ml_admin_bp,
|
||||||
thumbnails_bp,
|
|
||||||
sources_bp,
|
sources_bp,
|
||||||
platforms_bp,
|
platforms_bp,
|
||||||
posts_bp,
|
posts_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
|
|
||||||
+15
-337
@@ -1,13 +1,11 @@
|
|||||||
"""FC-3k: /api/admin — destructive admin actions.
|
"""FC-3k: /api/admin — destructive admin actions.
|
||||||
|
|
||||||
Action surfaces:
|
Five action surfaces:
|
||||||
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
|
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
|
||||||
POST /api/admin/images/bulk-delete (Tier C)
|
POST /api/admin/images/bulk-delete (Tier C)
|
||||||
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
||||||
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
||||||
POST /api/admin/tags/prune-unused (Tier A)
|
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)
|
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
|
||||||
|
|
||||||
Tier-C ops take a dry_run body flag (returns projection inline,
|
Tier-C ops take a dry_run body flag (returns projection inline,
|
||||||
@@ -20,16 +18,21 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select
|
||||||
|
|
||||||
from ..extensions import get_session
|
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 ..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")
|
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:
|
def _bulk_image_confirm_token(image_ids: list[int]) -> str:
|
||||||
"""Stable 8-hex token derived from the sorted id list. Mutates
|
"""Stable 8-hex token derived from the sorted id list. Mutates
|
||||||
when the selection changes; stays the same across modal opens of
|
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]
|
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"])
|
@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
|
||||||
async def artist_cascade_delete(slug: str):
|
async def artist_cascade_delete(slug: str):
|
||||||
body = await request.get_json(silent=True) or {}
|
body = await request.get_json(silent=True) or {}
|
||||||
@@ -119,19 +97,11 @@ async def images_bulk_delete():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
sha8 = _bulk_image_confirm_token(image_ids)
|
|
||||||
expected = f"delete-images-{sha8}"
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
# Hand the canonical Tier-C confirm token back with the
|
|
||||||
# projection so the frontend doesn't have to recompute SHA-256
|
|
||||||
# client-side via crypto.subtle (Secure-Context-gated,
|
|
||||||
# undefined on plain-HTTP origins per the homelab posture).
|
|
||||||
# Operator-flagged 2026-05-27.
|
|
||||||
projected["confirm_token"] = expected
|
|
||||||
return jsonify(projected)
|
return jsonify(projected)
|
||||||
|
|
||||||
|
sha8 = _bulk_image_confirm_token(image_ids)
|
||||||
|
expected = f"delete-images-{sha8}"
|
||||||
if supplied_confirm != expected:
|
if supplied_confirm != expected:
|
||||||
return _bad(
|
return _bad(
|
||||||
"confirm_mismatch",
|
"confirm_mismatch",
|
||||||
@@ -156,10 +126,6 @@ async def tag_delete(tag_id: int):
|
|||||||
)
|
)
|
||||||
except LookupError:
|
except LookupError:
|
||||||
return _bad("not_found", status=404)
|
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)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -175,30 +141,6 @@ async def tag_merge(dest_id: int):
|
|||||||
if not isinstance(source_id, int) or source_id == dest_id:
|
if not isinstance(source_id, int) or source_id == dest_id:
|
||||||
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
|
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:
|
async with get_session() as session:
|
||||||
try:
|
try:
|
||||||
result = await TagService(session).merge(
|
result = await TagService(session).merge(
|
||||||
@@ -246,277 +188,13 @@ async def tags_prune_unused():
|
|||||||
re-call with dry_run=false."""
|
re-call with dry_run=false."""
|
||||||
from ..services.cleanup_service import prune_unused_tags
|
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 {}
|
body = await request.get_json(silent=True) or {}
|
||||||
dry_run = bool(body.get("dry_run", False))
|
dry_run = bool(body.get("dry_run", False))
|
||||||
|
|
||||||
async with get_session() as session:
|
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(
|
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)
|
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
|
}), 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"])
|
@artists_bp.route("/autocomplete", methods=["GET"])
|
||||||
async def autocomplete():
|
async def autocomplete():
|
||||||
q = request.args.get("q") or ""
|
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,
|
|
||||||
})
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""FC-Cleanup: /api/cleanup/* — retroactive enforcement of import filters.
|
|
||||||
|
|
||||||
Endpoints:
|
|
||||||
POST /min-dimension/preview synchronous SQL audit
|
|
||||||
POST /min-dimension/delete synchronous SQL delete (Tier-C token)
|
|
||||||
POST /audit async transparency / single_color start
|
|
||||||
GET /audit list recent audit_run rows
|
|
||||||
GET /audit/<id> single audit_run row
|
|
||||||
POST /audit/<id>/apply apply matched_ids deletes (Tier-C token)
|
|
||||||
POST /audit/<id>/cancel flip running audit to cancelled
|
|
||||||
|
|
||||||
Unused-tags retroactive prune intentionally NOT in this namespace —
|
|
||||||
TagMaintenanceCard (Maintenance tab → moved to Cleanup tab in v26.05.25.7)
|
|
||||||
uses the existing /api/admin/tags/prune-unused endpoint via the admin
|
|
||||||
store. No duplicate route here.
|
|
||||||
|
|
||||||
Confirm-token format matches modal/DestructiveConfirmModal.vue convention:
|
|
||||||
`delete-min-dim-<sha8(w,h)>` for min-dim delete
|
|
||||||
`delete-audit-<id>` for audit apply
|
|
||||||
(Modal hardcodes action ∈ {'restore', 'delete'}; "apply audit" is semantically a delete of the matched images, so we use `delete-audit-<id>`.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from ..extensions import get_session
|
|
||||||
from ..models import LibraryAuditRun
|
|
||||||
from ..services import cleanup_service
|
|
||||||
from ._responses import error_response as _bad
|
|
||||||
|
|
||||||
cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")
|
|
||||||
|
|
||||||
IMAGES_ROOT = Path("/images")
|
|
||||||
|
|
||||||
|
|
||||||
def _min_dim_token(min_w: int, min_h: int) -> str:
|
|
||||||
# SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both
|
|
||||||
# sides use SHA-256 truncated to 8 hex chars.
|
|
||||||
canon = f"{min_w}x{min_h}"
|
|
||||||
return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_audit_run(audit: LibraryAuditRun) -> dict:
|
|
||||||
return {
|
|
||||||
"id": audit.id,
|
|
||||||
"rule": audit.rule,
|
|
||||||
"params": audit.params,
|
|
||||||
"status": audit.status,
|
|
||||||
"started_at": audit.started_at.isoformat() if audit.started_at else None,
|
|
||||||
"finished_at": audit.finished_at.isoformat() if audit.finished_at else None,
|
|
||||||
"scanned_count": audit.scanned_count,
|
|
||||||
"matched_count": audit.matched_count,
|
|
||||||
"matched_ids": audit.matched_ids,
|
|
||||||
"error": audit.error,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@cleanup_bp.route("/min-dimension/preview", methods=["POST"])
|
|
||||||
async def min_dim_preview():
|
|
||||||
body = await request.get_json(silent=True) or {}
|
|
||||||
try:
|
|
||||||
min_w = int(body.get("min_width", 0))
|
|
||||||
min_h = int(body.get("min_height", 0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return _bad("invalid_dimensions")
|
|
||||||
if min_w < 0 or min_h < 0:
|
|
||||||
return _bad("invalid_dimensions")
|
|
||||||
async with get_session() as session:
|
|
||||||
projection = await session.run_sync(
|
|
||||||
lambda s: cleanup_service.project_min_dimension_violations(
|
|
||||||
s, min_width=min_w, min_height=min_h,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Hand the canonical Tier-C delete token back with the preview so
|
|
||||||
# the frontend doesn't have to recompute SHA-256 client-side.
|
|
||||||
# window.crypto.subtle is Secure-Context-gated and undefined on
|
|
||||||
# plain-HTTP origins (homelab posture); without this the Delete
|
|
||||||
# button silently swallowed the TypeError and never opened the
|
|
||||||
# confirm modal. Operator-flagged 2026-05-27.
|
|
||||||
projection["confirm_token"] = _min_dim_token(min_w, min_h)
|
|
||||||
return jsonify(projection)
|
|
||||||
|
|
||||||
|
|
||||||
@cleanup_bp.route("/min-dimension/delete", methods=["POST"])
|
|
||||||
async def min_dim_delete():
|
|
||||||
body = await request.get_json(silent=True) or {}
|
|
||||||
try:
|
|
||||||
min_w = int(body.get("min_width", 0))
|
|
||||||
min_h = int(body.get("min_height", 0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return _bad("invalid_dimensions")
|
|
||||||
if min_w < 0 or min_h < 0:
|
|
||||||
return _bad("invalid_dimensions")
|
|
||||||
supplied = body.get("confirm", "")
|
|
||||||
expected = _min_dim_token(min_w, min_h)
|
|
||||||
if supplied != expected:
|
|
||||||
return _bad("confirm_mismatch", expected=expected)
|
|
||||||
async with get_session() as session:
|
|
||||||
deleted = await session.run_sync(
|
|
||||||
lambda s: cleanup_service.delete_min_dimension_violations(
|
|
||||||
s, min_width=min_w, min_height=min_h, images_root=IMAGES_ROOT,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return jsonify({"deleted": deleted})
|
|
||||||
|
|
||||||
|
|
||||||
@cleanup_bp.route("/audit", methods=["POST"])
|
|
||||||
async def audit_create():
|
|
||||||
body = await request.get_json(silent=True) or {}
|
|
||||||
rule = body.get("rule")
|
|
||||||
params = body.get("params") or {}
|
|
||||||
if rule not in ("transparency", "single_color"):
|
|
||||||
return _bad("invalid_rule")
|
|
||||||
if not isinstance(params, dict):
|
|
||||||
return _bad("invalid_params")
|
|
||||||
async with get_session() as session:
|
|
||||||
try:
|
|
||||||
audit_id = await session.run_sync(
|
|
||||||
lambda s: cleanup_service.start_audit_run(
|
|
||||||
s, rule=rule, params=params,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except cleanup_service.AuditAlreadyRunning as running_id:
|
|
||||||
return _bad(
|
|
||||||
"audit_already_running", status=409,
|
|
||||||
running_id=int(str(running_id)),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
return _bad(str(exc))
|
|
||||||
await session.commit()
|
|
||||||
return jsonify({"audit_id": audit_id, "status": "running"}), 202
|
|
||||||
|
|
||||||
|
|
||||||
@cleanup_bp.route("/audit/<int:audit_id>", methods=["GET"])
|
|
||||||
async def audit_get(audit_id: int):
|
|
||||||
async with get_session() as session:
|
|
||||||
audit = (await session.execute(
|
|
||||||
select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
|
|
||||||
)).scalar_one_or_none()
|
|
||||||
if audit is None:
|
|
||||||
return _bad("not_found", status=404)
|
|
||||||
return jsonify(_serialize_audit_run(audit))
|
|
||||||
|
|
||||||
|
|
||||||
@cleanup_bp.route("/audit", methods=["GET"])
|
|
||||||
async def audit_history():
|
|
||||||
try:
|
|
||||||
limit = min(int(request.args.get("limit", "20")), 100)
|
|
||||||
except ValueError:
|
|
||||||
return _bad("invalid_limit")
|
|
||||||
# Optional rule filter so a card can reconnect to ITS latest run on mount
|
|
||||||
# (?rule=transparency&limit=1) — the audit survives navigation; the UI
|
|
||||||
# rehydrates from this rather than losing the in-flight scan.
|
|
||||||
rule = request.args.get("rule") or None
|
|
||||||
async with get_session() as session:
|
|
||||||
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
|
|
||||||
if rule is not None:
|
|
||||||
stmt = stmt.where(LibraryAuditRun.rule == rule)
|
|
||||||
rows = (await session.execute(stmt.limit(limit))).scalars().all()
|
|
||||||
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
|
||||||
|
|
||||||
|
|
||||||
@cleanup_bp.route("/audit/<int:audit_id>/apply", methods=["POST"])
|
|
||||||
async def audit_apply(audit_id: int):
|
|
||||||
body = await request.get_json(silent=True) or {}
|
|
||||||
confirm = body.get("confirm", "")
|
|
||||||
async with get_session() as session:
|
|
||||||
try:
|
|
||||||
deleted = await session.run_sync(
|
|
||||||
lambda s: cleanup_service.apply_audit_run(
|
|
||||||
s, audit_id=audit_id, confirm_token=confirm,
|
|
||||||
images_root=IMAGES_ROOT,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except cleanup_service.AuditNotReady as exc:
|
|
||||||
return _bad("audit_not_ready", current_status=str(exc))
|
|
||||||
except cleanup_service.ConfirmTokenMismatch as exc:
|
|
||||||
return _bad("confirm_mismatch", expected=str(exc))
|
|
||||||
except ValueError as exc:
|
|
||||||
return _bad("not_found", status=404, detail=str(exc))
|
|
||||||
await session.commit()
|
|
||||||
return jsonify({"deleted": deleted})
|
|
||||||
|
|
||||||
|
|
||||||
@cleanup_bp.route("/audit/<int:audit_id>/cancel", methods=["POST"])
|
|
||||||
async def audit_cancel(audit_id: int):
|
|
||||||
async with get_session() as session:
|
|
||||||
await session.run_sync(
|
|
||||||
lambda s: cleanup_service.cancel_audit_run(s, audit_id=audit_id)
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return jsonify({"cancelled": True})
|
|
||||||
@@ -20,7 +20,6 @@ from ..services.credential_service import (
|
|||||||
UnknownPlatformError,
|
UnknownPlatformError,
|
||||||
WrongAuthTypeError,
|
WrongAuthTypeError,
|
||||||
)
|
)
|
||||||
from ._responses import error_response as _bad
|
|
||||||
|
|
||||||
credentials_bp = Blueprint("credentials", __name__, url_prefix="/api/credentials")
|
credentials_bp = Blueprint("credentials", __name__, url_prefix="/api/credentials")
|
||||||
|
|
||||||
@@ -39,6 +38,14 @@ def _get_crypto() -> CredentialCrypto:
|
|||||||
return _crypto
|
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:
|
async def _ext_key_ok(session) -> bool:
|
||||||
"""If X-Extension-Key is supplied, it must match the stored value.
|
"""If X-Extension-Key is supplied, it must match the stored value.
|
||||||
Missing header → True (browser path; accepted per homelab posture).
|
Missing header → True (browser path; accepted per homelab posture).
|
||||||
@@ -117,58 +124,3 @@ async def delete_credential(platform: str):
|
|||||||
except LookupError:
|
except LookupError:
|
||||||
return _bad("not_found", status=404)
|
return _bad("not_found", status=404)
|
||||||
return "", 204
|
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})
|
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ status/source/artist. Returns slim records.
|
|||||||
Detail view: full DownloadEvent including the metadata JSONB.
|
Detail view: full DownloadEvent including the metadata JSONB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import Artist, DownloadEvent, Source
|
from ..models import Artist, DownloadEvent, Source
|
||||||
@@ -44,9 +42,6 @@ def _list_record(event: DownloadEvent, source: Source | None, artist: Artist | N
|
|||||||
"bytes_downloaded": event.bytes_downloaded,
|
"bytes_downloaded": event.bytes_downloaded,
|
||||||
"error": event.error,
|
"error": event.error,
|
||||||
"summary": _summary_from_metadata(event.metadata_),
|
"summary": _summary_from_metadata(event.metadata_),
|
||||||
# plan #709: mid-walk live counts for a RUNNING native-ingester event
|
|
||||||
# (None otherwise; phase 3 overwrites metadata with run_stats on finish).
|
|
||||||
"live": (event.metadata_ or {}).get("live"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -100,83 +95,6 @@ async def list_downloads():
|
|||||||
return jsonify([_list_record(e, s, a) for e, s, a in rows])
|
return jsonify([_list_record(e, s, a) for e, s, a in rows])
|
||||||
|
|
||||||
|
|
||||||
@downloads_bp.route("/stats", methods=["GET"])
|
|
||||||
async def downloads_stats():
|
|
||||||
"""Status-grouped count over download_event for the dashboard stat chips.
|
|
||||||
|
|
||||||
`?window_hours=` (default 24) bounds by `started_at`. The full set of
|
|
||||||
statuses is always present in the response (zero for missing) so the
|
|
||||||
UI doesn't have to fill in defaults.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
window_hours = int(request.args.get("window_hours", "24"))
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "invalid_window_hours"}), 400
|
|
||||||
if window_hours < 1 or window_hours > 24 * 365:
|
|
||||||
return jsonify({"error": "invalid_window_hours"}), 400
|
|
||||||
|
|
||||||
since = datetime.now(UTC) - timedelta(hours=window_hours)
|
|
||||||
out = {"pending": 0, "running": 0, "ok": 0, "error": 0, "skipped": 0}
|
|
||||||
async with get_session() as session:
|
|
||||||
stmt = (
|
|
||||||
select(DownloadEvent.status, func.count())
|
|
||||||
.where(DownloadEvent.started_at >= since)
|
|
||||||
.group_by(DownloadEvent.status)
|
|
||||||
)
|
|
||||||
for status, n in (await session.execute(stmt)).all():
|
|
||||||
if status in out:
|
|
||||||
out[status] = int(n)
|
|
||||||
return jsonify(out)
|
|
||||||
|
|
||||||
|
|
||||||
@downloads_bp.route("/activity", methods=["GET"])
|
|
||||||
async def downloads_activity():
|
|
||||||
"""Hourly download-event counts over the last `?hours=` (default 24).
|
|
||||||
|
|
||||||
Returns a fixed-length, oldest-first bucket array so the UI can render
|
|
||||||
a sparkline directly. Bucketing is done in Python against UTC to dodge
|
|
||||||
session-timezone ambiguity in SQL date_trunc.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
hours = int(request.args.get("hours", "24"))
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "invalid_hours"}), 400
|
|
||||||
hours = max(1, min(168, hours))
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
end = now.replace(minute=0, second=0, microsecond=0)
|
|
||||||
start = end - timedelta(hours=hours - 1)
|
|
||||||
buckets = [
|
|
||||||
{"hour": (start + timedelta(hours=i)).isoformat(),
|
|
||||||
"ok": 0, "error": 0, "other": 0, "total": 0}
|
|
||||||
for i in range(hours)
|
|
||||||
]
|
|
||||||
|
|
||||||
async with get_session() as session:
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(DownloadEvent.started_at, DownloadEvent.status)
|
|
||||||
.where(DownloadEvent.started_at >= start)
|
|
||||||
)).all()
|
|
||||||
|
|
||||||
for started_at, status in rows:
|
|
||||||
if started_at is None:
|
|
||||||
continue
|
|
||||||
sa = started_at if started_at.tzinfo else started_at.replace(tzinfo=UTC)
|
|
||||||
idx = int((sa - start).total_seconds() // 3600)
|
|
||||||
if not (0 <= idx < hours):
|
|
||||||
continue
|
|
||||||
b = buckets[idx]
|
|
||||||
if status == "ok":
|
|
||||||
b["ok"] += 1
|
|
||||||
elif status == "error":
|
|
||||||
b["error"] += 1
|
|
||||||
else:
|
|
||||||
b["other"] += 1
|
|
||||||
b["total"] += 1
|
|
||||||
|
|
||||||
return jsonify({"hours": hours, "buckets": buckets})
|
|
||||||
|
|
||||||
|
|
||||||
@downloads_bp.route("/<int:event_id>", methods=["GET"])
|
@downloads_bp.route("/<int:event_id>", methods=["GET"])
|
||||||
async def get_download(event_id: int):
|
async def get_download(event_id: int):
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
@@ -190,20 +108,3 @@ async def get_download(event_id: int):
|
|||||||
return jsonify({"error": "not_found"}), 404
|
return jsonify({"error": "not_found"}), 404
|
||||||
event, source, artist = row
|
event, source, artist = row
|
||||||
return jsonify(_detail_record(event, source, artist))
|
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 asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from ..build_info import FC_CHANNEL as _FC_CHANNEL
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import AppSetting
|
from ..models import AppSetting
|
||||||
from ..services.extension_service import (
|
from ..services.extension_service import (
|
||||||
@@ -22,7 +20,6 @@ from ..services.extension_service import (
|
|||||||
UnknownPlatformError,
|
UnknownPlatformError,
|
||||||
)
|
)
|
||||||
from ..services.source_service import KNOWN_PLATFORMS
|
from ..services.source_service import KNOWN_PLATFORMS
|
||||||
from ._responses import error_response as _bad
|
|
||||||
|
|
||||||
extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension")
|
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$")
|
_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
|
def _bad(error: str, *, status: int = 400, **extra):
|
||||||
# a second time: /api/health reports the same value, and two independent
|
body = {"error": error}
|
||||||
# `os.environ.get` calls are two things that can drift.
|
body.update(extra)
|
||||||
#
|
return jsonify(body), status
|
||||||
# Still bound as a module-level name here, so tests monkeypatch
|
|
||||||
# `extension.FC_CHANNEL` exactly as they did before, same as XPI_DIR above.
|
|
||||||
FC_CHANNEL = _FC_CHANNEL
|
|
||||||
|
|
||||||
|
|
||||||
async def _ext_key_required(session) -> bool:
|
async def _ext_key_required(session) -> bool:
|
||||||
@@ -52,15 +46,7 @@ async def _ext_key_required(session) -> bool:
|
|||||||
stored = (await session.execute(
|
stored = (await session.execute(
|
||||||
select(AppSetting.value).where(AppSetting.key == "extension_api_key")
|
select(AppSetting.value).where(AppSetting.key == "extension_api_key")
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if stored is None:
|
return stored is not None and supplied == stored
|
||||||
return False
|
|
||||||
# compare_digest, not `==`: the stored key is a shared secret, and a
|
|
||||||
# short-circuiting compare leaks its prefix through timing. Costs nothing
|
|
||||||
# here — it is not that this route is exposed (#3072). Compared as BYTES:
|
|
||||||
# compare_digest's str form rejects non-ASCII with TypeError, and this
|
|
||||||
# header is attacker-supplied, so a str compare would turn a junk key into
|
|
||||||
# a 500 instead of a 403.
|
|
||||||
return hmac.compare_digest(supplied.encode("utf-8"), stored.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_version(xpi_name: str) -> str:
|
def _extract_version(xpi_name: str) -> str:
|
||||||
@@ -76,24 +62,6 @@ def _sha256(path: Path) -> str:
|
|||||||
return h.hexdigest()
|
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"])
|
@extension_bp.route("/quick-add-source", methods=["POST"])
|
||||||
async def quick_add_source():
|
async def quick_add_source():
|
||||||
body = await request.get_json(silent=True)
|
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():
|
if not isinstance(url, str) or not url.strip():
|
||||||
return _bad("invalid_body", detail="url is required")
|
return _bad("invalid_body", detail="url is required")
|
||||||
|
|
||||||
from .credentials import _get_crypto
|
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
if not await _ext_key_required(session):
|
if not await _ext_key_required(session):
|
||||||
return _bad("unauthorized", status=401)
|
return _bad("unauthorized", status=401)
|
||||||
try:
|
try:
|
||||||
# crypto lets a pixiv add resolve the artist's display name via the
|
result = await ExtensionService(session).quick_add_source(url)
|
||||||
# stored OAuth token (else it falls back to the numeric id). #130.
|
|
||||||
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
|
|
||||||
except UnknownPlatformError as exc:
|
except UnknownPlatformError as exc:
|
||||||
return _bad(
|
return _bad(
|
||||||
"unknown_platform",
|
"unknown_platform",
|
||||||
@@ -129,44 +93,17 @@ def _read_manifest_sync() -> dict | None:
|
|||||||
asyncio.to_thread (ASYNC240: no pathlib I/O in async functions)."""
|
asyncio.to_thread (ASYNC240: no pathlib I/O in async functions)."""
|
||||||
if not XPI_DIR.is_dir():
|
if not XPI_DIR.is_dir():
|
||||||
return None
|
return None
|
||||||
# Exclude the `fabledcurator-latest.xpi` alias when picking the file to
|
xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime)
|
||||||
# extract a version from — it's a copy of the latest versioned XPI,
|
if not xpis:
|
||||||
# written at the same mtime by build.yml, and would otherwise tie or
|
|
||||||
# win the sort (operator-flagged 2026-05-26: UI displayed "v latest"
|
|
||||||
# because `_extract_version("fabledcurator-latest.xpi")` returns
|
|
||||||
# the literal "latest"). The alias still serves as `latest_url`.
|
|
||||||
versioned = [
|
|
||||||
p for p in XPI_DIR.glob("fabledcurator-*.xpi")
|
|
||||||
if p.name != "fabledcurator-latest.xpi"
|
|
||||||
]
|
|
||||||
if not versioned:
|
|
||||||
return None
|
return None
|
||||||
versioned.sort(key=lambda p: p.stat().st_mtime)
|
latest = xpis[-1]
|
||||||
latest = versioned[-1]
|
return {
|
||||||
info = {
|
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"version": _extract_version(latest.name),
|
"version": _extract_version(latest.name),
|
||||||
"xpi_url": f"/extension/{latest.name}",
|
"xpi_url": f"/extension/{latest.name}",
|
||||||
"latest_url": "/extension/fabledcurator-latest.xpi",
|
"latest_url": "/extension/fabledcurator-latest.xpi",
|
||||||
"sha256": _sha256(latest),
|
"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"])
|
@extension_bp.route("/manifest", methods=["GET"])
|
||||||
|
|||||||
+43
-279
@@ -1,131 +1,54 @@
|
|||||||
"""Gallery API: cursor scroll, timeline, jump, image detail, facets."""
|
"""Gallery API: cursor scroll, timeline, jump, image detail."""
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import delete, select, update
|
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import (
|
from ..services.gallery_service import GalleryService
|
||||||
ImageRecord,
|
|
||||||
PresentationReview,
|
|
||||||
Tag,
|
|
||||||
TagSuggestionRejection,
|
|
||||||
)
|
|
||||||
from ..models.tag import image_tag
|
|
||||||
from ..services.gallery_service import GalleryService, image_url, thumbnail_url
|
|
||||||
|
|
||||||
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
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"])
|
@gallery_bp.route("/scroll", methods=["GET"])
|
||||||
async def scroll():
|
async def scroll():
|
||||||
cursor = request.args.get("cursor") or None
|
cursor = request.args.get("cursor") or None
|
||||||
try:
|
try:
|
||||||
limit = int(request.args.get("limit", "50"))
|
limit = int(request.args.get("limit", "50"))
|
||||||
filters, sort = _parse_filters()
|
|
||||||
except ValueError:
|
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:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
page = await svc.scroll(
|
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:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
return jsonify(
|
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,
|
"next_cursor": page.next_cursor,
|
||||||
"date_groups": [
|
"date_groups": [
|
||||||
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.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"])
|
@gallery_bp.route("/timeline", methods=["GET"])
|
||||||
async def timeline():
|
async def timeline():
|
||||||
try:
|
tag_id_raw = request.args.get("tag_id")
|
||||||
filters, _sort = _parse_filters()
|
tag_id = int(tag_id_raw) if tag_id_raw else None
|
||||||
except ValueError:
|
post_id_raw = request.args.get("post_id")
|
||||||
return jsonify({"error": "invalid filter parameter"}), 400
|
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:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
return jsonify(
|
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"])
|
@gallery_bp.route("/jump", methods=["GET"])
|
||||||
async def jump():
|
async def jump():
|
||||||
try:
|
try:
|
||||||
year = int(request.args["year"])
|
year = int(request.args["year"])
|
||||||
month = int(request.args["month"])
|
month = int(request.args["month"])
|
||||||
filters, sort = _parse_filters()
|
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
return jsonify({"error": "year and month query params required"}), 400
|
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:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
cursor = await svc.jump_cursor(
|
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:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
return jsonify({"cursor": cursor})
|
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"])
|
@gallery_bp.route("/image/<int:image_id>", methods=["GET"])
|
||||||
async def image_detail(image_id: int):
|
async def image_detail(image_id: int):
|
||||||
async with get_session() as session:
|
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.
|
"""Health endpoint — no DB or Redis touch; just liveness."""
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def get_health():
|
async def get_health():
|
||||||
body = {"status": "ok"}
|
return {"status": "ok"}, 200
|
||||||
if FC_VERSION:
|
|
||||||
body["version"] = FC_VERSION
|
|
||||||
if FC_CHANNEL:
|
|
||||||
body["channel"] = FC_CHANNEL
|
|
||||||
return body, 200
|
|
||||||
|
|||||||
@@ -35,26 +35,10 @@ async def trigger_scan():
|
|||||||
@import_admin_bp.route("/status", methods=["GET"])
|
@import_admin_bp.route("/status", methods=["GET"])
|
||||||
async def status():
|
async def status():
|
||||||
async with get_session() as session:
|
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 = (
|
active = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(ImportBatch)
|
select(ImportBatch)
|
||||||
.where(
|
.where(ImportBatch.status == "running")
|
||||||
ImportBatch.status == "running",
|
|
||||||
select(ImportTask.id)
|
|
||||||
.where(
|
|
||||||
ImportTask.batch_id == ImportBatch.id,
|
|
||||||
ImportTask.status.in_(["pending", "queued", "processing"]),
|
|
||||||
)
|
|
||||||
.exists(),
|
|
||||||
)
|
|
||||||
.order_by(ImportBatch.started_at.desc())
|
.order_by(ImportBatch.started_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -119,64 +103,24 @@ async def list_tasks():
|
|||||||
|
|
||||||
@import_admin_bp.route("/retry-failed", methods=["POST"])
|
@import_admin_bp.route("/retry-failed", methods=["POST"])
|
||||||
async def retry_failed():
|
async def retry_failed():
|
||||||
# Fold SELECT into UPDATE…WHERE…RETURNING — the prior SELECT-then-
|
|
||||||
# UPDATE-WHERE-id-IN pattern blew past psycopg's 65535-parameter
|
|
||||||
# ceiling once failed_ids exceeded ~65k rows.
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
result = await session.execute(
|
failed_ids = (
|
||||||
update(ImportTask)
|
await session.execute(select(ImportTask.id).where(ImportTask.status == "failed"))
|
||||||
.where(ImportTask.status == "failed")
|
).scalars().all()
|
||||||
.values(
|
if not failed_ids:
|
||||||
status="queued", error=None,
|
|
||||||
started_at=None, finished_at=None,
|
|
||||||
)
|
|
||||||
.returning(ImportTask.id, ImportTask.task_type)
|
|
||||||
)
|
|
||||||
failed = result.all()
|
|
||||||
if not failed:
|
|
||||||
return jsonify({"retried": 0})
|
return jsonify({"retried": 0})
|
||||||
|
await session.execute(
|
||||||
|
update(ImportTask)
|
||||||
|
.where(ImportTask.id.in_(failed_ids))
|
||||||
|
.values(status="queued", error=None, started_at=None, finished_at=None)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
from ..tasks.import_file import enqueue_import
|
from ..tasks.import_file import import_media_file
|
||||||
for tid, task_type in failed:
|
for tid in failed_ids:
|
||||||
enqueue_import(tid, task_type)
|
import_media_file.delay(tid)
|
||||||
|
|
||||||
return jsonify({"retried": len(failed)})
|
return jsonify({"retried": len(failed_ids)})
|
||||||
|
|
||||||
|
|
||||||
@import_admin_bp.route("/tasks/<int:task_id>/refetch", methods=["POST"])
|
|
||||||
async def refetch_task(task_id: int):
|
|
||||||
"""Layer-2 one-shot re-download: delete the (corrupt) file behind a
|
|
||||||
failed import task and re-run its source's downloader to fetch a
|
|
||||||
fresh copy. Only works for files that resolve to an enabled,
|
|
||||||
real-URL subscription Source; filesystem-only imports return
|
|
||||||
no_source.
|
|
||||||
|
|
||||||
Returns one of: refetch_queued (+source_id) / no_source /
|
|
||||||
already_refetched / not_found / not_failed.
|
|
||||||
"""
|
|
||||||
async with get_session() as session:
|
|
||||||
result = await session.run_sync(_refetch_task_sync, task_id)
|
|
||||||
if result["status"] == "not_found":
|
|
||||||
return jsonify(result), 404
|
|
||||||
if result["status"] == "not_failed":
|
|
||||||
return jsonify(result), 400
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
|
|
||||||
def _refetch_task_sync(session, task_id: int) -> dict:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ..models import ImportSettings
|
|
||||||
from ..services.refetch_service import attempt_refetch
|
|
||||||
|
|
||||||
task = session.get(ImportTask, task_id)
|
|
||||||
if task is None:
|
|
||||||
return {"status": "not_found"}
|
|
||||||
if task.status != "failed":
|
|
||||||
return {"status": "not_failed"}
|
|
||||||
settings = ImportSettings.load_sync(session)
|
|
||||||
return attempt_refetch(session, task, Path(settings.import_scan_path))
|
|
||||||
|
|
||||||
|
|
||||||
@import_admin_bp.route("/clear-stuck", methods=["POST"])
|
@import_admin_bp.route("/clear-stuck", methods=["POST"])
|
||||||
@@ -194,14 +138,17 @@ async def clear_stuck():
|
|||||||
autoretry-looped for 2 days after a corrupt-data PIL OSError.
|
autoretry-looped for 2 days after a corrupt-data PIL OSError.
|
||||||
"""
|
"""
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
# Fold SELECT into UPDATE…WHERE — see /retry-failed for the
|
stuck_ids = (
|
||||||
# 65535-parameter ceiling rationale. rowcount is enough here
|
await session.execute(
|
||||||
# because we don't need the ids afterward (no .delay()).
|
select(ImportTask.id).where(
|
||||||
clear_result = await session.execute(
|
|
||||||
update(ImportTask)
|
|
||||||
.where(
|
|
||||||
ImportTask.status.in_(["pending", "queued", "processing"])
|
ImportTask.status.in_(["pending", "queued", "processing"])
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if stuck_ids:
|
||||||
|
await session.execute(
|
||||||
|
update(ImportTask)
|
||||||
|
.where(ImportTask.id.in_(stuck_ids))
|
||||||
.values(
|
.values(
|
||||||
status="failed",
|
status="failed",
|
||||||
finished_at=datetime.now(UTC),
|
finished_at=datetime.now(UTC),
|
||||||
@@ -213,7 +160,6 @@ async def clear_stuck():
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tasks_failed = clear_result.rowcount or 0
|
|
||||||
|
|
||||||
# Finalize any 'running' ImportBatch that no longer has any
|
# Finalize any 'running' ImportBatch that no longer has any
|
||||||
# active children. The "Scanning..." banner is driven by
|
# active children. The "Scanning..." banner is driven by
|
||||||
@@ -249,7 +195,7 @@ async def clear_stuck():
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"tasks_failed": tasks_failed,
|
"tasks_failed": len(stuck_ids),
|
||||||
"batches_finalized": finalized_batches,
|
"batches_finalized": finalized_batches,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import MLSettings
|
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")
|
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 = (
|
_EDITABLE = (
|
||||||
"cpu_embed_enabled",
|
"suggestion_threshold_artist",
|
||||||
"video_frame_interval_seconds",
|
"suggestion_threshold_character",
|
||||||
"video_max_frames",
|
"suggestion_threshold_copyright",
|
||||||
"head_min_positives",
|
"suggestion_threshold_general",
|
||||||
"head_auto_apply_precision",
|
"centroid_similarity_threshold",
|
||||||
"head_auto_apply_enabled",
|
"min_reference_images",
|
||||||
"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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 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"])
|
@ml_admin_bp.route("/settings", methods=["GET"])
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
s = await MLSettings.load(session)
|
s = (
|
||||||
# Table-driven off _EDITABLE (which PATCH also writes) so a new settings field
|
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||||
# can never be silently absent from GET — the split that historically dropped
|
).scalar_one()
|
||||||
# fields. _EDITABLE already includes *_DETECTOR_FIELDS.
|
return jsonify(
|
||||||
return jsonify({f: getattr(s, f) for f in _EDITABLE})
|
{
|
||||||
|
"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"])
|
@ml_admin_bp.route("/settings", methods=["PATCH"])
|
||||||
async def patch_settings():
|
async def patch_settings():
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
return jsonify({"error": "body must be an object"}), 400
|
return jsonify({"error": "body must be an object"}), 400
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
s = await MLSettings.load(session)
|
s = (
|
||||||
|
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||||
# Merge the patch over current values, then validate the result as a
|
).scalar_one()
|
||||||
# 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}
|
|
||||||
for field in _EDITABLE:
|
for field in _EDITABLE:
|
||||||
if field in body:
|
if field in body:
|
||||||
proposed[field] = body[field]
|
setattr(s, 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])
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return await get_settings()
|
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"])
|
@ml_admin_bp.route("/backfill", methods=["POST"])
|
||||||
async def trigger_backfill():
|
async def trigger_backfill():
|
||||||
from ..tasks.ml import backfill
|
from ..tasks.ml import backfill
|
||||||
|
|
||||||
r = backfill.delay()
|
r = backfill.delay()
|
||||||
return jsonify({"celery_task_id": r.id}), 202
|
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 quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from ..extensions import get_session
|
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.post_feed_service import PostFeedService
|
||||||
from ..services.source_service import KNOWN_PLATFORMS
|
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")
|
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
|
||||||
|
|
||||||
_TRANSLATION_OVERRIDES = ("auto", "force", "original")
|
|
||||||
|
|
||||||
|
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
|
||||||
def _queue_for_sweep(post: Post) -> None:
|
body = {"error": error}
|
||||||
"""Mark a post untranslated (all translation columns NULL) so the periodic
|
if detail is not None:
|
||||||
sweep re-runs it under its new override — used when Interpreter is down and we
|
body["detail"] = detail
|
||||||
can't translate inline."""
|
body.update(extra)
|
||||||
post.post_title_translated = None
|
return jsonify(body), status
|
||||||
post.description_translated = None
|
|
||||||
post.translated_source_lang = None
|
|
||||||
post.translation_engine_version = None
|
|
||||||
post.translated_at = None
|
|
||||||
|
|
||||||
|
|
||||||
@posts_bp.route("", methods=["GET"])
|
@posts_bp.route("", methods=["GET"])
|
||||||
@@ -33,10 +24,7 @@ async def list_posts():
|
|||||||
cursor = args.get("cursor") or None
|
cursor = args.get("cursor") or None
|
||||||
artist_id_raw = args.get("artist_id")
|
artist_id_raw = args.get("artist_id")
|
||||||
platform = args.get("platform") or None
|
platform = args.get("platform") or None
|
||||||
q = (args.get("q") or "").strip() or None
|
|
||||||
limit_raw = args.get("limit", "24")
|
limit_raw = args.get("limit", "24")
|
||||||
direction = args.get("direction", "older")
|
|
||||||
around_raw = args.get("around")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
limit = int(limit_raw)
|
limit = int(limit_raw)
|
||||||
@@ -45,16 +33,6 @@ async def list_posts():
|
|||||||
if limit < 1 or limit > 100:
|
if limit < 1 or limit > 100:
|
||||||
return _bad("invalid_limit", detail="limit must be between 1 and 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
|
artist_id = None
|
||||||
if artist_id_raw is not None:
|
if artist_id_raw is not None:
|
||||||
try:
|
try:
|
||||||
@@ -69,19 +47,10 @@ async def list_posts():
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with get_session() as session:
|
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:
|
try:
|
||||||
page = await svc.scroll(
|
page = await PostFeedService(session).scroll(
|
||||||
cursor=cursor, artist_id=artist_id,
|
cursor=cursor, artist_id=artist_id,
|
||||||
platform=platform, q=q, limit=limit, direction=direction,
|
platform=platform, limit=limit,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# Service raises ValueError for malformed cursors only;
|
# Service raises ValueError for malformed cursors only;
|
||||||
@@ -98,70 +67,3 @@ async def get_post(post_id: int):
|
|||||||
if item is None:
|
if item is None:
|
||||||
return _bad("not_found", status=404, detail=f"post id={post_id}")
|
return _bad("not_found", status=404, detail=f"post id={post_id}")
|
||||||
return jsonify(item)
|
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."""
|
"""Settings API: import filters, system stats."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import (
|
from ..models import AppSetting, Artist, ImageRecord, ImportBatch, ImportSettings, ImportTask, Tag
|
||||||
AppSetting,
|
|
||||||
Artist,
|
|
||||||
ImageRecord,
|
|
||||||
ImportBatch,
|
|
||||||
ImportSettings,
|
|
||||||
ImportTask,
|
|
||||||
Post,
|
|
||||||
Tag,
|
|
||||||
TaskRun,
|
|
||||||
)
|
|
||||||
from ..services import interpreter_client as ic
|
|
||||||
|
|
||||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api")
|
settings_bp = Blueprint("settings", __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -37,38 +25,30 @@ _EDITABLE_FIELDS = (
|
|||||||
"download_schedule_default_seconds",
|
"download_schedule_default_seconds",
|
||||||
"download_event_retention_days",
|
"download_event_retention_days",
|
||||||
"download_failure_warning_threshold",
|
"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"])
|
@settings_bp.route("/settings/import", methods=["GET"])
|
||||||
async def get_import_settings():
|
async def get_import_settings():
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
row = await ImportSettings.load(session)
|
row = (
|
||||||
# Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field
|
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
|
||||||
# can't be silently absent from GET.
|
).scalar_one()
|
||||||
return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS})
|
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"])
|
@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:
|
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 100:
|
||||||
return _bad_int("download_failure_warning_threshold", 1, 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:
|
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:
|
for field in _EDITABLE_FIELDS:
|
||||||
if field in body:
|
if field in body:
|
||||||
setattr(row, field, body[field])
|
setattr(row, field, body[field])
|
||||||
@@ -173,18 +110,6 @@ async def update_import_settings():
|
|||||||
return await get_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"])
|
@settings_bp.route("/system/stats", methods=["GET"])
|
||||||
async def system_stats():
|
async def system_stats():
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
@@ -310,159 +235,3 @@ async def rotate_extension_api_key():
|
|||||||
row.value = new_value
|
row.value = new_value
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify({"key": new_value})
|
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 ..extensions import get_session
|
||||||
from ..models import DownloadEvent, Source
|
from ..models import DownloadEvent, Source
|
||||||
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
|
|
||||||
from ..services.source_service import (
|
from ..services.source_service import (
|
||||||
KNOWN_PLATFORMS,
|
KNOWN_PLATFORMS,
|
||||||
ArtistNotFoundError,
|
ArtistNotFoundError,
|
||||||
@@ -15,11 +14,18 @@ from ..services.source_service import (
|
|||||||
SourceService,
|
SourceService,
|
||||||
UnknownPlatformError,
|
UnknownPlatformError,
|
||||||
)
|
)
|
||||||
from ._responses import error_response as _bad
|
|
||||||
|
|
||||||
sources_bp = Blueprint("sources", __name__, url_prefix="/api/sources")
|
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"])
|
@sources_bp.route("", methods=["GET"])
|
||||||
async def list_sources():
|
async def list_sources():
|
||||||
artist_id_raw = request.args.get("artist_id")
|
artist_id_raw = request.args.get("artist_id")
|
||||||
@@ -29,19 +35,11 @@ async def list_sources():
|
|||||||
artist_id = int(artist_id_raw)
|
artist_id = int(artist_id_raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _bad("invalid_artist_id", detail="artist_id must be an integer")
|
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:
|
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])
|
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"])
|
@sources_bp.route("/<int:source_id>", methods=["GET"])
|
||||||
async def get_source(source_id: int):
|
async def get_source(source_id: int):
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
@@ -85,22 +83,6 @@ async def create_source():
|
|||||||
return _bad("empty_url", detail=str(exc))
|
return _bad("empty_url", detail=str(exc))
|
||||||
except DuplicateSourceError as exc:
|
except DuplicateSourceError as exc:
|
||||||
return _bad("duplicate", status=409, existing_id=exc.existing_id)
|
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
|
return jsonify(record.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -136,115 +118,12 @@ async def delete_source(source_id: int):
|
|||||||
return "", 204
|
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"])
|
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
|
||||||
async def check_source(source_id: int):
|
async def check_source(source_id: int):
|
||||||
"""FC-3c: enqueue a download for this source.
|
"""FC-3c: enqueue a download for this source.
|
||||||
|
|
||||||
Returns 202 with the new DownloadEvent id. If a pending/running
|
Returns 202 with the new DownloadEvent id. If a pending/running
|
||||||
event already exists for this source, returns 409 with that id. If
|
event already exists for this source, returns 409 with that id."""
|
||||||
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")
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
source = (await session.execute(
|
source = (await session.execute(
|
||||||
select(Source).where(Source.id == source_id)
|
select(Source).where(Source.id == source_id)
|
||||||
@@ -254,19 +133,6 @@ async def check_source(source_id: int):
|
|||||||
if not source.enabled:
|
if not source.enabled:
|
||||||
return _bad("source_disabled", detail="enable the source first")
|
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(
|
in_flight = (await session.execute(
|
||||||
select(DownloadEvent.id).where(
|
select(DownloadEvent.id).where(
|
||||||
DownloadEvent.source_id == source_id,
|
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"])
|
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
||||||
async def get_suggestions(image_id: int):
|
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:
|
async with get_session() as session:
|
||||||
sl = await SuggestionService(session).for_image(
|
sl = await SuggestionService(session).for_image(image_id)
|
||||||
image_id, threshold_override=override
|
|
||||||
)
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"by_category": {
|
"by_category": {
|
||||||
@@ -37,19 +23,7 @@ async def get_suggestions(image_id: int):
|
|||||||
"category": s.category,
|
"category": s.category,
|
||||||
"score": round(s.score, 4),
|
"score": round(s.score, 4),
|
||||||
"source": s.source,
|
"source": s.source,
|
||||||
# whether the score cleared the head's own suggest cut.
|
"creates_new_tag": s.creates_new_tag,
|
||||||
# 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,
|
|
||||||
}
|
}
|
||||||
for s in items
|
for s in items
|
||||||
]
|
]
|
||||||
@@ -68,9 +42,36 @@ async def accept_suggestion(image_id: int):
|
|||||||
return jsonify({"error": "tag_id required"}), 400
|
return jsonify({"error": "tag_id required"}), 400
|
||||||
tag_id = body["tag_id"]
|
tag_id = body["tag_id"]
|
||||||
async with get_session() as session:
|
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()
|
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(
|
@suggestions_bp.route(
|
||||||
@@ -86,21 +87,6 @@ async def dismiss_suggestion(image_id: int):
|
|||||||
return "", 204
|
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"])
|
@suggestions_bp.route("/suggestions/bulk", methods=["POST"])
|
||||||
async def bulk_suggestions():
|
async def bulk_suggestions():
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from sqlalchemy import desc, func, select
|
|||||||
from ..config import get_config
|
from ..config import get_config
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import TaskRun
|
from ..models import TaskRun
|
||||||
from ..services.scheduler_service import scheduler_status
|
|
||||||
|
|
||||||
system_activity_bp = Blueprint(
|
system_activity_bp = Blueprint(
|
||||||
"system_activity", __name__, url_prefix="/api/system/activity",
|
"system_activity", __name__, url_prefix="/api/system/activity",
|
||||||
@@ -31,7 +30,7 @@ system_activity_bp = Blueprint(
|
|||||||
# absent.
|
# absent.
|
||||||
_QUEUE_NAMES = (
|
_QUEUE_NAMES = (
|
||||||
"default", "import", "thumbnail", "ml",
|
"default", "import", "thumbnail", "ml",
|
||||||
"download", "scan", "maintenance", "maintenance_long",
|
"download", "scan", "maintenance",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cache module-level so all requests share the cache between polls.
|
# 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"])
|
@system_activity_bp.route("/queues", methods=["GET"])
|
||||||
async def get_queues():
|
async def get_queues():
|
||||||
"""Per-queue Redis LLEN. Cached 2s.
|
"""Per-queue Redis LLEN. Cached 2s.
|
||||||
|
|
||||||
Response: {queues: {name: depth_or_null}, fetched_at: iso8601}
|
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"])
|
@system_activity_bp.route("/workers", methods=["GET"])
|
||||||
@@ -113,41 +107,11 @@ async def get_workers():
|
|||||||
return jsonify(_WORKER_CACHE["data"])
|
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"])
|
@system_activity_bp.route("/runs", methods=["GET"])
|
||||||
async def list_runs():
|
async def list_runs():
|
||||||
"""Paginated task_run history. Query params:
|
"""Paginated task_run history. Query params:
|
||||||
queue=<name> filter to one queue
|
queue=<name> filter to one queue
|
||||||
status=<status> filter to one status (running/ok/error/timeout/retry)
|
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
|
limit=<int> default 50, max 200
|
||||||
before_id=<int> cursor for keyset pagination
|
before_id=<int> cursor for keyset pagination
|
||||||
|
|
||||||
@@ -162,7 +126,6 @@ async def list_runs():
|
|||||||
|
|
||||||
queue = request.args.get("queue")
|
queue = request.args.get("queue")
|
||||||
status = request.args.get("status")
|
status = request.args.get("status")
|
||||||
task = request.args.get("task")
|
|
||||||
before_id_raw = request.args.get("before_id")
|
before_id_raw = request.args.get("before_id")
|
||||||
before_id = int(before_id_raw) if before_id_raw else None
|
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)
|
stmt = stmt.where(TaskRun.queue == queue)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(TaskRun.status == 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:
|
if before_id is not None:
|
||||||
stmt = stmt.where(TaskRun.id < before_id)
|
stmt = stmt.where(TaskRun.id < before_id)
|
||||||
stmt = stmt.limit(limit + 1)
|
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:
|
def _row_to_dict(r: TaskRun) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from sqlalchemy import desc, select
|
|||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import BackupRun, ImportSettings
|
from ..models import BackupRun, ImportSettings
|
||||||
from ._responses import error_response as _bad
|
|
||||||
|
|
||||||
system_backup_bp = Blueprint(
|
system_backup_bp = Blueprint(
|
||||||
"system_backup", __name__, url_prefix="/api/system/backup",
|
"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:
|
def _row_to_dict(r: BackupRun) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@@ -227,7 +232,9 @@ async def delete_run(run_id: int):
|
|||||||
@system_backup_bp.route("/settings", methods=["GET"])
|
@system_backup_bp.route("/settings", methods=["GET"])
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
async with get_session() as session:
|
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({
|
return jsonify({
|
||||||
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
|
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
|
||||||
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
|
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
|
||||||
@@ -247,7 +254,9 @@ async def patch_settings():
|
|||||||
return err
|
return err
|
||||||
|
|
||||||
async with get_session() as session:
|
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:
|
for field in _BACKUP_SETTINGS_FIELDS:
|
||||||
if field in body:
|
if field in body:
|
||||||
setattr(row, field, body[field])
|
setattr(row, field, body[field])
|
||||||
|
|||||||
+48
-491
@@ -1,28 +1,20 @@
|
|||||||
"""Tags API: autocomplete, create, list/add/remove for an image."""
|
"""Tags API: autocomplete, create, list/add/remove for an image."""
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import Tag, TagHead, TagKind, TagPositiveConfirmation
|
from ..models import Tag, TagKind
|
||||||
from ..models.tag import image_tag
|
from ..models.tag_allowlist import TagAllowlist
|
||||||
from ..models.tag_suggestion_rejection import TagSuggestionRejection
|
|
||||||
from ..services.bulk_tag_service import BulkTagService
|
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.series_service import SeriesError, SeriesService
|
||||||
from ..services.tag_directory_service import TagDirectoryService
|
from ..services.tag_directory_service import TagDirectoryService
|
||||||
from ..services.tag_query import serialize_tag
|
|
||||||
from ..services.tag_service import (
|
from ..services.tag_service import (
|
||||||
TagMergeConflict,
|
TagMergeConflict,
|
||||||
TagService,
|
TagService,
|
||||||
TagValidationError,
|
TagValidationError,
|
||||||
normalize_tag_name,
|
|
||||||
)
|
)
|
||||||
from ..utils.tag_prefix import parse_kind_prefix
|
|
||||||
|
|
||||||
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
|
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -63,117 +55,6 @@ def _parse_bulk_ids(
|
|||||||
return ids, None
|
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"])
|
@tags_bp.route("/tags/autocomplete", methods=["GET"])
|
||||||
async def autocomplete():
|
async def autocomplete():
|
||||||
q = request.args.get("q", "")
|
q = request.args.get("q", "")
|
||||||
@@ -188,7 +69,17 @@ async def autocomplete():
|
|||||||
hits = await svc.autocomplete(q, kind=kind, limit=limit)
|
hits = await svc.autocomplete(q, kind=kind, limit=limit)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
[{**serialize_tag(h), "image_count": h.image_count} for h in hits]
|
[
|
||||||
|
{
|
||||||
|
"id": h.id,
|
||||||
|
"name": h.name,
|
||||||
|
"kind": h.kind,
|
||||||
|
"fandom_id": h.fandom_id,
|
||||||
|
"fandom_name": h.fandom_name,
|
||||||
|
"image_count": h.image_count,
|
||||||
|
}
|
||||||
|
for h in hits
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -214,46 +105,15 @@ async def directory():
|
|||||||
|
|
||||||
@tags_bp.route("/tags", methods=["POST"])
|
@tags_bp.route("/tags", methods=["POST"])
|
||||||
async def create_tag():
|
async def create_tag():
|
||||||
"""Create a tag. Two input shapes accepted:
|
|
||||||
1. Explicit: {name, kind, fandom_id?} — caller already split, kind wins.
|
|
||||||
2. IR-suffix: {name} where name = "kind:Name" (e.g. "artist:Eric").
|
|
||||||
The server runs parse_kind_prefix(name) to derive kind; the colon
|
|
||||||
and prefix are stripped from the stored tag name. If no recognized
|
|
||||||
prefix is present, the kind defaults to `general`.
|
|
||||||
Explicit kind ALWAYS wins (backward-compat for existing callers).
|
|
||||||
"""
|
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
if not body or "name" not in body:
|
if not body or "name" not in body or "kind" not in body:
|
||||||
return jsonify({"error": "name required"}), 400
|
return jsonify({"error": "name and kind required"}), 400
|
||||||
name = body["name"]
|
name = body["name"]
|
||||||
explicit_kind_raw = body.get("kind")
|
kind = _coerce_kind(body["kind"])
|
||||||
|
|
||||||
if explicit_kind_raw is not None:
|
|
||||||
# Caller provided kind — honor it; don't re-parse.
|
|
||||||
kind = _coerce_kind(explicit_kind_raw)
|
|
||||||
if kind is None:
|
if kind is None:
|
||||||
return jsonify({"error": f"invalid kind {explicit_kind_raw!r}"}), 400
|
return jsonify({"error": f"invalid kind {body['kind']!r}"}), 400
|
||||||
else:
|
|
||||||
# IR-style: parse "kind:Name" from the raw name.
|
|
||||||
parsed_kind, parsed_name = parse_kind_prefix(name)
|
|
||||||
if parsed_kind is not None:
|
|
||||||
name = parsed_name
|
|
||||||
kind = _coerce_kind(parsed_kind)
|
|
||||||
# parse_kind_prefix only returns kinds from KNOWN_KINDS which
|
|
||||||
# are all valid TagKind members, so _coerce_kind can't return
|
|
||||||
# None here — but defensive.
|
|
||||||
if kind is None:
|
|
||||||
return jsonify({"error": f"invalid kind {parsed_kind!r}"}), 400
|
|
||||||
else:
|
|
||||||
kind = TagKind.general
|
|
||||||
|
|
||||||
fandom_id = body.get("fandom_id")
|
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:
|
async with get_session() as session:
|
||||||
svc = TagService(session)
|
svc = TagService(session)
|
||||||
try:
|
try:
|
||||||
@@ -271,7 +131,17 @@ async def list_tags_for_image(image_id: int):
|
|||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = TagService(session)
|
svc = TagService(session)
|
||||||
tags = await svc.list_for_image(image_id)
|
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"])
|
@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
|
||||||
@@ -297,95 +167,15 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
|
|||||||
return "", 204
|
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"])
|
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
|
||||||
async def update_tag(tag_id: int):
|
async def rename_tag(tag_id: int):
|
||||||
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
|
body = await request.get_json()
|
||||||
`fandom_id` (a fandom tag id, or null to clear — character tags only).
|
if not body or "name" not in body:
|
||||||
`merge: true` resolves a collision by merging into the existing tag.
|
return jsonify({"error": "name required"}), 400
|
||||||
"""
|
|
||||||
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 with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = TagService(session)
|
svc = TagService(session)
|
||||||
try:
|
try:
|
||||||
tag = None
|
|
||||||
if has_name:
|
|
||||||
tag = await svc.rename(tag_id, body["name"])
|
tag = await svc.rename(tag_id, body["name"])
|
||||||
if has_fandom:
|
|
||||||
tag = await svc.set_fandom(
|
|
||||||
tag_id, body["fandom_id"], merge=do_merge
|
|
||||||
)
|
|
||||||
except TagMergeConflict as exc:
|
except TagMergeConflict as exc:
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
@@ -402,13 +192,7 @@ async def update_tag(tag_id: int):
|
|||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{"id": tag.id, "name": tag.name, "kind": tag.kind.value}
|
||||||
"id": tag.id,
|
|
||||||
"name": tag.name,
|
|
||||||
"kind": tag.kind.value,
|
|
||||||
"fandom_id": tag.fandom_id,
|
|
||||||
"is_system": tag.is_system,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -427,6 +211,13 @@ async def merge_tag(source_id: int):
|
|||||||
status = 404 if "not found" in msg else 400
|
status = 404 if "not found" in msg else 400
|
||||||
return jsonify({"error": msg}), status
|
return jsonify({"error": msg}), status
|
||||||
await session.commit()
|
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(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"target": {
|
"target": {
|
||||||
@@ -502,31 +293,6 @@ def _series_err(exc: SeriesError):
|
|||||||
return jsonify({"error": msg}), status
|
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"])
|
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
|
||||||
async def series_pages(tag_id: int):
|
async def series_pages(tag_id: int):
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
@@ -567,26 +333,15 @@ async def series_remove(tag_id: int):
|
|||||||
return jsonify({"removed_count": n})
|
return jsonify({"removed_count": n})
|
||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/series/<int:tag_id>/pages/number", methods=["POST"])
|
@tags_bp.route("/series/<int:tag_id>/reorder", methods=["POST"])
|
||||||
async def series_set_page_number(tag_id: int):
|
async def series_reorder(tag_id: int):
|
||||||
"""Set one placed page's number — the operator's value (sparse, gaps
|
body = await request.get_json()
|
||||||
allowed); pass page_number: null to leave it unnumbered."""
|
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||||
body = await request.get_json() or {}
|
if err:
|
||||||
image_id, ierr = _opt_int(body, "image_id")
|
return err
|
||||||
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
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
try:
|
try:
|
||||||
await SeriesService(session).set_page_number(
|
await SeriesService(session).reorder(tag_id, ids)
|
||||||
tag_id, image_id, page_number
|
|
||||||
)
|
|
||||||
except SeriesError as exc:
|
except SeriesError as exc:
|
||||||
return _series_err(exc)
|
return _series_err(exc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -609,201 +364,3 @@ async def series_cover(tag_id: int):
|
|||||||
return _series_err(exc)
|
return _series_err(exc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify({"ok": True})
|
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,28 +0,0 @@
|
|||||||
"""Thumbnail admin API: backfill trigger."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from quart import Blueprint, jsonify
|
|
||||||
|
|
||||||
thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
|
|
||||||
|
|
||||||
|
|
||||||
@thumbnails_bp.route("/backfill", methods=["POST"])
|
|
||||||
async def trigger_backfill():
|
|
||||||
"""Run the backfill scan synchronously, return the counts. The actual
|
|
||||||
thumbnail generation work is still off-loaded to the thumbnail Celery
|
|
||||||
queue via `generate_thumbnail.delay()` per missing row — so this
|
|
||||||
handler is fast even on a 100k-image library (a scan is just SELECT
|
|
||||||
id, thumbnail_path + a file.stat() per row, no heavy work).
|
|
||||||
|
|
||||||
Operator-flagged 2026-06-01: the previous fire-and-forget shape
|
|
||||||
returned `{celery_task_id}` only, so the admin UI had no idea whether
|
|
||||||
backfill found 0 or 5000 candidates — \"found nothing\" was
|
|
||||||
indistinguishable from \"the worker isn't picking up the task.\""""
|
|
||||||
from ..tasks.thumbnail import _run_backfill_scan
|
|
||||||
|
|
||||||
# Sync scan inside an executor so we don't block the event loop.
|
|
||||||
counts = await asyncio.get_running_loop().run_in_executor(
|
|
||||||
None, _run_backfill_scan,
|
|
||||||
)
|
|
||||||
return jsonify(counts), 200
|
|
||||||
@@ -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()
|
|
||||||
+13
-206
@@ -7,7 +7,7 @@ Queues:
|
|||||||
download — gallery-dl tasks (FC-3)
|
download — gallery-dl tasks (FC-3)
|
||||||
scan — periodic source checks (FC-3) — kept separate so long imports
|
scan — periodic source checks (FC-3) — kept separate so long imports
|
||||||
don't starve the scheduler
|
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
|
default — anything not explicitly routed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -28,14 +28,11 @@ def make_celery() -> Celery:
|
|||||||
"backend.app.tasks.import_file",
|
"backend.app.tasks.import_file",
|
||||||
"backend.app.tasks.thumbnail",
|
"backend.app.tasks.thumbnail",
|
||||||
"backend.app.tasks.maintenance",
|
"backend.app.tasks.maintenance",
|
||||||
|
"backend.app.tasks.migration",
|
||||||
"backend.app.tasks.ml",
|
"backend.app.tasks.ml",
|
||||||
"backend.app.tasks.gpu_queue",
|
|
||||||
"backend.app.tasks.download",
|
"backend.app.tasks.download",
|
||||||
"backend.app.tasks.external",
|
|
||||||
"backend.app.tasks.backup",
|
"backend.app.tasks.backup",
|
||||||
"backend.app.tasks.admin",
|
"backend.app.tasks.admin",
|
||||||
"backend.app.tasks.library_audit",
|
|
||||||
"backend.app.tasks.translation",
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
app.conf.update(
|
app.conf.update(
|
||||||
@@ -43,69 +40,18 @@ def make_celery() -> Celery:
|
|||||||
task_routes={
|
task_routes={
|
||||||
"backend.app.tasks.import_file.*": {"queue": "import"},
|
"backend.app.tasks.import_file.*": {"queue": "import"},
|
||||||
"backend.app.tasks.ml.*": {"queue": "ml"},
|
"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.thumbnail.*": {"queue": "thumbnail"},
|
||||||
"backend.app.tasks.download.*": {"queue": "download"},
|
"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"},
|
"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.maintenance.*": {"queue": "maintenance"},
|
||||||
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
|
"backend.app.tasks.migration.*": {"queue": "maintenance"},
|
||||||
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
|
"backend.app.tasks.backup.*": {"queue": "maintenance"},
|
||||||
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
|
"backend.app.tasks.admin.*": {"queue": "maintenance"},
|
||||||
# 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"},
|
|
||||||
},
|
},
|
||||||
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
||||||
task_acks_late=True,
|
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,
|
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_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={
|
beat_schedule={
|
||||||
"recover-interrupted-tasks": {
|
"recover-interrupted-tasks": {
|
||||||
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
|
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
|
||||||
@@ -115,89 +61,16 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
|
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
|
||||||
"schedule": 86400.0, # daily
|
"schedule": 86400.0, # daily
|
||||||
},
|
},
|
||||||
"cleanup-orphaned-temp-files": {
|
"ml-backfill-daily": {
|
||||||
"task": "backend.app.tasks.maintenance.cleanup_orphaned_temp_files",
|
"task": "backend.app.tasks.ml.backfill",
|
||||||
"schedule": 86400.0, # daily — sweep .part/.partial left by a
|
"schedule": 86400.0,
|
||||||
# download/import killed mid-write (graceful-shutdown fallout)
|
|
||||||
},
|
},
|
||||||
"train-heads-nightly": {
|
"recompute-centroids-daily": {
|
||||||
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
"task": "backend.app.tasks.ml.recompute_centroids",
|
||||||
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
"schedule": 86400.0,
|
||||||
},
|
},
|
||||||
"refresh-character-prototypes": {
|
"apply-allowlist-sweep-daily": {
|
||||||
"task": "backend.app.tasks.ml.refresh_character_prototypes",
|
"task": "backend.app.tasks.ml.apply_allowlist_tags",
|
||||||
"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",
|
|
||||||
"schedule": 86400.0,
|
"schedule": 86400.0,
|
||||||
},
|
},
|
||||||
"integrity-verify-weekly": {
|
"integrity-verify-weekly": {
|
||||||
@@ -212,10 +85,6 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.maintenance.cleanup_old_download_events",
|
"task": "backend.app.tasks.maintenance.cleanup_old_download_events",
|
||||||
"schedule": 86400.0, # daily
|
"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": {
|
"recover-stalled-task-runs": {
|
||||||
"task": "backend.app.tasks.maintenance.recover_stalled_task_runs",
|
"task": "backend.app.tasks.maintenance.recover_stalled_task_runs",
|
||||||
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
|
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
|
||||||
@@ -224,10 +93,6 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.maintenance.prune_task_runs",
|
"task": "backend.app.tasks.maintenance.prune_task_runs",
|
||||||
"schedule": 86400.0, # daily
|
"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": {
|
"fc3h-backup-db-nightly": {
|
||||||
"task": "backend.app.tasks.backup.backup_db_nightly",
|
"task": "backend.app.tasks.backup.backup_db_nightly",
|
||||||
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
|
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
|
||||||
@@ -236,64 +101,6 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.backup.prune_backups",
|
"task": "backend.app.tasks.backup.prune_backups",
|
||||||
"schedule": 86400.0, # daily
|
"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",
|
timezone="UTC",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,14 +54,7 @@ _INT32_MIN = -2_147_483_648
|
|||||||
|
|
||||||
def _queue_for(task) -> str:
|
def _queue_for(task) -> str:
|
||||||
"""Reverse the task→queue routing from celery_app.task_routes.
|
"""Reverse the task→queue routing from celery_app.task_routes.
|
||||||
Keep in sync if task_routes is reordered.
|
Keep in sync if task_routes is reordered."""
|
||||||
|
|
||||||
Audit 2026-06-02: backup/admin/library_audit prefixes were
|
|
||||||
missing here even though task_routes sent all three to
|
|
||||||
'maintenance'. The TaskRun.queue column then lied for those
|
|
||||||
rows (claimed 'default') so per-queue dashboard filters and
|
|
||||||
per-queue threshold overrides silently missed them.
|
|
||||||
"""
|
|
||||||
name = getattr(task, "name", "") or ""
|
name = getattr(task, "name", "") or ""
|
||||||
if name.startswith("backend.app.tasks.import_file."):
|
if name.startswith("backend.app.tasks.import_file."):
|
||||||
return "import"
|
return "import"
|
||||||
@@ -69,23 +62,13 @@ def _queue_for(task) -> str:
|
|||||||
return "ml"
|
return "ml"
|
||||||
if name.startswith("backend.app.tasks.thumbnail."):
|
if name.startswith("backend.app.tasks.thumbnail."):
|
||||||
return "thumbnail"
|
return "thumbnail"
|
||||||
if name.startswith((
|
if name.startswith("backend.app.tasks.download."):
|
||||||
"backend.app.tasks.download.",
|
|
||||||
# External file-host fetches share the download lane (celery_app
|
|
||||||
# routes external.* → download). Mirror it here or TaskRun.queue
|
|
||||||
# lies 'default' for them, so per-queue dashboard filters and the
|
|
||||||
# per-queue threshold override miss them — the same gap the
|
|
||||||
# 2026-06-02 audit fixed for backup/admin/library_audit.
|
|
||||||
"backend.app.tasks.external.",
|
|
||||||
)):
|
|
||||||
return "download"
|
return "download"
|
||||||
if name.startswith("backend.app.tasks.scan."):
|
if name.startswith("backend.app.tasks.scan."):
|
||||||
return "scan"
|
return "scan"
|
||||||
if name.startswith((
|
if name.startswith((
|
||||||
"backend.app.tasks.maintenance.",
|
"backend.app.tasks.maintenance.",
|
||||||
"backend.app.tasks.backup.",
|
"backend.app.tasks.migration.",
|
||||||
"backend.app.tasks.admin.",
|
|
||||||
"backend.app.tasks.library_audit.",
|
|
||||||
)):
|
)):
|
||||||
return "maintenance"
|
return "maintenance"
|
||||||
return "default"
|
return "default"
|
||||||
|
|||||||
@@ -2,43 +2,25 @@
|
|||||||
|
|
||||||
from .app_setting import AppSetting
|
from .app_setting import AppSetting
|
||||||
from .artist import Artist
|
from .artist import Artist
|
||||||
from .artist_visit import ArtistVisit
|
|
||||||
from .backup_run import BackupRun
|
from .backup_run import BackupRun
|
||||||
from .base import Base
|
from .base import Base
|
||||||
from .character_prototype import CcipPrototypeState, CharacterPrototype
|
|
||||||
from .credential import Credential
|
from .credential import Credential
|
||||||
from .download_event import DownloadEvent
|
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_provenance import ImageProvenance
|
||||||
from .image_record import ImageRecord
|
from .image_record import ImageRecord
|
||||||
from .image_region import ImageRegion
|
|
||||||
from .import_batch import ImportBatch
|
from .import_batch import ImportBatch
|
||||||
from .import_settings import ImportSettings
|
from .import_settings import ImportSettings
|
||||||
from .import_task import ImportTask
|
from .import_task import ImportTask
|
||||||
from .library_audit_run import LibraryAuditRun
|
from .migration_run import MigrationRun
|
||||||
from .ml_settings import MLSettings
|
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 import Post
|
||||||
from .post_attachment import PostAttachment, attachment_download_url
|
from .post_attachment import PostAttachment
|
||||||
from .presentation_review import PresentationReview
|
|
||||||
from .series_chapter import SeriesChapter
|
|
||||||
from .series_page import SeriesPage
|
from .series_page import SeriesPage
|
||||||
from .series_suggestion import SeriesSuggestion
|
|
||||||
from .source import Source
|
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 import Tag, TagKind, image_tag
|
||||||
from .tag_alias import TagAlias
|
from .tag_alias import TagAlias
|
||||||
from .tag_head import TagHead
|
from .tag_allowlist import TagAllowlist
|
||||||
from .tag_positive_confirmation import TagPositiveConfirmation
|
from .tag_reference_embedding import TagReferenceEmbedding
|
||||||
from .tag_suggestion_rejection import TagSuggestionRejection
|
from .tag_suggestion_rejection import TagSuggestionRejection
|
||||||
from .task_run import TaskRun
|
from .task_run import TaskRun
|
||||||
|
|
||||||
@@ -46,46 +28,26 @@ __all__ = [
|
|||||||
"Base",
|
"Base",
|
||||||
"AppSetting",
|
"AppSetting",
|
||||||
"Artist",
|
"Artist",
|
||||||
"ArtistVisit",
|
|
||||||
"BackupRun",
|
"BackupRun",
|
||||||
"Source",
|
"Source",
|
||||||
"Credential",
|
"Credential",
|
||||||
"PatreonFailedMedia",
|
|
||||||
"PatreonSeenMedia",
|
|
||||||
"PixivFailedMedia",
|
|
||||||
"PixivSeenMedia",
|
|
||||||
"SubscribeStarFailedMedia",
|
|
||||||
"SubscribeStarSeenMedia",
|
|
||||||
"Post",
|
"Post",
|
||||||
"PostAttachment",
|
"PostAttachment",
|
||||||
"attachment_download_url",
|
|
||||||
"PresentationReview",
|
|
||||||
"SeriesChapter",
|
|
||||||
"SeriesPage",
|
"SeriesPage",
|
||||||
"SeriesSuggestion",
|
|
||||||
"ImageRecord",
|
"ImageRecord",
|
||||||
"ImageProvenance",
|
"ImageProvenance",
|
||||||
"ImageRegion",
|
|
||||||
"Tag",
|
"Tag",
|
||||||
"TagKind",
|
"TagKind",
|
||||||
"image_tag",
|
"image_tag",
|
||||||
"DownloadEvent",
|
"DownloadEvent",
|
||||||
"ExternalLink",
|
|
||||||
"GpuJob",
|
|
||||||
"ImportBatch",
|
"ImportBatch",
|
||||||
"ImportTask",
|
"ImportTask",
|
||||||
"ImportSettings",
|
"ImportSettings",
|
||||||
"LibraryAuditRun",
|
|
||||||
"MLSettings",
|
"MLSettings",
|
||||||
"HeadAutoApplyRun",
|
"MigrationRun",
|
||||||
"HeadMetric",
|
|
||||||
"HeadMetricsSnapshot",
|
|
||||||
"HeadTrainingRun",
|
|
||||||
"TagAlias",
|
"TagAlias",
|
||||||
"TagHead",
|
"TagAllowlist",
|
||||||
"CharacterPrototype",
|
"TagReferenceEmbedding",
|
||||||
"CcipPrototypeState",
|
|
||||||
"TagPositiveConfirmation",
|
|
||||||
"TagSuggestionRejection",
|
"TagSuggestionRejection",
|
||||||
"TaskRun",
|
"TaskRun",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,14 +15,7 @@ class Artist(Base):
|
|||||||
__tablename__ = "artist"
|
__tablename__ = "artist"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
# Display name: freely editable, NON-unique (two real creators can share a
|
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
# 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.
|
|
||||||
slug: 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)
|
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
|
|
||||||
)
|
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
"""ImageProvenance — links an ImageRecord to a Post.
|
"""ImageProvenance — links an ImageRecord to a Post.
|
||||||
|
|
||||||
One image can have many provenance rows — different posts each contribute
|
Many-to-one (one image, many provenance rows) enables the enrich-on-duplicate
|
||||||
metadata (enrich-on-duplicate rule, spec §3: a downloaded image that is a
|
rule (spec §3): when a downloaded image is a pHash dupe of an existing
|
||||||
pHash dupe of an existing record gets a NEW provenance row for the new post
|
record, we append a new provenance row to the existing record rather than
|
||||||
appended, rather than the metadata being dropped). But the (image, post)
|
dropping the metadata.
|
||||||
pair is unique — alembic 0021 enforces uq_image_provenance_image_post
|
|
||||||
after operator-flagged 2026-05-26 saw _apply_sidecar's existence-check +
|
|
||||||
INSERT race plant duplicates that then broke .scalar_one_or_none() on
|
|
||||||
every later deep-scan rederive (MultipleResultsFound).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, UniqueConstraint, func
|
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -20,12 +16,6 @@ from .base import Base
|
|||||||
|
|
||||||
class ImageProvenance(Base):
|
class ImageProvenance(Base):
|
||||||
__tablename__ = "image_provenance"
|
__tablename__ = "image_provenance"
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"image_record_id", "post_id",
|
|
||||||
name="uq_image_provenance_image_post",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
image_record_id: Mapped[int] = mapped_column(
|
image_record_id: Mapped[int] = mapped_column(
|
||||||
@@ -34,22 +24,8 @@ class ImageProvenance(Base):
|
|||||||
post_id: Mapped[int] = mapped_column(
|
post_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
# Nullable since alembic 0030 — provenance rows for filesystem-imported
|
source_id: Mapped[int] = mapped_column(
|
||||||
# content with no subscription have NULL source_id. FK ondelete SET
|
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
# 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,
|
|
||||||
)
|
)
|
||||||
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
captured_at: Mapped[datetime] = mapped_column(
|
captured_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from datetime import datetime
|
|||||||
|
|
||||||
from pgvector.sqlalchemy import Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
BigInteger,
|
BigInteger,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
Float,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
@@ -39,10 +39,6 @@ class ImageRecord(Base):
|
|||||||
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
height: 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'.
|
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
|
||||||
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
|
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
|
||||||
@@ -53,18 +49,6 @@ class ImageRecord(Base):
|
|||||||
# Thumbnail (populated by FC-2)
|
# Thumbnail (populated by FC-2)
|
||||||
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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 / provenance pointers
|
||||||
origin: Mapped[str] = mapped_column(Enum(*ORIGIN_CHOICES, name="origin_enum"), nullable=False)
|
origin: Mapped[str] = mapped_column(Enum(*ORIGIN_CHOICES, name="origin_enum"), nullable=False)
|
||||||
primary_post_id: Mapped[int | None] = mapped_column(
|
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
|
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
|
# ML fields (populated by FC-2's ml-worker)
|
||||||
# embedding dim; siglip_model_version stamps which model produced it (so an
|
tagger_predictions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
# operator model swap, #1190, can re-embed the stale rows). A different-dim
|
tagger_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
# model would need a column-width migration.
|
# 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_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
|
||||||
siglip_model_version: Mapped[str | None] = mapped_column(String(128), 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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
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(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
nullable=False,
|
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.
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -63,83 +63,3 @@ class ImportSettings(Base):
|
|||||||
backup_images_keep_last_n: Mapped[int] = mapped_column(
|
backup_images_keep_last_n: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, default=3,
|
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 datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
BigInteger,
|
|
||||||
Boolean,
|
|
||||||
DateTime,
|
|
||||||
ForeignKey,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
Text,
|
|
||||||
func,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -35,13 +26,6 @@ class ImportTask(Base):
|
|||||||
task_type: Mapped[str] = mapped_column(String(16), nullable=False) # media|archive
|
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)
|
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(
|
result_image_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("image_record.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("image_record.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"""LibraryAuditRun — async transparency / single_color audit lifecycle.
|
|
||||||
|
|
||||||
State machine: running → ready → applied / cancelled / error.
|
|
||||||
matched_ids JSONB is appended-to by scan_library_for_rule; apply_audit_run
|
|
||||||
reads it and routes through cleanup_service.delete_images.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class LibraryAuditRun(Base):
|
|
||||||
__tablename__ = "library_audit_run"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
rule: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
|
||||||
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(16), nullable=False, default="running", index=True,
|
|
||||||
)
|
|
||||||
# running | ready | applied | cancelled | error
|
|
||||||
started_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
|
||||||
)
|
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True,
|
|
||||||
)
|
|
||||||
scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
|
|
||||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
# Chunked-scan state (alembic 0039): keyset cursor the next chunk resumes
|
|
||||||
# from, and the last time a chunk made progress (so the recovery sweep can
|
|
||||||
# tell a progressing multi-chunk audit from a stuck one).
|
|
||||||
resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
last_progress_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True,
|
|
||||||
)
|
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -2,16 +2,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import CheckConstraint, DateTime, Float, Integer, String, func
|
||||||
Boolean,
|
|
||||||
CheckConstraint,
|
|
||||||
DateTime,
|
|
||||||
Float,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
func,
|
|
||||||
select,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -24,203 +15,30 @@ class MLSettings(Base):
|
|||||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
# CPU whole-image embedding (B3, operator 2026-07-02). The ml-worker's ONLY
|
suggestion_threshold_artist: Mapped[float] = mapped_column(
|
||||||
# processing role is the embed fallback for stacks WITHOUT a GPU agent — ON
|
Float, nullable=False, default=0.30
|
||||||
# by default so a fresh install works with no agent. Stacks that run the
|
|
||||||
# agent and drop the ml-worker container turn this OFF so import hooks stop
|
|
||||||
# queueing embed work nothing will consume (the daily GPU 'embed' backfill
|
|
||||||
# covers those images instead).
|
|
||||||
cpu_embed_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True
|
|
||||||
)
|
)
|
||||||
# Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
|
suggestion_threshold_character: Mapped[float] = mapped_column(
|
||||||
# a fixed count) so coverage reflects real screen time regardless of length;
|
Float, nullable=False, default=0.50
|
||||||
# cap the total so a long video can't explode into hundreds of embeds. The
|
|
||||||
# per-frame SigLIP embeddings are mean-pooled. Operator-tunable.
|
|
||||||
video_frame_interval_seconds: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=4.0
|
|
||||||
)
|
)
|
||||||
video_max_frames: Mapped[int] = mapped_column(
|
suggestion_threshold_copyright: Mapped[float] = mapped_column(
|
||||||
Integer, nullable=False, default=64
|
Float, nullable=False, default=0.50
|
||||||
)
|
)
|
||||||
# Tagging-v2 head training (#114). The head is the suggestion source that
|
suggestion_threshold_general: Mapped[float] = mapped_column(
|
||||||
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
|
|
||||||
# needs >= head_min_positives labelled images before a head is trained;
|
|
||||||
# head_auto_apply_precision is the precision bar a head must clear (at some
|
|
||||||
# operating point) to "graduate" into earned auto-apply. Operator-tunable.
|
|
||||||
head_min_positives: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=8
|
|
||||||
)
|
|
||||||
head_auto_apply_precision: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.97
|
|
||||||
)
|
|
||||||
# Earned auto-apply (#114). A graduated head fires (tags images without a
|
|
||||||
# human) when this master switch is on AND the head has at least
|
|
||||||
# head_auto_apply_min_positives clean labels — so a precise-looking but
|
|
||||||
# under-supported low-N head can't spray tags across the library. ON by
|
|
||||||
# default (operator-asked 2026-06-29: opt-OUT, not opt-in); the support +
|
|
||||||
# measured-precision gates keep it safe, and every auto-tag is reversible.
|
|
||||||
head_auto_apply_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True
|
|
||||||
)
|
|
||||||
head_auto_apply_min_positives: Mapped[int] = mapped_column(
|
|
||||||
# Support floor raised 30→50 (operator-asked 2026-07-06): a head needs
|
|
||||||
# more human labels before it may fire without a human.
|
|
||||||
Integer, nullable=False, default=50
|
|
||||||
)
|
|
||||||
# CCIP character-match cosine cut (#114). 0.85 default — the v1 flat 0.75
|
|
||||||
# over-fired (high-reference characters matched a scatter of images); 0.85
|
|
||||||
# keeps the confident single-character matches. Tunable from the agent card.
|
|
||||||
ccip_match_threshold: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.85
|
|
||||||
)
|
|
||||||
# CCIP auto-apply (#114). Confident matches (>= ccip_auto_apply_threshold,
|
|
||||||
# above the suggest cut) auto-tag on a daily sweep. ON by default (opt-out);
|
|
||||||
# single-character references + the high bar keep it safe, every tag reversible.
|
|
||||||
ccip_auto_apply_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True
|
|
||||||
)
|
|
||||||
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
|
|
||||||
# Raised 0.92→0.95 (operator-asked 2026-07-06) so only very confident
|
|
||||||
# character matches auto-tag.
|
|
||||||
Float, nullable=False, default=0.95
|
Float, nullable=False, default=0.95
|
||||||
)
|
)
|
||||||
# -- Presentation chrome auto-hide (#141) -------------------------------
|
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
||||||
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
|
Float, nullable=False, default=0.55
|
||||||
# with its OWN flat threshold (decoupled from content-head graduation) and is
|
|
||||||
# HIDDEN from the gallery. Hiding is consequential so it runs HIGH. When an
|
|
||||||
# image would be auto-hidden but ALSO scores >= presentation_conflict_threshold
|
|
||||||
# on a content head, it's still hidden but flagged for review
|
|
||||||
# (PresentationReview, mode='chrome') instead of buried silently. ON by default
|
|
||||||
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
|
|
||||||
# screenshot` are no longer chrome — they went to the PROCESS path below.
|
|
||||||
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True
|
|
||||||
)
|
)
|
||||||
presentation_auto_apply_threshold: Mapped[float] = mapped_column(
|
min_reference_images: Mapped[int] = mapped_column(
|
||||||
Float, nullable=False, default=0.90
|
Integer, nullable=False, default=5
|
||||||
)
|
)
|
||||||
presentation_conflict_threshold: Mapped[float] = mapped_column(
|
tagger_model_version: Mapped[str] = mapped_column(
|
||||||
Float, nullable=False, default=0.50
|
String(128), nullable=False, default="camie-tagger-v2"
|
||||||
)
|
)
|
||||||
# -- Process auto-apply (#1464) ----------------------------------------
|
|
||||||
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
|
|
||||||
# screenshots that must stay OUT of head/CCIP training but, unlike chrome,
|
|
||||||
# remain VISIBLE in the gallery (operator 2026-07-12). They auto-apply on the
|
|
||||||
# sweep with their OWN flat threshold and a PROVISIONAL source (`process_auto`,
|
|
||||||
# in training_data._AUTO_SOURCES) so the head NEVER trains on its own output —
|
|
||||||
# it learns only from title (`wip_title`) + manual labels, which breaks the
|
|
||||||
# runaway loop. When a process tag would be applied but the image ALSO scores
|
|
||||||
# >= process_conflict_threshold on a content head, it's flagged for review
|
|
||||||
# (PresentationReview, mode='process') rather than silently marked. OFF by
|
|
||||||
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
|
|
||||||
process_auto_apply_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=False
|
|
||||||
)
|
|
||||||
process_auto_apply_threshold: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.90
|
|
||||||
)
|
|
||||||
process_conflict_threshold: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.50
|
|
||||||
)
|
|
||||||
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
|
|
||||||
# existing libraries keep their stored value until the operator re-embeds.
|
|
||||||
embedder_model_version: Mapped[str] = mapped_column(
|
embedder_model_version: Mapped[str] = mapped_column(
|
||||||
String(128), nullable=False, default="siglip2-so400m-patch16-512"
|
String(128), nullable=False, default="siglip-so400m-patch14-384"
|
||||||
)
|
|
||||||
# The HF model NAME the embedder loads (server CPU embed + announced to the
|
|
||||||
# GPU agent in the lease). Operator-settable so the embedder is a choice, not
|
|
||||||
# a hardcode (#1190): set name + version together, then re-embed + retrain.
|
|
||||||
embedder_model_name: Mapped[str] = mapped_column(
|
|
||||||
String(128), nullable=False, default="google/siglip2-so400m-patch16-512"
|
|
||||||
)
|
|
||||||
# -- Crop proposers / detectors (#1202, #134) --------------------------
|
|
||||||
# WHERE-to-crop YOLO detectors feeding the crop→SigLIP bag + CCIP. Config
|
|
||||||
# lives HERE (DB) and is announced to the GPU agent in the lease — same as
|
|
||||||
# the embedder model — so it is UI-tunable with NO restart, and the agent's
|
|
||||||
# env is bootstrap-only. Each weights spec is an ultralytics builtin name,
|
|
||||||
# an http(s) URL, or "hf_repo::file" (agent's _resolve). enabled off (or an
|
|
||||||
# empty weights) skips that proposer. All ON by default (operator 2026-07-05)
|
|
||||||
# so a fresh install crops out-of-the-box.
|
|
||||||
# person: general COCO figure detector for Western/realistic art the anime
|
|
||||||
# person-detector misses → NMS-merged with imgutils → CCIP + concept.
|
|
||||||
detector_person_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True
|
|
||||||
)
|
|
||||||
detector_person_weights: Mapped[str] = mapped_column(
|
|
||||||
String(512), nullable=False, default="yolo11n.pt"
|
|
||||||
)
|
|
||||||
detector_person_conf: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.35
|
|
||||||
)
|
|
||||||
# anatomy: booru_yolo anime/furry/NSFW torso components → concept crops.
|
|
||||||
# Default = yolov11m_aa22 (26 classes, best mAP50-95 0.96), committed in the
|
|
||||||
# upstream repo so the URL resolves. License UNSTATED — fine for a private
|
|
||||||
# homelab (operator accepted #1202).
|
|
||||||
detector_anatomy_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True
|
|
||||||
)
|
|
||||||
detector_anatomy_weights: Mapped[str] = mapped_column(
|
|
||||||
String(512), nullable=False,
|
|
||||||
default=(
|
|
||||||
"https://github.com/aperveyev/booru_yolo/raw/main/models/"
|
|
||||||
"yolov11m_aa22.pt"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
detector_anatomy_conf: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.30
|
|
||||||
)
|
|
||||||
# panel: comic page → panel regions → concept crops (Apache-2.0, YOLOv12x).
|
|
||||||
detector_panel_enabled: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=True
|
|
||||||
)
|
|
||||||
detector_panel_weights: Mapped[str] = mapped_column(
|
|
||||||
String(512), nullable=False,
|
|
||||||
default="mosesb/best-comic-panel-detection::best.pt",
|
|
||||||
)
|
|
||||||
detector_panel_conf: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.30
|
|
||||||
)
|
|
||||||
# Per-frame caps bound the crop→embed explosion; max_regions is the hard
|
|
||||||
# per-job backstop; dedupe_iou drops near-duplicate crops before the embed.
|
|
||||||
detector_max_figures: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=8
|
|
||||||
)
|
|
||||||
detector_max_components: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=8
|
|
||||||
)
|
|
||||||
detector_max_panels: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=8
|
|
||||||
)
|
|
||||||
detector_max_regions: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=128
|
|
||||||
)
|
|
||||||
detector_dedupe_iou: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.85
|
|
||||||
)
|
|
||||||
# -- CCIP character prototypes (#1317) ---------------------------------
|
|
||||||
# The per-character reference set is precomputed + refreshed INCREMENTALLY
|
|
||||||
# (services.ml.character_prototypes) instead of rebuilt on the request path.
|
|
||||||
# ccip_ref_signature is the cheap GLOBAL gate — when it's unchanged the
|
|
||||||
# refresh no-ops; ccip_prototype_cap bounds the reference vectors kept per
|
|
||||||
# character so MATCH cost doesn't grow with a character's popularity.
|
|
||||||
ccip_ref_signature: Mapped[str | None] = mapped_column(
|
|
||||||
String(128), nullable=True
|
|
||||||
)
|
|
||||||
ccip_prototype_cap: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=64
|
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def load(cls, session) -> MLSettings:
|
|
||||||
"""The singleton settings row (id=1), via an async session. Mirrors
|
|
||||||
ImportSettings.load — the shared singleton-loader pattern."""
|
|
||||||
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load_sync(cls, session) -> MLSettings:
|
|
||||||
"""The singleton settings row (id=1), via a sync session."""
|
|
||||||
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
"""PatreonFailedMedia — per-source dead-letter ledger of Patreon media that
|
|
||||||
keeps failing to download/validate.
|
|
||||||
|
|
||||||
Plan #705 (#7). A media that fails every walk (404'd CDN URL, deleted post,
|
|
||||||
geo-blocked Mux stream, persistently-corrupt bytes) would otherwise re-error
|
|
||||||
forever and re-burn backfill chunks. After ``attempts`` reaches the dead-letter
|
|
||||||
threshold the ingester skips it on routine tick/backfill walks (recovery still
|
|
||||||
re-attempts it — the operator's "try everything again"). A later clean download
|
|
||||||
clears the row (the media recovered).
|
|
||||||
|
|
||||||
`filehash` is the same per-media key the seen-ledger uses (32-hex CDN MD5, or a
|
|
||||||
``video:`` / ``post:filename`` synthesized key) — hence String(128). UNIQUE
|
|
||||||
(source_id, filehash) is the upsert key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import DateTime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PatreonFailedMedia(Base):
|
|
||||||
__tablename__ = "patreon_failed_media"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"source_id", "filehash", name="uq_patreon_failed_media_source_id"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
source_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
||||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
first_failed_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
last_failed_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""PatreonSeenMedia — per-source ledger of Patreon media already
|
|
||||||
downloaded+processed.
|
|
||||||
|
|
||||||
Replaces gallery-dl's archive.sqlite3 with our own queryable table so
|
|
||||||
routine walks can skip media we've already ingested (and a future
|
|
||||||
"recovery" mode can deliberately bypass the ledger to re-walk).
|
|
||||||
|
|
||||||
`filehash` is normally a Patreon CDN MD5 (32 hex chars), but videos —
|
|
||||||
which have no stable content hash at discovery time — use a sentinel of
|
|
||||||
the form ``video:<post_id>:<media_id>``, hence String(128) rather than 32.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import DateTime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PatreonSeenMedia(Base):
|
|
||||||
__tablename__ = "patreon_seen_media"
|
|
||||||
__table_args__ = (
|
|
||||||
# Dedup key the downloader upserts against: one ledger row per
|
|
||||||
# (source, media). A second sighting of the same media is a no-op.
|
|
||||||
UniqueConstraint("source_id", "filehash", name="uq_patreon_seen_media_source_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
source_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
seen_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""PixivFailedMedia — per-source dead-letter ledger of Pixiv media that keeps
|
|
||||||
failing to download/validate.
|
|
||||||
|
|
||||||
Mirror of PatreonFailedMedia/SubscribeStarFailedMedia. Media that fails every
|
|
||||||
walk (404'd pximg URL, deleted work, persistently-corrupt bytes) would
|
|
||||||
otherwise re-error forever and re-burn backfill chunks. After ``attempts``
|
|
||||||
reaches the dead-letter threshold the ingester skips it on routine
|
|
||||||
tick/backfill walks (recovery still re-attempts). A later clean download
|
|
||||||
clears the row.
|
|
||||||
|
|
||||||
`filehash` is the same synthesized ``<illust_id>:p<num>`` /
|
|
||||||
``<illust_id>:ugoira`` key the seen-ledger uses. UNIQUE (source_id, filehash)
|
|
||||||
is the upsert key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import DateTime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PixivFailedMedia(Base):
|
|
||||||
__tablename__ = "pixiv_failed_media"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"source_id", "filehash", name="uq_pixiv_failed_media_source_id"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
source_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
||||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
first_failed_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
last_failed_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""PixivSeenMedia — per-source ledger of Pixiv media already
|
|
||||||
downloaded+processed.
|
|
||||||
|
|
||||||
Mirror of PatreonSeenMedia/SubscribeStarSeenMedia for the Pixiv native
|
|
||||||
ingester (replacing gallery-dl). One queryable row per (source, media) so
|
|
||||||
routine walks skip media we've already ingested; recovery mode bypasses the
|
|
||||||
ledger to re-walk.
|
|
||||||
|
|
||||||
Pixiv original URLs carry no content hash, so `filehash` is always the
|
|
||||||
synthesized ``<illust_id>:p<num>`` (page) / ``<illust_id>:ugoira`` (frame
|
|
||||||
zip) key — stable across any URL-shape drift. String(128) matches the sibling
|
|
||||||
ledgers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import DateTime
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PixivSeenMedia(Base):
|
|
||||||
__tablename__ = "pixiv_seen_media"
|
|
||||||
__table_args__ = (
|
|
||||||
# Dedup key the downloader upserts against: one ledger row per
|
|
||||||
# (source, media). A second sighting of the same media is a no-op.
|
|
||||||
UniqueConstraint(
|
|
||||||
"source_id", "filehash", name="uq_pixiv_seen_media_source_id"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
source_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
seen_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
||||||
)
|
|
||||||
@@ -1,24 +1,11 @@
|
|||||||
"""Post — provenance anchor for one creator post (may contain many images).
|
"""Post — provenance anchor for content downloaded from a Source.
|
||||||
|
|
||||||
`source_id` is nullable since alembic 0030 — filesystem-imported posts
|
A Post is one creator post; it may contain many images/videos.
|
||||||
with no live subscription have NULL source_id. `artist_id` is the
|
|
||||||
denormalized always-present link to the creator (added in 0030 so
|
|
||||||
artist-filter queries don't depend on the Source detour).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
JSON,
|
|
||||||
CheckConstraint,
|
|
||||||
DateTime,
|
|
||||||
ForeignKey,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
Text,
|
|
||||||
UniqueConstraint,
|
|
||||||
func,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -27,29 +14,12 @@ from .base import Base
|
|||||||
class Post(Base):
|
class Post(Base):
|
||||||
__tablename__ = "post"
|
__tablename__ = "post"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Source-bound dedup. Postgres treats NULL != NULL so rows
|
|
||||||
# with source_id IS NULL aren't deduped by this constraint;
|
|
||||||
# the partial unique index `uq_post_artist_external_id_null_source`
|
|
||||||
# (created in alembic 0030) covers that case via
|
|
||||||
# (artist_id, external_post_id).
|
|
||||||
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
|
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
|
||||||
CheckConstraint(
|
|
||||||
"translation_override IN ('auto', 'force', 'original')",
|
|
||||||
name="ck_post_translation_override",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
source_id: Mapped[int | None] = mapped_column(
|
source_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True
|
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
|
||||||
# Denormalized; always equals source.artist_id when source_id is set
|
|
||||||
# (the importer is responsible for keeping them consistent on insert).
|
|
||||||
# Filter queries (artist detail, artist-scoped posts feed) use this
|
|
||||||
# directly instead of joining through Source.
|
|
||||||
artist_id: Mapped[int] = mapped_column(
|
|
||||||
ForeignKey("artist.id", ondelete="CASCADE"),
|
|
||||||
nullable=False, index=True,
|
|
||||||
)
|
)
|
||||||
external_post_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
external_post_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
post_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
post_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -61,34 +31,6 @@ class Post(Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
# -- Post-text translation (milestone 143). Filled by the translate_posts
|
|
||||||
# sweep via the Interpreter LAN service so viewing is instant.
|
|
||||||
# translated_source_lang is the DETECTED original language; "en" (or a
|
|
||||||
# passthrough) means nothing to translate and the *_translated columns stay
|
|
||||||
# NULL. engine_version keys the Interpreter cache — re-runs are ~1ms and a
|
|
||||||
# model upgrade re-translates instead of serving stale.
|
|
||||||
post_title_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
description_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
translated_source_lang: Mapped[str | None] = mapped_column(
|
|
||||||
String(8), nullable=True
|
|
||||||
)
|
|
||||||
translation_engine_version: Mapped[str | None] = mapped_column(
|
|
||||||
String(128), nullable=True
|
|
||||||
)
|
|
||||||
translated_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
# Sticky per-post override of the translation decision (milestone 155):
|
|
||||||
# 'auto' = the acceptance gate decides; 'force' = always store Interpreter's
|
|
||||||
# translation even below the confidence floor (rescue a skipped legit-foreign
|
|
||||||
# title); 'original' = never translate, keep the original (kill a confidently
|
|
||||||
# mis-flagged one the floor can't catch). The sweep reads this on every run,
|
|
||||||
# and re-translate leaves 'original' posts alone, so the choice survives a
|
|
||||||
# Re-translate-all.
|
|
||||||
translation_override: Mapped[str] = mapped_column(
|
|
||||||
String(16), nullable=False, default="auto", server_default="auto",
|
|
||||||
)
|
|
||||||
|
|
||||||
downloaded_at: Mapped[datetime] = mapped_column(
|
downloaded_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user