Compare commits
150
Commits
e2246e861e
...
dev
+2
-3
@@ -56,9 +56,8 @@ SECRET_KEY=
|
||||
# LINE. Leaving it set disarms the protection permanently, on an instance that
|
||||
# by then has credentials worth protecting.
|
||||
#
|
||||
# BACK UP ./images/secrets/ ALONGSIDE YOUR DATABASE. The key is the only thing
|
||||
# that can read your stored credentials; a database restored without it needs
|
||||
# every credential re-entered by hand.
|
||||
# Backups leave the key and the stored logins out on purpose: after restoring
|
||||
# onto a new install, sign in to each account again.
|
||||
CURATOR_BOOTSTRAP_NEW_KEY=1
|
||||
|
||||
|
||||
|
||||
@@ -79,17 +79,27 @@ jobs:
|
||||
- name: Resolve the Postgres service and install deps
|
||||
run: |
|
||||
set -eux
|
||||
# Same service-IP dance as ci.yml's integration job; see the long
|
||||
# Same service-IP dance as build.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"
|
||||
# Only THIS job's services. The runner's docker daemon is shared, so another
|
||||
# repo's job can be running beside this one, and a job-name filter matches its
|
||||
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
|
||||
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
|
||||
# <n> from our own container (its id is in the /etc/hostname bind mount) and
|
||||
# require exactly one match.
|
||||
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
||||
SELF=${SELF:-$(hostname)}
|
||||
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
|
||||
test -n "$TASK"
|
||||
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=pgvector/pgvector:pg16" -q)
|
||||
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
|
||||
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"
|
||||
# Socket probe in python, not bash's /dev/tcp — these steps run under
|
||||
# `sh -e`, where that path does not exist. Same fix and same reasoning
|
||||
# as ci.yml's integration job; see the comment there.
|
||||
# as build.yml's integration job; see the comment there.
|
||||
pg_ready=""
|
||||
for i in $(seq 1 60); do
|
||||
if python -c "import socket,sys; s=socket.socket(); s.settimeout(2); sys.exit(0 if s.connect_ex(('$PG_IP', 5432)) == 0 else 1)"; then
|
||||
|
||||
+1234
-569
File diff suppressed because it is too large
Load Diff
@@ -1,294 +0,0 @@
|
||||
name: CI
|
||||
|
||||
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
|
||||
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
||||
# - extension-version: the derived version resolves and is a shape AMO takes.
|
||||
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
|
||||
# - frontend-build: vitest unit + vite build.
|
||||
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
# Renovate opens PRs from `renovate/*` branches into `dev`. Those branches
|
||||
# never push to dev/main, so the push trigger above gives them NO pre-merge
|
||||
# CI — a bump could only be validated after it was already merged. This
|
||||
# pull_request trigger (base `dev` only) validates Renovate PRs before merge.
|
||||
# It deliberately does NOT fire on dev→main PRs (base `main`), which still
|
||||
# rely on the dev push run — so no duplicate runs. FC has no fork PRs
|
||||
# (single-operator Forgejo repo), so secrets-on-PR is not a concern.
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so
|
||||
# this runs with NO dependency install and surfaces the most common bounce
|
||||
# class (lint: I001 / UP037 / ASYNC109 / W293 …) in seconds — instead of
|
||||
# after the backend job's ~30-60s wheel install. ruff is static analysis,
|
||||
# so no DB/secret env is needed.
|
||||
lint:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ruff lint
|
||||
# agent/ included so the GPU-agent is linted before its image is built
|
||||
# (build.yml only `docker build`s it — this is where it gets checked).
|
||||
# scripts/ likewise: release_notes.py runs only on a tag push, so a
|
||||
# syntax or import error there would otherwise surface at the one
|
||||
# moment nobody wants to debug a workflow.
|
||||
run: ruff check backend/ tests/ alembic/ agent/ scripts/
|
||||
- name: Agent syntax check
|
||||
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
|
||||
# CI image, so we can't import it — but compileall parses every module,
|
||||
# catching syntax errors before the image build.
|
||||
run: python -m compileall -q agent/fc_agent
|
||||
|
||||
# The extension version is DERIVED, not hand-maintained (milestone 271 step
|
||||
# 4): build.yml computes it from the commit TIME of the newest packaged
|
||||
# extension change and stamps it into manifest.json / package.json at build
|
||||
# time. The guard that used to live here — "packaged files changed but nobody
|
||||
# bumped the version" — was therefore checking a fact that had stopped
|
||||
# existing. Worse than useless: it would have failed this lane on every real
|
||||
# extension change, demanding a bump that decides nothing. Retired 2026-08-27
|
||||
# rather than left running beside the new mechanism (rule 22).
|
||||
#
|
||||
# Two things are still worth asserting, and this is the only lane that can:
|
||||
# the extension.yml suite runs on node:24-slim, which is exactly why
|
||||
# version.spec.js sticks to packaging.sh's git-free subcommands.
|
||||
# 1. the derivation actually resolves on this commit
|
||||
# 2. the derived string is one AMO will accept, checked against Mozilla's
|
||||
# own published grammar rather than a loose "digits and dots"
|
||||
#
|
||||
# The MAJOR.MINOR-agreement check that used to be (2) is gone with milestone
|
||||
# 318 step 8: the committed version no longer seeds anything, so there is no
|
||||
# hand-set part left for the two files to disagree about.
|
||||
#
|
||||
# Deliberately NOT checked here: that the derived value beats what has already
|
||||
# been signed. That guard belongs in build.yml, where it compares against the
|
||||
# real ext-* releases. Comparing against origin/main here would be wrong —
|
||||
# dev legitimately derives a LOWER value whenever main is ahead on the
|
||||
# extension, and a lane that fails for being behind is a lane people learn to
|
||||
# ignore.
|
||||
extension-version:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The derivation needs real history: a depth-1 clone sees one commit
|
||||
# and produces a wrong, too-low value RATHER THAN FAILING. Checking
|
||||
# that here is half the point of the lane.
|
||||
fetch-depth: 0
|
||||
- name: Extension version derives cleanly
|
||||
run: |
|
||||
set -eu
|
||||
# busybox sh on the act_runner — no bashisms (family rule).
|
||||
VERSION=$(sh extension/scripts/packaging.sh version)
|
||||
echo "derived: $VERSION"
|
||||
|
||||
# Mozilla's published grammar for AMO, transcribed verbatim from
|
||||
# MDN's manifest.json/version page:
|
||||
#
|
||||
# ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
|
||||
#
|
||||
# Not the looser `^[0-9]+(\.[0-9]+)*$` this lane used to carry. That
|
||||
# one passes `2026.08.29.0201`, which AMO REJECTS — a segment must be
|
||||
# the single digit 0 or start 1-9 — and it also passes five segments,
|
||||
# where AMO allows four. Both would surface as a failed sign with the
|
||||
# version already burned: AMO 409s on re-signing, so a rejected value
|
||||
# cannot be reclaimed and cannot be reused. This lane is the cheap
|
||||
# place to find out. (#3138, milestone 318 step 8.)
|
||||
if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$'; then
|
||||
echo "ERROR: derived version '$VERSION' is not a version AMO accepts."
|
||||
echo "AMO's grammar: ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$"
|
||||
echo "Most likely cause: a zero-padded segment (08, 0201). The rest"
|
||||
echo "of the family pads; the extension must not — see packaging.sh."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ...and the shape this project actually derives. AMO would happily
|
||||
# take `1.0.3500147` too, so the grammar check alone would not notice
|
||||
# a regression to the pre-318 shape — which orders BELOW everything
|
||||
# signed since, and is unrecoverable once Firefox has the higher one.
|
||||
if ! echo "$VERSION" | grep -qE '^20[0-9][0-9]\.[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,4}$'; then
|
||||
echo "ERROR: derived version '$VERSION' is not YYYY.M.D.HHMM."
|
||||
echo "Rule 148's CalVer is what build.yml signs; the old"
|
||||
echo "1.0.<minutes> shape would order below every ext-2026.* release."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: derived version $VERSION"
|
||||
|
||||
backend-lint-and-test:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
# DB_PASSWORD and SECRET_KEY are required by config.py at import time
|
||||
# even though unit tests don't actually touch the DB or use the secret.
|
||||
DB_PASSWORD: ci_unit_test_placeholder
|
||||
SECRET_KEY: ci_unit_test_placeholder
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history for tests/test_artifact_identity.py, which derives
|
||||
# each artifact's revision to check the identity scheme. On a
|
||||
# depth-1 clone that derivation either fails or returns the tip sha
|
||||
# — so the lane would go green while asserting nothing, which is
|
||||
# the one outcome worse than a red one.
|
||||
fetch-depth: 0
|
||||
|
||||
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
||||
# broken on this homelab runner since 2026-05-15 (first as request-
|
||||
# timeout warnings, then as hard "Cannot find module .../dist/restore/
|
||||
# index.js" failures that tank the whole job). The cache step targeted
|
||||
# ~/.cache/pip but the install below uses `uv pip install` primarily,
|
||||
# whose own cache lives at ~/.cache/uv — so the cache step's real
|
||||
# benefit was marginal even when working. Cost of removal: ~30s of
|
||||
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
|
||||
# docker volume at the runner level (skips actions/cache entirely),
|
||||
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
|
||||
# pin act_runner version, etc.).
|
||||
|
||||
- name: Install Python deps
|
||||
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
||||
# Dockerfile's RUFF_VERSION). Per FabledRulebook ci-runners.md, toolchain
|
||||
# versions live on the runner image, not here.
|
||||
# uv: 5-10x faster wheel resolve than pip for cold caches.
|
||||
# Falls back to pip install on uv-missing runners (older images).
|
||||
run: |
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
|
||||
# Ruff moved to the dedicated fast `lint` job above (fails in seconds,
|
||||
# no dep install). This job is now unit tests only.
|
||||
- name: Pytest (unit only — integration runs in the integration job)
|
||||
run: pytest tests/ -v -m "not integration"
|
||||
|
||||
frontend-build:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# No package-lock.json is tracked yet (we don't run npm locally per
|
||||
# feedback-no-local-runs). Using `npm install` instead of `npm ci`.
|
||||
# If we want strict lockfile-based reproducibility later, commit a
|
||||
# package-lock.json and flip this back to `npm ci`.
|
||||
- run: npm install --no-audit --no-fund
|
||||
# No type-check step: the frontend is pure JS (no .ts files, no JSDoc),
|
||||
# so a type-checker has nothing to do. The vue-tsc devDep + its `check`
|
||||
# script were dropped 2026-07-11 rather than bumped to v3. If we add
|
||||
# TS/JSDoc later, re-add a tsconfig.json + vue-tsc + a type-check step.
|
||||
- run: npm run test:unit
|
||||
- run: npm run build
|
||||
|
||||
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
|
||||
# The shards existed to parallelize ~8.5min of integration tests; once the
|
||||
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
|
||||
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
|
||||
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
|
||||
# runner slots for no wall-clock gain. One job now: spin up once, install
|
||||
# once, migrate once, run every integration test.
|
||||
#
|
||||
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
|
||||
# containers by job name. act_runner strips underscores from job names when
|
||||
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
|
||||
# stays separator-free (`integration`). The step prints `docker ps -a` first
|
||||
# so a future naming-convention shift surfaces in the log without a
|
||||
# guess-and-push cycle.
|
||||
#
|
||||
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
|
||||
# per ci-requirements.md, FC is the only Python consumer of that image and the
|
||||
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
|
||||
integration:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
DB_USER: fabledcurator
|
||||
DB_PASSWORD: ci_integration
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: fabledcurator_test
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: fabledcurator
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: fabledcurator_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U fabledcurator"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Integration suite (resolve service IPs, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
echo "=== end landscape ==="
|
||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
test -n "$PG" && test -n "$RD"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||
test -n "$PG_IP" && test -n "$RD_IP"
|
||||
export DB_HOST="$PG_IP"
|
||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
||||
# These steps run under `sh -e`, not bash, so bash's /dev/tcp magic
|
||||
# path does not exist here — the probe this loop used to run could
|
||||
# never succeed and simply burned the full 120s on every run, green
|
||||
# or red, then continued without having established anything. Python
|
||||
# is in the image and needs no installed package for a socket
|
||||
# connect, so it is the probe. Exhausting the budget is now a named
|
||||
# failure rather than a silent fall-through (rule 156): if Postgres
|
||||
# is genuinely not up, that is what the log should say, instead of
|
||||
# whatever the first query happens to raise two minutes later.
|
||||
pg_ready=""
|
||||
for i in $(seq 1 60); do
|
||||
if python -c "import socket,sys; s=socket.socket(); s.settimeout(2); sys.exit(0 if s.connect_ex(('$PG_IP', 5432)) == 0 else 1)"; then
|
||||
pg_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$pg_ready" ]; then
|
||||
echo "postgres at $PG_IP:5432 did not accept a connection within 120s"
|
||||
exit 1
|
||||
fi
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
# Relax durability on the throwaway CI Postgres so the per-test
|
||||
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
|
||||
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
|
||||
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
|
||||
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
|
||||
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
|
||||
# surprise can't red the job; fabledcurator is the postgres image's
|
||||
# bootstrap superuser.
|
||||
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
|
||||
alembic upgrade head
|
||||
pytest tests/ -v -m integration --durations=15
|
||||
@@ -1,87 +0,0 @@
|
||||
name: extension
|
||||
# Lint + unit tests. The sign-and-publish dance moved into build.yml's
|
||||
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
|
||||
# because sign-extension runs as a build-web dependency in the SAME workflow,
|
||||
# eliminating the prior race between build.yml and a separate extension.yml.
|
||||
# Signed XPIs are cached in Forgejo Release Assets named `ext-<version>`.
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.forgejo/workflows/extension.yml'
|
||||
# test/version.spec.js asserts things ABOUT the other two workflows —
|
||||
# that neither inlines the packaged-file set, and that build.yml derives
|
||||
# the shipped version rather than reading it out of the repo. A
|
||||
# workflow-only edit can therefore break this suite, so it has to trigger
|
||||
# it. build.yml joined the list at milestone 271 step 5, when the spec
|
||||
# started asserting against it.
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
- '.forgejo/workflows/build.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
- '.forgejo/workflows/build.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: node:24-bookworm-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Not --no-save: vitest and web-ext are both real devDependencies now,
|
||||
# and the suite needs vitest resolvable from node_modules.
|
||||
- name: Install dev dependencies
|
||||
run: cd extension && npm install --no-audit --no-fund
|
||||
- name: Lint
|
||||
run: cd extension && npm run lint
|
||||
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
|
||||
# package version-consistency checks. No browser, no network.
|
||||
- name: Unit tests
|
||||
run: cd extension && npm run test:unit
|
||||
|
||||
# Everything else about packaging is asserted against our own declaration
|
||||
# of what ships. This is the only check that asks web-ext what it ACTUALLY
|
||||
# put in the archive. Until now that was an unverified assumption about
|
||||
# glob semantics — and a fragile one: `test/**` reaches web-ext intact
|
||||
# only because callers `set -f` first, so losing that quoting would
|
||||
# silently start shipping dev files with no other signal.
|
||||
- name: Verify XPI contents
|
||||
run: |
|
||||
set -eu
|
||||
command -v unzip >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq unzip; }
|
||||
cd extension
|
||||
npm run build
|
||||
ZIP=$(ls web-ext-artifacts/*.zip | head -1)
|
||||
echo "=== packaged entries in $ZIP ==="
|
||||
unzip -Z1 "$ZIP" | sort
|
||||
echo "=== end ==="
|
||||
ENTRIES=$(unzip -Z1 "$ZIP")
|
||||
fail=0
|
||||
# Must NOT ship: repo infrastructure with no business in a user's browser.
|
||||
for pat in 'test/' 'scripts/' 'vitest.config.js' 'package.json' 'package-lock.json' 'README.md' 'node_modules/' 'web-ext-artifacts/'; do
|
||||
if echo "$ENTRIES" | grep -q "^$pat"; then
|
||||
echo "ERROR: '$pat' was packaged into the XPI but must not be"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
# Must ship: if an exclusion pattern ever over-matches, the extension
|
||||
# breaks at runtime rather than at build time, so assert presence too.
|
||||
for req in 'manifest.json' 'lib/url.js' 'lib/api.js' 'lib/platforms.js' 'lib/cookies.js'; do
|
||||
if ! echo "$ENTRIES" | grep -q "^$req$"; then
|
||||
echo "ERROR: '$req' is missing from the XPI"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
for dir in 'background/' 'popup/' 'options/' 'content/' 'icons/'; do
|
||||
if ! echo "$ENTRIES" | grep -q "^$dir"; then
|
||||
echo "ERROR: nothing from '$dir' was packaged"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
echo "XPI contents verified."
|
||||
+103
-3
@@ -28,6 +28,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
postgresql-client \
|
||||
zstd \
|
||||
megatools \
|
||||
# PID 1 for every role. See the ENTRYPOINT note at the foot of this file:
|
||||
# without it the image needs `init: true` in whatever runs it, which is a
|
||||
# deployment remembering a flag for the image to behave correctly.
|
||||
tini \
|
||||
libjpeg62-turbo \
|
||||
libwebp7 \
|
||||
libpng16-16 \
|
||||
@@ -36,9 +40,59 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
COPY requirements.txt requirements-ml.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# --- ML, merged from Dockerfile.ml (milestone 422 step 6) --------------------
|
||||
#
|
||||
# ONE image now serves every lane. It was two because the ML lane ran in its
|
||||
# own container; with the single-container layout (step 5) running every lane
|
||||
# in one process tree, a second image would mean the `ml` lane could never be
|
||||
# enabled from the UI — there would be no worker in this container to enable.
|
||||
#
|
||||
# THE COST, MEASURED from run 7273 rather than guessed — and it is far
|
||||
# smaller than the estimate this comment first carried, which said "everyone
|
||||
# pulls ~4GB":
|
||||
#
|
||||
# torch 2.12.1+cpu wheel 192.3 MB
|
||||
# torchvision 0.27.1+cpu 1.8 MB
|
||||
# transformers / onnxruntime / opencv / sklearn and friends (opencv and
|
||||
# onnxruntime since dropped, #1451 — nothing here imported them)
|
||||
# 62.0, 35.3, 23.6, 16.7, 12.3, 9.2, 6.9 MB
|
||||
# largest newly-pushed layer 222.07 MB
|
||||
#
|
||||
# So the ML code adds a few hundred MB to the pull, not gigabytes. The CPU
|
||||
# index is what makes that true: the default PyPI torch wheel bundles the
|
||||
# NVIDIA CUDA runtime and is ~2GB on its own.
|
||||
#
|
||||
# The GIGABYTES are in the MODEL — ~3.5GB of SigLIP weights — and those are
|
||||
# NOT in this image. They arrive only when the operator enables the lane,
|
||||
# which is what lets rule 164 permit a runtime fetch at all ("optional and
|
||||
# clearly off"). That also settles the trade this step was asked to weigh:
|
||||
# baking the weights in would add ~3.5GB to every pull for a feature many
|
||||
# adopters never enable, against ~350MB for the code that makes the switch
|
||||
# available. Off-by-default wins by an order of magnitude, which was NOT
|
||||
# obvious before measuring — the estimate had the two costs within 15% of
|
||||
# each other.
|
||||
#
|
||||
# `--index-url`, not `--extra-index-url`: the latter would let pip resolve a
|
||||
# +cu wheel anyway, and the whole saving above depends on it not doing that.
|
||||
#
|
||||
# CPU-only torch from the PyTorch CPU index. Nothing here uses a GPU — the
|
||||
# GPU agent is a separate service with its own image.
|
||||
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
"torch>=2.14" "torchvision>=0.29"
|
||||
RUN pip install -r requirements-ml.txt
|
||||
|
||||
# Where the model lands. Deliberately NOT a VOLUME instruction: that mints an
|
||||
# anonymous volume when nobody mounts one, which survives `docker rm` and
|
||||
# accumulates 3.5GB copies nobody can find. The compose files mount it
|
||||
# explicitly instead, so an unmounted run simply re-downloads — visible, and
|
||||
# recoverable.
|
||||
ENV HF_HOME=/models/.huggingface \
|
||||
TRANSFORMERS_CACHE=/models/.huggingface \
|
||||
ML_MODEL_DIR=/models
|
||||
|
||||
COPY backend/ ./backend/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini ./
|
||||
@@ -72,5 +126,51 @@ ENV FC_VERSION=${FC_VERSION}
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
CMD ["web"]
|
||||
# ONE healthcheck for every role, because the image knows which role it is
|
||||
# running and a deployment should not have to repeat it. `healthcheck` reads
|
||||
# the role entrypoint.sh recorded and asks the right question: HTTP for web,
|
||||
# a self-addressed celery ping for a worker lane, both-for-every-lane for the
|
||||
# consolidated `all`.
|
||||
#
|
||||
# start-period covers the SLOWEST role, which is `all`: alembic, then
|
||||
# hypercorn, then four celery workers registering with the broker. A web-only
|
||||
# container is ready long before this; the cost of the shared number is that
|
||||
# a broken one takes a little longer to be called broken.
|
||||
#
|
||||
# A service may still declare its own healthcheck and docker will prefer it —
|
||||
# the escape hatch for a deployment that wants something different.
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=90s --retries=3 \
|
||||
CMD ["python", "-m", "backend.app.scripts.healthcheck"]
|
||||
|
||||
# tini is PID 1, and the image brings its own rather than asking the
|
||||
# deployment for one.
|
||||
#
|
||||
# PID 1 carries a duty no other process has: every orphaned process in the
|
||||
# container reparents to it and must be reaped, or it stays a zombie holding
|
||||
# a PID slot. This app makes orphans in normal operation — six service
|
||||
# modules shell out (gallery-dl, ffmpeg, pg_dump, the external fetchers) and
|
||||
# celery's prefork pool forks children that spawn them.
|
||||
#
|
||||
# Whatever the role, something that is not an init ends up as PID 1:
|
||||
# supervisord for `all`, hypercorn for `web`, celery for a worker. The fix
|
||||
# was `init: true` in the compose/stack file, which is out of the norm and
|
||||
# put correct process handling in the hands of whoever deploys the image —
|
||||
# the same mistake as declaring the healthcheck per service. A flag that is
|
||||
# silently dropped (an older Swarm, a `docker run` without it) costs reaping
|
||||
# with no signal at all.
|
||||
#
|
||||
# So the image owns it. `docker run <image>` is correct on its own, and
|
||||
# nothing downstream has to know. The smoke asserts /proc/1/comm is tini.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "./entrypoint.sh"]
|
||||
# The DEFAULT is the whole application, not one lane of it.
|
||||
#
|
||||
# `docker run fabledcurator` with no command starts hypercorn plus every
|
||||
# worker lane under supervisord — the shape an adopter wants and the shape the
|
||||
# consolidated stack runs. It was `web`, which meant the single-container
|
||||
# layout only worked if you knew to ask for it by name, and a compose file
|
||||
# that forgot `command:` got a web server with nothing processing its queues:
|
||||
# a gallery that loads, accepts an import, and never finishes one.
|
||||
#
|
||||
# The multi-service stack is unaffected — every service there names its role
|
||||
# explicitly, which is exactly what makes it the multi-service stack.
|
||||
CMD ["all"]
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.25
|
||||
|
||||
FROM python:3.14-slim
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
HF_HOME=/models/.huggingface \
|
||||
TRANSFORMERS_CACHE=/models/.huggingface \
|
||||
ML_MODEL_DIR=/models
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libpq5 \
|
||||
libjpeg62-turbo \
|
||||
libwebp7 \
|
||||
libpng16-16 \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements-ml.txt requirements.txt ./
|
||||
# CPU-only torch: the default PyPI wheel bundles the CUDA runtime (~5.6GB
|
||||
# layer); this pipeline never uses a GPU. --index-url (not --extra-index-url)
|
||||
# guarantees only +cpu wheels are considered, so no nvidia-*-cu12 deps.
|
||||
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
"torch>=2.12,<3.0" "torchvision>=0.27,<0.28"
|
||||
RUN pip install -r requirements-ml.txt
|
||||
|
||||
COPY backend/ ./backend/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini ./
|
||||
COPY entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Models self-heal into /models on first start (FC-2 implements this)
|
||||
VOLUME ["/models"]
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
CMD ["ml-worker"]
|
||||
@@ -22,7 +22,8 @@ through afterwards.
|
||||
- **ML tagging.** Runs image models in-container to suggest tags, group
|
||||
characters, find near-duplicates and power similarity search. Suggestions are
|
||||
reviewable — it proposes, you confirm, and it learns which proposals you keep
|
||||
rejecting.
|
||||
rejecting. It ships switched off: turn it on under Settings → System when
|
||||
you want it, and it fetches its model weights then.
|
||||
- **Deduplication and provenance.** Everything that arrives is hashed and
|
||||
deduplicated by content, metadata sidecars are read wherever the source
|
||||
writes them, and every file keeps a record of where it came from.
|
||||
@@ -113,15 +114,19 @@ that cannot decrypt anything, leaving an instance that looks healthy while every
|
||||
paywalled download fails. Making you say so once, on an empty install, is the
|
||||
price of that not happening silently later.
|
||||
|
||||
**Which means: back up `./images/secrets/` alongside your database.** It is the
|
||||
only thing that can read your stored credentials. A database restored without it
|
||||
needs every credential entered again by hand.
|
||||
**Your account logins are sealed with that key, and no backup carries either.**
|
||||
FabledCurator's backups (below) leave the key and the logins out on purpose, so
|
||||
a stolen backup holds no way into your accounts. After a restore, sign in to
|
||||
each account again through the browser extension.
|
||||
|
||||
A few other things are worth knowing about the first few minutes:
|
||||
|
||||
- **The ML worker downloads its model weights on first boot**, several GB from
|
||||
HuggingFace into `./models`. Until that finishes, tagging is queued rather
|
||||
than broken. It is idempotent — a restart resumes rather than refetches.
|
||||
- **ML tagging starts switched off, and nothing is downloaded at boot.** Give
|
||||
the ML lane a slot under **Settings → System** and it fetches its model
|
||||
weights then — several GB from HuggingFace into `./models`, shown as a job
|
||||
under **Settings → Activity** that you can watch and retry. Until it
|
||||
finishes, tagging is queued rather than broken, and the fetch only takes
|
||||
what is missing, so turning the lane off and on again does not refetch.
|
||||
- **The gallery starts empty**, and that is the expected state. Add a creator
|
||||
under **Subscriptions** and it fills as posts come down.
|
||||
- **If you already have a library on disk**, there is no screen that imports
|
||||
@@ -173,6 +178,52 @@ Run it for a burst of tagging, stop it to get your card back. It deploys from
|
||||
`agent/docker-compose.yml`, not the main stack — see
|
||||
[agent/README.md](agent/README.md).
|
||||
|
||||
## Backing up and restoring
|
||||
|
||||
Backups live under **Settings → Maintenance → Backups**. With nothing else set
|
||||
up, the database is dumped nightly into `./images/_backups`, which is on the
|
||||
same disk as what it protects. Images and attachments are backed up only to an
|
||||
off-site destination.
|
||||
|
||||
To keep a copy off the machine, set up an off-site destination on the same
|
||||
screen: any S3 bucket (Fabled Archives, MinIO, AWS) and a key that may add and
|
||||
read objects. Each night FabledCurator then sends the database as a dated dump
|
||||
under `database/`, and every image and attachment once, named by its
|
||||
fingerprint, under `media/`. It never deletes from the bucket; the bucket's own
|
||||
retention thins old dumps. The first copy of a large library takes hours; a
|
||||
restart along the way carries on where it stopped rather than starting over.
|
||||
|
||||
Once a week the bucket is checked against what was sent, and anything missing
|
||||
goes again in the next run. When the newest database backup is more than a day
|
||||
old or failed, or the check found files missing, the **Backups** row on
|
||||
**Settings → System** and the dot beside the brand turn amber and say why.
|
||||
|
||||
**Test a backup** proves a dump restores without touching your install: the dump
|
||||
is loaded into a separate, temporary database on the same Postgres server,
|
||||
every table is checked against the row counts recorded as the dump was taken,
|
||||
the copy is brought up to this version, and then it is deleted. It needs about
|
||||
as much free space on the database's disk as the database itself, while it
|
||||
runs. A dump taken before counts were recorded is shown beside the live
|
||||
database instead.
|
||||
|
||||
To rebuild after losing the server, start a fresh install (first boot as
|
||||
above), enter the same destination, and under **Restore from the bucket** choose
|
||||
a dump. The database is replaced, then every file is fetched back to its place
|
||||
and checked against its fingerprint as it arrives; progress shows on the same
|
||||
screen. **Fetch missing files** does only the second half, for a library that
|
||||
lost files but kept its database.
|
||||
|
||||
If the web page isn't reachable, the same restore runs from a shell:
|
||||
|
||||
```bash
|
||||
docker compose exec web python -m backend.app.scripts.restore list
|
||||
docker compose exec web python -m backend.app.scripts.restore database --dump latest --yes
|
||||
```
|
||||
|
||||
On an install with no destination saved yet, add `--endpoint`, `--bucket` and
|
||||
`--access-key`, and put the secret key in `FC_BACKUP_SECRET_KEY` (or type it at
|
||||
the prompt).
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
@@ -245,9 +296,9 @@ reasoning is note #3127 §5). Rolling back is `docker pull …:c-<sha>`.
|
||||
|
||||
Each artifact still has a version, derived rather than chosen: the commit time
|
||||
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.
|
||||
`YYYY.MM.DD.HHMM` UTC (rule 148). Three artifacts, three independent versions
|
||||
— a push touching only `agent/` re-versions the agent and leaves web and the
|
||||
extension 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
|
||||
@@ -260,22 +311,32 @@ commits since the previous tag; it builds no image.
|
||||
|
||||
## What's in here
|
||||
|
||||
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
|
||||
Four 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. |
|
||||
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`, `ml-worker`, or `all` (every lane under supervisord, the single-container layout). The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
|
||||
| **GPU agent** | `agent/Dockerfile` | `fabledcurator-agent` | Optional desktop-GPU worker (`agent/`). Leases jobs over **HTTP only** — never touches the database or Redis. See `agent/README.md`. |
|
||||
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both `dev` and `main` (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See `extension/README.md`. |
|
||||
| **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
|
||||
|
||||
## CI / Forgejo setup
|
||||
|
||||
Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
|
||||
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
|
||||
content verification), `build.yml` (sign + publish), and `release.yml`, which
|
||||
runs only on a `v*` tag and publishes a changelog without building anything.
|
||||
Two workflows that matter here: `build.yml` (the six verification lanes — lint,
|
||||
extension-version check, backend unit tests, frontend build, extension lint +
|
||||
vitest + XPI content check, integration — and then sign + publish), and
|
||||
`release.yml`, which runs only on a `v*` tag and publishes a changelog without
|
||||
building anything. The extension lane was its own `extension.yml` until
|
||||
milestone 429, which let a red extension suite sign and ship the XPI anyway.
|
||||
|
||||
**The lanes and the publish are one workflow on purpose.** They were two
|
||||
(`ci.yml` and `build.yml`) until 2026-09-23, on the same push trigger, which
|
||||
meant the build could not see the tests' verdict and published whatever it
|
||||
built — a red unit lane and a fresh `:dev` image, in the same minute. A
|
||||
`needs:` edge only exists inside one workflow graph, so the two are one graph
|
||||
and the gate is that edge: a lane that fails, **or that merely skips**, leaves
|
||||
the publishing jobs unrun. Pull-request runs (Renovate bumps into `dev`) are
|
||||
the lanes and nothing else.
|
||||
|
||||
**The toolchain each job runs in is its `container.image`, not its `runs-on`
|
||||
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
|
||||
|
||||
+40
-10
@@ -1,10 +1,21 @@
|
||||
# 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
|
||||
#
|
||||
# The `base` flavour, not `cudnn-runtime`: CUDA and cuDNN arrive as the
|
||||
# `nvidia-*` pip packages torch and onnxruntime-gpu depend on, so the base only
|
||||
# has to hand the container the driver (it sets NVIDIA_VISIBLE_DEVICES /
|
||||
# NVIDIA_DRIVER_CAPABILITIES for the Container Toolkit). Until #1451 this was
|
||||
# `12.9.2-cudnn-runtime` under a `torch==2.6.0+cu124` — and requirements.txt then
|
||||
# REPLACED that torch with PyPI's CUDA-13 build (ultralytics pulls torchvision,
|
||||
# which pulls its matching torch), beside a CUDA-13 onnxruntime-gpu. The image
|
||||
# ran CUDA 13 on a CUDA-12 base, carrying ~3 GB of base libraries and a ~3 GB
|
||||
# torch nothing loaded: 10 GB compressed.
|
||||
#
|
||||
# 13.0 because that is the line both wheels are built for (torch's cu130 index,
|
||||
# onnxruntime-gpu's `nvidia-cuda-runtime~=13.0`). Needs an NVIDIA driver that
|
||||
# supports CUDA 13 (580+); fc_agent/accel.py logs at startup whether torch and
|
||||
# onnxruntime actually got the GPU, since both fall back to the CPU silently.
|
||||
# ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
|
||||
FROM nvidia/cuda:13.0.3-base-ubuntu24.04
|
||||
|
||||
# PIP_BREAK_SYSTEM_PACKAGES: Ubuntu 24.04 marks its system Python as externally
|
||||
# managed (PEP 668), so a global `pip install` errors without this. It's a
|
||||
@@ -16,10 +27,12 @@ RUN apt-get update \
|
||||
&& 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
|
||||
# torch AND torchvision from the cu130 index, together and first. Installing
|
||||
# torch alone is what let the next step swap it out: ultralytics needs
|
||||
# torchvision, PyPI's torchvision pins its own torch, and pip replaced ours to
|
||||
# match. With both present, requirements.txt finds them satisfied.
|
||||
RUN pip3 install --no-cache-dir --index-url https://download.pytorch.org/whl/cu130 \
|
||||
torch torchvision
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
COPY fc_agent ./fc_agent
|
||||
@@ -27,6 +40,23 @@ 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
|
||||
|
||||
# Declared LAST on purpose, exactly as the web Dockerfile does: an ARG/ENV
|
||||
# invalidates every layer below it, and these are the only values that differ
|
||||
# between builds of otherwise identical source. Any earlier and the ~6.3 GB
|
||||
# CUDA + torch layers could never be shared between the dev and main builds of
|
||||
# one commit — which is the cost #3114 measured at 9m26s cold.
|
||||
#
|
||||
# Three values, never folded together (rule 149) — the NAME a person reads, the
|
||||
# CHANNEL it came from, and the REVISION that identifies the content. See
|
||||
# fc_agent/build_info.py; CI derives all three from scripts/artifacts.sh.
|
||||
ARG FC_CHANNEL=""
|
||||
ENV FC_CHANNEL=${FC_CHANNEL}
|
||||
ARG FC_VERSION=""
|
||||
ENV FC_VERSION=${FC_VERSION}
|
||||
ARG FC_REVISION=""
|
||||
ENV FC_REVISION=${FC_REVISION}
|
||||
|
||||
EXPOSE 8770
|
||||
|
||||
# The control UI; the worker is started from it (or POST /start).
|
||||
|
||||
+17
-2
@@ -15,13 +15,28 @@ 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
|
||||
docker run --rm --gpus all nvidia/cuda:13.0.3-base-ubuntu24.04 nvidia-smi
|
||||
# the header's CUDA version must be 13.0 or later (driver 580+)
|
||||
```
|
||||
|
||||
### After a driver update: regenerate the CDI spec
|
||||
If the agent's first log lines say `accel: torch is NOT on the GPU` or report
|
||||
`cudaGetDeviceCount: unknown error (999)` while `nvidia-smi` still works, the
|
||||
toolkit's saved device list (`/etc/cdi/nvidia.yaml`) is out of date. The
|
||||
`nvidia-uvm` device number changes between driver versions, and a spec
|
||||
generated before the update hands the container a device node that no longer
|
||||
exists (2026-09-24: host `511,0`, container `235,0`). Compare
|
||||
`ls -l /dev/nvidia-uvm` on the host with the same inside the container, then:
|
||||
```sh
|
||||
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
|
||||
# if your toolkit ships it, this keeps it current on every driver update:
|
||||
sudo systemctl enable --now nvidia-cdi-refresh.path
|
||||
```
|
||||
|
||||
## 1. Get a token
|
||||
In FC: **Settings → Tagging → GPU agent → Generate token** (or Rotate). Copy it.
|
||||
|
||||
## 2. Pull (CI publishes it alongside the web/ml images)
|
||||
## 2. Pull (CI publishes it alongside the web image)
|
||||
```sh
|
||||
docker pull git.fabledsword.com/bvandeusen/fabledcurator-agent:latest
|
||||
```
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Which accelerator each runtime actually got — reported once, at startup.
|
||||
|
||||
The agent has two GPU runtimes and both fall back to the CPU without raising:
|
||||
torch when the driver is too old for its CUDA build, and onnxruntime (the imgutils
|
||||
detector + CCIP models) when its CUDA provider cannot load its libraries. A
|
||||
fallback shows up only as slower work, and nothing reported it. On 2026-09-24 the
|
||||
image turned out to be running a CUDA-13 torch and onnxruntime on a CUDA-12 base
|
||||
(#1451), and whether the ONNX half was on the GPU could not be answered from
|
||||
anything the agent had ever logged.
|
||||
|
||||
Also the fix for the likeliest way the ONNX half misses: onnxruntime-gpu's CUDA
|
||||
provider finds libcudart/cuBLAS/cuDNN only on the loader path, and in this image
|
||||
they live in the `nvidia-*` pip packages torch installs. `preload_dlls()` (ORT
|
||||
1.21+) loads them from there, so the provider resolves them by soname.
|
||||
|
||||
Stdlib-only at import, so the unit suite can import it — torch and onnxruntime
|
||||
are imported inside the functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("fc_agent.accel")
|
||||
|
||||
# Filled by report(); /status carries it so the page can show it too.
|
||||
LAST: dict = {}
|
||||
|
||||
|
||||
def torch_status(imp=importlib.import_module) -> dict:
|
||||
try:
|
||||
torch = imp("torch")
|
||||
except Exception as e:
|
||||
return {"device": "unavailable", "error": str(e)}
|
||||
out = {"version": torch.__version__, "cuda_build": torch.version.cuda}
|
||||
if torch.cuda.is_available():
|
||||
out["device"] = "cuda"
|
||||
out["gpu"] = torch.cuda.get_device_name(0)
|
||||
else:
|
||||
out["device"] = "cpu"
|
||||
return out
|
||||
|
||||
|
||||
def onnx_status(imp=importlib.import_module, load=ctypes.CDLL) -> dict:
|
||||
try:
|
||||
ort = imp("onnxruntime")
|
||||
except Exception as e:
|
||||
return {"device": "unavailable", "error": str(e)}
|
||||
out = {"version": ort.__version__, "providers": list(ort.get_available_providers())}
|
||||
if "CUDAExecutionProvider" not in out["providers"]:
|
||||
out["device"] = "cpu"
|
||||
return out
|
||||
preload = getattr(ort, "preload_dlls", None)
|
||||
if preload is not None:
|
||||
try:
|
||||
preload()
|
||||
except Exception as e:
|
||||
out["preload_error"] = str(e)
|
||||
# "Available" only means the build HAS the provider. Loading its library is
|
||||
# what resolves libcudart/cuBLAS/cuDNN — the step that fails when they are
|
||||
# missing, and the one a session would otherwise fail silently on.
|
||||
capi = Path(ort.__file__).parent / "capi"
|
||||
try:
|
||||
load(str(capi / "libonnxruntime_providers_shared.so"), mode=ctypes.RTLD_GLOBAL)
|
||||
load(str(capi / "libonnxruntime_providers_cuda.so"))
|
||||
except OSError as e:
|
||||
out["device"] = "cpu"
|
||||
out["error"] = str(e)
|
||||
return out
|
||||
# Loading proves the libraries resolve, NOT that a GPU can be used: on
|
||||
# 2026-09-24 this reported "onnx on GPU" beside torch failing cuInit with
|
||||
# "CUDA unknown error" (a driver update awaiting a reboot). Asking the CUDA
|
||||
# runtime for a device initialises the driver the provider would use.
|
||||
error = _cuda_device_error(load)
|
||||
out["device"] = "cpu" if error else "cuda"
|
||||
if error:
|
||||
out["error"] = error
|
||||
return out
|
||||
|
||||
|
||||
def _cuda_device_error(load=ctypes.CDLL) -> str | None:
|
||||
"""None when the CUDA runtime can reach a device, else why it cannot."""
|
||||
try:
|
||||
cudart = load("libcudart.so.13")
|
||||
except OSError as e:
|
||||
return str(e)
|
||||
count = ctypes.c_int(0)
|
||||
rc = cudart.cudaGetDeviceCount(ctypes.byref(count))
|
||||
if rc != 0:
|
||||
cudart.cudaGetErrorString.restype = ctypes.c_char_p
|
||||
return f"cudaGetDeviceCount: {cudart.cudaGetErrorString(rc).decode()} ({rc})"
|
||||
return None if count.value > 0 else "no CUDA device visible"
|
||||
|
||||
|
||||
def summary() -> dict | None:
|
||||
"""The report as FabledCurator stores it: each runtime's device, and why
|
||||
when it is not the GPU. Sent on every lease and heartbeat, so the System
|
||||
view can call a running agent that fell back to the CPU "degraded" rather
|
||||
than "running" — the 2026-09-24 fallback went unseen for weeks because
|
||||
only this agent's own log said so. None before report() has run."""
|
||||
if not LAST:
|
||||
return None
|
||||
out = {}
|
||||
for name, s in LAST.items():
|
||||
entry = {"device": s.get("device")}
|
||||
if s.get("error"):
|
||||
entry["error"] = str(s["error"])[:200]
|
||||
out[name] = entry
|
||||
return out
|
||||
|
||||
|
||||
def report() -> dict:
|
||||
"""Check both runtimes, log the result, and keep it for /status."""
|
||||
LAST.clear()
|
||||
LAST.update(torch=torch_status(), onnx=onnx_status())
|
||||
for name, s in LAST.items():
|
||||
if s.get("device") == "cuda":
|
||||
log.info("accel: %s on GPU (%s)", name, s)
|
||||
else:
|
||||
log.warning("accel: %s is NOT on the GPU — work runs on the CPU (%s)", name, s)
|
||||
return dict(LAST)
|
||||
+55
-11
@@ -11,17 +11,22 @@ import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from . import logbuf
|
||||
from . import accel, logbuf
|
||||
from .build_info import FC_CHANNEL, FC_REVISION, FC_VERSION, build_id, display_version
|
||||
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"
|
||||
# DERIVED at image build time, not hand-maintained — see build_info. This was a
|
||||
# literal an author was asked to bump on every agent change, and the September
|
||||
# image printed the same "2026-07-17.1" as the July one, so the surface meant to
|
||||
# answer "did my pull work?" answered the same either way.
|
||||
#
|
||||
# Two values with two jobs, kept apart (rule 149): the page SHOWS the version
|
||||
# and COMPARES the build id. /status reports both, plus the raw fields, so a
|
||||
# reader never has to take a formatted string apart to get at one of them.
|
||||
|
||||
logbuf.install()
|
||||
cfg = Config.from_env()
|
||||
@@ -42,6 +47,9 @@ async def _no_store(request, call_next):
|
||||
|
||||
@app.on_event("startup")
|
||||
def _maybe_autostart() -> None:
|
||||
# Before the worker: the report also preloads the CUDA libraries the ONNX
|
||||
# models need, and it says in the log which runtimes landed on the GPU.
|
||||
accel.report()
|
||||
# With AUTO_START set, a container restart (host reboot, or `restart:
|
||||
# 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
|
||||
@@ -52,7 +60,14 @@ def _maybe_autostart() -> None:
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
return _PAGE.replace("__BUILD__", VERSION)
|
||||
# Two substitutions, not one: `__VERSION__` is what a person reads in the
|
||||
# meta line, `__BUILD_ID__` is what the script compares against /status to
|
||||
# notice the page is a cached copy from a previous build.
|
||||
return (
|
||||
_PAGE
|
||||
.replace("__VERSION__", display_version())
|
||||
.replace("__BUILD_ID__", build_id())
|
||||
)
|
||||
|
||||
|
||||
@app.post("/start")
|
||||
@@ -117,7 +132,15 @@ def status():
|
||||
s["fc_url"] = cfg.fc_url
|
||||
s["configured"] = bool(cfg.token)
|
||||
s["queue"] = worker.latest_queue()
|
||||
s["build"] = VERSION
|
||||
# `build` is the comparison token the page checks — see build_info.
|
||||
# `version`/`channel`/`revision` ride BESIDE it rather than inside it, so a
|
||||
# reader wanting the version never has to parse it back out of something
|
||||
# else. Absent rather than empty when the image carries no stamp.
|
||||
s["build"] = build_id()
|
||||
s["version"] = FC_VERSION or None
|
||||
s["channel"] = FC_CHANNEL or None
|
||||
s["revision"] = FC_REVISION or None
|
||||
s["accel"] = accel.LAST or None
|
||||
return JSONResponse(s)
|
||||
|
||||
|
||||
@@ -169,7 +192,11 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
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}
|
||||
color:var(--fg);border:1px solid var(--bd);border-radius:8px;appearance:textfield;-moz-appearance:textfield}
|
||||
/* The browser's own spin arrows, hidden: the − / + beside each field are the
|
||||
control, styled like the rest of the page (operator, 2026-09-24). */
|
||||
#conc::-webkit-inner-spin-button,#conc::-webkit-outer-spin-button,
|
||||
#bw::-webkit-inner-spin-button,#bw::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
|
||||
.unit{color:var(--mut);font-size:12px;font-weight:600}
|
||||
.hint{color:var(--mut);font-size:12px;margin-top:12px}
|
||||
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
|
||||
@@ -203,11 +230,12 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
<div class=brand><span class=logo>◆</span> FabledCurator <span class=sub>GPU agent</span></div>
|
||||
<div class=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>
|
||||
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__VERSION__</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=accelbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3"></div>
|
||||
<div id=banner class=banner style=display:none>
|
||||
curator unreachable — holding work + retrying, resumes on its own (no restart needed)
|
||||
</div>
|
||||
@@ -225,7 +253,9 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
<button class=step onclick=setc(1)>+</button>
|
||||
</div>
|
||||
<div class=stepper title="aggregate download cap, downloads + video streams combined — 0 = unlimited">
|
||||
<button class=step onclick=stepbw(-1)>−</button>
|
||||
<input id=bw type=number min=0 step=1 value=8 onchange="setbw(this.value)">
|
||||
<button class=step onclick=stepbw(1)>+</button>
|
||||
<span class=unit>MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,7 +292,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
const PAGE_BUILD="__BUILD__"
|
||||
const PAGE_BUILD="__BUILD_ID__"
|
||||
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
|
||||
@@ -293,6 +323,14 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
await fetch('/auto',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({value:on})});refresh()
|
||||
}
|
||||
function stepbw(d){ setbw((parseFloat(bw.value)||0)+d) }
|
||||
// Runtimes that did NOT get the GPU, from the startup report. Both fall back
|
||||
// to the CPU without raising, so this banner and the pill are the only place
|
||||
// on this page a slow, CPU-bound agent announces itself.
|
||||
function cpuOnly(s){
|
||||
const a=s.accel||{}
|
||||
return Object.keys(a).filter(k=>a[k] && a[k].device!=='cuda')
|
||||
}
|
||||
async function setbw(v){
|
||||
v=Math.max(0,parseFloat(v)||0); bw.value=v
|
||||
await fetch('/bandwidth',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
@@ -363,11 +401,17 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
// 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==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable'
|
||||
if(s.queue && cpuOnly(s).length){ dc='dot amber'; lbl='running · CPU only (degraded)' } }
|
||||
else if(st==='starting'){ dc='dot amber'; lbl='starting…' }
|
||||
else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' }
|
||||
dot.className=dc; connlbl.textContent=lbl
|
||||
banner.style.display=(st==='running' && !s.queue)?'block':'none'
|
||||
const slow=cpuOnly(s)
|
||||
accelbanner.style.display=slow.length?'block':'none'
|
||||
accelbanner.textContent=slow.length?('degraded — '+slow.join(' + ')+' not on the GPU, so that work runs on the CPU: '
|
||||
+slow.map(k=>k+': '+(s.accel[k].error||s.accel[k].device)).join(' · ')
|
||||
+'. After a driver update, regenerate the CDI spec (agent README).'):''
|
||||
queue.textContent=s.queue?('queue · pending '+s.queue.pending+' · in flight '+s.queue.leased+' · done '+s.queue.done+' · errored '+s.queue.error):'queue · unreachable'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""What this agent build IS — stamped at image build time, not configurable.
|
||||
|
||||
The mirror of `backend/app/build_info.py`, for the same reasons and with the
|
||||
same posture. Kept as its own module rather than as constants in `app.py`
|
||||
because it is stdlib-only and therefore importable by the test suite, which
|
||||
cannot import `app` (torch, transformers and ultralytics are not in the CI
|
||||
image — see build.yml's "Agent syntax check").
|
||||
|
||||
## Why this replaced a hand-written string
|
||||
|
||||
`app.VERSION` used to be a literal an author was asked to bump, carrying a
|
||||
version AND a changelog in one string:
|
||||
|
||||
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle ..."
|
||||
|
||||
Nobody bumped it. The September image printed the identical string to the July
|
||||
one, so the one surface that was supposed to answer *"did my pull work?"*
|
||||
answered *"2026-07-17.1"* either way. An artifact that cannot identify itself
|
||||
is worse than one that says nothing, because the stale value reads as an
|
||||
answer.
|
||||
|
||||
The values are now derived by `scripts/artifacts.sh` from the commit its
|
||||
shipped files last changed in — the same derivation the web image has used
|
||||
since milestone 313, and the same one the reuse check already ran for the
|
||||
agent and discarded.
|
||||
|
||||
## Three values, never folded together (rule 149)
|
||||
|
||||
* `FC_VERSION` — the NAME, `YYYY.MM.DD.HHMM` UTC, derived from COMMIT time.
|
||||
For people to read and quote. Identical on `dev` and `main` for the same
|
||||
source, which is the property that makes "am I running the same code as
|
||||
production?" answerable at a glance.
|
||||
* `FC_CHANNEL` — a SIBLING field, never a suffix inside the name.
|
||||
* `FC_REVISION` — the 12-char commit sha, the artifact's IDENTITY. This is
|
||||
what the reuse check already keys on as the `fc.revision` image label.
|
||||
|
||||
**Absent rather than empty when unknown.** A locally built image has no
|
||||
stamp, and neither does any image predating this module. One spelling of
|
||||
"cannot say" instead of two.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
FC_VERSION = os.environ.get("FC_VERSION", "").strip()
|
||||
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
|
||||
FC_REVISION = os.environ.get("FC_REVISION", "").strip()
|
||||
|
||||
|
||||
def display_version() -> str:
|
||||
"""The build, as a line for a human: `2026.09.24.1052 (dev)`.
|
||||
|
||||
`unknown` rather than a blank when unstamped — an empty slot in the meta
|
||||
line reads as "no version", which is a different and false claim from "this
|
||||
build does not carry one".
|
||||
"""
|
||||
if not FC_VERSION:
|
||||
return "unknown"
|
||||
return f"{FC_VERSION} ({FC_CHANNEL})" if FC_CHANNEL else FC_VERSION
|
||||
|
||||
|
||||
def build_id() -> str:
|
||||
"""The token the control page compares against `/status` to notice it is
|
||||
showing a CACHED page from a previous build.
|
||||
|
||||
Deliberately NOT `display_version()`. That is the value for reading; this
|
||||
is the value for deciding, and folding the two is what rule 149 is about.
|
||||
The revision is the better discriminator of the two — two builds of the
|
||||
same commit ARE the same agent and should not prompt a reload, and two
|
||||
different commits always differ here even when they land in the same
|
||||
minute and derive one version name.
|
||||
|
||||
A locally built image falls through to a constant, so the reload banner
|
||||
cannot fire for it. That is honest rather than a gap: nothing in an
|
||||
unstamped image knows what source it was built from, and a per-process
|
||||
nonce would make every ordinary container RESTART claim a new version had
|
||||
arrived — a false positive on the exact surface the banner exists to keep
|
||||
trustworthy.
|
||||
"""
|
||||
return FC_REVISION or FC_VERSION or "local"
|
||||
@@ -7,6 +7,8 @@ import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from . import accel
|
||||
|
||||
|
||||
class FcClient:
|
||||
def __init__(self, base_url: str, token: str, agent_id: str):
|
||||
@@ -72,7 +74,10 @@ class FcClient:
|
||||
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},
|
||||
json={
|
||||
"agent_id": self.agent_id, "batch_size": batch_size,
|
||||
"accel": accel.summary(),
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
@@ -90,7 +95,9 @@ class FcClient:
|
||||
})
|
||||
|
||||
def heartbeat(self, job_ids: list[int]) -> None:
|
||||
self._post_quiet("/api/gpu/jobs/heartbeat", {"job_ids": job_ids})
|
||||
self._post_quiet(
|
||||
"/api/gpu/jobs/heartbeat", {"job_ids": job_ids, "accel": accel.summary()},
|
||||
)
|
||||
|
||||
def fail(self, job_id: int, error: str) -> None:
|
||||
self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error})
|
||||
|
||||
@@ -342,15 +342,44 @@ class Worker:
|
||||
|
||||
# --- background loops ---------------------------------------------------
|
||||
def _heartbeat_loop(self) -> None:
|
||||
"""Keep every held lease alive so buffered jobs waiting on the GPU aren't
|
||||
reclaimed by curator's 180s TTL. Errors are swallowed by client.heartbeat;
|
||||
a reclaimed lease just re-leases elsewhere — never fatal."""
|
||||
"""Keep every held lease alive, and say we are here even when holding none.
|
||||
|
||||
Leases: buffered jobs waiting on the GPU would otherwise be reclaimed by
|
||||
curator's 180s TTL. Errors are swallowed by client.heartbeat; a reclaimed
|
||||
lease just re-leases elsewhere — never fatal.
|
||||
|
||||
## Why this sends with an EMPTY list rather than skipping
|
||||
|
||||
Curator's roster records a check-in on this call (and on `lease`), and
|
||||
calls an agent stopped after 300s of silence. This loop used to be
|
||||
gated on `if ids:` — so an agent holding no leases sent nothing at all,
|
||||
and the only check-in left was the lease poll, which sleep mode backs
|
||||
off exponentially to a 900s ceiling (see IDLE_POLL_MAX_SECONDS).
|
||||
|
||||
900 against 300: an IDLE agent was structurally guaranteed to read as
|
||||
stopped. Operator, 2026-09-23: *"I'm running the gpu agent on my device
|
||||
and it currently reads as 'offline' but it's running and has checked in
|
||||
recently."* It had — twelve minutes ago, partway up the backoff ladder.
|
||||
|
||||
The two halves were written ten weeks apart and never reconciled: sleep
|
||||
mode landed 2026-07-02, and the roster adopted the lease as its
|
||||
check-in on 2026-09-02 without noticing the call it was piggybacking on
|
||||
had been deliberately slowed.
|
||||
|
||||
An empty heartbeat extends nothing (`id.in_([])` matches no rows) and
|
||||
costs one small POST every 45s — against the 6/min lease poll sleep
|
||||
mode exists to avoid, that is not a cadence worth protecting, and it is
|
||||
what makes "is the agent alive" answerable at all.
|
||||
|
||||
Still gated on `self._running`: a worker that has been stopped is not
|
||||
checking in for work, and reporting it as present would be a different
|
||||
lie.
|
||||
"""
|
||||
while True:
|
||||
if self._running:
|
||||
with self._held_lock:
|
||||
ids = list(self._held)
|
||||
if ids:
|
||||
self.client.heartbeat(ids)
|
||||
self.client.heartbeat(ids)
|
||||
time.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
def _queue_poll_loop(self):
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# 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;
|
||||
# server-side fallback run. The extras declare the CUDA/cuDNN pip packages its
|
||||
# CUDA provider loads (fc_agent/accel.py preloads them) rather than relying on
|
||||
# torch happening to install the same ones.
|
||||
onnxruntime-gpu[cuda,cudnn]
|
||||
# The crop EMBEDDER (concept bag). torch + torchvision are installed separately
|
||||
# in the Dockerfile from the cu130 wheel index, so pip never swaps them out;
|
||||
# transformers loads whatever SigLIP-family model the server announces.
|
||||
transformers>=4.45
|
||||
# Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""library_placement_run — the placement reconciler's plan/apply/undo ledger.
|
||||
|
||||
Milestone #421 step 3. The survey (#4245) measured 33,789 ImageRecord rows
|
||||
sitting outside their artist's canonical directory, across 56 artists. This
|
||||
table holds one run of the sweep that trues them up: the plan, what it did,
|
||||
and where every file came from.
|
||||
|
||||
## Why the moves live in a table rather than a log line
|
||||
|
||||
`ImageRecord.path` is the only pointer at the bytes, so a move rewrites the
|
||||
row. Once that write lands, the previous location exists nowhere — unless it
|
||||
was recorded first. `moves` is that record, which is what makes a 33,789-file
|
||||
operation something the operator can undo per artist after looking at the
|
||||
result, rather than a one-way door.
|
||||
|
||||
An `applied` row is therefore HISTORY, not state (lesson #4226). Any future
|
||||
retention on this table may prune `ready`, `cancelled` and `error` runs; an
|
||||
`applied` one is only disposable once someone decides undo is no longer
|
||||
wanted. That is deliberately not a timer's decision, and no pruning is added
|
||||
here.
|
||||
|
||||
Revision ID: 0099
|
||||
Revises: 0098
|
||||
Create Date: 2026-09-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0099"
|
||||
down_revision: Union[str, None] = "0098"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"library_placement_run",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), server_default="running",
|
||||
nullable=False,
|
||||
),
|
||||
# SET NULL, not CASCADE: deleting an artist must not destroy the
|
||||
# record of where their files were moved.
|
||||
sa.Column("artist_id", sa.Integer(), nullable=True),
|
||||
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(
|
||||
"planned_count", sa.Integer(), server_default="0", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"moved_count", sa.Integer(), server_default="0", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"refused_count", sa.Integer(), server_default="0", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"moves", postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'[]'::jsonb"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"refusals", postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'[]'::jsonb"), nullable=False,
|
||||
),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["artist_id"], ["artist.id"],
|
||||
name="fk_library_placement_run_artist_id", ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_library_placement_run_status", "library_placement_run", ["status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_library_placement_run_artist_id", "library_placement_run",
|
||||
["artist_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Dropping this table destroys the only record of where moved files came
|
||||
# from. That is correct for a downgrade — the code that reads it is going
|
||||
# away too — but it is worth saying out loud rather than discovering.
|
||||
op.drop_index(
|
||||
"ix_library_placement_run_artist_id",
|
||||
table_name="library_placement_run",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_library_placement_run_status", table_name="library_placement_run",
|
||||
)
|
||||
op.drop_table("library_placement_run")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Drop library_placement_run — the placement reconciler is removed.
|
||||
|
||||
Milestone #421 built a sweep that compared each image's `artist_id` to the
|
||||
name of the directory its file sat in, and called every mismatch a misplaced
|
||||
file. On the operator's library that reported 33,789 of 63,605 images as
|
||||
wrongly filed.
|
||||
|
||||
That number was an artefact of the comparison, not a fact about the library:
|
||||
|
||||
- **97.1%** of it was one artist's own folder spelled differently —
|
||||
`Telepurte/` versus `telepurte/`. Same artist, same art, nothing wrong.
|
||||
- Of the 1% that sat in a differently-named folder, querying `ImageProvenance`
|
||||
— which records the post and source each file was actually downloaded from —
|
||||
showed 87 where provenance agreed with the FOLDER and not the record, and 40
|
||||
genuinely posted by several creators. The sweep would have misfiled or
|
||||
arbitrarily picked for roughly 41% of that set.
|
||||
|
||||
The system already knows where every file came from. The reconciler inferred
|
||||
it from a column and a directory name instead, and manufactured work out of a
|
||||
naming convention. Operator's call, 2026-09-21: *"the current system
|
||||
consistently records where items are and where they came from this is just
|
||||
complicating something works and doesn't need fixing."*
|
||||
|
||||
Rule #22 — no legacy to preserve. The table goes with the code.
|
||||
|
||||
## What is deliberately kept
|
||||
|
||||
`utils.paths.canonical_subdir` stays: new filesystem imports derive their
|
||||
directory from the artist's slug, matching what the downloader has always
|
||||
done. It is not part of this tool and removing it would be churn for no fix.
|
||||
Run 1's 327 moved files (`InsoUwu/` -> `insouwu/`) also stay where they are —
|
||||
same artist either way, and the gallery renders them correctly.
|
||||
|
||||
Revision ID: 0100
|
||||
Revises: 0099
|
||||
Create Date: 2026-09-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0100"
|
||||
down_revision: Union[str, None] = "0099"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_library_placement_run_artist_id",
|
||||
table_name="library_placement_run",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_library_placement_run_status", table_name="library_placement_run",
|
||||
)
|
||||
op.drop_table("library_placement_run")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreates the table only. The three runs it held (one applied, two
|
||||
# planned-and-never-run) are not restored and are not worth restoring —
|
||||
# the code that reads them is gone.
|
||||
op.create_table(
|
||||
"library_placement_run",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), server_default="running",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("artist_id", sa.Integer(), nullable=True),
|
||||
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(
|
||||
"planned_count", sa.Integer(), server_default="0", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"moved_count", sa.Integer(), server_default="0", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"refused_count", sa.Integer(), server_default="0", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"moves", postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'[]'::jsonb"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"refusals", postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'[]'::jsonb"), nullable=False,
|
||||
),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["artist_id"], ["artist.id"],
|
||||
name="fk_library_placement_run_artist_id", ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_library_placement_run_status", "library_placement_run", ["status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_library_placement_run_artist_id", "library_placement_run",
|
||||
["artist_id"],
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Clear failure state on sources that are disabled (#4279).
|
||||
|
||||
`failing_sources_clause()` now means "enabled AND erroring", so a disabled
|
||||
source no longer counts as failing. That fixes what the surfaces REPORT; it
|
||||
does not touch what the rows already CARRY, and the rows are the reason the
|
||||
operator saw a banner for six days with no way to act on it (lesson #4202 —
|
||||
a guard does not undo the value already stored).
|
||||
|
||||
## The row this exists for
|
||||
|
||||
Ebi77 (source 19): the membership sweep stopped it as `former_patron` at
|
||||
02:50 on 2026-09-15 and correctly cleared its failure state. A deep scan was
|
||||
armed twenty minutes later — `/backfill` had no `enabled` guard, which this
|
||||
release also fixes — and could not complete without access, so the recovery
|
||||
sweep stranded it:
|
||||
|
||||
consecutive_failures = 1
|
||||
last_error = "stranded by recovery sweep (no terminal status after time_limit)"
|
||||
|
||||
Nothing could clear that. A disabled source is never scheduled, so no
|
||||
successful run resets the counter; `SourceService.update` clears failure
|
||||
state only on an explicit disable, and the source was already disabled; and
|
||||
the card's Retry routes to `/check`, which refuses a disabled source.
|
||||
|
||||
## Why every disabled source, not just that one
|
||||
|
||||
The clear matches what `SourceService.update` already does when a source is
|
||||
disabled through the app — "disable the subs you're not paying for without
|
||||
them lingering as failing" — so this brings rows disabled by any OTHER path
|
||||
(the membership sweep, a retired platform in 0097) into line with the rows
|
||||
disabled by hand. Same shape as 0097: a repair migration reaches the live
|
||||
instance on deploy rather than waiting for someone to find the row.
|
||||
|
||||
Enabled sources are untouched — a real failure on a live source must keep
|
||||
showing.
|
||||
|
||||
Revision ID: 0101
|
||||
Revises: 0100
|
||||
Create Date: 2026-09-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0101"
|
||||
down_revision: Union[str, None] = "0100"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"UPDATE source SET last_error = NULL, error_type = NULL, "
|
||||
"consecutive_failures = 0 "
|
||||
"WHERE NOT enabled "
|
||||
"AND (last_error IS NOT NULL OR error_type IS NOT NULL "
|
||||
" OR consecutive_failures <> 0)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Irreversible by design: the cleared strings and counts are not recorded
|
||||
# anywhere, and restoring a failure state nobody can act on would only
|
||||
# re-create the banner this removes. Rule #22 owes no story backwards.
|
||||
pass
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Drop pixiv's ledgers, and delete credentials for platforms FC no longer has.
|
||||
|
||||
Milestone #406 step 6 (with issue #3980 folded in). Phase 1 unregistered pixiv
|
||||
and the commit alongside this one deleted its client, downloader, ingester and
|
||||
models. This removes the data those models described, and the stored secrets of
|
||||
every platform that has been retired.
|
||||
|
||||
## The two ledger tables
|
||||
|
||||
`pixiv_seen_media` and `pixiv_failed_media` are the per-source seen / dead-letter
|
||||
ledgers for a downloader that no longer exists. They were created in
|
||||
`0089_baseline.py`, so dropping them needs a new revision rather than an edit
|
||||
there.
|
||||
|
||||
## The credentials
|
||||
|
||||
Written as *delete every credential whose platform is not registered* rather
|
||||
than as `platform = 'pixiv'`, at the explicit ask in this step's plan. That is
|
||||
what makes one migration cover two retirements:
|
||||
|
||||
- **pixiv** — a live OAuth refresh token for a service FC no longer talks to.
|
||||
- **deviantart** — issue #3980. #3069 retired DeviantArt in code on 2026-08-27
|
||||
and left its stored session behind; seven weeks later it was still there.
|
||||
|
||||
And it is the only way either row can go. The credentials UI
|
||||
(`subscriptions/SettingsTab.vue`) renders one card per platform returned by
|
||||
`/api/platforms`, then looks the credential up by key — so a row whose platform
|
||||
is unregistered has no card, no Remove button, and no way for the operator to
|
||||
reach it. `CredentialService.list()` would return it; nothing asks.
|
||||
|
||||
The registered set is written out literally instead of importing
|
||||
`known_platform_keys()`. A migration is a statement about one moment in the
|
||||
schema's history: if it imported the live registry, retiring a fifth platform
|
||||
in 2027 would silently change what this 2026 revision did on a fresh database.
|
||||
The list below is the registry as of 2026-09-21.
|
||||
|
||||
## What is deliberately kept
|
||||
|
||||
**Every pixiv `Source` row.** The original plan deleted them; the operator's
|
||||
call on 2026-09-21 was to keep them, and the reason is that `platform` is
|
||||
stored ONLY on `Source` — neither `Post` nor `ImageRecord` carries it. Both
|
||||
FKs are `ON DELETE SET NULL`, so a delete would not lose the art, but it would
|
||||
drop every pixiv image into the gallery's `__unsourced__` bucket and strip the
|
||||
platform chip off every pixiv post. The rows stay disabled (0097) and their
|
||||
platform is unregistered, so nothing schedules them, nothing downloads through
|
||||
them, and `POST /api/sources` will not make another. Keeping them costs
|
||||
nothing and keeps the attribution the milestone's goal — *"the art already
|
||||
downloaded from pixiv stays"* — is actually about.
|
||||
|
||||
Every pixiv `Post` and `ImageRecord` is likewise untouched.
|
||||
|
||||
Revision ID: 0102
|
||||
Revises: 0101
|
||||
Create Date: 2026-09-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0102"
|
||||
down_revision: Union[str, None] = "0101"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# services/platforms/__init__.py's PLATFORMS as of this revision. See the
|
||||
# docstring for why this is a literal and not an import.
|
||||
_REGISTERED_PLATFORMS = ("patreon", "subscribestar", "hentaifoundry", "discord")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM credential WHERE platform NOT IN :registered"
|
||||
).bindparams(
|
||||
sa.bindparam("registered", value=_REGISTERED_PLATFORMS, expanding=True)
|
||||
)
|
||||
)
|
||||
op.drop_index("ix_pixiv_failed_media_source_id", table_name="pixiv_failed_media")
|
||||
op.drop_table("pixiv_failed_media")
|
||||
op.drop_index("ix_pixiv_seen_media_source_id", table_name="pixiv_seen_media")
|
||||
op.drop_table("pixiv_seen_media")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The tables come back empty, and the credentials do not come back at all:
|
||||
# they were encrypted blobs, this migration does not copy them anywhere,
|
||||
# and restoring a live token for a platform FC cannot talk to would only
|
||||
# re-create the liability. Rule #22 owes no story backwards.
|
||||
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("url", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_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(
|
||||
"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("url", sa.Text(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("attempts", sa.Integer(), server_default="1", nullable=False),
|
||||
sa.Column(
|
||||
"created_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,
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""worker_lane — settings-backed slots for each celery lane.
|
||||
|
||||
Milestone 422 step 1. One row per lane, holding only what an operator can
|
||||
change: how many slots it runs, the ceiling they have set for themselves, and
|
||||
whether it consumes its queues at all.
|
||||
|
||||
## What is deliberately not a column
|
||||
|
||||
**The queues.** They are decided by `celery_app.py`'s `task_routes`, not by
|
||||
preference, so a stored copy could contradict the routing table with nothing
|
||||
to notice until a queue had no consumer. They live in
|
||||
`services/worker_lanes.py`.
|
||||
|
||||
**The derived ceiling.** Computed from the container's cgroup limits on every
|
||||
read. A row written on a 32GB host and later run in a 4GB container must be
|
||||
bounded by the 4GB; a stored ceiling would quietly authorise what the box can
|
||||
no longer hold.
|
||||
|
||||
## The seeded values
|
||||
|
||||
Written out literally rather than imported from `worker_lanes.LANES`. A
|
||||
migration is a statement about one moment in the schema's history — if it
|
||||
imported the live defaults, changing them in 2027 would silently change what
|
||||
this 2026 revision does on a fresh database. The two are allowed to diverge
|
||||
afterwards, and that is correct: `LANES` supplies defaults for a lane added
|
||||
later, this file records what was seeded today.
|
||||
|
||||
lane slots cap enabled
|
||||
worker 1 4 yes
|
||||
scheduler 1 2 yes
|
||||
maintenance_long 1 2 yes
|
||||
ml 0 1 NO
|
||||
|
||||
One of each, per the operator (2026-09-22: *"that starting value should be one
|
||||
of each"*), and far below their own production numbers — worker 8 and ml 2 are
|
||||
tuned for their hardware and are not a sane first boot for a stranger.
|
||||
|
||||
**ml ships at zero and disabled**, which is milestone 422 step 6's requirement
|
||||
arriving early: enabling the lane is what triggers the SigLIP download, and
|
||||
rule 164 permits a runtime fetch only for a feature that is "optional and
|
||||
clearly off". Seeding it on would make every fresh install reach HuggingFace.
|
||||
|
||||
The caps start low on purpose. A cap that begins at the ceiling is a rubber
|
||||
stamp; starting at 4/2/2/1 means raising slots within the cap is ordinary and
|
||||
raising the cap is a deliberate act.
|
||||
|
||||
## Existing installs
|
||||
|
||||
Nothing is migrated FROM. The `CELERY_QUEUES` / `CELERY_CONCURRENCY` env vars
|
||||
stay exactly as they are and remain the baseline each lane boots at; these
|
||||
rows are the adjustment applied on top (step 3). So this migration changes no
|
||||
behaviour on a running stack — it only makes the numbers storable.
|
||||
|
||||
Revision ID: 0103
|
||||
Revises: 0102
|
||||
Create Date: 2026-09-22
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0103"
|
||||
down_revision: Union[str, None] = "0102"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# (name, slots, slots_cap, enabled) — see the docstring for why these are
|
||||
# literals and not an import.
|
||||
_SEED = (
|
||||
("worker", 1, 4, True),
|
||||
("scheduler", 1, 2, True),
|
||||
("maintenance_long", 1, 2, True),
|
||||
("ml", 0, 1, False),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
worker_lane = op.create_table(
|
||||
"worker_lane",
|
||||
sa.Column("name", sa.String(length=32), nullable=False),
|
||||
sa.Column("slots", sa.Integer(), nullable=False),
|
||||
sa.Column("slots_cap", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("name", name=op.f("pk_worker_lane")),
|
||||
# Bare constraint names: Base.metadata's naming convention prepends
|
||||
# ck_worker_lane_, and pre-prefixing doubles it — the defect alembic
|
||||
# 0088 had to rename four constraints for (#3275). op.f() marks these
|
||||
# as already-final so autogenerate does not propose renaming them.
|
||||
sa.CheckConstraint("slots >= 0", name=op.f("ck_worker_lane_slots_non_negative")),
|
||||
sa.CheckConstraint("slots_cap >= 0", name=op.f("ck_worker_lane_cap_non_negative")),
|
||||
# The invariant that makes the cap mean anything, in the database
|
||||
# rather than only in the service: a row violating it is not a
|
||||
# rejected request, it is a lane that step 3's reconcile will drive UP
|
||||
# to a number the operator capped.
|
||||
sa.CheckConstraint("slots <= slots_cap", name=op.f("ck_worker_lane_slots_within_cap")),
|
||||
)
|
||||
# No index beyond the primary key, deliberately — four rows, forever. Same
|
||||
# reasoning as service_seen, and the lesson of #3301, which removed seven
|
||||
# indexes that were write cost buying nothing.
|
||||
op.bulk_insert(
|
||||
worker_lane,
|
||||
[
|
||||
{"name": name, "slots": slots, "slots_cap": cap, "enabled": enabled}
|
||||
for name, slots, cap, enabled in _SEED
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The rows go with the table. They are settings with shipped defaults, not
|
||||
# operator data that predates this revision — a downgrade returns the stack
|
||||
# to reading its concurrency from env, which is where it reads it from
|
||||
# today anyway.
|
||||
op.drop_table("worker_lane")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""worker_lane.autoscale — may this lane grow itself?
|
||||
|
||||
Milestone 422 step 7. One boolean, defaulting FALSE on every existing row and
|
||||
on every new one.
|
||||
|
||||
## Why the default is false and not "sensible"
|
||||
|
||||
This is the only part of the milestone that acts without anyone watching. The
|
||||
manual dial (step 4) and the reconcile (step 3) both do exactly what someone
|
||||
asked for; this one decides. Shipping it on would mean every install starts
|
||||
with a process that changes its own resource usage based on a heuristic tuned
|
||||
against nobody's workload.
|
||||
|
||||
Off also makes the failure mode benign: if the signal is wrong, nothing
|
||||
happens until an operator opts a lane in, and they opted in while watching.
|
||||
|
||||
## Why per lane and not one global switch
|
||||
|
||||
The lanes are not alike in what a slot costs. A `worker` slot is a process;
|
||||
an `ml` slot is another copy of a ~3.5GB model. A global switch would enable
|
||||
growth on a lane whose behaviour under load nobody has observed, and the one
|
||||
it would hurt most is the one whose cost is least visible.
|
||||
|
||||
Revision ID: 0104
|
||||
Revises: 0103
|
||||
Create Date: 2026-09-22
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0104"
|
||||
down_revision: Union[str, None] = "0103"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"worker_lane",
|
||||
sa.Column(
|
||||
"autoscale", sa.Boolean(),
|
||||
server_default=sa.text("false"), nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("worker_lane", "autoscale")
|
||||
@@ -0,0 +1,133 @@
|
||||
"""worker_lane — one number: the cap. `slots`, `enabled` and `autoscale` go.
|
||||
|
||||
Milestone 422, reshaped by the operator 2026-09-23:
|
||||
|
||||
"auto should be always on, not a setting, so that idle instances quiet
|
||||
down when not running. the number that is visible and something the user
|
||||
can tweak and manage should be the cap itself the number of running
|
||||
workers is handled by the autoscaling function which is always on."
|
||||
|
||||
## What each dropped column was, and why it is not needed
|
||||
|
||||
**`slots`** — how many workers the lane should run. That is a MEASUREMENT,
|
||||
not a preference: the autoscaler moves the live pool between one and the cap
|
||||
according to the backlog, and reads it back from the worker every minute.
|
||||
Storing it made it look like something to keep in agreement with the cap,
|
||||
which is exactly what the operator had to do.
|
||||
|
||||
**`autoscale`** — whether the lane was allowed to size itself. It gated the
|
||||
mechanism behind a per-lane opt-in, so a lane nobody enabled simply never
|
||||
gave its slots back. Always on now, which is the only way "idle instances
|
||||
quiet down" can be true of an install nobody has configured.
|
||||
|
||||
**`enabled`** — whether the lane consumes its queues. Derived from `cap > 0`.
|
||||
It and `slots = 0` were two spellings of one fact and were free to disagree;
|
||||
this migration picks the one an operator can see.
|
||||
|
||||
## Why the caps are rewritten rather than preserved
|
||||
|
||||
The old defaults were 4 / 2 / 2 / 1, chosen when the number meant "the most
|
||||
you may raise SLOTS to" — a bound on a manual control, deliberately loose
|
||||
because moving within it was the ordinary act. The number now means "the most
|
||||
workers this lane may actually use", which is a different promise, and
|
||||
carrying the old figure over would silently quadruple the worker lane on
|
||||
every existing install at the moment this deploys.
|
||||
|
||||
So every row is reset to the new defaults: **one for each required lane, zero
|
||||
for ML.** That loses whatever an operator had set — which is the honest
|
||||
trade, because what they set was an answer to a different question. The UI
|
||||
now tells a busy lane's operator to raise its cap, which is how the number
|
||||
gets back up on an install that needs it.
|
||||
|
||||
ML at zero also keeps rule 164's carve-out intact: no consumers, so no model
|
||||
download until someone raises the cap.
|
||||
|
||||
Revision ID: 0105
|
||||
Revises: 0104
|
||||
Create Date: 2026-09-23
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0105"
|
||||
down_revision: Union[str, None] = "0104"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# (name, cap) — the same values `services/worker_lanes.LANES` declares. Seeded
|
||||
# here as literals rather than imported: a migration must describe the schema
|
||||
# at ITS point in history, and importing the live table would make this file
|
||||
# change meaning every time that table does.
|
||||
_CAPS = (
|
||||
("worker", 1),
|
||||
("scheduler", 1),
|
||||
("maintenance_long", 1),
|
||||
("ml", 0),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The constraints go first: they name `slots`, so dropping the column out
|
||||
# from under them fails on Postgres.
|
||||
#
|
||||
# `op.f()` around each name, and it is load-bearing. Without it alembic
|
||||
# runs the name through Base.metadata's naming convention, which prepends
|
||||
# `ck_worker_lane_` to a string that already carries it — and the DROP
|
||||
# goes looking for `ck_worker_lane_ck_worker_lane_slots_within_cap`, which
|
||||
# no database has. That is #3275 exactly, from the other direction:
|
||||
# alembic 0088 had to RENAME four constraints created with the same
|
||||
# doubling. Caught here by the integration lane, run 7365.
|
||||
op.drop_constraint(
|
||||
op.f("ck_worker_lane_slots_within_cap"), "worker_lane", type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
op.f("ck_worker_lane_slots_non_negative"), "worker_lane", type_="check",
|
||||
)
|
||||
op.drop_column("worker_lane", "slots")
|
||||
op.drop_column("worker_lane", "enabled")
|
||||
op.drop_column("worker_lane", "autoscale")
|
||||
|
||||
# Reset to the new meaning. See the docstring: the old value answered a
|
||||
# different question, and carrying it over would raise every lane.
|
||||
for name, cap in _CAPS:
|
||||
op.execute(
|
||||
sa.text("UPDATE worker_lane SET slots_cap = :cap WHERE name = :name")
|
||||
.bindparams(cap=cap, name=name)
|
||||
)
|
||||
|
||||
# A lane the old seed never wrote — or one an operator added by hand — is
|
||||
# left alone rather than guessed at. `_rows_by_name` creates any missing
|
||||
# row at the lane's default on first read.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"worker_lane",
|
||||
sa.Column("slots", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.add_column(
|
||||
"worker_lane",
|
||||
sa.Column(
|
||||
"enabled", sa.Boolean(), nullable=False, server_default=sa.text("true"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"worker_lane",
|
||||
sa.Column(
|
||||
"autoscale", sa.Boolean(), nullable=False, server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
# Restore the pre-0105 invariants. `slots` comes back as 1 everywhere and
|
||||
# the caps are 1/1/1/0, so a lane at cap 0 would violate `slots <= cap` —
|
||||
# hence the clamp before the constraint is added.
|
||||
op.execute(sa.text("UPDATE worker_lane SET slots = 0 WHERE slots_cap = 0"))
|
||||
op.execute(sa.text("UPDATE worker_lane SET enabled = (slots_cap > 0)"))
|
||||
op.create_check_constraint(
|
||||
op.f("ck_worker_lane_slots_non_negative"), "worker_lane", "slots >= 0",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
op.f("ck_worker_lane_slots_within_cap"), "worker_lane", "slots <= slots_cap",
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""service_seen — delete the roster rows the fixed code can no longer write.
|
||||
|
||||
Operator, 2026-09-23: *"clean up the stale service_seen rows"*. They were not
|
||||
stale. They were PHANTOMS, written on purpose by code that identified a celery
|
||||
worker from the queues it was consuming.
|
||||
|
||||
A lane at cap 0 has its consumers cancelled, so it answers `active_queues()`
|
||||
with an empty list. The roster grouped on that empty set, wrote it under the
|
||||
key `celery:` and rendered `role_display_name(())` as the display name — a row
|
||||
called **`Worker ()`**, reported as running, beside the real lane's row going
|
||||
stale because nothing updated it any more.
|
||||
|
||||
`worker_lanes.lane_for_node` fixes the cause: a worker is attributed by its
|
||||
NODE NAME, which survives having no consumers. Nothing will write `celery:`
|
||||
again.
|
||||
|
||||
## Why a migration and not a retention sweep
|
||||
|
||||
Lesson #4202: a guard that refuses to produce a bad value does not undo the
|
||||
bad value already stored. The row is the thing that has to change.
|
||||
|
||||
And it must be deleted rather than aged out, because the roster deliberately
|
||||
NEVER forgets — *"anything that has run at least once stays listed, that is
|
||||
what lets a stopped one be noticed rather than simply vanishing"*. A row that
|
||||
merely goes quiet is exactly what the roster is for. Only a row that cannot
|
||||
correspond to anything real is safe to remove, and `celery:` is precisely
|
||||
that: the empty queue set, which no correctly-attributed worker can produce.
|
||||
|
||||
## What is deliberately NOT deleted
|
||||
|
||||
**Celery rows with a real but unmatched queue set.** A deployment slicing
|
||||
`CELERY_QUEUES` differently is supported and its rows are true. It is not this
|
||||
migration's business to decide that somebody else's worker is obsolete.
|
||||
|
||||
**Agent rows, including a possible `agent:agent` from a build that omitted
|
||||
`agent_id`.** Nothing here can tell an abandoned agent id from a second agent
|
||||
that is currently down, and deleting a real one would hide a genuinely dead
|
||||
GPU agent — the one thing the roster exists to show. If such a row is present
|
||||
it needs a person to look at it, not a migration guessing.
|
||||
|
||||
Revision ID: 0106
|
||||
Revises: 0105
|
||||
Create Date: 2026-09-23
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0106"
|
||||
down_revision: Union[str, None] = "0105"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Exactly the one key the empty queue set produced. Matched literally
|
||||
# rather than by a LIKE or a prefix: `celery:` with nothing after it is
|
||||
# the phantom, and `celery:ml` is a real lane.
|
||||
op.execute(
|
||||
sa.text("DELETE FROM service_seen WHERE key = :key").bindparams(key="celery:")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Nothing. The row carried no information — an empty queue set and a
|
||||
# timestamp — and the roster re-learns anything real on its next refresh.
|
||||
# Re-creating it would put a phantom back.
|
||||
pass
|
||||
@@ -0,0 +1,63 @@
|
||||
"""worker_lane_sample — where the sizing sweep leaves what it measured.
|
||||
|
||||
Operator, 2026-09-23, on the System tab: *"there is a repull every time this
|
||||
page loads — is there a reason this info isn't being tracked in the
|
||||
background and stored in some way?"*
|
||||
|
||||
`/api/system/workers` ran a full celery inspect on every call — four
|
||||
broadcasts on an eleven-second budget — and the page polls it every fifteen
|
||||
seconds. `size_worker_lanes` was already inspecting on a timer to decide pool
|
||||
sizes, computing exactly these numbers and discarding them. This table is
|
||||
where they land instead, and the endpoint becomes a plain read.
|
||||
|
||||
## Why a new table rather than columns on `worker_lane`
|
||||
|
||||
`worker_lane` holds the one number an operator sets. Putting a measurement
|
||||
beside it is the mistake alembic 0105 undid: `slots` sat next to `slots_cap`,
|
||||
and a measurement next to a preference reads as a second preference.
|
||||
|
||||
No backfill. A row appears when the sweep first runs (within its period), and
|
||||
until then the lane reads as not-yet-measured, which is true.
|
||||
|
||||
Revision ID: 0107
|
||||
Revises: 0106
|
||||
Create Date: 2026-09-23
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0107"
|
||||
down_revision: Union[str, None] = "0106"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"worker_lane_sample",
|
||||
sa.Column("lane", sa.String(length=32), primary_key=True),
|
||||
# Nullable=False with no server_default: the sweep writes every column
|
||||
# on every upsert, so a row only ever exists complete.
|
||||
sa.Column("present", sa.Boolean(), nullable=False),
|
||||
sa.Column("replicas", sa.Integer(), nullable=False),
|
||||
# Nullable on purpose — unknown, never zero. A worker that answered
|
||||
# without reporting its pool, and a queue the broker did not answer
|
||||
# for, must not be summed as empty.
|
||||
sa.Column("pool", sa.Integer(), nullable=True),
|
||||
sa.Column("active", sa.Integer(), nullable=False),
|
||||
sa.Column("reserved", sa.Integer(), nullable=False),
|
||||
sa.Column("queue_depth", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"measured_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("worker_lane_sample")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""download_revisit_days — how far back a tick keeps looking for EDITED posts.
|
||||
|
||||
Operator, 2026-09-23, pointing at a Floppystack post: *"this post has been
|
||||
updated as he implements hot fixes — any chance we have a way to scan for or
|
||||
see updated posts so we can update ours to match and pull the new attachments
|
||||
and pictures etc."*
|
||||
|
||||
A tick stopped after 20 contiguous already-have-it items. That is the right
|
||||
instinct and the wrong unit: a post edited three days after publication sits
|
||||
well below twenty seen items, so the walk turned around before reaching it. The
|
||||
walk now needs BOTH a run of seen items and a post older than this many days
|
||||
before it stops.
|
||||
|
||||
A settings row rather than a constant (rule 25) because the right window is a
|
||||
property of the CREATOR, not of FabledCurator — one artist appends hotfix
|
||||
builds for a fortnight, another never touches a post again. 0 turns the revisit
|
||||
off entirely and restores the pure count early-out.
|
||||
|
||||
30 days is the operator's own number, 2026-09-23.
|
||||
|
||||
Revision ID: 0108
|
||||
Revises: 0107
|
||||
Create Date: 2026-09-23
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0108"
|
||||
down_revision: Union[str, None] = "0107"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# server_default so the existing single settings row gets the window without
|
||||
# a data migration — and so an install that predates this column reads 30
|
||||
# rather than 0. 0 is a real, meaningful value here (revisit off), so the
|
||||
# column must never be allowed to arrive at it by omission.
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"download_revisit_days",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="30",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("import_settings", "download_revisit_days")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""discord_link_auto — whether FC links a conclusive pair without asking.
|
||||
|
||||
Operator, 2026-09-24: *"I don't want this to be manual that defeats the
|
||||
convenience that I'm going for."*
|
||||
|
||||
Confirm-only was right while every signal was circumstantial. Time proximity
|
||||
and a body that mentions Discord can never be more than suggestive, so asking
|
||||
was the honest response. A shared working name is different in kind: when the
|
||||
creator's own name for a piece appears in exactly these two posts and nowhere
|
||||
else in their library, there is nothing left for the operator to adjudicate,
|
||||
and asking is just a chore FC invented for them.
|
||||
|
||||
Defaults ON, which is a real change of posture and deliberate. It only governs
|
||||
the conclusive band — weaker evidence still queues — and a link is a row the
|
||||
operator can dismiss, so the reversal is a click rather than a migration.
|
||||
|
||||
Revision ID: 0109
|
||||
Revises: 0108
|
||||
Create Date: 2026-09-24
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0109"
|
||||
down_revision = "0108"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_link_auto",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("true"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("import_settings", "discord_link_auto")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""The unified post card — fold window, family window, and who linked a pair.
|
||||
|
||||
Milestone 388, #4402 and #4401. A Patreon teaser's card shows the Discord drop
|
||||
it announced, and the rest of that piece's variants, by REFERENCE: nothing is
|
||||
absorbed, nothing changes owner, and every Discord post keeps its own place.
|
||||
|
||||
Three columns:
|
||||
|
||||
* `import_settings.discord_link_fold_hours` — a linked drop leaves the feed
|
||||
only when it is this close to its teaser (the same release, shown twice).
|
||||
* `import_settings.discord_family_window_days` — how far from the teaser the
|
||||
card reaches for variants. 60 is measured: named families spread up to 44
|
||||
days on artist 8, every collision found over 500.
|
||||
* `post_association.linked_by` — "fc" or "operator", so a link FC made by
|
||||
itself can say so on the card and offer the undo the operator asked for.
|
||||
|
||||
Revision ID: 0110
|
||||
Revises: 0109
|
||||
Create Date: 2026-09-24
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0110"
|
||||
down_revision = "0109"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_link_fold_hours",
|
||||
sa.Float(),
|
||||
nullable=False,
|
||||
server_default=sa.text("24"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_family_window_days",
|
||||
sa.Float(),
|
||||
nullable=False,
|
||||
server_default=sa.text("60"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"post_association",
|
||||
sa.Column("linked_by", sa.String(length=16), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("post_association", "linked_by")
|
||||
op.drop_column("import_settings", "discord_family_window_days")
|
||||
op.drop_column("import_settings", "discord_link_fold_hours")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Discord native ingester ledgers — seen and dead-letter, per source.
|
||||
|
||||
Milestone 428, #4415. Discord moves off gallery-dl onto the native core, which
|
||||
keeps its memory of what a source has already fetched in these two tables
|
||||
instead of gallery-dl's archive. Same shape as the SubscribeStar pair.
|
||||
|
||||
Revision ID: 0111
|
||||
Revises: 0110
|
||||
Create Date: 2026-09-24
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0111"
|
||||
down_revision = "0110"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"discord_seen_media",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_id", sa.Integer(), nullable=False),
|
||||
sa.Column("filehash", sa.String(length=128), nullable=False),
|
||||
sa.Column("post_id", sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
"seen_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_id"], ["source.id"],
|
||||
name=op.f("fk_discord_seen_media_source_id_source"), ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_discord_seen_media")),
|
||||
sa.UniqueConstraint("source_id", "filehash", name="uq_discord_seen_media_source_id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_discord_seen_media_source_id"), "discord_seen_media", ["source_id"],
|
||||
)
|
||||
op.create_table(
|
||||
"discord_failed_media",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_id", sa.Integer(), nullable=False),
|
||||
sa.Column("filehash", sa.String(length=128), nullable=False),
|
||||
sa.Column("attempts", sa.Integer(), server_default="1", nullable=False),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"first_failed_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"last_failed_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_id"], ["source.id"],
|
||||
name=op.f("fk_discord_failed_media_source_id_source"), ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_discord_failed_media")),
|
||||
sa.UniqueConstraint(
|
||||
"source_id", "filehash", name="uq_discord_failed_media_source_id",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_discord_failed_media_source_id"), "discord_failed_media", ["source_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(op.f("ix_discord_failed_media_source_id"), table_name="discord_failed_media")
|
||||
op.drop_table("discord_failed_media")
|
||||
op.drop_index(op.f("ix_discord_seen_media_source_id"), table_name="discord_seen_media")
|
||||
op.drop_table("discord_seen_media")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Retire the sketch/doodle WIP title tier — its tags, its review flags, its toggle.
|
||||
|
||||
Milestone 430, #4428. The soft tier (#1474) tagged `wip` on any post titled
|
||||
sketch / doodle / scribble. Measured on the operator's library it was 6,096 of
|
||||
8,876 wip tags, and its conflict audit filled the Gallery's review strip with
|
||||
2,086 cards, because most finished art scores >= 0.5 on some content head. A
|
||||
"sketch" is usually finished work, so the operator retired it (2026-09-25).
|
||||
|
||||
Data:
|
||||
|
||||
* A soft tag the operator stood behind is kept and relabelled `manual`: one they
|
||||
confirmed (tag_positive_confirmation), or one whose review flag they resolved
|
||||
while leaving the tag on (the strip's "Keep tag").
|
||||
* Every other `wip_title_soft` row is deleted.
|
||||
* Unresolved review flags whose tag is no longer on the image are deleted: the
|
||||
question they ask no longer applies. That is the audit's cards, and any older
|
||||
orphan the same way.
|
||||
|
||||
Then `import_settings.wip_soft_title_tagging_enabled` is dropped. The downgrade
|
||||
restores the column only; deleted tags are not recreated.
|
||||
|
||||
Revision ID: 0112
|
||||
Revises: 0111
|
||||
Create Date: 2026-09-25
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0112"
|
||||
down_revision = "0111"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def retire_soft_wip_tags(conn) -> None:
|
||||
"""The data half, on a plain connection, so a test can run it directly."""
|
||||
conn.execute(sa.text("""
|
||||
UPDATE image_tag it SET source = 'manual'
|
||||
WHERE it.source = 'wip_title_soft'
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_positive_confirmation c
|
||||
WHERE c.image_record_id = it.image_record_id AND c.tag_id = it.tag_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM presentation_review pr
|
||||
WHERE pr.image_record_id = it.image_record_id AND pr.tag_id = it.tag_id
|
||||
AND pr.resolved_at IS NOT NULL
|
||||
)
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("DELETE FROM image_tag WHERE source = 'wip_title_soft'"))
|
||||
conn.execute(sa.text("""
|
||||
DELETE FROM presentation_review pr
|
||||
WHERE pr.resolved_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM image_tag it
|
||||
WHERE it.image_record_id = pr.image_record_id AND it.tag_id = pr.tag_id
|
||||
)
|
||||
"""))
|
||||
|
||||
|
||||
def upgrade():
|
||||
retire_soft_wip_tags(op.get_bind())
|
||||
op.drop_column("import_settings", "wip_soft_title_tagging_enabled")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"wip_soft_title_tagging_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Re-date images whose post's date arrived after they were linked.
|
||||
|
||||
#4431. The native ingesters import a post's media before its record, and the
|
||||
date travels in the record (`_post.json`). Every natively downloaded image was
|
||||
therefore linked to an undated post and kept its download time in both gallery
|
||||
date columns, while the post itself was dated correctly. The importer now
|
||||
re-dates a post's images when its record lands; this repairs the images that
|
||||
landed before that.
|
||||
|
||||
Both columns get back the rules the importer keeps:
|
||||
|
||||
* `effective_date` is the primary post's date (left alone when that post has
|
||||
none, as the importer does);
|
||||
* `earliest_post_date` is the earliest dated post the image is linked to.
|
||||
|
||||
Only rows that differ are written. The downgrade does nothing: the old values
|
||||
were download times that no one chose.
|
||||
|
||||
Revision ID: 0113
|
||||
Revises: 0112
|
||||
Create Date: 2026-09-25
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0113"
|
||||
down_revision = "0112"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def redate_images(conn) -> None:
|
||||
"""The data step, on a plain connection, so a test can run it directly."""
|
||||
conn.execute(sa.text("""
|
||||
UPDATE image_record ir SET effective_date = p.post_date
|
||||
FROM post p
|
||||
WHERE p.id = ir.primary_post_id
|
||||
AND p.post_date IS NOT NULL
|
||||
AND ir.effective_date IS DISTINCT FROM p.post_date
|
||||
"""))
|
||||
conn.execute(sa.text("""
|
||||
UPDATE image_record ir SET earliest_post_date = m.earliest
|
||||
FROM (
|
||||
SELECT ip.image_record_id, MIN(p.post_date) AS earliest
|
||||
FROM image_provenance ip JOIN post p ON p.id = ip.post_id
|
||||
WHERE p.post_date IS NOT NULL
|
||||
GROUP BY ip.image_record_id
|
||||
) m
|
||||
WHERE m.image_record_id = ir.id
|
||||
AND ir.earliest_post_date IS DISTINCT FROM m.earliest
|
||||
"""))
|
||||
|
||||
|
||||
def upgrade():
|
||||
redate_images(op.get_bind())
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Fold the undated shell posts a misfiled attachment created into the real post.
|
||||
|
||||
#4435. A non-image file (an archive, a pdf) downloaded for one of an artist's
|
||||
Discord channels was filed under the artist's FIRST Discord source: the
|
||||
attachment path looked the source up by (artist, platform), which takes the
|
||||
lowest id. That created an undated, url-less post there holding only the
|
||||
attachment, and the message's real post record then created the dated post
|
||||
under the right source. The importer now uses the source it was downloading
|
||||
for; this repairs the pairs it left.
|
||||
|
||||
A shell is folded only when all of this holds:
|
||||
|
||||
* it has no date and no url, and nothing synthesized it;
|
||||
* another post of the same artist, platform and external id HAS a date;
|
||||
* no image is linked to the shell (it held an attachment and nothing else).
|
||||
|
||||
Its attachments move to the dated post, dropping any the dated post already
|
||||
has (same sha256, which the per-post unique forbids twice), and the shell is
|
||||
deleted. A shell with no dated twin is left alone: which channel it belongs to
|
||||
is not recorded anywhere but the file name. The downgrade does nothing.
|
||||
|
||||
Revision ID: 0114
|
||||
Revises: 0113
|
||||
Create Date: 2026-09-25
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0114"
|
||||
down_revision = "0113"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_PAIRS = """
|
||||
SELECT DISTINCT ON (shell.id) shell.id AS shell_id, real.id AS real_id
|
||||
FROM post shell
|
||||
JOIN source ss ON ss.id = shell.source_id
|
||||
JOIN post real
|
||||
ON real.artist_id = shell.artist_id
|
||||
AND real.external_post_id = shell.external_post_id
|
||||
AND real.id <> shell.id
|
||||
AND real.post_date IS NOT NULL
|
||||
JOIN source rs ON rs.id = real.source_id AND rs.platform = ss.platform
|
||||
WHERE shell.post_date IS NULL
|
||||
AND shell.post_url IS NULL
|
||||
AND shell.synthesized_by IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM image_provenance ip WHERE ip.post_id = shell.id)
|
||||
ORDER BY shell.id, real.id
|
||||
"""
|
||||
|
||||
|
||||
def fold_misfiled_attachment_posts(conn) -> int:
|
||||
"""The data step, on a plain connection, so a test can run it directly.
|
||||
Returns how many shells were folded."""
|
||||
pairs = conn.execute(sa.text(_PAIRS)).all()
|
||||
for shell_id, real_id in pairs:
|
||||
conn.execute(sa.text("""
|
||||
DELETE FROM post_attachment pa
|
||||
WHERE pa.post_id = :shell
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM post_attachment keep
|
||||
WHERE keep.post_id = :real AND keep.sha256 = pa.sha256
|
||||
)
|
||||
"""), {"shell": shell_id, "real": real_id})
|
||||
conn.execute(
|
||||
sa.text("UPDATE post_attachment SET post_id = :real WHERE post_id = :shell"),
|
||||
{"shell": shell_id, "real": real_id},
|
||||
)
|
||||
conn.execute(sa.text("DELETE FROM post WHERE id = :shell"), {"shell": shell_id})
|
||||
return len(pairs)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
fold_misfiled_attachment_posts(op.get_bind())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,27 @@
|
||||
"""source.display_name — what a source is called on its platform.
|
||||
|
||||
#4481. A Discord source's URL is a server id and a channel id, so the
|
||||
Subscriptions page could only show two numbers. The Discord ingester already
|
||||
loads the server and channel names to walk them; it now keeps them here,
|
||||
refreshed on every walk. NULL until a walk has read it.
|
||||
|
||||
Revision ID: 0115
|
||||
Revises: 0114
|
||||
Create Date: 2026-09-28
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0115"
|
||||
down_revision = "0114"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("source", sa.Column("display_name", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("source", "display_name")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""The single-color filter's default becomes near-total: 0.95 → 0.995.
|
||||
|
||||
#4483. At 0.95 the filter rejected line art: a pencil doodle on white is
|
||||
mostly white, and five of Todding's Discord doodles were skipped on import as
|
||||
"single color", leaving their posts with text and no image. The predicate now
|
||||
samples real pixels instead of a blurred thumbnail, and the default only
|
||||
calls an image blank when it essentially is one.
|
||||
|
||||
A stored 0.95 is the old default, so it moves with it. Any other value was
|
||||
chosen by hand and is left alone.
|
||||
|
||||
Revision ID: 0116
|
||||
Revises: 0115
|
||||
Create Date: 2026-09-28
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0116"
|
||||
down_revision = "0115"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column("import_settings", "single_color_threshold", server_default="0.995")
|
||||
op.execute(
|
||||
"UPDATE import_settings SET single_color_threshold = 0.995 "
|
||||
"WHERE single_color_threshold = 0.95"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("import_settings", "single_color_threshold", server_default="0.95")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""external_link.started_at: when a fetch claimed the link.
|
||||
|
||||
A worker killed mid-fetch (the Celery hard limit, an OOM, a restart) never
|
||||
reaches the code that moves its link out of 'downloading'. The external-link
|
||||
sweep returns such a link to the retry path once it has been 'downloading'
|
||||
longer than the task's hard limit, which needs the claim time.
|
||||
|
||||
A row in 'downloading' with no claim time is treated as stale: no worker
|
||||
survives the deploy that adds this column.
|
||||
|
||||
Revision ID: 0117
|
||||
Revises: 0116
|
||||
Create Date: 2026-11-05
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0117"
|
||||
down_revision = "0116"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"external_link",
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("external_link", "started_at")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""external_link.recoveries: how many times recovery has retried a dead link.
|
||||
|
||||
The daily recovery gives a dead link one more attempt, spaced further apart
|
||||
each time, and leaves it dead once the count reaches its limit. A link that
|
||||
can never succeed is then downloaded a handful of times in total, not every
|
||||
day.
|
||||
|
||||
Revision ID: 0118
|
||||
Revises: 0117
|
||||
Create Date: 2026-11-05
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0118"
|
||||
down_revision = "0117"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"external_link",
|
||||
sa.Column(
|
||||
"recoveries", sa.Integer(), nullable=False, server_default=sa.text("0")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("external_link", "recoveries")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""import_settings: the off-site backup destination (an S3 bucket).
|
||||
|
||||
Address, bucket, access key and region are plain settings; the secret key is
|
||||
stored sealed with the credential key. All empty means no destination is set
|
||||
up, which is every existing install's state.
|
||||
|
||||
Revision ID: 0119
|
||||
Revises: 0118
|
||||
Create Date: 2026-11-06
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0119"
|
||||
down_revision = "0118"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("import_settings", sa.Column("backup_s3_endpoint", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"import_settings", sa.Column("backup_s3_bucket", sa.String(255), nullable=True),
|
||||
)
|
||||
op.add_column("import_settings", sa.Column("backup_s3_access_key", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"import_settings", sa.Column("backup_s3_secret_sealed", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"backup_s3_region", sa.String(64), nullable=False, server_default="us-east-1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for column in (
|
||||
"backup_s3_region", "backup_s3_secret_sealed", "backup_s3_access_key",
|
||||
"backup_s3_bucket", "backup_s3_endpoint",
|
||||
):
|
||||
op.drop_column("import_settings", column)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""import_settings: a bandwidth limit for uploads to the off-site backup bucket.
|
||||
|
||||
In megabits per second; 0 means no limit. Every install starts at 200 Mbit/s,
|
||||
which leaves room on a gigabit link for everything else the server does.
|
||||
|
||||
Revision ID: 0120
|
||||
Revises: 0119
|
||||
Create Date: 2026-11-07
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0120"
|
||||
down_revision = "0119"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"backup_upload_limit_mbps", sa.Integer(), nullable=False, server_default="200",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("import_settings", "backup_upload_limit_mbps")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""backup_blob: which library files are already in the off-site bucket.
|
||||
|
||||
One row per (destination, sha256) sent. The media backup's work list is every
|
||||
image and attachment sha with no row for the current destination, so a run is
|
||||
incremental and resumable by construction, and pointing backups at a new
|
||||
bucket starts that bucket's copy from the beginning.
|
||||
|
||||
backup_run.last_progress_at is the heartbeat of a run that spans hours; the
|
||||
stall sweep measures a media run's silence from it, not from its start.
|
||||
|
||||
Revision ID: 0121
|
||||
Revises: 0120
|
||||
Create Date: 2026-11-07
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0121"
|
||||
down_revision = "0120"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"backup_blob",
|
||||
sa.Column("target", sa.Text(), nullable=False),
|
||||
sa.Column("sha256", sa.String(64), nullable=False),
|
||||
sa.Column("key", sa.Text(), nullable=False),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column(
|
||||
"uploaded_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("target", "sha256", name=op.f("pk_backup_blob")),
|
||||
)
|
||||
op.add_column(
|
||||
"backup_run",
|
||||
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("backup_run", "last_progress_at")
|
||||
op.drop_table("backup_blob")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Retire the images tarball backup.
|
||||
|
||||
Images and attachments are backed up off-site, one object per file
|
||||
(backup_blob). The tarball of /images that this replaces is gone, with its
|
||||
retention setting, its path column, and any runs it left.
|
||||
|
||||
Revision ID: 0122
|
||||
Revises: 0121
|
||||
Create Date: 2026-11-08
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0122"
|
||||
down_revision = "0121"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM backup_run WHERE kind = 'images'")
|
||||
op.drop_column("backup_run", "tar_path")
|
||||
op.drop_column("import_settings", "backup_images_keep_last_n")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("import_settings", sa.Column(
|
||||
"backup_images_keep_last_n", sa.Integer(), nullable=False, server_default="3"))
|
||||
op.add_column("backup_run", sa.Column("tar_path", sa.Text(), nullable=True))
|
||||
@@ -41,6 +41,7 @@ def all_blueprints() -> list[Blueprint]:
|
||||
from .system_health import system_health_bp
|
||||
from .tags import tags_bp
|
||||
from .thumbnails import thumbnails_bp
|
||||
from .workers import workers_bp
|
||||
return [
|
||||
api_bp,
|
||||
attachments_bp,
|
||||
@@ -52,6 +53,7 @@ def all_blueprints() -> list[Blueprint]:
|
||||
showcase_bp,
|
||||
settings_bp,
|
||||
system_activity_bp,
|
||||
workers_bp,
|
||||
system_health_bp,
|
||||
system_backup_bp,
|
||||
admin_bp,
|
||||
|
||||
@@ -475,6 +475,21 @@ async def trigger_reclaim_attachments():
|
||||
return _queued(async_result)
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/import-unimported", methods=["POST"])
|
||||
async def trigger_import_unimported():
|
||||
"""Import files in the artists' folders that the library has no record of.
|
||||
Body {"dry_run": bool}; dry_run (the DEFAULT) lists what was found, per
|
||||
artist, and changes nothing. dry_run=false queues them as an import batch
|
||||
that attaches each file where it lies. Returns the Celery task id — poll
|
||||
/maintenance/task-result/<id> for the summary."""
|
||||
from ..tasks.admin import import_unimported_files_task
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", True))
|
||||
async_result = import_unimported_files_task.delay(dry_run=dry_run)
|
||||
return _queued(async_result)
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/repair-discord-downloads", methods=["POST"])
|
||||
async def trigger_repair_discord_downloads():
|
||||
"""Clean re-download of the Discord files broken by the `None` naming
|
||||
|
||||
@@ -30,7 +30,7 @@ from sqlalchemy import select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import LibraryAuditRun
|
||||
from ..services import cleanup_service, library_layout
|
||||
from ..services import cleanup_service
|
||||
from ._responses import error_response as _bad
|
||||
|
||||
cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")
|
||||
@@ -196,25 +196,3 @@ async def audit_cancel(audit_id: int):
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"cancelled": True})
|
||||
|
||||
|
||||
@cleanup_bp.route("/layout", methods=["GET"])
|
||||
async def layout_survey():
|
||||
"""Milestone #421 blast radius: which ImageRecord rows sit outside their
|
||||
artist's canonical slug directory, per artist.
|
||||
|
||||
Read-only. `?check_disk=1` additionally stats every destination to find
|
||||
collisions with a file already there and sources that have gone missing —
|
||||
the numbers the apply refuses on, at the cost of one stat per misplaced
|
||||
row over NFS. It is OFF by default because a count-only pass answers "how
|
||||
big is this" in seconds where the disk pass can run for minutes and time
|
||||
the request out.
|
||||
"""
|
||||
check_disk = request.args.get("check_disk", "").lower() in ("1", "true", "yes")
|
||||
async with get_session() as session:
|
||||
report = await session.run_sync(
|
||||
lambda s: library_layout.survey_layout(
|
||||
s, IMAGES_ROOT, check_disk=check_disk,
|
||||
)
|
||||
)
|
||||
return jsonify({**report.as_dict(), "checked_disk": check_disk})
|
||||
|
||||
@@ -19,7 +19,10 @@ from ..models import AppSetting
|
||||
from ..services.extension_service import (
|
||||
ExtensionService,
|
||||
InvalidUrlError,
|
||||
PosterLookupError,
|
||||
UnknownArtistError,
|
||||
UnknownPlatformError,
|
||||
UnknownSourceError,
|
||||
)
|
||||
from ..services.source_service import KNOWN_PLATFORMS
|
||||
from ._responses import error_response as _bad
|
||||
@@ -87,10 +90,16 @@ async def probe_source():
|
||||
url = (request.args.get("url") or "").strip()
|
||||
if not url:
|
||||
return _bad("invalid_body", detail="url query parameter is required")
|
||||
from .credentials import _get_crypto
|
||||
|
||||
async with get_session() as session:
|
||||
if not await _ext_key_required(session):
|
||||
return _bad("unauthorized", status=401)
|
||||
result = await ExtensionService(session).probe(url)
|
||||
# crypto lets a Discord probe name the server and channel with the
|
||||
# stored token; every other platform ignores it.
|
||||
result = await ExtensionService(session, _get_crypto()).probe(
|
||||
url, names=request.args.get("names") in ("1", "true"),
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -102,6 +111,22 @@ async def quick_add_source():
|
||||
url = body.get("url")
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
return _bad("invalid_body", detail="url is required")
|
||||
# Optional: connect the new source to an existing artist (artist_id) or to
|
||||
# the artist of that name (artist_name). A Discord channel names no
|
||||
# creator, so the extension's Add panel always sends one of them.
|
||||
artist_id = body.get("artist_id")
|
||||
if artist_id is not None and (isinstance(artist_id, bool) or not isinstance(artist_id, int)):
|
||||
return _bad("invalid_body", detail="artist_id must be an integer")
|
||||
artist_name = body.get("artist_name")
|
||||
if artist_name is not None and not isinstance(artist_name, str):
|
||||
return _bad("invalid_body", detail="artist_name must be a string")
|
||||
# Patreon is canon: adding a Patreon source to an existing artist can take
|
||||
# the creator's Patreon display name (name only; the slug never moves).
|
||||
use_platform_name = body.get("use_platform_name") is True
|
||||
# #4491: a Discord add can carry the posters picked in the panel.
|
||||
discord_authors = body.get("discord_authors")
|
||||
if discord_authors is not None and not _valid_authors(discord_authors):
|
||||
return _bad("invalid_body", detail="discord_authors must be a list of {id, name}")
|
||||
|
||||
from .credentials import _get_crypto
|
||||
|
||||
@@ -109,9 +134,15 @@ async def quick_add_source():
|
||||
if not await _ext_key_required(session):
|
||||
return _bad("unauthorized", status=401)
|
||||
try:
|
||||
# crypto lets a pixiv add resolve the artist's display name via the
|
||||
# stored OAuth token (else it falls back to the numeric id). #130.
|
||||
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
|
||||
# crypto lets an add resolve the artist's display name via the
|
||||
# stored credential (else it falls back to the URL handle). #130.
|
||||
result = await ExtensionService(session, _get_crypto()).quick_add_source(
|
||||
url, artist_id=artist_id, artist_name=artist_name,
|
||||
use_platform_name=use_platform_name,
|
||||
discord_authors=discord_authors,
|
||||
)
|
||||
except UnknownArtistError as exc:
|
||||
return _bad("not_found", detail=str(exc), status=404)
|
||||
except UnknownPlatformError as exc:
|
||||
return _bad(
|
||||
"unknown_platform",
|
||||
@@ -123,6 +154,59 @@ async def quick_add_source():
|
||||
return jsonify(result), (201 if result["created_source"] else 200)
|
||||
|
||||
|
||||
def _valid_authors(value) -> bool:
|
||||
"""[{id, name}]: the id is what the list keys on, the name only shows it."""
|
||||
return isinstance(value, list) and all(
|
||||
isinstance(a, dict) and isinstance(a.get("id"), str | int)
|
||||
and not isinstance(a.get("id"), bool)
|
||||
and (a.get("name") is None or isinstance(a.get("name"), str))
|
||||
for a in value
|
||||
)
|
||||
|
||||
|
||||
@extension_bp.route("/discord/posters", methods=["GET"])
|
||||
async def discord_posters():
|
||||
"""Who posts in the Discord channel `url` shows, lately, with the likely
|
||||
creator marked (#4491). Reads the channel with the stored token; writes
|
||||
nothing."""
|
||||
url = (request.args.get("url") or "").strip()
|
||||
if not url:
|
||||
return _bad("invalid_body", detail="url query parameter is required")
|
||||
from .credentials import _get_crypto
|
||||
|
||||
async with get_session() as session:
|
||||
if not await _ext_key_required(session):
|
||||
return _bad("unauthorized", status=401)
|
||||
try:
|
||||
result = await ExtensionService(session, _get_crypto()).discord_posters(url)
|
||||
except (InvalidUrlError, UnknownPlatformError) as exc:
|
||||
return _bad("invalid_url", detail=str(exc))
|
||||
except PosterLookupError as exc:
|
||||
return _bad("posters_unavailable", detail=str(exc), status=409)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@extension_bp.route("/discord/posters", methods=["POST"])
|
||||
async def set_discord_posters():
|
||||
"""Set the poster list of the source following `url`'s channel."""
|
||||
body = await request.get_json(silent=True)
|
||||
if not isinstance(body, dict) or not isinstance(body.get("url"), str):
|
||||
return _bad("invalid_body", detail="url is required")
|
||||
authors = body.get("authors")
|
||||
if not _valid_authors(authors):
|
||||
return _bad("invalid_body", detail="authors must be a list of {id, name}")
|
||||
async with get_session() as session:
|
||||
if not await _ext_key_required(session):
|
||||
return _bad("unauthorized", status=401)
|
||||
try:
|
||||
result = await ExtensionService(session).set_discord_posters(body["url"], authors)
|
||||
except (InvalidUrlError, UnknownPlatformError, PosterLookupError) as exc:
|
||||
return _bad("invalid_url", detail=str(exc))
|
||||
except UnknownSourceError as exc:
|
||||
return _bad("not_found", detail=str(exc), status=404)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _read_manifest_sync() -> dict | None:
|
||||
"""All the filesystem-touching work for /api/extension/manifest,
|
||||
in a sync helper so the async route can dispatch it via
|
||||
|
||||
+31
-2
@@ -245,6 +245,29 @@ async def errors_recover(image_id: int):
|
||||
|
||||
# --- Agent (bearer token): lease / submit / heartbeat / fail ------------
|
||||
|
||||
|
||||
def _accel_detail(body: dict) -> dict:
|
||||
"""The agent's own report of which runtime got the GPU, kept on its roster
|
||||
row so the System view can call a CPU-bound agent degraded (#4410).
|
||||
|
||||
Only a dict of {runtime: {device, error?}} is kept, and each value is
|
||||
reduced to those two short strings: this is written on every lease, by a
|
||||
client the server does not control. An agent that sends nothing (an
|
||||
older build) simply has no `accel`, which reads as not-yet-reported.
|
||||
"""
|
||||
raw = body.get("accel")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
accel = {}
|
||||
for name, entry in list(raw.items())[:4]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
clean = {"device": str(entry.get("device") or "")[:16]}
|
||||
if entry.get("error"):
|
||||
clean["error"] = str(entry["error"])[:200]
|
||||
accel[str(name)[:16]] = clean
|
||||
return {"accel": accel} if accel else {}
|
||||
|
||||
@gpu_bp.route("/jobs/lease", methods=["POST"])
|
||||
async def lease():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
@@ -267,7 +290,10 @@ async def lease():
|
||||
key=f"agent:{agent_id}",
|
||||
kind="agent",
|
||||
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
|
||||
details={"agent_id": agent_id, "last_call": "lease", "leased": len(jobs)},
|
||||
details={
|
||||
"agent_id": agent_id, "last_call": "lease", "leased": len(jobs),
|
||||
**_accel_detail(body),
|
||||
},
|
||||
)
|
||||
ml = await MLSettings.load(session)
|
||||
# image rows for url/mime in one shot
|
||||
@@ -347,7 +373,10 @@ async def heartbeat():
|
||||
key=f"agent:{agent_id}",
|
||||
kind="agent",
|
||||
display_name="GPU agent" if agent_id == "agent" else f"GPU agent ({agent_id})",
|
||||
details={"agent_id": agent_id, "last_call": "heartbeat", "extended": n},
|
||||
details={
|
||||
"agent_id": agent_id, "last_call": "heartbeat", "extended": n,
|
||||
**_accel_detail(body),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"extended": n})
|
||||
|
||||
@@ -207,6 +207,8 @@ async def rescan_associations():
|
||||
be within the window to exist at all); this is the button for a first run
|
||||
over a library that predates the feature."""
|
||||
async with get_session() as session:
|
||||
result = await association_rescan(session)
|
||||
# full=True: the button reaches the whole history, which the hourly
|
||||
# sweep's 48-hour horizon never does.
|
||||
result = await association_rescan(session, full=True)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
@@ -36,6 +36,7 @@ _EDITABLE_FIELDS = (
|
||||
"download_validate_files",
|
||||
"download_schedule_default_seconds",
|
||||
"download_event_retention_days",
|
||||
"download_revisit_days",
|
||||
"download_failure_warning_threshold",
|
||||
"series_suggest_enabled",
|
||||
"series_suggest_threshold",
|
||||
@@ -43,6 +44,9 @@ _EDITABLE_FIELDS = (
|
||||
"discord_link_enabled",
|
||||
"discord_link_threshold",
|
||||
"discord_link_window_hours",
|
||||
"discord_link_auto",
|
||||
"discord_link_fold_hours",
|
||||
"discord_family_window_days",
|
||||
"extdl_mega_enabled",
|
||||
"extdl_gdrive_enabled",
|
||||
"extdl_mediafire_enabled",
|
||||
@@ -53,7 +57,6 @@ _EDITABLE_FIELDS = (
|
||||
"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.
|
||||
@@ -113,6 +116,12 @@ async def update_import_settings():
|
||||
v = body["download_schedule_default_seconds"]
|
||||
if not isinstance(v, int) or isinstance(v, bool) or v < 60 or v > 86400:
|
||||
return _bad_int("download_schedule_default_seconds", 60, 86400)
|
||||
# 0 is a real value (revisit off), so the floor is 0, not 1 — and the
|
||||
# ceiling is a year, past which a "tick" is a backfill wearing a hat.
|
||||
if "download_revisit_days" in body:
|
||||
v = body["download_revisit_days"]
|
||||
if not isinstance(v, int) or isinstance(v, bool) or v < 0 or v > 365:
|
||||
return _bad_int("download_revisit_days", 0, 365)
|
||||
if "download_event_retention_days" in body:
|
||||
v = body["download_event_retention_days"]
|
||||
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 3650:
|
||||
@@ -158,6 +167,10 @@ async def update_import_settings():
|
||||
body["discord_link_enabled"], bool
|
||||
):
|
||||
return jsonify({"error": "discord_link_enabled must be a boolean"}), 400
|
||||
if "discord_link_auto" in body and not isinstance(
|
||||
body["discord_link_auto"], bool
|
||||
):
|
||||
return jsonify({"error": "discord_link_auto must be a boolean"}), 400
|
||||
if "discord_link_threshold" in body:
|
||||
v = body["discord_link_threshold"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||
@@ -170,18 +183,18 @@ async def update_import_settings():
|
||||
return jsonify(
|
||||
{"error": "discord_link_window_hours must be a positive number"}
|
||||
), 400
|
||||
# Zero is meaningful for both: fold nothing, or reference no variants.
|
||||
for key in ("discord_link_fold_hours", "discord_family_window_days"):
|
||||
if key in body:
|
||||
v = body[key]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0:
|
||||
return jsonify({"error": f"{key} must be a number >= 0"}), 400
|
||||
if "wip_title_tagging_enabled" in body and not isinstance(
|
||||
body["wip_title_tagging_enabled"], bool
|
||||
):
|
||||
return jsonify(
|
||||
{"error": "wip_title_tagging_enabled must be a boolean"}
|
||||
), 400
|
||||
if "wip_soft_title_tagging_enabled" in body and not isinstance(
|
||||
body["wip_soft_title_tagging_enabled"], bool
|
||||
):
|
||||
return jsonify(
|
||||
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
|
||||
), 400
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""FC-3a: CRUD over Source rows. FC-3c adds POST /<id>/check."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
|
||||
from ..services import discord_poster_cleanup as poster_cleanup
|
||||
from ..services.artist_membership_service import ArtistMembershipService
|
||||
from ..services.artist_membership_service import rescan as membership_rescan
|
||||
from ..services.artist_service import ArtistService
|
||||
@@ -201,6 +204,16 @@ async def set_backfill(source_id: int):
|
||||
rec = await SourceService(session).get(source_id)
|
||||
if rec is None:
|
||||
return _bad("not_found", status=404)
|
||||
# A disabled source must not be armable for a deep walk — the same
|
||||
# rule /check has carried all along (see `source_disabled` below).
|
||||
# Arming one anyway is how #4279 happened: the membership sweep had
|
||||
# stopped Ebi77 as `former_patron`, a deep scan was armed twenty
|
||||
# minutes later, the walk could not complete without access, and
|
||||
# the recovery sweep stranded it with a failure count no surface
|
||||
# could clear — a disabled source is never scheduled again, and
|
||||
# Retry routes to /check, which refuses it.
|
||||
if not rec.enabled:
|
||||
return _bad("source_disabled", detail="enable the source first")
|
||||
native = uses_native_ingester(rec.platform)
|
||||
if native:
|
||||
cred = CredentialService(session, _get_crypto())
|
||||
@@ -453,3 +466,49 @@ async def adopt_membership():
|
||||
return jsonify({"already_tracked": exc.existing_id})
|
||||
artist_id = artist.id
|
||||
return jsonify({"source_id": record.id, "artist_id": artist_id}), 201
|
||||
|
||||
|
||||
# -- Discord: remove posts by people outside the poster list (#4486) --------------
|
||||
|
||||
_IMAGES_ROOT = Path("/images")
|
||||
|
||||
|
||||
async def _poster_cleanup_preview(source_id: int):
|
||||
async with get_session() as session:
|
||||
return await session.run_sync(
|
||||
lambda s: poster_cleanup.preview(s, source_id=source_id)
|
||||
)
|
||||
|
||||
|
||||
@sources_bp.route("/<int:source_id>/discord/other-posters", methods=["GET"])
|
||||
async def other_posters_preview(source_id: int):
|
||||
"""What removing other posters' posts would delete. Nothing is touched."""
|
||||
try:
|
||||
projection = await _poster_cleanup_preview(source_id)
|
||||
except LookupError:
|
||||
return _bad("not_found", status=404)
|
||||
except poster_cleanup.PosterCleanupError as exc:
|
||||
return _bad("not_applicable", detail=str(exc), status=409)
|
||||
projection["confirm_token"] = poster_cleanup.confirm_token(projection)
|
||||
return jsonify(projection)
|
||||
|
||||
|
||||
@sources_bp.route("/<int:source_id>/discord/other-posters/remove", methods=["POST"])
|
||||
async def other_posters_remove(source_id: int):
|
||||
"""Delete them. `confirm` must be the token of the preview the operator saw:
|
||||
a poster list edited since then changes the set, and the token with it."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
try:
|
||||
projection = await _poster_cleanup_preview(source_id)
|
||||
except LookupError:
|
||||
return _bad("not_found", status=404)
|
||||
except poster_cleanup.PosterCleanupError as exc:
|
||||
return _bad("not_applicable", detail=str(exc), status=409)
|
||||
expected = poster_cleanup.confirm_token(projection)
|
||||
if body.get("confirm") != expected:
|
||||
return _bad("confirm_mismatch", expected=expected)
|
||||
async with get_session() as session:
|
||||
result = await session.run_sync(
|
||||
lambda s: poster_cleanup.apply(s, source_id=source_id, images_root=_IMAGES_ROOT)
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
@@ -21,18 +21,22 @@ from ..config import get_config
|
||||
from ..extensions import get_session
|
||||
from ..models import TaskRun
|
||||
from ..services.scheduler_service import scheduler_status
|
||||
from ..services.worker_lanes import LANES
|
||||
|
||||
system_activity_bp = Blueprint(
|
||||
"system_activity", __name__, url_prefix="/api/system/activity",
|
||||
)
|
||||
|
||||
# Canonical queue order — must match celery_app.task_routes. UI renders
|
||||
# in this order; queues with no LLEN response show as null rather than
|
||||
# absent.
|
||||
_QUEUE_NAMES = (
|
||||
"default", "import", "thumbnail", "ml",
|
||||
"download", "scan", "maintenance", "maintenance_long",
|
||||
)
|
||||
# Every queue, grouped by the lane that consumes it. DERIVED from
|
||||
# `worker_lanes.LANES` (milestone 422 step 1) rather than written out:
|
||||
# this was a hand-kept third copy of "which queues exist", alongside
|
||||
# celery_app.task_routes and service_roster.ROLE_NAMES, and its own comment
|
||||
# admitted the coupling — "must match celery_app.task_routes".
|
||||
#
|
||||
# The rendered ORDER changes with this: lane order rather than the previous
|
||||
# hand-chosen one. That is the better grouping for a lane-oriented UI, and
|
||||
# queues with no LLEN response still show as null rather than absent.
|
||||
_QUEUE_NAMES = tuple(q for lane in LANES for q in lane.queues)
|
||||
|
||||
# Cache module-level so all requests share the cache between polls.
|
||||
# Tests can reset via direct dict mutation if needed.
|
||||
@@ -148,6 +152,8 @@ async def list_runs():
|
||||
queue=<name> filter to one queue
|
||||
status=<status> filter to one status (running/ok/error/timeout/retry)
|
||||
task=<substr> case-insensitive substring match on task_name
|
||||
celery_task_id=<id> exactly one run — how a page follows a job it
|
||||
started without having to know its lane
|
||||
limit=<int> default 50, max 200
|
||||
before_id=<int> cursor for keyset pagination
|
||||
|
||||
@@ -163,6 +169,7 @@ async def list_runs():
|
||||
queue = request.args.get("queue")
|
||||
status = request.args.get("status")
|
||||
task = request.args.get("task")
|
||||
celery_task_id = request.args.get("celery_task_id")
|
||||
before_id_raw = request.args.get("before_id")
|
||||
before_id = int(before_id_raw) if before_id_raw else None
|
||||
|
||||
@@ -172,6 +179,8 @@ async def list_runs():
|
||||
stmt = stmt.where(TaskRun.queue == queue)
|
||||
if status:
|
||||
stmt = stmt.where(TaskRun.status == status)
|
||||
if celery_task_id:
|
||||
stmt = stmt.where(TaskRun.celery_task_id == celery_task_id)
|
||||
if task:
|
||||
# Task names contain literal underscores (download_source,
|
||||
# vacuum_analyze) — escape LIKE wildcards so a search for
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""FC-3h: /api/system/backup — create/list/restore/delete/tag for
|
||||
DB + image backups.
|
||||
"""/api/system/backup: database dumps, the off-site destination and its media
|
||||
backup, restores, test restores, and the run history.
|
||||
|
||||
Read endpoints are public on FC (operator-facing internal API; same
|
||||
posture as /api/system/activity). Write endpoints take a typed
|
||||
@@ -20,13 +20,12 @@ system_backup_bp = Blueprint(
|
||||
"system_backup", __name__, url_prefix="/api/system/backup",
|
||||
)
|
||||
|
||||
_KINDS = frozenset({"db", "images"})
|
||||
_KINDS = frozenset({"db", "media", "restore", "reconcile", "test"})
|
||||
_TAG_MAX_LEN = 64
|
||||
_BACKUP_SETTINGS_FIELDS = (
|
||||
"backup_db_nightly_enabled",
|
||||
"backup_db_nightly_hour_utc",
|
||||
"backup_db_keep_last_n",
|
||||
"backup_images_keep_last_n",
|
||||
)
|
||||
|
||||
|
||||
@@ -44,11 +43,13 @@ def _row_to_dict(r: BackupRun) -> dict:
|
||||
if r.finished_at and r.started_at else None
|
||||
),
|
||||
"sql_path": r.sql_path,
|
||||
"tar_path": r.tar_path,
|
||||
"size_bytes": r.size_bytes,
|
||||
"error": r.error,
|
||||
"restored_from_id": r.restored_from_id,
|
||||
"manifest": r.manifest or {},
|
||||
# Where an off-site dump went ({endpoint, bucket, key}); None for a
|
||||
# backup kept on this server.
|
||||
"offsite": (r.manifest or {}).get("offsite"),
|
||||
}
|
||||
|
||||
|
||||
@@ -78,10 +79,6 @@ def _validate_backup_settings_patch(body: dict):
|
||||
v = body["backup_db_keep_last_n"]
|
||||
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 365):
|
||||
return _bad("invalid_value", detail="backup_db_keep_last_n must be 1..365")
|
||||
if "backup_images_keep_last_n" in body:
|
||||
v = body["backup_images_keep_last_n"]
|
||||
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 100):
|
||||
return _bad("invalid_value", detail="backup_images_keep_last_n must be 1..100")
|
||||
return None
|
||||
|
||||
|
||||
@@ -96,14 +93,173 @@ async def trigger_db_backup():
|
||||
return jsonify({"status": "dispatched"}), 202
|
||||
|
||||
|
||||
@system_backup_bp.route("/images", methods=["POST"])
|
||||
async def trigger_images_backup():
|
||||
@system_backup_bp.route("/media", methods=["POST"])
|
||||
async def trigger_media_backup():
|
||||
"""Send the images and attachments not yet in the off-site bucket. Refused
|
||||
when no destination is set up; a run already going is left to finish."""
|
||||
from ..services import backup_destination as dest
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
if not dest.describe(row)["configured"]:
|
||||
return _bad("no_destination",
|
||||
detail="set up an off-site destination before sending files")
|
||||
from ..tasks.backup import backup_media_task
|
||||
backup_media_task.delay(triggered_by="manual")
|
||||
return jsonify({"status": "dispatched"}), 202
|
||||
|
||||
|
||||
@system_backup_bp.route("/media", methods=["GET"])
|
||||
async def media_coverage():
|
||||
"""How much of the library is in the off-site bucket: files and bytes,
|
||||
sent and total, each file counted once however many posts hold it."""
|
||||
from ..services import backup_destination as dest
|
||||
from ..services import backup_media
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
f = dest.fields_of(row)
|
||||
configured = dest.describe(row)["configured"]
|
||||
tid = backup_media.destination_id(f.endpoint, f.bucket) if configured else None
|
||||
counts = await session.run_sync(lambda s: backup_media.coverage(s, tid))
|
||||
return jsonify({"configured": configured, **counts})
|
||||
|
||||
|
||||
# ── Restoring from the off-site bucket ──────────────────────────────────────
|
||||
|
||||
|
||||
async def _saved_target():
|
||||
"""(target, None) for the saved destination, or (None, error response)."""
|
||||
from ..services import backup_destination as dest
|
||||
from ..services.backup_s3 import S3Error
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
try:
|
||||
target = dest.saved_target(row)
|
||||
except S3Error as exc:
|
||||
return None, _bad("destination_unreadable", detail=str(exc))
|
||||
if target is None:
|
||||
return None, _bad("no_destination",
|
||||
detail="set up an off-site destination before restoring from it")
|
||||
return target, None
|
||||
|
||||
|
||||
async def _busy_reason() -> str | None:
|
||||
from ..tasks.backup import backup_busy
|
||||
|
||||
async with get_session() as session:
|
||||
return await session.run_sync(backup_busy)
|
||||
|
||||
|
||||
def restore_token(ts: str) -> str:
|
||||
"""What the person types to confirm replacing the database with the dump
|
||||
taken at `ts`."""
|
||||
return f"restore-offsite-{ts}"
|
||||
|
||||
|
||||
@system_backup_bp.route("/offsite/dumps", methods=["GET"])
|
||||
async def list_offsite_dumps():
|
||||
"""The database dumps in the off-site bucket, newest first, each with
|
||||
the confirmation a restore of it asks for."""
|
||||
import asyncio
|
||||
|
||||
from ..services import backup_restore
|
||||
from ..services.backup_s3 import S3Error
|
||||
|
||||
target, err = await _saved_target()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
dumps = await asyncio.to_thread(backup_restore.list_dumps, target)
|
||||
except S3Error as exc:
|
||||
return _bad("destination_unreachable", detail=str(exc), status=502)
|
||||
return jsonify({
|
||||
"dumps": [
|
||||
{"key": d.key, "ts": d.ts, "size_bytes": d.size_bytes, "counted": d.counted,
|
||||
"confirm": restore_token(d.ts)}
|
||||
for d in dumps
|
||||
],
|
||||
# A test restore needs about this much free space on the database's
|
||||
# disk while it runs.
|
||||
"database_bytes": await _database_bytes(),
|
||||
})
|
||||
|
||||
|
||||
async def _database_bytes() -> int:
|
||||
from sqlalchemy import text
|
||||
|
||||
async with get_session() as session:
|
||||
return (await session.execute(
|
||||
text("SELECT pg_database_size(current_database())"))).scalar_one()
|
||||
|
||||
|
||||
async def _restore_test_blocked() -> str | None:
|
||||
from ..tasks.backup import restore_test_blocked
|
||||
|
||||
async with get_session() as session:
|
||||
return await session.run_sync(restore_test_blocked)
|
||||
|
||||
|
||||
@system_backup_bp.route("/offsite/test", methods=["POST"])
|
||||
async def restore_test_offsite():
|
||||
"""Test a dump from the bucket without touching this install: it is
|
||||
loaded into a temporary database, checked table by table, and dropped.
|
||||
Body: {key}, a dump's key from /offsite/dumps, or {} for the newest.
|
||||
Needs no confirmation; refused while a restore or another test is going."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
tag = _validate_tag(body.get("tag"))
|
||||
if isinstance(tag, tuple):
|
||||
return tag
|
||||
from ..tasks.backup import backup_images_task
|
||||
backup_images_task.delay(tag=tag, triggered_by="manual")
|
||||
key = body.get("key")
|
||||
if key is not None and (not isinstance(key, str) or not key.startswith("database/")):
|
||||
return _bad("invalid_key", detail="key must be a dump's key from /offsite/dumps")
|
||||
_, err = await _saved_target()
|
||||
if err:
|
||||
return err
|
||||
busy = await _restore_test_blocked()
|
||||
if busy:
|
||||
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
|
||||
from ..tasks.backup import restore_test_task
|
||||
restore_test_task.delay(dump_key=key)
|
||||
return jsonify({"status": "dispatched"}), 202
|
||||
|
||||
|
||||
@system_backup_bp.route("/offsite/restore", methods=["POST"])
|
||||
async def restore_from_offsite():
|
||||
"""Replace this install's database with a dump from the bucket, then
|
||||
fetch the library's files back. Body: {key, confirm}, where confirm is
|
||||
the dump's token from /offsite/dumps. Refused while a backup or another
|
||||
restore is going."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
key = body.get("key")
|
||||
if not isinstance(key, str) or not key.startswith("database/"):
|
||||
return _bad("invalid_key", detail="key must be a dump's key from /offsite/dumps")
|
||||
ts = key.removeprefix("database/fabledcurator-").removesuffix(".dump")
|
||||
expected = restore_token(ts)
|
||||
if body.get("confirm") != expected:
|
||||
return _bad("confirm_mismatch", detail=f"confirm must equal {expected!r}",
|
||||
expected=expected)
|
||||
_, err = await _saved_target()
|
||||
if err:
|
||||
return err
|
||||
busy = await _busy_reason()
|
||||
if busy:
|
||||
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
|
||||
from ..tasks.backup import restore_offsite_task
|
||||
restore_offsite_task.delay(dump_key=key)
|
||||
return jsonify({"status": "dispatched"}), 202
|
||||
|
||||
|
||||
@system_backup_bp.route("/offsite/fetch-files", methods=["POST"])
|
||||
async def fetch_missing_files():
|
||||
"""Fetch every library file that is missing from disk back from the
|
||||
bucket, leaving the database as it is."""
|
||||
_, err = await _saved_target()
|
||||
if err:
|
||||
return err
|
||||
busy = await _busy_reason()
|
||||
if busy:
|
||||
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
|
||||
from ..tasks.backup import restore_offsite_task
|
||||
restore_offsite_task.delay(files_only=True)
|
||||
return jsonify({"status": "dispatched"}), 202
|
||||
|
||||
|
||||
@@ -175,10 +331,16 @@ async def trigger_restore(run_id: int):
|
||||
row = await session.get(BackupRun, run_id)
|
||||
if row is None:
|
||||
return _bad("not_found", status=404)
|
||||
if row.status != "ok":
|
||||
if (row.manifest or {}).get("offsite"):
|
||||
return _bad(
|
||||
"not_restorable",
|
||||
detail=f"source backup status={row.status!r}; only 'ok' rows are restorable",
|
||||
detail=("this dump is kept in the off-site bucket, not on this server, "
|
||||
"so it can't be restored from here"),
|
||||
)
|
||||
if row.kind != "db" or row.status != "ok" or not row.sql_path:
|
||||
return _bad(
|
||||
"not_restorable",
|
||||
detail="only a finished database dump kept on this server can be restored here",
|
||||
)
|
||||
expected = f"restore-{row.kind}-{row.id}"
|
||||
if supplied != expected:
|
||||
@@ -187,15 +349,29 @@ async def trigger_restore(run_id: int):
|
||||
detail=f"confirm must equal {expected!r}",
|
||||
expected=expected,
|
||||
)
|
||||
kind = row.kind
|
||||
|
||||
if kind == "db":
|
||||
from ..tasks.backup import restore_db_task
|
||||
restore_db_task.delay(source_backup_run_id=run_id)
|
||||
else: # 'images' (the only other value _KINDS allows via the trigger path)
|
||||
from ..tasks.backup import restore_images_task
|
||||
restore_images_task.delay(source_backup_run_id=run_id)
|
||||
return jsonify({"status": "dispatched", "kind": kind}), 202
|
||||
from ..tasks.backup import restore_db_task
|
||||
restore_db_task.delay(source_backup_run_id=run_id)
|
||||
return jsonify({"status": "dispatched", "kind": "db"}), 202
|
||||
|
||||
|
||||
@system_backup_bp.route("/runs/<int:run_id>/test", methods=["POST"])
|
||||
async def restore_test_local(run_id: int):
|
||||
"""Test a database dump kept on this server, the same way as one in the
|
||||
bucket. Needs no confirmation."""
|
||||
async with get_session() as session:
|
||||
row = await session.get(BackupRun, run_id)
|
||||
if row is None:
|
||||
return _bad("not_found", status=404)
|
||||
if row.kind != "db" or row.status != "ok" or not row.sql_path:
|
||||
return _bad("not_testable",
|
||||
detail="only a finished database dump on this server can be tested here")
|
||||
busy = await _restore_test_blocked()
|
||||
if busy:
|
||||
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
|
||||
from ..tasks.backup import restore_test_task
|
||||
restore_test_task.delay(source_backup_run_id=run_id)
|
||||
return jsonify({"status": "dispatched"}), 202
|
||||
|
||||
|
||||
@system_backup_bp.route("/runs/<int:run_id>", methods=["DELETE"])
|
||||
@@ -216,7 +392,7 @@ async def delete_run(run_id: int):
|
||||
)
|
||||
from ..services import backup_service
|
||||
backup_service.unlink_artifact_files(
|
||||
sql_path=row.sql_path, tar_path=row.tar_path,
|
||||
sql_path=row.sql_path,
|
||||
manifest_path=(row.manifest or {}).get("manifest_path"),
|
||||
)
|
||||
await session.delete(row)
|
||||
@@ -232,10 +408,121 @@ async def get_settings():
|
||||
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
|
||||
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
|
||||
"backup_db_keep_last_n": row.backup_db_keep_last_n,
|
||||
"backup_images_keep_last_n": row.backup_images_keep_last_n,
|
||||
})
|
||||
|
||||
|
||||
@system_backup_bp.route("/destination", methods=["GET"])
|
||||
async def get_destination():
|
||||
"""The off-site destination as the settings screen shows it. The secret
|
||||
key is never returned, only whether one is saved."""
|
||||
from ..services import backup_destination as dest
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
return jsonify(dest.describe(row))
|
||||
|
||||
|
||||
@system_backup_bp.route("/destination", methods=["PATCH"])
|
||||
async def patch_destination():
|
||||
"""Save the destination. Body {endpoint, bucket, access_key, region,
|
||||
secret_key, upload_limit_mbps}; an empty field clears it, and a missing
|
||||
upload_limit_mbps keeps the saved limit. A missing or empty secret_key
|
||||
keeps the saved one, so the screen can save the other fields without the
|
||||
secret being typed again."""
|
||||
from ..services import backup_destination as dest
|
||||
|
||||
body = await request.get_json(silent=True)
|
||||
if not isinstance(body, dict):
|
||||
return _bad("invalid_body", detail="body must be a JSON object")
|
||||
fields, why = dest.clean_fields(body)
|
||||
if fields is None:
|
||||
return _bad("invalid_destination", detail=why)
|
||||
secret = body.get("secret_key")
|
||||
if secret is not None and not isinstance(secret, str):
|
||||
return _bad("invalid_destination", detail="secret_key must be text")
|
||||
limit = body.get("upload_limit_mbps")
|
||||
if limit is not None and (
|
||||
not isinstance(limit, int) or isinstance(limit, bool) or not 0 <= limit <= 100_000
|
||||
):
|
||||
return _bad("invalid_destination",
|
||||
detail="upload_limit_mbps must be a whole number from 0 (no limit) up")
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
if limit is not None:
|
||||
row.backup_upload_limit_mbps = limit
|
||||
row.backup_s3_endpoint = fields.endpoint
|
||||
row.backup_s3_bucket = fields.bucket
|
||||
row.backup_s3_access_key = fields.access_key
|
||||
row.backup_s3_region = fields.region
|
||||
if secret and secret.strip():
|
||||
row.backup_s3_secret_sealed = dest.seal(secret.strip())
|
||||
await session.commit()
|
||||
return jsonify(dest.describe(row))
|
||||
|
||||
|
||||
@system_backup_bp.route("/destination", methods=["DELETE"])
|
||||
async def delete_destination():
|
||||
"""Forget the destination, secret included. What was already sent stays in
|
||||
the bucket: FabledCurator never deletes there."""
|
||||
from ..services import backup_destination as dest
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
row.backup_s3_endpoint = None
|
||||
row.backup_s3_bucket = None
|
||||
row.backup_s3_access_key = None
|
||||
row.backup_s3_secret_sealed = None
|
||||
row.backup_s3_region = dest.DEFAULT_REGION
|
||||
await session.commit()
|
||||
return jsonify(dest.describe(row))
|
||||
|
||||
|
||||
@system_backup_bp.route("/destination/test", methods=["POST"])
|
||||
async def try_destination():
|
||||
"""Reach the bucket with the fields in the body (the form as typed, saved
|
||||
or not); a missing secret_key uses the saved one. Lists the bucket and
|
||||
writes nothing. Answers {ok, message} either way: a destination that
|
||||
can't be reached is a result to show, not an API error."""
|
||||
import asyncio
|
||||
|
||||
from ..services import backup_destination as dest
|
||||
from ..services.backup_s3 import S3Error
|
||||
from ..services.credential_crypto import InvalidCredentialBlob
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _bad("invalid_body", detail="body must be a JSON object")
|
||||
fields, why = dest.clean_fields(body)
|
||||
if fields is None:
|
||||
return jsonify({"ok": False, "message": f"Not tested: {why}."})
|
||||
secret = body.get("secret_key")
|
||||
secret = secret.strip() if isinstance(secret, str) else ""
|
||||
if not secret:
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
sealed = row.backup_s3_secret_sealed
|
||||
if sealed:
|
||||
try:
|
||||
secret = dest.unseal(sealed)
|
||||
except InvalidCredentialBlob:
|
||||
return jsonify({"ok": False, "message": (
|
||||
"Not tested: the saved secret key can't be opened on this server. "
|
||||
"Enter the secret key again."
|
||||
)})
|
||||
target = dest.target_for(fields, secret or None)
|
||||
if target is None:
|
||||
missing = [name for name, v in (
|
||||
("address", fields.endpoint), ("bucket", fields.bucket),
|
||||
("access key", fields.access_key), ("secret key", secret),
|
||||
) if not v]
|
||||
return jsonify({"ok": False, "message": f"Not tested: fill in the {', '.join(missing)}."})
|
||||
try:
|
||||
return jsonify(await asyncio.to_thread(dest.check_target, target))
|
||||
except S3Error as exc:
|
||||
return jsonify({"ok": False, "message": str(exc)})
|
||||
|
||||
|
||||
@system_backup_bp.route("/settings", methods=["PATCH"])
|
||||
async def patch_settings():
|
||||
body = await request.get_json(silent=True)
|
||||
|
||||
@@ -26,7 +26,6 @@ a true statement.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -36,9 +35,8 @@ from sqlalchemy import select, text
|
||||
from ..config import get_config
|
||||
from ..extensions import get_session
|
||||
from ..models import ServiceSeen
|
||||
from ..services.service_roster import refresh_if_stale
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from ..services import backup_health
|
||||
from ..services.worker_lanes import SWEEP_PERIOD_SECONDS
|
||||
|
||||
system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system")
|
||||
|
||||
@@ -53,14 +51,36 @@ system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system"
|
||||
STALE_AFTER_SECONDS = 90
|
||||
DOWN_AFTER_SECONDS = 300
|
||||
|
||||
# The celery roster is written by `size_worker_lanes` and by nothing else, so
|
||||
# these thresholds are only meaningful against ITS cadence. Asserted at import
|
||||
# rather than left to a reader, because this is precisely the comparison that
|
||||
# was never made for the GPU agent: its lease poll backed off to 900s while
|
||||
# the roster called it stopped at 300s, and both numbers were individually
|
||||
# correct, in different directions, in different files (lesson #4355).
|
||||
#
|
||||
# Two clear sweeps before a part is even called STALE. One missed tick is
|
||||
# routine — the sweep rides the maintenance queue and does an inspect that can
|
||||
# take eleven seconds — and must not turn the page yellow.
|
||||
_SWEEPS_BEFORE_STALE = 2
|
||||
assert STALE_AFTER_SECONDS >= SWEEP_PERIOD_SECONDS * _SWEEPS_BEFORE_STALE, (
|
||||
f"a {SWEEP_PERIOD_SECONDS}s sweep cannot keep a roster fresh against a "
|
||||
f"{STALE_AFTER_SECONDS}s stale threshold: raise the threshold or shorten "
|
||||
f"the sweep"
|
||||
)
|
||||
|
||||
# Probes cross a process boundary, so they carry deadlines. A hung Postgres
|
||||
# must make this endpoint say "postgres: down", not hang alongside it.
|
||||
PROBE_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
_OK, _STALE, _DOWN, _UNKNOWN = "ok", "stale", "down", "unknown"
|
||||
# Checking in, but working at a fraction of its speed: a GPU agent whose
|
||||
# runtimes fell back to the CPU (#4410). Below stale — a part that may have
|
||||
# stopped is the more urgent question — and above unknown, because this one
|
||||
# IS known to be wrong.
|
||||
_DEGRADED = "degraded"
|
||||
|
||||
# Worst-first, so an overall verdict is just the max.
|
||||
_SEVERITY = {_OK: 0, _UNKNOWN: 1, _STALE: 2, _DOWN: 3}
|
||||
_SEVERITY = {_OK: 0, _UNKNOWN: 1, _DEGRADED: 2, _STALE: 3, _DOWN: 4}
|
||||
|
||||
|
||||
def _age_state(age_seconds: float) -> str:
|
||||
@@ -86,6 +106,39 @@ def _describe_learned(name: str, state: str, age: float, details: dict) -> str:
|
||||
return f"{name} has not checked in for {ago} — treat it as stopped"
|
||||
|
||||
|
||||
def _cpu_runtimes(details: dict) -> list[str]:
|
||||
"""The runtimes an agent reported as NOT on the GPU, with why.
|
||||
|
||||
Both torch and onnxruntime fall back to the CPU without raising, so an
|
||||
agent in that state leases, works and checks in exactly like a healthy
|
||||
one. On 2026-09-24 one had been doing so since a driver update left a
|
||||
stale CDI spec; the only sign was a line in the agent's own log.
|
||||
"""
|
||||
accel = details.get("accel")
|
||||
if not isinstance(accel, dict):
|
||||
return []
|
||||
out = []
|
||||
for name, entry in sorted(accel.items()):
|
||||
if not isinstance(entry, dict) or entry.get("device") == "cuda":
|
||||
continue
|
||||
why = entry.get("error") or entry.get("device") or "unknown"
|
||||
out.append(f"{name} ({why})")
|
||||
return out
|
||||
|
||||
|
||||
def _learned_state(name: str, state: str, age: float, details: dict) -> tuple[str, str]:
|
||||
"""A roster row's state and its sentence, degraded included."""
|
||||
if state == _OK:
|
||||
cpu = _cpu_runtimes(details)
|
||||
if cpu:
|
||||
return _DEGRADED, (
|
||||
f"{name} is running on the CPU — not on the GPU: {'; '.join(cpu)}. "
|
||||
"After a driver update, regenerate the agent host's CDI spec "
|
||||
"(agent README)."
|
||||
)
|
||||
return state, _describe_learned(name, state, age, details)
|
||||
|
||||
|
||||
async def _probe_postgres(session) -> dict:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
@@ -151,31 +204,41 @@ async def system_health():
|
||||
parts.append(pg)
|
||||
|
||||
if pg["state"] == _OK:
|
||||
# Rate-limited inside; see service_roster on why the web process
|
||||
# is the right observer.
|
||||
try:
|
||||
await refresh_if_stale(session)
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning("system health: roster refresh failed", exc_info=True)
|
||||
|
||||
# A PURE READ since 2026-09-23. This used to refresh the celery
|
||||
# roster here, rate-limited to once per 20s — so the roster only
|
||||
# advanced while somebody had a browser open, and a broadcast rode
|
||||
# on a request. `size_worker_lanes` writes it now, on a timer, and
|
||||
# the assertion below is what keeps that cadence honest.
|
||||
rows = (
|
||||
await session.execute(select(ServiceSeen).order_by(ServiceSeen.display_name))
|
||||
).scalars().all()
|
||||
for row in rows:
|
||||
age = (now - row.last_seen_at).total_seconds()
|
||||
state = _age_state(age)
|
||||
state, detail = _learned_state(
|
||||
row.display_name, _age_state(age), age, row.details or {},
|
||||
)
|
||||
parts.append({
|
||||
"key": row.key,
|
||||
"kind": row.kind,
|
||||
"name": row.display_name,
|
||||
"state": state,
|
||||
"detail": _describe_learned(row.display_name, state, age, row.details or {}),
|
||||
"detail": detail,
|
||||
"last_seen_at": row.last_seen_at.isoformat(),
|
||||
"first_seen_at": row.first_seen_at.isoformat(),
|
||||
**{k: v for k, v in (row.details or {}).items() if k != "agent_id"},
|
||||
})
|
||||
|
||||
# Whether the backups are keeping up. Read like the rest, and a
|
||||
# failure to read it is reported as unknown rather than raised.
|
||||
try:
|
||||
backup = await session.run_sync(lambda s: backup_health.backup_part(s, now))
|
||||
except Exception as exc: # noqa: BLE001 — a probe reports, it never raises
|
||||
backup = {"key": "backup", "kind": "backup", "name": "Backups",
|
||||
"state": _UNKNOWN,
|
||||
"detail": f"could not be checked: {type(exc).__name__}"}
|
||||
if backup is not None:
|
||||
parts.append(backup)
|
||||
|
||||
parts.append(await _probe_redis())
|
||||
|
||||
overall = max((p["state"] for p in parts), key=lambda s: _SEVERITY[s], default=_UNKNOWN)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Worker lanes: what each is doing, and the dial that changes it.
|
||||
|
||||
Milestone 422 step 2. The write half of a surface `api/system_activity.py`
|
||||
only reads.
|
||||
|
||||
## Why this is a separate blueprint
|
||||
|
||||
`system_activity` says in its own first line that it is read-only, and it
|
||||
answers a different question: its `/workers` is keyed on celery HOSTNAME and
|
||||
reports which nodes answered. That stays as it is — the existing
|
||||
SystemActivityTab consumes it.
|
||||
|
||||
This is keyed on LANE, joins the stored cap to the live pool, and accepts
|
||||
writes. Two endpoints answering "which celery processes exist" and "how much
|
||||
work is each lane allowed to do" are not the same endpoint, and folding the
|
||||
second into the first would make a read-only module a write one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
|
||||
from quart import Blueprint, current_app, jsonify, request
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..services.worker_control import (
|
||||
LaneUpdateRefused,
|
||||
lane_settings,
|
||||
lane_view,
|
||||
push_lane_cap,
|
||||
store_lane_cap,
|
||||
)
|
||||
from ..services.worker_lanes import (
|
||||
LANES_BY_NAME,
|
||||
SWEEP_PERIOD_SECONDS,
|
||||
Lane,
|
||||
derived_ceiling,
|
||||
)
|
||||
from ._responses import error_response as _bad
|
||||
|
||||
workers_bp = Blueprint("workers", __name__, url_prefix="/api/system/workers")
|
||||
|
||||
|
||||
@workers_bp.route("", methods=["GET"])
|
||||
async def list_lanes():
|
||||
"""Every lane: its cap, the ceiling above it, and what is live.
|
||||
|
||||
Response: {lanes: [...], fetched_at: iso8601}
|
||||
|
||||
One database read, and NO broker call. Operator, 2026-09-23: *"there is a
|
||||
repull every time this page loads — is there a reason this info isn't
|
||||
being tracked in the background and stored in some way?"*
|
||||
|
||||
It used to inspect the broker here, four broadcasts on an eleven-second
|
||||
budget, four times a minute per open tab — while `size_worker_lanes` was
|
||||
already inspecting on a timer and discarding the same numbers. The sweep
|
||||
stores them now (`worker_lane_sample`) and this reads them.
|
||||
|
||||
So the live figures are up to `SWEEP_PERIOD_SECONDS` old, and each lane
|
||||
carries the `measured_at` that says so. `sweep_period_seconds` is returned
|
||||
alongside, so the UI can explain the age without hard-coding the cadence
|
||||
in a second place.
|
||||
"""
|
||||
async with get_session() as session:
|
||||
settings = await lane_settings(session)
|
||||
return jsonify({
|
||||
"lanes": lane_view(settings),
|
||||
"fetched_at": datetime.now(UTC).isoformat(),
|
||||
"sweep_period_seconds": SWEEP_PERIOD_SECONDS,
|
||||
})
|
||||
|
||||
|
||||
@workers_bp.route("/<name>", methods=["POST"])
|
||||
async def update_lane(name: str):
|
||||
"""Set a lane's cap. Stores it, answers, and makes the lane follow after.
|
||||
|
||||
ONE field, since 2026-09-23. It used to take `slots`, `slots_cap`,
|
||||
`enabled` and `autoscale`; how many workers are running is now a
|
||||
measurement the sizing pass owns, and `enabled` is `cap > 0`.
|
||||
|
||||
## The reply does not wait for the lane
|
||||
|
||||
Operator, 2026-09-23: *"when the number is changed the change should be
|
||||
queued so that it isn't blocking of the webui or the system itself. we
|
||||
shouldn't have to wait for the validation live."*
|
||||
|
||||
So the request does exactly one thing that can be slow — a row update —
|
||||
and hands the broker work to a background task. Turning a lane off is
|
||||
four `cancel_consumer` messages and a resize; lowering a cap is an
|
||||
`inspect` on an eleven-second budget. Both used to happen between the
|
||||
click and the response, with the stepper disabled the whole time.
|
||||
|
||||
Nothing is lost by not waiting: the cap in the database is what the
|
||||
system obeys, the sizing pass re-reads it every minute, and the table
|
||||
polls, so the live columns catch up on their own. If the web process dies
|
||||
before the background task runs, that sweep is the backstop — which is
|
||||
the same guarantee the awaited version had, since a push could fail
|
||||
there too.
|
||||
|
||||
Refusals still happen inline, because they are decided from the value and
|
||||
the machine's ceiling alone and never touch the broker:
|
||||
|
||||
* **400** — the value is not allowed (negative, or above what this
|
||||
container can hold). Nothing was stored. The body carries `detail`,
|
||||
which is the sentence the UI shows; a refused control with no reason
|
||||
reads as a bug.
|
||||
"""
|
||||
lane = LANES_BY_NAME.get(name)
|
||||
if lane is None:
|
||||
return _bad("unknown_lane", detail=name, known=sorted(LANES_BY_NAME))
|
||||
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return _bad("invalid_body", detail="body must be a JSON object")
|
||||
|
||||
if "slots_cap" not in body:
|
||||
return _bad("invalid_body", detail="give slots_cap")
|
||||
value = body["slots_cap"]
|
||||
# Rejected rather than coerced: `True` is an int in Python, and silently
|
||||
# reading it as a cap of 1 would be a control that appears to work and
|
||||
# sets something nobody asked for.
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
return _bad("invalid_body", detail="slots_cap must be an integer")
|
||||
|
||||
# Store, close the session, THEN hand off. The session must not be held
|
||||
# across broker work — that is what made this page block the whole site
|
||||
# (see `worker_control.LaneSettings`) — and now the request does not wait
|
||||
# for that work either.
|
||||
async with get_session() as session:
|
||||
try:
|
||||
was_cap = await store_lane_cap(session, lane, value)
|
||||
except LaneUpdateRefused as exc:
|
||||
return _bad("refused", detail=str(exc))
|
||||
|
||||
_schedule_push(lane, value, was_cap)
|
||||
|
||||
return jsonify({
|
||||
"name": lane.name,
|
||||
"slots_cap": value,
|
||||
"ceiling": derived_ceiling(lane),
|
||||
"enabled": value > 0,
|
||||
# The value is stored; the live lane is being told separately. The UI
|
||||
# patches its row from this and lets the next poll bring the live
|
||||
# columns, rather than refetching and paying for an inspect it just
|
||||
# avoided.
|
||||
"queued": True,
|
||||
# Raising the cap off zero is what downloads the model (step 6), and
|
||||
# the background task does it. Reported here so the UI can say a
|
||||
# download has started rather than leaving the operator to wonder why
|
||||
# a lane they just turned on is busy.
|
||||
"fetching_models": value > 0 and was_cap == 0 and bool(lane.models),
|
||||
})
|
||||
|
||||
|
||||
def _schedule_push(lane: Lane, slots_cap: int, was_cap: int) -> None:
|
||||
"""Run the live push after the response has gone out.
|
||||
|
||||
A seam, not an abstraction: it is one call, and it exists so the tests can
|
||||
hold the push still — a background task that outlived a test's patches
|
||||
would reach the real broker during teardown.
|
||||
|
||||
Quart tracks the task on the app and awaits it at shutdown, so an
|
||||
in-flight push survives a graceful restart. `partial` rather than passing
|
||||
`was_cap=` through `add_background_task`, so nothing depends on how that
|
||||
forwards keyword arguments.
|
||||
"""
|
||||
current_app.add_background_task(
|
||||
partial(push_lane_cap, lane, slots_cap, was_cap=was_cap)
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Celery beat that remembers when each job last ran — from task_run, not a file.
|
||||
|
||||
Celery's default PersistentScheduler keeps its memory in a shelve file in the
|
||||
working directory. Nothing mounts that directory, so every container recreate
|
||||
forgets it, and a scheduler that remembers nothing seeds every entry with
|
||||
`last_run_at = now`: each job waits a FULL interval after startup. A daily job
|
||||
therefore needs 24 hours without a redeploy to fire. Since the one-container
|
||||
image (172e33d) every redeploy restarts beat, and on 2026-09-24 no daily or
|
||||
weekly job had run since the 21st (#4408).
|
||||
|
||||
task_run already records every task that starts (celery_signals), indexed on
|
||||
(task_name, started_at DESC), and prune_task_runs keeps the newest row of each
|
||||
task however old it is. So on startup each entry takes its last_run_at from
|
||||
there:
|
||||
- a job that is overdue runs at once;
|
||||
- a job that is not due waits only the remainder of its interval;
|
||||
- a job that has never run is due now.
|
||||
|
||||
Beat keeps last_run_at in memory from then on, as the default scheduler does;
|
||||
only the startup seed changes. If the database cannot be read, the entries keep
|
||||
Celery's own default rather than beat failing to start.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from celery.beat import Scheduler
|
||||
from sqlalchemy import func, select
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Seed for a job with no recorded run: far enough back that any interval or
|
||||
# crontab reads as due.
|
||||
NEVER = datetime(2000, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def last_runs(session, task_names: list[str]) -> dict[str, datetime]:
|
||||
"""The latest recorded start of each task, by task name."""
|
||||
from .models import TaskRun
|
||||
|
||||
if not task_names:
|
||||
return {}
|
||||
rows = session.execute(
|
||||
select(TaskRun.task_name, func.max(TaskRun.started_at))
|
||||
.where(TaskRun.task_name.in_(sorted(set(task_names))))
|
||||
.group_by(TaskRun.task_name)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def seed(entries, last: dict[str, datetime]) -> None:
|
||||
"""Set each entry's last_run_at from `last`; a task with none is due now."""
|
||||
for entry in entries:
|
||||
entry.last_run_at = last.get(entry.task, NEVER)
|
||||
|
||||
|
||||
class TaskRunScheduler(Scheduler):
|
||||
"""An in-memory beat seeded from task_run history at startup."""
|
||||
|
||||
def setup_schedule(self):
|
||||
super().setup_schedule()
|
||||
try:
|
||||
from .tasks._sync_engine import sync_session_factory
|
||||
|
||||
with sync_session_factory()() as session:
|
||||
last = last_runs(session, [e.task for e in self.schedule.values()])
|
||||
except Exception:
|
||||
log.exception("beat: could not read task_run; every job waits a full interval")
|
||||
return
|
||||
seed(self.schedule.values(), last)
|
||||
due = sum(1 for e in self.schedule.values() if e.is_due()[0])
|
||||
log.info(
|
||||
"beat: seeded %d job(s) from task_run, %d due now",
|
||||
len(self.schedule), due,
|
||||
)
|
||||
@@ -4,7 +4,7 @@ Queues:
|
||||
import — filesystem import path (FC-2)
|
||||
ml — WD14 + SigLIP inference (FC-2; runs in the ml-worker image)
|
||||
thumbnail — image and video thumbnail generation (FC-2)
|
||||
download — gallery-dl tasks (FC-3)
|
||||
download — source walks: the native ingesters and gallery-dl (FC-3)
|
||||
scan — periodic source checks (FC-3) — kept separate so long imports
|
||||
don't starve the scheduler
|
||||
maintenance — recovery sweeps, pHash backfill, GPU-queue coordination, etc.
|
||||
@@ -14,6 +14,7 @@ Queues:
|
||||
from celery import Celery
|
||||
|
||||
from .config import get_config
|
||||
from .services.worker_lanes import SWEEP_PERIOD_SECONDS
|
||||
|
||||
|
||||
def make_celery() -> Celery:
|
||||
@@ -61,6 +62,15 @@ def make_celery() -> Celery:
|
||||
# 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"},
|
||||
# The one long job in maintenance.py: a whole-library phash
|
||||
# recompute (35 min hard limit; the library was cleared for
|
||||
# re-hashing by migration 0098). On the quick lane it held a
|
||||
# scheduler process for its whole run, and the minute ticks queued
|
||||
# up behind it (2026-09-24: 7 waiting, "all workers busy for 18
|
||||
# minutes"). An exact name wins over the glob above.
|
||||
"backend.app.tasks.maintenance.backfill_phash": {"queue": "maintenance_long"},
|
||||
# Walks a folder tree per artist with undated posts (#4436).
|
||||
"backend.app.tasks.maintenance.date_posts_from_records": {"queue": "maintenance_long"},
|
||||
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
|
||||
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
|
||||
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
|
||||
@@ -111,6 +121,34 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
|
||||
"schedule": 300.0, # every 5 minutes
|
||||
},
|
||||
"size-worker-lanes": {
|
||||
"task": "backend.app.tasks.maintenance.size_worker_lanes",
|
||||
"schedule": SWEEP_PERIOD_SECONDS,
|
||||
#
|
||||
# The number lives in `services/worker_lanes` because three
|
||||
# places must agree on it: this schedule, the freshness of the
|
||||
# sample the System tab reads, and the roster staleness
|
||||
# thresholds in `api/system_health` — which now depend on this
|
||||
# sweep rather than on a browser being open, and assert their
|
||||
# headroom over it at import.
|
||||
#
|
||||
# ONE entry, replacing `autoscale-worker-lanes` (60s) and
|
||||
# `reconcile-worker-lanes` (300s) on 2026-09-23. They were two
|
||||
# sweeps over one number and most of the autoscaler's design
|
||||
# existed to stop the reconcile undoing its work; with the
|
||||
# stored `slots` gone there is nothing to disagree about.
|
||||
#
|
||||
# Fast enough to react to a BACKLOG — a five-minute reaction to
|
||||
# a queue filling up is no reaction. It also carries what the
|
||||
# reconcile was for: a worker restarted at its ENV concurrency
|
||||
# is corrected on the next tick rather than after five.
|
||||
#
|
||||
# Cheap when settled: one inspect plus one LLEN sweep, and no
|
||||
# control messages at all once every lane matches. It is also
|
||||
# now the ONLY thing that inspects — nothing on a request path
|
||||
# does — so this is the whole broker cost of the System tab,
|
||||
# whether nobody or ten tabs are watching.
|
||||
},
|
||||
"cleanup-old-tasks": {
|
||||
"task": "backend.app.tasks.maintenance.cleanup_old_tasks",
|
||||
"schedule": 86400.0, # daily
|
||||
@@ -120,6 +158,11 @@ def make_celery() -> Celery:
|
||||
"schedule": 86400.0, # daily — sweep .part/.partial left by a
|
||||
# download/import killed mid-write (graceful-shutdown fallout)
|
||||
},
|
||||
"date-posts-from-records-hourly": {
|
||||
"task": "backend.app.tasks.maintenance.date_posts_from_records",
|
||||
"schedule": 3600.0, # an empty query once every native post is
|
||||
# dated; otherwise upserts the _post.json a killed walk left (#4436)
|
||||
},
|
||||
"backfill-phash-daily": {
|
||||
"task": "backend.app.tasks.maintenance.backfill_phash",
|
||||
"schedule": 86400.0, # daily — NULL-only, so a no-op once the
|
||||
@@ -187,11 +230,6 @@ def make_celery() -> Celery:
|
||||
"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
|
||||
@@ -264,6 +302,10 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.backup.prune_backups",
|
||||
"schedule": 86400.0, # daily
|
||||
},
|
||||
"reconcile-media-backup": {
|
||||
"task": "backend.app.tasks.backup.reconcile_media_backup",
|
||||
"schedule": 604800.0, # weekly: list the bucket, re-send what's missing
|
||||
},
|
||||
# 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.
|
||||
@@ -324,6 +366,9 @@ def make_celery() -> Celery:
|
||||
},
|
||||
},
|
||||
timezone="UTC",
|
||||
# Beat's memory of when each job last ran comes from task_run, not a
|
||||
# shelve file nothing persists — see beat_scheduler (#4408).
|
||||
beat_scheduler="backend.app.beat_scheduler:TaskRunScheduler",
|
||||
)
|
||||
# FC-3i: register task_run signal handlers (side-effect import).
|
||||
from . import celery_signals # noqa: F401
|
||||
|
||||
@@ -18,11 +18,18 @@ dark for that interval. Monitoring NEVER breaks the thing it's
|
||||
monitoring.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from celery.signals import task_failure, task_postrun, task_prerun, task_retry
|
||||
from celery.signals import (
|
||||
task_failure,
|
||||
task_postrun,
|
||||
task_prerun,
|
||||
task_retry,
|
||||
worker_ready,
|
||||
)
|
||||
|
||||
from .models import TaskRun
|
||||
from .tasks._sync_engine import sync_session_factory
|
||||
@@ -53,42 +60,29 @@ _INT32_MIN = -2_147_483_648
|
||||
|
||||
|
||||
def _queue_for(task) -> str:
|
||||
"""Reverse the task→queue routing from celery_app.task_routes.
|
||||
Keep in sync if task_routes is reordered.
|
||||
"""The queue Celery routes this task to — asked of the router itself.
|
||||
|
||||
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.
|
||||
This was a hand-kept copy of `celery_app.task_routes`, and it drifted
|
||||
twice (the 2026-06-02 audit, then #4432). Long-lane jobs were recorded as
|
||||
`maintenance`, and translation and gpu_queue runs as `default`, where the
|
||||
5-minute stall sweep failed healthy 35-minute translation runs. The router
|
||||
answers from the same table the broker uses, so the two cannot disagree.
|
||||
"""
|
||||
name = getattr(task, "name", "") or ""
|
||||
if name.startswith("backend.app.tasks.import_file."):
|
||||
return "import"
|
||||
if name.startswith("backend.app.tasks.ml."):
|
||||
return "ml"
|
||||
if name.startswith("backend.app.tasks.thumbnail."):
|
||||
return "thumbnail"
|
||||
if name.startswith((
|
||||
"backend.app.tasks.download.",
|
||||
# External file-host fetches share the download lane (celery_app
|
||||
# routes external.* → download). Mirror it here or TaskRun.queue
|
||||
# lies 'default' for them, so per-queue dashboard filters and the
|
||||
# per-queue threshold override miss them — the same gap the
|
||||
# 2026-06-02 audit fixed for backup/admin/library_audit.
|
||||
"backend.app.tasks.external.",
|
||||
)):
|
||||
return "download"
|
||||
if name.startswith("backend.app.tasks.scan."):
|
||||
return "scan"
|
||||
if name.startswith((
|
||||
"backend.app.tasks.maintenance.",
|
||||
"backend.app.tasks.backup.",
|
||||
"backend.app.tasks.admin.",
|
||||
"backend.app.tasks.library_audit.",
|
||||
)):
|
||||
return "maintenance"
|
||||
return "default"
|
||||
app = getattr(task, "app", None)
|
||||
if app is None:
|
||||
from .celery_app import celery as app
|
||||
return _routed_queue(app, name)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def _routed_queue(app, name: str) -> str:
|
||||
try:
|
||||
queue = app.amqp.router.route({}, name).get("queue")
|
||||
except Exception: # noqa: BLE001 — monitoring never breaks the task
|
||||
log.warning("task_run: could not resolve the queue for %s", name)
|
||||
return "default"
|
||||
return getattr(queue, "name", None) or (queue if isinstance(queue, str) else "default")
|
||||
|
||||
|
||||
def _target_id_from_args(args) -> int | None:
|
||||
@@ -225,3 +219,64 @@ def _on_retry(sender=None, request=None, reason=None, einfo=None, **_):
|
||||
error_message=str(reason) if reason else None,
|
||||
retry_count=getattr(request, "retries", 0),
|
||||
)
|
||||
|
||||
|
||||
def _consumed_queues(consumer) -> set[str]:
|
||||
"""The queue names this worker process consumes (its `-Q`), or empty when
|
||||
they can't be read — which makes the boot hook below a no-op, not a guess."""
|
||||
try:
|
||||
return {q.name for q in consumer.task_consumer.queues}
|
||||
except Exception: # noqa: BLE001 — shape varies across celery versions
|
||||
return set()
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def _on_worker_ready(sender=None, **_):
|
||||
"""A lane picks up what its previous process left behind. Only the lane
|
||||
that runs the work does it; the other lanes booting beside it must not,
|
||||
or a lane restarting on its own would disturb work still running.
|
||||
Best-effort: a failure is logged and the worker still starts."""
|
||||
queues = _consumed_queues(sender)
|
||||
if "download" in queues:
|
||||
_clear_download_leftovers()
|
||||
if "maintenance_long" in queues:
|
||||
_resume_offsite_runs()
|
||||
|
||||
|
||||
def _resume_offsite_runs() -> None:
|
||||
"""The long-maintenance lane carries on an off-site backup or restore that
|
||||
a restart cut off mid-chunk. With nothing queuing its next chunk, the run
|
||||
would read "running" for hours, and block every new run, until the stall
|
||||
sweep ended it."""
|
||||
try:
|
||||
from .tasks.backup import resume_offsite_runs
|
||||
|
||||
resume_offsite_runs()
|
||||
except Exception: # noqa: BLE001 — never block the worker from starting
|
||||
log.exception("long-maintenance lane boot: resuming off-site runs failed")
|
||||
|
||||
|
||||
def _clear_download_leftovers() -> None:
|
||||
"""The download lane clears what its previous process left behind.
|
||||
|
||||
A restart SIGKILLs any walk that outlives the stop grace, so its event is
|
||||
never finalized and its platform lock is never released. Both used to wait
|
||||
out timers — a 30-min stall sweep that then blamed the source, and a 27-min
|
||||
lock TTL that stalled every other source on the platform.
|
||||
"""
|
||||
booted_at = datetime.now(UTC)
|
||||
try:
|
||||
from .services.download_recovery import interrupt_orphaned_download_events
|
||||
from .services.platform_lock import release_all_platform_locks
|
||||
|
||||
with sync_session_factory()() as session:
|
||||
closed = interrupt_orphaned_download_events(session, booted_at=booted_at)
|
||||
session.commit()
|
||||
released = release_all_platform_locks()
|
||||
if closed or released:
|
||||
log.info(
|
||||
"download lane boot: closed %d orphaned download event(s) as "
|
||||
"interrupted, released %d platform lock(s)", closed, released,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — never block the worker from starting
|
||||
log.exception("download lane boot recovery failed")
|
||||
|
||||
+20
-2
@@ -46,6 +46,14 @@ async def serve_extension(filename: str):
|
||||
|
||||
The application/x-xpinstall MIME tells Firefox to show its native
|
||||
install prompt instead of downloading the file as a blob.
|
||||
|
||||
Caching differs by name, and has to. A versioned name is one build's bytes
|
||||
forever, so it can be cached for good. `fabledcurator-latest.xpi` is ONE
|
||||
URL whose bytes change on every release, and Quart's default for a file
|
||||
is `public, max-age=43200`: a browser that fetched it once reused those
|
||||
bytes for 12 hours, so "install the latest" quietly reinstalled the
|
||||
previous build (operator-flagged 2026-09-25). It is `no-cache` — the ETag
|
||||
still makes an unchanged file a cheap 304.
|
||||
"""
|
||||
if not _XPI_NAME_RE.fullmatch(filename):
|
||||
abort(404)
|
||||
@@ -56,10 +64,11 @@ async def serve_extension(filename: str):
|
||||
if not xpis:
|
||||
abort(404)
|
||||
latest = xpis[-1]
|
||||
return await send_file(
|
||||
resp = await send_file(
|
||||
latest, mimetype="application/x-xpinstall",
|
||||
attachment_filename=latest.name,
|
||||
)
|
||||
return _cache(resp, "no-cache")
|
||||
target = (XPI_DIR / filename).resolve()
|
||||
try:
|
||||
target.relative_to(XPI_DIR)
|
||||
@@ -67,10 +76,19 @@ async def serve_extension(filename: str):
|
||||
abort(404)
|
||||
if not target.is_file():
|
||||
abort(404)
|
||||
return await send_file(
|
||||
resp = await send_file(
|
||||
target, mimetype="application/x-xpinstall",
|
||||
attachment_filename=filename,
|
||||
)
|
||||
return _cache(resp, "public, max-age=31536000, immutable")
|
||||
|
||||
|
||||
def _cache(resp, policy: str):
|
||||
"""Set the XPI's Cache-Control, dropping the Expires send_file adds so the
|
||||
two can never disagree."""
|
||||
resp.headers["Cache-Control"] = policy
|
||||
resp.headers.pop("Expires", None)
|
||||
return resp
|
||||
|
||||
|
||||
@frontend_bp.route("/")
|
||||
|
||||
@@ -4,10 +4,13 @@ from .app_setting import AppSetting
|
||||
from .artist import Artist
|
||||
from .artist_membership_suggestion import ArtistMembershipSuggestion
|
||||
from .artist_visit import ArtistVisit
|
||||
from .backup_blob import BackupBlob
|
||||
from .backup_run import BackupRun
|
||||
from .base import Base
|
||||
from .character_prototype import CcipPrototypeState, CharacterPrototype
|
||||
from .credential import Credential
|
||||
from .discord_failed_media import DiscordFailedMedia
|
||||
from .discord_seen_media import DiscordSeenMedia
|
||||
from .download_event import DownloadEvent
|
||||
from .external_link import ExternalLink
|
||||
from .gpu_job import GpuJob
|
||||
@@ -26,8 +29,6 @@ from .membership_sync import MembershipSync
|
||||
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 .platform_membership import PlatformMembership
|
||||
from .post import Post
|
||||
from .post_association import PostAssociation
|
||||
@@ -46,6 +47,8 @@ from .tag_head import TagHead
|
||||
from .tag_positive_confirmation import TagPositiveConfirmation
|
||||
from .tag_suggestion_rejection import TagSuggestionRejection
|
||||
from .task_run import TaskRun
|
||||
from .worker_lane import WorkerLane
|
||||
from .worker_lane_sample import WorkerLaneSample
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -53,13 +56,14 @@ __all__ = [
|
||||
"Artist",
|
||||
"ArtistMembershipSuggestion",
|
||||
"ArtistVisit",
|
||||
"BackupBlob",
|
||||
"BackupRun",
|
||||
"Source",
|
||||
"Credential",
|
||||
"DiscordFailedMedia",
|
||||
"DiscordSeenMedia",
|
||||
"PatreonFailedMedia",
|
||||
"PatreonSeenMedia",
|
||||
"PixivFailedMedia",
|
||||
"PixivSeenMedia",
|
||||
"SubscribeStarFailedMedia",
|
||||
"SubscribeStarSeenMedia",
|
||||
"Post",
|
||||
@@ -98,4 +102,6 @@ __all__ = [
|
||||
"TagPositiveConfirmation",
|
||||
"TagSuggestionRejection",
|
||||
"TaskRun",
|
||||
"WorkerLane",
|
||||
"WorkerLaneSample",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""backup_blob: a library file already sent to the off-site bucket.
|
||||
|
||||
One row per (destination, sha256). `target` names the destination as
|
||||
"<endpoint>/<bucket>", so pointing backups at another bucket leaves these rows
|
||||
inert and that bucket's copy starts from the beginning. `key` is where the
|
||||
file sits in the bucket (`media/ab/cd/<sha256><ext>`).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class BackupBlob(Base):
|
||||
__tablename__ = "backup_blob"
|
||||
|
||||
target: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
sha256: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
@@ -1,6 +1,8 @@
|
||||
"""FC-3h: backup_run — operator-facing artifact record for a backup run.
|
||||
|
||||
One row per backup attempt (kind='db' or 'images'). Lifecycle
|
||||
One row per run: kind 'db' (a database dump), 'media' (the off-site upload of
|
||||
images and attachments, which spans many task chunks), 'restore', 'reconcile'
|
||||
(the weekly bucket check) or 'test' (a test restore). Lifecycle
|
||||
tracking (started_at/finished_at/duration_ms/exception text) lives
|
||||
in task_run from FC-3i — this row records artifact metadata: file
|
||||
paths, sizes, tag (retention protection), and restore lineage via
|
||||
@@ -54,7 +56,6 @@ class BackupRun(Base):
|
||||
DateTime(timezone=True), nullable=True, index=True,
|
||||
)
|
||||
sql_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tar_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
manifest: Mapped[dict] = mapped_column(
|
||||
@@ -62,6 +63,11 @@ class BackupRun(Base):
|
||||
)
|
||||
# Self-referential FK, unindexed until 0089 (#3300): SET NULL has to find
|
||||
# the rows pointing at a deleted run before it can null them.
|
||||
# Heartbeat of a run that spans hours (the media backup): set as it makes
|
||||
# progress, and what the stall sweep measures its silence from.
|
||||
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
restored_from_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("backup_run.id", ondelete="SET NULL"),
|
||||
nullable=True, index=True,
|
||||
|
||||
+8
-17
@@ -1,16 +1,9 @@
|
||||
"""PixivFailedMedia — per-source dead-letter ledger of Pixiv media that keeps
|
||||
failing to download/validate.
|
||||
"""DiscordFailedMedia — per-source dead-letter ledger of Discord files that
|
||||
keep failing to download or 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.
|
||||
Mirror of SubscribeStarFailedMedia. After `attempts` reaches the dead-letter
|
||||
threshold a routine walk skips the file (recovery still retries it); a later
|
||||
clean download clears the row. `filehash` is the seen-ledger's key.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -22,12 +15,10 @@ from sqlalchemy.types import DateTime
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PixivFailedMedia(Base):
|
||||
__tablename__ = "pixiv_failed_media"
|
||||
class DiscordFailedMedia(Base):
|
||||
__tablename__ = "discord_failed_media"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"source_id", "filehash", name="uq_pixiv_failed_media_source_id"
|
||||
),
|
||||
UniqueConstraint("source_id", "filehash", name="uq_discord_failed_media_source_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""DiscordSeenMedia — per-source ledger of Discord files already downloaded.
|
||||
|
||||
Mirror of SubscribeStarSeenMedia for the native Discord ingester (milestone
|
||||
428). `filehash` holds the ingester's per-file key, `<message_id>:<media_id>`:
|
||||
the attachment id, or for an embed a hash of its URL path. Not the file's
|
||||
position in the message — an edit that removes a file renumbers the rest
|
||||
(see `discord_client.MediaItem`). The message record's own gate is the
|
||||
synthetic `message:<id>` key in the same column.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import DateTime
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class DiscordSeenMedia(Base):
|
||||
__tablename__ = "discord_seen_media"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_id", "filehash", name="uq_discord_seen_media_source_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -80,12 +80,23 @@ class ExternalLink(Base):
|
||||
Integer, nullable=False, server_default=text("0")
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Times the daily recovery has given this link another attempt after it
|
||||
# went dead. Recovery stops at a limit, so a link that can never succeed
|
||||
# isn't downloaded forever.
|
||||
recoveries: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default=text("0")
|
||||
)
|
||||
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()
|
||||
)
|
||||
# When the current fetch claimed the link ('downloading'). The sweep uses it
|
||||
# to recover a link whose worker died mid-fetch.
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
Float,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
select,
|
||||
text,
|
||||
@@ -39,7 +40,7 @@ class ImportSettings(Base):
|
||||
transparency_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.9, server_default="0.9")
|
||||
|
||||
skip_single_color: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95, server_default="0.95")
|
||||
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.995, server_default="0.995")
|
||||
single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30, server_default="30")
|
||||
|
||||
# Hamming distance over a 256-bit pHash (utils.phash, hash_size=16). The
|
||||
@@ -68,6 +69,16 @@ class ImportSettings(Base):
|
||||
Integer, nullable=False, default=90,
|
||||
server_default="90",
|
||||
)
|
||||
# How far back a routine tick keeps looking after it has run out of new
|
||||
# posts, so a creator who EDITS an older post to attach a hotfix build is
|
||||
# still reached (ingest_core.DEFAULT_REVISIT_DAYS carries the reasoning).
|
||||
# A knob rather than a constant because how long a creator keeps editing is
|
||||
# a property of the creator, not of FabledCurator: 0 turns the revisit off
|
||||
# and restores the pure count early-out.
|
||||
download_revisit_days: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=30,
|
||||
server_default="30",
|
||||
)
|
||||
download_failure_warning_threshold: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=5,
|
||||
server_default="5",
|
||||
@@ -86,9 +97,19 @@ class ImportSettings(Base):
|
||||
Integer, nullable=False, default=14,
|
||||
server_default="14",
|
||||
)
|
||||
backup_images_keep_last_n: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=3,
|
||||
server_default="3",
|
||||
# Off-site backup destination: an S3 bucket (services/backup_destination).
|
||||
# The secret key is stored sealed with the credential key, never in clear.
|
||||
backup_s3_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
backup_s3_bucket: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
backup_s3_access_key: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
backup_s3_secret_sealed: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
backup_s3_region: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, default="us-east-1",
|
||||
server_default="us-east-1",
|
||||
)
|
||||
# Upload bandwidth to that bucket, in megabits per second; 0 = no limit.
|
||||
backup_upload_limit_mbps: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=200, server_default="200",
|
||||
)
|
||||
|
||||
# FC-6.3 series continuation matcher. enabled gates the rescan; threshold is
|
||||
@@ -129,6 +150,50 @@ class ImportSettings(Base):
|
||||
server_default="24",
|
||||
)
|
||||
|
||||
# Whether FC links a CONCLUSIVE pair without asking.
|
||||
#
|
||||
# Operator, 2026-09-24: *"I don't want this to be manual that defeats the
|
||||
# convenience that I'm going for."* Confirm-only was the right default
|
||||
# while the only signals were circumstantial — proximity and a body that
|
||||
# mentions Discord can never be more than suggestive, and asking was the
|
||||
# honest response to that. A shared working name is different in kind: when
|
||||
# the name appears in exactly these two posts and nowhere else in the
|
||||
# artist's library, there is nothing for the operator to adjudicate.
|
||||
#
|
||||
# Only the conclusive band is affected (post_association_service.
|
||||
# AUTO_LINK_FLOOR). Everything weaker still queues, and an accepted link is
|
||||
# a row the operator can dismiss, so this is reversible in the UI rather
|
||||
# than only in the database.
|
||||
discord_link_auto: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
|
||||
# The unified card (#4402). A linked Discord drop is NOT absorbed into its
|
||||
# teaser — it keeps its own post, date and provenance, and the teaser's card
|
||||
# shows it by REFERENCE. Operator, 2026-09-24: *"discord 'posts' land as
|
||||
# normal and only hidden from the post view they're posted the same day."*
|
||||
#
|
||||
# So the drop's own card leaves the feed only when it sits within this many
|
||||
# hours of the teaser that references it — the adjacency that reads as the
|
||||
# same thing twice. Hours rather than a calendar day: a teaser at 23:00 and
|
||||
# its drop at 01:00 are one release, and "the same day" has no timezone
|
||||
# the server can know.
|
||||
discord_link_fold_hours: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=24.0,
|
||||
server_default="24",
|
||||
)
|
||||
# How far from the teaser the card reaches for the rest of a piece's
|
||||
# variants — the wips, alts and censor passes a creator trickles out under
|
||||
# one working name (#4401). Measured on artist 8: named families spread a
|
||||
# median 5 days and up to 44, while every name collision found spreads
|
||||
# over 500. A reference, not a regrouping, so a generous value costs one
|
||||
# extra thumbnail at worst — never a post moved or hidden.
|
||||
discord_family_window_days: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=60.0,
|
||||
server_default="60",
|
||||
)
|
||||
|
||||
# #830 off-platform file-host downloads — per-host enable lever (default on,
|
||||
# rule #26). Column names are extdl_<host>_enabled so the worker reads them
|
||||
# via getattr(settings, f"extdl_{host}_enabled", True).
|
||||
@@ -184,13 +249,6 @@ class ImportSettings(Base):
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -91,6 +91,13 @@ class PostAssociation(Base):
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default="pending", index=True
|
||||
)
|
||||
# WHO linked it: "fc" when the matcher linked a conclusive pair by itself
|
||||
# (discord_link_auto), "operator" when a person accepted it. The card needs
|
||||
# this to be honest — a link FC asserted on its own says so and offers an
|
||||
# undo, which the operator chose over a silent merge (#4402). NULL on a row
|
||||
# that is not linked, and on rows linked before the column existed, all of
|
||||
# which an operator accepted: auto-linking shipped in the same release.
|
||||
linked_by: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -46,6 +46,11 @@ class Source(Base):
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
|
||||
config_overrides: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# alembic 0115: what the source is called on its platform, where the URL
|
||||
# alone doesn't say — a Discord link is two numbers. The ingester that
|
||||
# walks it refreshes it every walk, so a rename reads as the new name.
|
||||
# NULL until a walk has read it, and on platforms that don't write it.
|
||||
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""worker_lane — the most workers the operator will let each lane use.
|
||||
|
||||
Milestone 422 step 1, reshaped 2026-09-23. One row per lane in
|
||||
`services/worker_lanes.LANES`, and ONE COLUMN an operator sets.
|
||||
|
||||
## Why there is only one number now
|
||||
|
||||
There were three — `slots`, `slots_cap` and `autoscale` — because the manual
|
||||
dial was built first and the autoscaler arrived last, beside a control that
|
||||
already existed rather than in place of it.
|
||||
|
||||
Operator: *"auto should be always on, not a setting, so that idle instances
|
||||
quiet down when not running. the number that is visible and something the
|
||||
user can tweak and manage should be the cap itself the number of running
|
||||
workers is handled by the autoscaling function which is always on."*
|
||||
|
||||
So `slots` is gone. How many workers a lane is running right now is a
|
||||
MEASUREMENT — read live from the worker, moved by the autoscaler, never
|
||||
stored. Storing it made it look like a preference, which meant the operator
|
||||
had to keep two numbers in agreement and the autoscaler had to be told it was
|
||||
allowed to touch one of them.
|
||||
|
||||
`autoscale` is gone for the same reason: it gated the mechanism behind a
|
||||
choice, and a lane nobody opted in simply never gave its slots back.
|
||||
|
||||
`enabled` is gone too, and is now DERIVED: a cap of zero means no consumers.
|
||||
"Off" and "may use no workers" were two spellings of one fact, free to
|
||||
disagree.
|
||||
|
||||
## What remains
|
||||
|
||||
1 <= live pool <= slots_cap <= derived_ceiling
|
||||
(autoscaler) (this row) (computed)
|
||||
|
||||
The floor is one PROCESS, not zero: billiard will not run an empty pool, and
|
||||
the parked process is what `add_consumer` lands on when the cap goes back up.
|
||||
|
||||
The DERIVED CEILING is deliberately absent from this table. It is computed
|
||||
from the container's cgroup limits on every read, so a row written on a 32GB
|
||||
host and later run in a 4GB container is bounded by the 4GB — a stored
|
||||
ceiling would quietly authorise what the box can no longer hold.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class WorkerLane(Base):
|
||||
__tablename__ = "worker_lane"
|
||||
__table_args__ = (
|
||||
# Bare name — Base.metadata's naming convention prepends
|
||||
# ck_worker_lane_. Pre-prefixing here doubles it, which is what
|
||||
# alembic 0088 had to rename four constraints for (#3275).
|
||||
CheckConstraint("slots_cap >= 0", name="cap_non_negative"),
|
||||
)
|
||||
|
||||
# The lane name from services/worker_lanes.LANES — never a container
|
||||
# hostname. See models/service_seen.py for why: celery's worker names here
|
||||
# are `celery@<container id>`, minted fresh on every deploy.
|
||||
name: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
|
||||
# The most workers this lane may use. Zero means off — no consumers, so
|
||||
# the lane takes no work and (for ML) downloads no model.
|
||||
#
|
||||
# There is no upper CHECK here, because the bound it would need is the
|
||||
# derived ceiling, and no column holds that: it depends on the cgroup the
|
||||
# container is running in right now. Enforced at write instead.
|
||||
slots_cap: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""worker_lane_sample — the last thing the sizing sweep measured about a lane.
|
||||
|
||||
Milestone 422, 2026-09-23. A MEASUREMENT table, deliberately separate from
|
||||
`worker_lane`, which holds the one number an operator sets.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Operator, 2026-09-23, looking at the System tab: *"there is a repull every
|
||||
time this page loads — is there a reason this info isn't being tracked in the
|
||||
background and stored in some way?"*
|
||||
|
||||
There was not a good one. `/api/system/workers` ran a full celery inspect —
|
||||
four broadcast round trips on an eleven-second budget — on every call, and
|
||||
the page polls it every fifteen seconds. Meanwhile `size_worker_lanes` was
|
||||
already inspecting on a timer to decide pool sizes, computing exactly these
|
||||
numbers, using them, and throwing them away. The browser then asked the
|
||||
broker for them again.
|
||||
|
||||
So the sweep writes what it saw here, and the endpoint reads this table. The
|
||||
request path makes no broker call at all any more.
|
||||
|
||||
## Why NOT columns on `worker_lane`
|
||||
|
||||
Because that is the mistake this milestone already made once and undid. That
|
||||
table used to carry `slots` — how many workers were running — beside
|
||||
`slots_cap`, and a measurement sitting next to a preference reads as a second
|
||||
preference: the operator had to keep two numbers in agreement, and the
|
||||
autoscaler had to be granted permission to move one of them.
|
||||
|
||||
The distinction is the whole design, so it is a table boundary. Nothing an
|
||||
operator sets lives here; nothing here is ever an input to a decision about
|
||||
what they wanted.
|
||||
|
||||
## Freshness is a value, not an assumption
|
||||
|
||||
`measured_at` is returned to the UI, which says how old the reading is rather
|
||||
than implying it is live. A sample is a fact about a moment, and a page that
|
||||
presents a one-minute-old number as current is how an operator ends up
|
||||
mistrusting the whole surface.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class WorkerLaneSample(Base):
|
||||
__tablename__ = "worker_lane_sample"
|
||||
|
||||
# The lane name from services/worker_lanes.LANES. One row per lane,
|
||||
# overwritten in place: this is the LATEST reading, not a history. A time
|
||||
# series would be a different table with a different retention problem,
|
||||
# and nothing has asked for one.
|
||||
lane: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
|
||||
# Whether anything answered for this lane. NOT the same as "zero workers"
|
||||
# — an unswept absence is not a verdict (snippet #3969). False here means
|
||||
# the inspect came back without this lane, so every count below is
|
||||
# meaningless and the UI must say "not answering" rather than "0".
|
||||
present: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
replicas: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Pool size of ONE process, nullable because a worker that answered
|
||||
# without reporting its pool is unknown rather than empty.
|
||||
pool: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
active: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
reserved: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Redis LLEN across the lane's queues. Nullable for the same reason as
|
||||
# `pool`: a queue the broker did not answer for is unknown, and summing it
|
||||
# as zero would report a buried lane as idle.
|
||||
queue_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
measured_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Emit a supervisord config for the single-container layout.
|
||||
|
||||
Milestone 422 step 5. Writes to stdout; `entrypoint.sh all` redirects it to a
|
||||
file and execs supervisord against it.
|
||||
|
||||
## Why this is generated and not a checked-in .conf
|
||||
|
||||
A static config would spell out each lane's `-Q` list, and that would be a
|
||||
FIFTH hand-kept copy of the queue names — after `celery_app.task_routes`, and
|
||||
the three collapsed in steps 1, 2 and 4 (`service_roster.ROLE_NAMES`,
|
||||
`system_activity._QUEUE_NAMES`, and the Activity filter). Every one of those
|
||||
had already drifted by the time it was found.
|
||||
|
||||
Generating from `worker_lanes.LANES` makes a stronger guarantee than "they
|
||||
match today": the processes this container runs and the lanes the application
|
||||
believes in are the same list, so a lane added to `LANES` gets a process
|
||||
without anyone remembering to add one, and a queue can never end up with no
|
||||
consumer because a config file was missed.
|
||||
|
||||
## Why supervisord
|
||||
|
||||
It is one pip dependency on an image that is already Python, and it does the
|
||||
four things this needs without being clever: restart a program that exits,
|
||||
give each one its OWN stop timeout, signal the process GROUP rather than the
|
||||
leader, and put every program's output on one stdout.
|
||||
|
||||
The process-group part is not a detail. Celery's prefork pool forks children,
|
||||
and a TERM delivered only to the parent leaves them running — which is how a
|
||||
"graceful" shutdown turns into orphaned workers holding tasks. `stopasgroup`
|
||||
and `killasgroup` are both set for every program.
|
||||
|
||||
s6-overlay is the other standard answer and would work; it needs a build-time
|
||||
download and a second mental model, and its advantage (correct PID-1 signal
|
||||
and zombie handling) is available here from `init: true` in compose, which
|
||||
puts tini in front of supervisord. Neither choice reaches the application —
|
||||
nothing in FC talks to the supervisor — so this is reversible without touching
|
||||
a line of product code.
|
||||
|
||||
## Every lane, including ml
|
||||
|
||||
Step 6 merged the images, so this one carries torch and the ML requirements
|
||||
and the `ml` lane gets a program like any other. It starts at one slot with
|
||||
its consumers CANCELLED — `enabled=false` in the seeded settings — so it
|
||||
holds a process and no model. That matters: `add_consumer` needs a running
|
||||
worker to reach, and without one the UI switch would have nothing to switch.
|
||||
|
||||
Nothing is downloaded by starting it. The model fetch is enqueued when the
|
||||
lane is enabled, which is what lets rule 164 permit a runtime fetch at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from ..services.worker_lanes import LANES, MIN_POOL_SLOTS, Lane
|
||||
|
||||
# One number for the whole container, and it must cover the SLOWEST lane —
|
||||
# docker gives the container a single stop timeout, where compose today gives
|
||||
# each service its own (90/60/180/120s). `maintenance_long` is the 180s one:
|
||||
# DB backups, library audits and translation backfill. Anything less turns a
|
||||
# routine restart into a SIGKILL mid-backup.
|
||||
#
|
||||
# Per-program values below are the old per-service ones, preserved: supervisord
|
||||
# waits `stopwaitsecs` for each, and they stop in parallel, so the container's
|
||||
# own timeout needs to cover the max rather than the sum.
|
||||
STOP_WAIT_SECONDS: dict[str, int] = {
|
||||
"worker": 90,
|
||||
"scheduler": 60,
|
||||
"maintenance_long": 180,
|
||||
"ml": 120,
|
||||
}
|
||||
DEFAULT_STOP_WAIT = 60
|
||||
|
||||
def _program(lane: Lane, *, slots: int) -> str:
|
||||
"""One [program:x] block.
|
||||
|
||||
`stdout_logfile=/dev/fd/1` with maxbytes 0 puts the lane's output straight
|
||||
on the container's stdout unbuffered, so `docker logs` shows every lane
|
||||
interleaved rather than supervisord swallowing them into rotated files.
|
||||
|
||||
The output is prefixed through `sed` so a line can be attributed to a lane
|
||||
— four celery workers and hypercorn on one stream are otherwise
|
||||
indistinguishable. The shell that the pipe requires is exactly why
|
||||
`stopasgroup` matters: the signal has to reach the celery process, not the
|
||||
`sh` holding the pipeline.
|
||||
"""
|
||||
inner = f"./entrypoint.sh {lane.entrypoint_role}"
|
||||
prefixed = f"{inner} 2>&1 | sed -u 's/^/[{lane.name}] /'"
|
||||
stop_wait = STOP_WAIT_SECONDS.get(lane.name, DEFAULT_STOP_WAIT)
|
||||
return "\n".join([
|
||||
f"[program:{lane.name}]",
|
||||
f"command=sh -c {shlex.quote(prefixed)}",
|
||||
# QUOTED, and that is load-bearing. supervisord parses `environment`
|
||||
# as a COMMA-separated KEY=VALUE list, so an unquoted queue list reads
|
||||
# as CELERY_QUEUES=default followed by three malformed entries — and
|
||||
# the lane would consume only its first queue. Silent: the worker
|
||||
# starts, reports healthy, and simply never picks up `import`.
|
||||
f'environment=CELERY_QUEUES="{",".join(lane.queues)}",'
|
||||
f"CELERY_CONCURRENCY={slots},"
|
||||
# A UNIQUE celery node name per lane, and the reason is not cosmetic.
|
||||
# These processes share one hostname, so celery's default
|
||||
# `celery@<hostname>` made all four the SAME node: inspect collapsed
|
||||
# their replies, three lanes read as absent, and which three varied
|
||||
# per call (run 7319). The healthcheck could never pass, and
|
||||
# pool_grow's `destination` would have addressed an arbitrary lane.
|
||||
f"CELERY_NODENAME={lane.name}",
|
||||
"autostart=true",
|
||||
"autorestart=true",
|
||||
# A lane that dies instantly and repeatedly is a broken image, not a
|
||||
# transient fault. Backing off stops it burning a core in a restart
|
||||
# loop while still recovering from a one-off crash.
|
||||
"startretries=3",
|
||||
"startsecs=5",
|
||||
f"stopwaitsecs={stop_wait}",
|
||||
"stopasgroup=true",
|
||||
"killasgroup=true",
|
||||
"stdout_logfile=/dev/fd/1",
|
||||
"stdout_logfile_maxbytes=0",
|
||||
"redirect_stderr=true",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
# supervisord's control socket. /tmp for the same reason the generated config
|
||||
# lives there — writable by every role, and per-container by nature.
|
||||
SOCKET_PATH = "/tmp/supervisor.sock"
|
||||
|
||||
|
||||
def _web_program() -> str:
|
||||
"""hypercorn. Started FIRST (priority) because its role runs
|
||||
`alembic upgrade head`, and a worker that boots against an un-migrated
|
||||
schema fails in a way that looks like application breakage."""
|
||||
prefixed = "./entrypoint.sh web 2>&1 | sed -u 's/^/[web] /'"
|
||||
return "\n".join([
|
||||
"[program:web]",
|
||||
f"command=sh -c {shlex.quote(prefixed)}",
|
||||
"priority=1",
|
||||
"autostart=true",
|
||||
"autorestart=true",
|
||||
"startretries=3",
|
||||
"startsecs=5",
|
||||
# Short: HTTP requests and the occasional file download. Matches the
|
||||
# 30s the operator's production stack gives the web service.
|
||||
"stopwaitsecs=30",
|
||||
"stopasgroup=true",
|
||||
"killasgroup=true",
|
||||
"stdout_logfile=/dev/fd/1",
|
||||
"stdout_logfile_maxbytes=0",
|
||||
"redirect_stderr=true",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def render() -> str:
|
||||
parts = [
|
||||
"\n".join([
|
||||
"[supervisord]",
|
||||
# PID 1 in the container, so it must not daemonise.
|
||||
"nodaemon=true",
|
||||
# supervisord's OWN log. /dev/fd/1 keeps it on the container's
|
||||
# stdout beside the programs rather than in a file nobody reads.
|
||||
"logfile=/dev/fd/1",
|
||||
"logfile_maxbytes=0",
|
||||
"loglevel=info",
|
||||
"",
|
||||
]),
|
||||
# THE CONTROL SOCKET, and it is not optional furniture.
|
||||
#
|
||||
# Without these three sections supervisord runs perfectly and
|
||||
# `supervisorctl` cannot talk to it at all:
|
||||
#
|
||||
# Error: .ini file does not include supervisorctl section
|
||||
#
|
||||
# Which is the first thing anyone reaches for when a lane misbehaves
|
||||
# in the consolidated container — `docker exec <c> supervisorctl
|
||||
# status` to see which processes are up, or `restart ml` to bounce one
|
||||
# without taking the whole application down with it. Consolidation
|
||||
# took away `docker ps` as the way to see the lanes; this is what
|
||||
# replaces it, and shipping without it would have left an operator
|
||||
# with one container, five processes inside it, and no way to ask
|
||||
# about any of them.
|
||||
#
|
||||
# Found by the smoke's own diagnostic line on run 7322, which printed
|
||||
# this error instead of a process list. It was behind `|| true`, so it
|
||||
# cost nothing and said so anyway — the argument for printing evidence
|
||||
# even where nothing depends on it.
|
||||
#
|
||||
# /tmp, like the generated config itself: writable by every role
|
||||
# without assuming a volume, and per-container state that must not
|
||||
# outlive the container.
|
||||
"\n".join([
|
||||
"[unix_http_server]",
|
||||
f"file={SOCKET_PATH}",
|
||||
"chmod=0700",
|
||||
"",
|
||||
"[rpcinterface:supervisor]",
|
||||
"supervisor.rpcinterface_factory = "
|
||||
"supervisor.rpcinterface:make_main_rpcinterface",
|
||||
"",
|
||||
"[supervisorctl]",
|
||||
f"serverurl=unix://{SOCKET_PATH}",
|
||||
"",
|
||||
]),
|
||||
_web_program(),
|
||||
]
|
||||
# Lanes after web, in LANES order, so the log reads in a stable sequence.
|
||||
for lane in LANES:
|
||||
# A lane configured at zero slots still gets a PROCESS, at one slot
|
||||
# with its consumers cancelled by the reconcile. Without a running
|
||||
# worker there is nothing for `add_consumer` to reach, so enabling the
|
||||
# lane from the UI could not work at all — the process has to exist for
|
||||
# the switch to have something to switch.
|
||||
parts.append(_program(lane, slots=MIN_POOL_SLOTS))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.parse_args(argv)
|
||||
sys.stdout.write(render())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,181 @@
|
||||
"""The container's healthcheck. Picks the right check from the role it runs.
|
||||
|
||||
Exit 0 healthy, non-zero unhealthy.
|
||||
|
||||
## Why this is in the IMAGE and not in every compose file
|
||||
|
||||
Because the image is the only thing that knows what it is running. A
|
||||
deployment had to declare a healthcheck per service, which meant every
|
||||
compose file, stack file and README repeated the same knowledge:
|
||||
|
||||
web -> curl /api/health
|
||||
worker -> celery inspect ping -d celery@$HOSTNAME
|
||||
all -> both, for every lane
|
||||
|
||||
Three checks, written out by hand, once per service, in every file anyone
|
||||
ever wrote — and none of them wrong until a role changed. Operator, 2026-09-23:
|
||||
*"why isn't the healthcheck built into the image or base on what command runs
|
||||
if one is passed in."* There was no reason. The role is already a fact the
|
||||
container holds; asking the deployment to restate it is the same duplication
|
||||
the lane table exists to remove one level down.
|
||||
|
||||
So `entrypoint.sh` records the role it started, the Dockerfile declares ONE
|
||||
`HEALTHCHECK` that runs this, and a stack file says nothing at all. Declaring
|
||||
one anyway still works — docker lets a service override the image's — which
|
||||
is the escape hatch for a deployment that genuinely wants something else.
|
||||
|
||||
## What each role is asked
|
||||
|
||||
* **web** — hypercorn answers `/api/health`. No database: the endpoint is a
|
||||
no-DB 200 that proves the app booted and is serving after `alembic upgrade
|
||||
head`, which is what a rolling deploy needs to know.
|
||||
* **worker / scheduler / ml-worker** — THIS container's celery node answers a
|
||||
ping over the broker. Not "some worker answered": the node name is pinned
|
||||
to this container, or a healthy sibling would keep a dead one looking alive.
|
||||
* **all** — both halves, for every lane in the table. The failure mode
|
||||
consolidation creates is that docker can no longer see the lanes as
|
||||
separate services, so a web-only check reports a healthy container with
|
||||
every worker dead.
|
||||
* **shell / alembic / anything else** — nothing to check. These are one-shot
|
||||
or interactive; a liveness probe on them has no meaning, so it passes
|
||||
rather than inventing a verdict.
|
||||
|
||||
## An unrecorded role passes rather than failing
|
||||
|
||||
If the role file is missing, the entrypoint did not run — someone used
|
||||
`--entrypoint` or ran a bare command. That is a debugging shape, and a
|
||||
healthcheck that cannot tell what it is looking at must not assert that the
|
||||
thing is broken (snippet #3969: an unswept read is not a verdict). It says so
|
||||
on stdout and exits 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# Written by entrypoint.sh at boot. /tmp because it is the one path writable
|
||||
# by every role without assuming a volume, and the value is per-container
|
||||
# state that must NOT survive into a new container.
|
||||
ROLE_FILE = os.environ.get("FC_ROLE_FILE", "/tmp/fc-role")
|
||||
|
||||
WEB_URL = "http://localhost:8080/api/health"
|
||||
WEB_TIMEOUT = 5.0
|
||||
|
||||
# A broker round trip, so it gets a deadline (rule 156). Generous relative to
|
||||
# `inspect`'s 2s elsewhere: this runs every 30s with retries, and a transient
|
||||
# blip flagging a worker unhealthy would roll back a deployment that is fine.
|
||||
PING_TIMEOUT = 10.0
|
||||
|
||||
CELERY_ROLES = {"worker", "scheduler", "ml-worker"}
|
||||
# Roles with nothing to probe. Listed rather than treated as the default, so
|
||||
# an unknown role takes the "I cannot tell" path and says so.
|
||||
NO_CHECK_ROLES = {"shell", "bash", "alembic"}
|
||||
|
||||
|
||||
def current_role() -> str | None:
|
||||
"""The role this container was started with, or None if nothing recorded."""
|
||||
env = os.environ.get("FC_ROLE")
|
||||
if env:
|
||||
return env.strip()
|
||||
try:
|
||||
with open(ROLE_FILE) as fh:
|
||||
return fh.read().strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _web_ok() -> tuple[bool, str]:
|
||||
try:
|
||||
with urllib.request.urlopen(WEB_URL, timeout=WEB_TIMEOUT) as resp:
|
||||
if resp.status == 200:
|
||||
return True, ""
|
||||
return False, f"web returned {resp.status}"
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
return False, f"web unreachable: {exc}"
|
||||
|
||||
|
||||
def _this_node_ok() -> tuple[bool, str]:
|
||||
"""Ping THIS container's celery node, by name.
|
||||
|
||||
Pinned to this node deliberately. A bare `ping()` is answered by any
|
||||
worker on the broker, so in a stack with several replicas a dead one
|
||||
would go on reporting healthy for as long as a sibling was alive — the
|
||||
healthcheck would be measuring the cluster, not the container it is in.
|
||||
"""
|
||||
from ..celery_app import celery as celery_app
|
||||
|
||||
node = f"{os.environ.get('CELERY_NODENAME', 'celery')}@{socket.gethostname()}"
|
||||
try:
|
||||
replies = celery_app.control.ping(destination=[node], timeout=PING_TIMEOUT)
|
||||
except Exception as exc: # noqa: BLE001 — a probe reports, never raises
|
||||
return False, f"could not reach the broker: {exc}"
|
||||
if not replies:
|
||||
return False, f"{node} did not answer a ping"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _lanes_ok() -> tuple[bool, str]:
|
||||
"""Every lane in the table is answering.
|
||||
|
||||
Deliberately ignores whether a lane is ON: a lane at cap 0 still runs its
|
||||
process with its consumers cancelled, so it answers `inspect` and is
|
||||
healthy. Health is "is the process alive"; whether it should be consuming
|
||||
is a settings question the sizing pass owns, and conflating them would
|
||||
make turning a lane off mark the container unhealthy.
|
||||
|
||||
That was not merely a risk — it was happening. Until 2026-09-23 a worker
|
||||
was attributed to its lane by the queues it was CONSUMING, and a lane with
|
||||
its consumers cancelled reports none, so it read as absent and this check
|
||||
failed. ML ships at cap 0, so a fresh install was permanently unhealthy
|
||||
and Swarm restarts an unhealthy task forever. The docstring above said the
|
||||
right thing while the code did the opposite; `worker_lanes.lane_for_node`
|
||||
is what makes it true.
|
||||
"""
|
||||
from ..services.worker_control import inspect_lanes_sync
|
||||
from ..services.worker_lanes import LANES
|
||||
|
||||
live = inspect_lanes_sync()
|
||||
missing = sorted(lane.name for lane in LANES if not live[lane.name].present)
|
||||
if missing:
|
||||
return False, "lanes not answering: " + ", ".join(missing)
|
||||
return True, ""
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
role = current_role()
|
||||
|
||||
if role is None:
|
||||
# Not a failure. See the module docstring: the entrypoint did not run,
|
||||
# so there is no role to check against and no basis for a verdict.
|
||||
print("no role recorded; nothing to check")
|
||||
return 0
|
||||
|
||||
if role in NO_CHECK_ROLES:
|
||||
print(f"{role}: nothing to check")
|
||||
return 0
|
||||
|
||||
checks = []
|
||||
if role == "all":
|
||||
checks = [_web_ok, _lanes_ok]
|
||||
elif role == "web":
|
||||
checks = [_web_ok]
|
||||
elif role in CELERY_ROLES:
|
||||
checks = [_this_node_ok]
|
||||
else:
|
||||
print(f"unknown role {role!r}; nothing to check")
|
||||
return 0
|
||||
|
||||
for check in checks:
|
||||
ok, detail = check()
|
||||
if not ok:
|
||||
print(detail, file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Restore FabledCurator from its off-site bucket, from a shell in the
|
||||
container. The Backup screen does the same; this is for an install whose page
|
||||
you can't reach yet, or for scripting a rebuild.
|
||||
|
||||
python -m backend.app.scripts.restore list
|
||||
python -m backend.app.scripts.restore database --dump latest --yes
|
||||
python -m backend.app.scripts.restore files
|
||||
|
||||
On a brand-new install with no destination saved, name it on the command
|
||||
line: --endpoint, --bucket and --access-key, with the secret key in the
|
||||
FC_BACKUP_SECRET_KEY environment variable (or typed at the prompt). It is
|
||||
checked by listing the bucket, then saved as this install's destination,
|
||||
sealed, exactly as the settings page saves it.
|
||||
|
||||
`database` replaces the database with the chosen dump here, then hands the
|
||||
files to the worker; their progress shows on the Backup screen. `files`
|
||||
fetches only what is missing from disk and leaves the database alone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ..models import ImportSettings
|
||||
from ..services import backup_destination, backup_restore
|
||||
from ..services.backup_s3 import S3Error
|
||||
from ..tasks._sync_engine import sync_session_factory
|
||||
|
||||
|
||||
def _human(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1000 or unit == "TB":
|
||||
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
||||
n /= 1000
|
||||
return str(n)
|
||||
|
||||
|
||||
def _destination(args) -> object:
|
||||
"""The target to restore from: the one named on the command line, saved
|
||||
first, or the one already saved."""
|
||||
session_factory = sync_session_factory()
|
||||
if args.endpoint or args.bucket or args.access_key:
|
||||
fields, why = backup_destination.clean_fields({
|
||||
"endpoint": args.endpoint, "bucket": args.bucket,
|
||||
"access_key": args.access_key, "region": args.region,
|
||||
})
|
||||
if fields is None:
|
||||
raise SystemExit(f"Destination not usable: {why}.")
|
||||
secret = os.environ.get("FC_BACKUP_SECRET_KEY") or getpass.getpass("Secret key: ")
|
||||
target = backup_destination.target_for(fields, secret.strip() or None)
|
||||
if target is None:
|
||||
raise SystemExit("Name the address, bucket, access key and secret key.")
|
||||
check = backup_destination.check_target(target)
|
||||
print(check["message"])
|
||||
if not check["ok"]:
|
||||
raise SystemExit(1)
|
||||
with session_factory() as session:
|
||||
settings = ImportSettings.load_sync(session)
|
||||
limit = settings.backup_upload_limit_mbps
|
||||
backup_restore.keep_destination(session, target, limit)
|
||||
print("Saved as this install's off-site destination.")
|
||||
return target
|
||||
with session_factory() as session:
|
||||
target = backup_destination.saved_target(ImportSettings.load_sync(session))
|
||||
if target is None:
|
||||
raise SystemExit("No off-site destination is saved; name one with "
|
||||
"--endpoint, --bucket and --access-key.")
|
||||
return target
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="python -m backend.app.scripts.restore",
|
||||
description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("action", choices=("list", "database", "files"))
|
||||
parser.add_argument("--dump", default="latest",
|
||||
help="the dump's key from `list`, or latest (the default)")
|
||||
parser.add_argument("--yes", action="store_true",
|
||||
help="confirm replacing this install's database")
|
||||
parser.add_argument("--endpoint")
|
||||
parser.add_argument("--bucket")
|
||||
parser.add_argument("--access-key")
|
||||
parser.add_argument("--region", default=None)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
target = _destination(args)
|
||||
if args.action == "list":
|
||||
for d in backup_restore.list_dumps(target):
|
||||
print(f"{d.key} {_human(d.size_bytes)}")
|
||||
return 0
|
||||
|
||||
from ..tasks.backup import restore_offsite_task
|
||||
if args.action == "files":
|
||||
restore_offsite_task.delay(files_only=True)
|
||||
print("Queued: the worker fetches every missing file; watch the Backup screen.")
|
||||
return 0
|
||||
|
||||
dump = backup_restore.find_dump(target, args.dump)
|
||||
if not args.yes:
|
||||
print(f"This replaces this install's database with {dump.key} "
|
||||
f"({_human(dump.size_bytes)}). Run again with --yes to go ahead.")
|
||||
return 2
|
||||
print(f"Restoring {dump.key}; this takes a while for a large library…")
|
||||
result = restore_offsite_task(dump_key=dump.key)
|
||||
if result.get("skipped"):
|
||||
print(f"Not started: {result['skipped']}.")
|
||||
return 1
|
||||
print("The database is restored. The worker is fetching the library's files; "
|
||||
"watch the Backup screen.")
|
||||
return 0
|
||||
except S3Error as exc:
|
||||
print(f"Couldn't restore: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Block until Postgres and Redis accept connections. Exit 0 ready, 1 timed out.
|
||||
|
||||
## Why the container has to do this itself
|
||||
|
||||
Compose has `depends_on: {condition: service_healthy}`, and **Swarm ignores
|
||||
it**. `docker stack deploy` has no ordering primitive at all: every service in
|
||||
the stack starts at once, so FabledCurator races Postgres on every cold
|
||||
deploy and always has.
|
||||
|
||||
The multi-service stack hid how sharp that is. `web` ran `alembic upgrade
|
||||
head`, failed against a Postgres that was still doing `initdb`, and the task
|
||||
died — but Swarm restarts a failed task forever, so the service came up a few
|
||||
seconds later and nobody saw a problem worth naming.
|
||||
|
||||
Consolidation removes that safety net. supervisord gives each program
|
||||
`startretries=3`, so a web program that fails three times in the first
|
||||
seconds goes FATAL and **stays** FATAL: supervisord keeps running, the
|
||||
container keeps running, and the application never starts. The healthcheck
|
||||
catches it — but as a container that is permanently unhealthy for a reason
|
||||
that has nothing to do with the image, on a stack whose database simply took
|
||||
twenty seconds to initialise.
|
||||
|
||||
Operator, 2026-09-23: *"it's a single container that need to connect
|
||||
successfully to redis and postgres before starting work shouldn't that simply
|
||||
be a check (with retries) at the start of the container."* Yes.
|
||||
|
||||
## A TCP connect, not a query
|
||||
|
||||
The same probe `build.yml`'s integration lane and the build smoke already use.
|
||||
It answers the question that is actually being asked — is something listening
|
||||
— and it cannot fail for a reason that retrying will never fix.
|
||||
|
||||
A real query would be a stronger readiness signal and a worse gate: a wrong
|
||||
password or a missing database is not a transient condition, and a loop that
|
||||
waits for one to heal turns a five-second misconfiguration into a two-minute
|
||||
timeout with a misleading message. Those belong to alembic, which runs
|
||||
seconds later and says exactly what is wrong.
|
||||
|
||||
The Postgres image is well behaved here: during `initdb` it serves on a unix
|
||||
socket only and opens TCP when it is ready for clients, so the connect is a
|
||||
good proxy for "ready" rather than merely "process exists".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Long enough for a first-ever `initdb` on a slow disk, which is the worst
|
||||
# case this exists for and is measured in tens of seconds, not minutes. A
|
||||
# deploy that is genuinely misconfigured should fail while someone is still
|
||||
# watching it rather than hold the container open for a quarter of an hour.
|
||||
DEFAULT_TIMEOUT = 120.0
|
||||
CONNECT_TIMEOUT = 2.0
|
||||
RETRY_DELAY = 1.0
|
||||
# Progress every N attempts. `docker logs` on a container that is waiting must
|
||||
# say what it is waiting for — silence is indistinguishable from a hang.
|
||||
REPORT_EVERY = 5
|
||||
|
||||
|
||||
def _target(url: str | None, default_port: int) -> tuple[str, int] | None:
|
||||
"""(host, port) from a connection URL, or None if there is nothing to wait for."""
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
return parsed.hostname, parsed.port or default_port
|
||||
|
||||
|
||||
def targets() -> list[tuple[str, tuple[str, int]]]:
|
||||
"""What this container must reach, read from the same env the app reads.
|
||||
|
||||
Derived rather than passed in, so the wait cannot drift from what the
|
||||
application will actually connect to — a gate that checks a different
|
||||
host than the app uses is worse than no gate.
|
||||
"""
|
||||
out: list[tuple[str, tuple[str, int]]] = []
|
||||
|
||||
host = os.environ.get("DB_HOST")
|
||||
if host:
|
||||
out.append(("postgres", (host, int(os.environ.get("DB_PORT") or 5432))))
|
||||
|
||||
broker = _target(os.environ.get("CELERY_BROKER_URL"), 6379)
|
||||
if broker:
|
||||
out.append(("redis", broker))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _accepts(host: str, port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=CONNECT_TIMEOUT):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def wait(
|
||||
name: str, host: str, port: int, deadline: float, now=time.monotonic,
|
||||
) -> bool:
|
||||
attempt = 0
|
||||
while True:
|
||||
if _accepts(host, port):
|
||||
print(f"[wait] {name} at {host}:{port} is accepting connections")
|
||||
return True
|
||||
attempt += 1
|
||||
if now() >= deadline:
|
||||
print(
|
||||
f"[wait] TIMEOUT: {name} at {host}:{port} never accepted a "
|
||||
f"connection ({attempt} attempts)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if attempt % REPORT_EVERY == 0:
|
||||
left = int(deadline - now())
|
||||
print(f"[wait] {name} at {host}:{port} not ready yet, {left}s left")
|
||||
time.sleep(RETRY_DELAY)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Wait for Postgres and Redis.")
|
||||
ap.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
wanted = targets()
|
||||
if not wanted:
|
||||
# Nothing configured to wait for. Not an error: `shell` and one-off
|
||||
# runs are legitimate, and refusing to start would make this gate the
|
||||
# reason a debugging container will not boot.
|
||||
print("[wait] no database or broker configured; nothing to wait for")
|
||||
return 0
|
||||
|
||||
deadline = time.monotonic() + args.timeout
|
||||
for name, (host, port) in wanted:
|
||||
if not wait(name, host, port, deadline):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -57,6 +57,50 @@ def is_archive(path: Path) -> bool:
|
||||
return detect_archive_format(path) is not None
|
||||
|
||||
|
||||
# Files that only a program ships: executables and libraries, and the packed
|
||||
# data and compiled scripts of the common game engines (Ren'Py, Unity, Godot,
|
||||
# RPG Maker, Python builds). An archive holding any of them is a game or app
|
||||
# build. Its images are sprites, UI and CGs bundled for the program, not art
|
||||
# for the library, so the archive is kept whole as an attachment.
|
||||
SOFTWARE_EXTS = frozenset({
|
||||
".exe", ".msi", ".dll", ".so", ".dylib", ".apk",
|
||||
".rpa", ".rpyc", ".rpy", ".pyc", ".pyo",
|
||||
".pak", ".pck", ".unity3d", ".assets", ".ress",
|
||||
".rgssad", ".rgss2a", ".rgss3a", ".rxdata", ".rvdata", ".rvdata2",
|
||||
})
|
||||
|
||||
|
||||
def member_names(path: Path) -> list[str]:
|
||||
"""Every member name in the archive, read from its directory without
|
||||
decompressing anything. Empty when the archive can't be listed."""
|
||||
try:
|
||||
fmt = detect_archive_format(path)
|
||||
if fmt == "zip":
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
return zf.namelist()
|
||||
if fmt == "rar":
|
||||
import rarfile
|
||||
|
||||
with rarfile.RarFile(path) as rf:
|
||||
return rf.namelist()
|
||||
if fmt == "7z":
|
||||
import py7zr
|
||||
|
||||
with py7zr.SevenZipFile(path, "r") as zf:
|
||||
return zf.getnames()
|
||||
except Exception as exc:
|
||||
log.warning("archive listing failed for %s: %s", path, exc)
|
||||
return []
|
||||
|
||||
|
||||
def software_marker(path: Path) -> str | None:
|
||||
"""The first member that marks the archive as a software build, or None."""
|
||||
for name in member_names(path):
|
||||
if Path(name).suffix.lower() in SOFTWARE_EXTS:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def extract_archive(path: Path):
|
||||
"""Context manager yielding a list of (member_name, extracted_path)
|
||||
|
||||
@@ -316,17 +316,32 @@ class ArtistService:
|
||||
cleaned = (prefix or "").strip()
|
||||
if not cleaned:
|
||||
return []
|
||||
like = f"%{cleaned.lower()}%"
|
||||
prefix_like = f"{cleaned.lower()}%"
|
||||
# Rank: exact (0) < prefix (1) < substring (2).
|
||||
low = cleaned.lower()
|
||||
like = f"%{low}%"
|
||||
prefix_like = f"{low}%"
|
||||
# Spacing- and punctuation-insensitive too, so "Tamada Heijun" finds
|
||||
# "TamadaHeijun" and "sabu_art" finds "Sabu Art" — the same creator is
|
||||
# spelled differently on every platform, and the browser extension's
|
||||
# Add panel matches a Discord server name against these (milestone 429).
|
||||
# [[:alnum:]] keeps non-Latin letters; a query with none (all
|
||||
# punctuation) skips this arm rather than matching every artist.
|
||||
squashed = "".join(ch for ch in low if ch.isalnum())
|
||||
name_squashed = func.regexp_replace(func.lower(Artist.name), "[^[:alnum:]]", "", "g")
|
||||
matches = [func.lower(Artist.name).like(like)]
|
||||
if squashed:
|
||||
matches.append(name_squashed.like(f"%{squashed}%"))
|
||||
# Rank: exact (0) < exact ignoring spacing (1) < prefix (2) <
|
||||
# substring (3) < substring ignoring spacing (4).
|
||||
rank = case(
|
||||
(func.lower(Artist.name) == cleaned.lower(), 0),
|
||||
(func.lower(Artist.name).like(prefix_like), 1),
|
||||
else_=2,
|
||||
(func.lower(Artist.name) == low, 0),
|
||||
(name_squashed == squashed, 1),
|
||||
(func.lower(Artist.name).like(prefix_like), 2),
|
||||
(func.lower(Artist.name).like(like), 3),
|
||||
else_=4,
|
||||
).label("rank")
|
||||
rows = (await self.session.execute(
|
||||
select(Artist, rank)
|
||||
.where(func.lower(Artist.name).like(like))
|
||||
.where(or_(*matches))
|
||||
.order_by(rank, Artist.name.asc())
|
||||
.limit(limit)
|
||||
)).all()
|
||||
|
||||
@@ -4,11 +4,19 @@ predicate for BOTH surfaces: FC-Cleanup's retroactive audit and — since
|
||||
2026-07-02 — the import-side filter (Importer._single_color_hit /
|
||||
SkipReason.single_color), so what the audit flags and what the import
|
||||
skips can never disagree.
|
||||
|
||||
It is meant to catch the blank: a placeholder, an error tile, a solid fill.
|
||||
Line art is the case it must not catch (#4483): a pencil doodle on white can be
|
||||
well over 95% white even at full size, and a smoothing downsample blends its strokes
|
||||
into the paper until it measures as blank. So the sample is taken by NEAREST
|
||||
(each sampled pixel is a real pixel, strokes keep their contrast), at 256px
|
||||
so thin strokes are still hit, and the default threshold is near-total
|
||||
(0.995) — a few percent of ink is a drawing, not an empty image.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
_THUMB_SIZE = (64, 64)
|
||||
_THUMB_SIZE = (256, 256)
|
||||
|
||||
|
||||
def evaluate(
|
||||
@@ -20,7 +28,8 @@ def evaluate(
|
||||
"""True iff the fraction of pixels within `tolerance` (Euclidean RGB
|
||||
distance) of the dominant color exceeds `threshold`.
|
||||
|
||||
Downsamples to 64x64 for speed (~4ms regardless of source size).
|
||||
Samples 256x256 by NEAREST (see the module docstring for why not a
|
||||
smoothing resample).
|
||||
Alpha channels are stripped; only RGB is considered. Animated images
|
||||
use frame 0 (PIL's default after Image.open without seek).
|
||||
"""
|
||||
@@ -30,7 +39,7 @@ def evaluate(
|
||||
elif im.mode not in ("RGB", "L"):
|
||||
im = im.convert("RGB")
|
||||
if im.size != _THUMB_SIZE:
|
||||
im = im.resize(_THUMB_SIZE, Image.Resampling.BILINEAR)
|
||||
im = im.resize(_THUMB_SIZE, Image.Resampling.NEAREST)
|
||||
pixels = list(im.getdata())
|
||||
if not pixels:
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""The off-site backup destination: an S3 bucket (Fabled Archives, or any S3
|
||||
service) that the database dumps and the media blobs are sent to.
|
||||
|
||||
The address, bucket, access key and region are plain settings. The secret key
|
||||
is sealed at rest with the credential key (`/images/secrets`), the same key
|
||||
that seals account logins, and no API ever returns it: the settings answer
|
||||
only whether one is set. Because the credential key is kept out of every
|
||||
backup, a database dump carries the secret only as ciphertext nobody can open
|
||||
without the original server.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .backup_s3 import S3Error, S3Target, list_objects
|
||||
from .credential_crypto import CredentialCrypto, InvalidCredentialBlob
|
||||
|
||||
KEY_PATH = Path("/images/secrets/credential_key.b64")
|
||||
DEFAULT_REGION = "us-east-1"
|
||||
_TEST_LISTING = 5
|
||||
|
||||
|
||||
def _crypto() -> CredentialCrypto:
|
||||
return CredentialCrypto(KEY_PATH)
|
||||
|
||||
|
||||
def seal(secret: str) -> str:
|
||||
return _crypto().encrypt(secret).decode("ascii")
|
||||
|
||||
|
||||
def unseal(sealed: str) -> str:
|
||||
return _crypto().decrypt(sealed.encode("ascii"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DestinationFields:
|
||||
endpoint: str | None
|
||||
bucket: str | None
|
||||
access_key: str | None
|
||||
region: str
|
||||
|
||||
|
||||
def clean_fields(body: dict) -> tuple[DestinationFields | None, str | None]:
|
||||
"""The destination fields from a request body, trimmed, with empty ones as
|
||||
None; or (None, why) when the address can't be an S3 endpoint."""
|
||||
def text(name: str) -> str | None:
|
||||
v = body.get(name)
|
||||
if v is None:
|
||||
return None
|
||||
if not isinstance(v, str):
|
||||
raise TypeError(name)
|
||||
return v.strip() or None
|
||||
|
||||
try:
|
||||
endpoint = text("endpoint")
|
||||
bucket = text("bucket")
|
||||
access_key = text("access_key")
|
||||
region = text("region") or DEFAULT_REGION
|
||||
except TypeError as exc:
|
||||
return None, f"{exc.args[0]} must be text"
|
||||
if endpoint is not None:
|
||||
u = urlsplit(endpoint)
|
||||
if u.scheme not in ("http", "https") or not u.hostname:
|
||||
return None, ("the address must start with http:// or https:// and name "
|
||||
"a host, e.g. http://archives.example:8081")
|
||||
if u.query or u.fragment:
|
||||
return None, "the address can't carry a query or a fragment"
|
||||
endpoint = endpoint.rstrip("/")
|
||||
if bucket is not None and "/" in bucket:
|
||||
return None, "the bucket is a name, not a path: put any path in the address"
|
||||
return DestinationFields(endpoint, bucket, access_key, region), None
|
||||
|
||||
|
||||
def target_for(fields: DestinationFields, secret: str | None) -> S3Target | None:
|
||||
"""The S3 target, or None while any part of it is missing."""
|
||||
if not (fields.endpoint and fields.bucket and fields.access_key and secret):
|
||||
return None
|
||||
return S3Target(fields.endpoint, fields.bucket, fields.access_key, secret,
|
||||
region=fields.region)
|
||||
|
||||
|
||||
def saved_target(settings) -> S3Target | None:
|
||||
"""The destination saved in ImportSettings, or None when none is set up.
|
||||
Raises S3Error when the secret can't be opened (the credential key
|
||||
changed since it was saved)."""
|
||||
sealed = settings.backup_s3_secret_sealed
|
||||
if not sealed:
|
||||
return None
|
||||
try:
|
||||
secret = unseal(sealed)
|
||||
except InvalidCredentialBlob:
|
||||
raise S3Error(
|
||||
"The saved secret key can't be opened on this server (the credential "
|
||||
"key changed since it was saved). Enter the secret key again."
|
||||
) from None
|
||||
return target_for(fields_of(settings), secret)
|
||||
|
||||
|
||||
def fields_of(settings) -> DestinationFields:
|
||||
return DestinationFields(
|
||||
settings.backup_s3_endpoint, settings.backup_s3_bucket,
|
||||
settings.backup_s3_access_key, settings.backup_s3_region or DEFAULT_REGION,
|
||||
)
|
||||
|
||||
|
||||
def describe(settings) -> dict:
|
||||
"""The destination as the settings screen shows it: never the secret."""
|
||||
f = fields_of(settings)
|
||||
return {
|
||||
"endpoint": f.endpoint or "",
|
||||
"bucket": f.bucket or "",
|
||||
"access_key": f.access_key or "",
|
||||
"region": f.region,
|
||||
"secret_set": bool(settings.backup_s3_secret_sealed),
|
||||
"upload_limit_mbps": settings.backup_upload_limit_mbps,
|
||||
"configured": bool(
|
||||
f.endpoint and f.bucket and f.access_key and settings.backup_s3_secret_sealed
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def check_target(target: S3Target) -> dict:
|
||||
"""Reach the bucket by listing it. A listing proves the address, the
|
||||
bucket and both keys at once, and writes nothing, because an Archives key
|
||||
is allowed to add objects but a test object could never be removed."""
|
||||
try:
|
||||
page = list_objects(target, limit=_TEST_LISTING)
|
||||
except S3Error as exc:
|
||||
return {"ok": False, "message": str(exc)}
|
||||
if not page.objects:
|
||||
said = f"Reached {target.bucket} at {target.endpoint}. The bucket is empty."
|
||||
else:
|
||||
more = "+" if page.next_token else ""
|
||||
said = (f"Reached {target.bucket} at {target.endpoint}. It holds "
|
||||
f"{len(page.objects)}{more} object(s), e.g. {page.objects[0].key}.")
|
||||
return {"ok": True, "message": said}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Are the backups keeping up? One row on the System view, and the nav dot.
|
||||
|
||||
The backups run on their own at night, so the only way to notice one has
|
||||
stopped is to be told. The System view's health endpoint asks `backup_part`,
|
||||
which answers with a part like any other: `ok`, or `stale` with the sentence
|
||||
that says what is wrong. It warns when
|
||||
|
||||
- the newest database backup is more than a day old, or there is none;
|
||||
- the newest database backup failed;
|
||||
- the weekly check of the off-site bucket found files missing from it, and no
|
||||
media run has finished since to send them again.
|
||||
|
||||
No part at all when backups aren't set up (no off-site destination and the
|
||||
nightly backup off): someone who chose not to back up is not told so every
|
||||
fifteen seconds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..models import BackupRun, ImportSettings
|
||||
from . import backup_destination
|
||||
|
||||
# A nightly backup is due every 24 hours. The hour of slack covers the dump
|
||||
# itself and a beat tick that lands a little late.
|
||||
DUMP_DUE_SECONDS = 25 * 3600
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupFacts:
|
||||
expected: bool
|
||||
offsite: bool
|
||||
last_dump_at: datetime | None = None
|
||||
last_dump_error: str | None = None
|
||||
missing: int = 0
|
||||
|
||||
|
||||
def _age(seconds: float) -> str:
|
||||
hours = int(seconds // 3600)
|
||||
if hours >= 48:
|
||||
return f"{hours // 24} days"
|
||||
if hours >= 1:
|
||||
return f"{hours} h"
|
||||
return f"{max(1, int(seconds // 60))} min"
|
||||
|
||||
|
||||
def verdict(facts: BackupFacts, now: datetime) -> dict | None:
|
||||
"""The part for these facts, or None when backups aren't set up."""
|
||||
if not facts.expected:
|
||||
return None
|
||||
where = "to the off-site bucket" if facts.offsite else "on this server"
|
||||
problems = []
|
||||
if facts.last_dump_error:
|
||||
problems.append(f"The last database backup failed: {facts.last_dump_error}")
|
||||
if facts.last_dump_at is None:
|
||||
if not facts.last_dump_error:
|
||||
problems.append(f"No database backup has been taken {where} yet")
|
||||
else:
|
||||
age = (now - facts.last_dump_at).total_seconds()
|
||||
if age >= DUMP_DUE_SECONDS:
|
||||
problems.append(f"The newest database backup {where} is {_age(age)} old")
|
||||
if facts.missing:
|
||||
problems.append(
|
||||
f"The bucket check found {facts.missing:,} file(s) missing from the bucket; "
|
||||
"the next media run sends them again"
|
||||
)
|
||||
part = {"key": "backup", "kind": "backup", "name": "Backups"}
|
||||
if problems:
|
||||
return {**part, "state": "stale", "detail": ". ".join(problems)}
|
||||
age = (now - facts.last_dump_at).total_seconds()
|
||||
return {**part, "state": "ok",
|
||||
"detail": f"The newest database backup {where} is {_age(age)} old"}
|
||||
|
||||
|
||||
def gather(session) -> BackupFacts:
|
||||
"""Read the facts `verdict` judges, from a sync session."""
|
||||
settings = ImportSettings.load_sync(session)
|
||||
offsite = backup_destination.describe(settings)["configured"]
|
||||
expected = offsite or bool(settings.backup_db_nightly_enabled)
|
||||
if not expected:
|
||||
return BackupFacts(expected=False, offsite=False)
|
||||
|
||||
def newest(kind: str, statuses: tuple[str, ...]):
|
||||
return session.execute(
|
||||
select(BackupRun).where(BackupRun.kind == kind)
|
||||
.where(BackupRun.status.in_(statuses))
|
||||
.order_by(BackupRun.started_at.desc()).limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
last_ok = newest("db", ("ok",))
|
||||
last_done = newest("db", ("ok", "error"))
|
||||
failed = None
|
||||
if last_done is not None and last_done.status == "error":
|
||||
failed = last_done.error or "no reason recorded"
|
||||
|
||||
missing = 0
|
||||
if offsite:
|
||||
check = newest("reconcile", ("ok",))
|
||||
dropped = int((check.manifest or {}).get("dropped") or 0) if check else 0
|
||||
if dropped:
|
||||
sent_since = session.execute(
|
||||
select(BackupRun.id).where(BackupRun.kind == "media")
|
||||
.where(BackupRun.status == "ok")
|
||||
.where(BackupRun.finished_at > check.finished_at).limit(1)
|
||||
).scalar_one_or_none()
|
||||
missing = 0 if sent_since is not None else dropped
|
||||
|
||||
return BackupFacts(
|
||||
expected=True, offsite=offsite,
|
||||
last_dump_at=(last_ok.finished_at or last_ok.started_at) if last_ok else None,
|
||||
last_dump_error=failed, missing=missing,
|
||||
)
|
||||
|
||||
|
||||
def backup_part(session, now: datetime) -> dict | None:
|
||||
return verdict(gather(session), now)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""The off-site copy of the library's files: every image and attachment, sent
|
||||
once each to the backup bucket as a content-addressed object.
|
||||
|
||||
Layout in the bucket, beside the database dumps under `database/`:
|
||||
|
||||
media/ab/cd/<sha256><ext>
|
||||
|
||||
`ab` and `cd` are the first four hex digits of the file's sha256, which keeps
|
||||
any one prefix small, and the extension keeps the files readable in the bucket
|
||||
without FabledCurator. The sha256 is the one recorded at import, so a file
|
||||
uploads exactly once however many rows or posts point at it, and a run is
|
||||
incremental: its work list is every sha with no `backup_blob` row for the
|
||||
current destination. A file removed from the library stays in the bucket until
|
||||
the bucket's own retention removes it; FabledCurator never deletes there.
|
||||
|
||||
The bucket checks every object against that recorded sha256, so a file whose
|
||||
bytes changed on disk since import is refused and reported, never stored under
|
||||
the wrong name.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from ..models import BackupBlob
|
||||
from .backup_s3 import (
|
||||
Pacer,
|
||||
S3Error,
|
||||
S3Target,
|
||||
list_objects,
|
||||
object_present,
|
||||
put_file,
|
||||
put_file_in_parts,
|
||||
)
|
||||
|
||||
PREFIX = "media/"
|
||||
# Files above this size go in parts (S3 refuses a single PUT over 5 GiB, and a
|
||||
# part that fails is resent alone instead of the whole file).
|
||||
MULTIPART_OVER = 1 << 30
|
||||
# The bucket is listed this many keys at a time during a reconcile, with a
|
||||
# short rest between pages.
|
||||
LIST_PAGE = 1000
|
||||
LIST_REST_SECONDS = 0.2
|
||||
_EXT = re.compile(r"^\.[a-z0-9]{1,10}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Blob:
|
||||
sha256: str
|
||||
path: str
|
||||
size_bytes: int
|
||||
mime: str | None
|
||||
|
||||
|
||||
def destination_id(endpoint: str, bucket: str) -> str:
|
||||
"""How backup_blob names a destination: one copy per endpoint and bucket."""
|
||||
return f"{endpoint.rstrip('/')}/{bucket}"
|
||||
|
||||
|
||||
def target_id(target: S3Target) -> str:
|
||||
return destination_id(target.endpoint, target.bucket)
|
||||
|
||||
|
||||
def blob_key(sha256: str, path: str) -> str:
|
||||
"""The bucket key for a file: `media/ab/cd/<sha256><ext>`. An extension
|
||||
that isn't short and plain is left off, so a key is always safe."""
|
||||
ext = Path(path).suffix.lower()
|
||||
return f"{PREFIX}{sha256[:2]}/{sha256[2:4]}/{sha256}{ext if _EXT.match(ext) else ''}"
|
||||
|
||||
|
||||
# Every library file once per sha256: images and attachments together. An
|
||||
# attachment's sha can repeat across posts, and an image extracted from an
|
||||
# archive is its own file with its own sha.
|
||||
_LIBRARY = """
|
||||
SELECT sha256, path, size_bytes, mime FROM image_record
|
||||
UNION ALL
|
||||
SELECT sha256, path, size_bytes, mime FROM post_attachment
|
||||
"""
|
||||
|
||||
|
||||
def pending(session, tid: str, *, after: str = "", limit: int = 500) -> list[Blob]:
|
||||
"""The next `limit` files not yet in the bucket, in sha order after
|
||||
`after`, so a run walks the library once even when some files fail."""
|
||||
rows = session.execute(text(f"""
|
||||
SELECT DISTINCT ON (lib.sha256) lib.sha256, lib.path, lib.size_bytes, lib.mime
|
||||
FROM ({_LIBRARY}) AS lib
|
||||
WHERE lib.sha256 > :after
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM backup_blob b WHERE b.target = :tid AND b.sha256 = lib.sha256
|
||||
)
|
||||
ORDER BY lib.sha256, lib.path
|
||||
LIMIT :limit
|
||||
"""), {"after": after, "tid": tid, "limit": limit}).all()
|
||||
return [Blob(r.sha256, r.path, r.size_bytes, r.mime) for r in rows]
|
||||
|
||||
|
||||
def coverage(session, tid: str | None) -> dict:
|
||||
"""How much of the library is in the bucket: files and bytes, sent and
|
||||
total, each sha counted once."""
|
||||
row = session.execute(text(f"""
|
||||
WITH lib AS (
|
||||
SELECT DISTINCT ON (sha256) sha256, size_bytes FROM ({_LIBRARY}) AS u
|
||||
ORDER BY sha256
|
||||
)
|
||||
SELECT count(*) AS files_total,
|
||||
coalesce(sum(lib.size_bytes), 0) AS bytes_total,
|
||||
count(b.sha256) AS files_sent,
|
||||
coalesce(sum(lib.size_bytes) FILTER (WHERE b.sha256 IS NOT NULL), 0)
|
||||
AS bytes_sent
|
||||
FROM lib
|
||||
LEFT JOIN backup_blob b ON b.sha256 = lib.sha256 AND b.target = :tid
|
||||
"""), {"tid": tid or ""}).one()
|
||||
return {
|
||||
"files_total": row.files_total, "bytes_total": int(row.bytes_total),
|
||||
"files_sent": row.files_sent, "bytes_sent": int(row.bytes_sent),
|
||||
}
|
||||
|
||||
|
||||
def send(target: S3Target, blob: Blob, images_root: Path, pacer: Pacer | None) -> str:
|
||||
"""Upload one file to its key and return the key. Raises S3Error (or
|
||||
OSError) with the reason when it can't be sent."""
|
||||
path = Path(blob.path)
|
||||
if not path.is_absolute():
|
||||
path = images_root / path
|
||||
if not path.is_file():
|
||||
raise S3Error(f"{path} is not on disk.")
|
||||
size = path.stat().st_size
|
||||
if size != blob.size_bytes:
|
||||
raise S3Error(f"{path} is {size} bytes on disk but {blob.size_bytes} when imported.")
|
||||
key = blob_key(blob.sha256, blob.path)
|
||||
content_type = blob.mime or "application/octet-stream"
|
||||
try:
|
||||
if size > MULTIPART_OVER:
|
||||
put_file_in_parts(target, key, path, sha256=blob.sha256,
|
||||
content_type=content_type, pacer=pacer)
|
||||
else:
|
||||
put_file(target, key, path, content_type=content_type,
|
||||
payload_sha256=blob.sha256, pacer=pacer)
|
||||
except S3Error:
|
||||
if object_present(target, key, size):
|
||||
return key # already there from an earlier run
|
||||
raise
|
||||
return key
|
||||
|
||||
|
||||
def record(session, tid: str, blobs: list[tuple[Blob, str]]) -> None:
|
||||
"""Note files as sent: (blob, key) pairs. A row already there is kept."""
|
||||
if blobs:
|
||||
session.execute(insert(BackupBlob).values([
|
||||
{"target": tid, "sha256": b.sha256, "key": key, "size_bytes": b.size_bytes}
|
||||
for b, key in blobs
|
||||
]).on_conflict_do_nothing())
|
||||
|
||||
|
||||
def reconcile(session, target: S3Target) -> dict:
|
||||
"""Check backup_blob against what the bucket actually holds under
|
||||
`media/`. A row whose object is missing or the wrong size is dropped, so
|
||||
the next run sends that file again; an object the bucket holds for a
|
||||
library file with no row (a restored database, a lost commit) is recorded,
|
||||
so it isn't sent twice. Returns the counts."""
|
||||
tid = target_id(target)
|
||||
held: dict[str, int] = {}
|
||||
token = None
|
||||
while True:
|
||||
page = list_objects(target, PREFIX, LIST_PAGE, continuation=token)
|
||||
held.update((o.key, o.size) for o in page.objects)
|
||||
token = page.next_token
|
||||
if not token:
|
||||
break
|
||||
time.sleep(LIST_REST_SECONDS)
|
||||
|
||||
rows = session.execute(
|
||||
select(BackupBlob.sha256, BackupBlob.key, BackupBlob.size_bytes)
|
||||
.where(BackupBlob.target == tid)
|
||||
).all()
|
||||
gone = [r.sha256 for r in rows if held.get(r.key) != r.size_bytes]
|
||||
for at in range(0, len(gone), 1000):
|
||||
session.execute(delete(BackupBlob).where(
|
||||
BackupBlob.target == tid, BackupBlob.sha256.in_(gone[at:at + 1000]),
|
||||
))
|
||||
kept = {r.sha256 for r in rows} - set(gone)
|
||||
|
||||
found = []
|
||||
for r in session.execute(text(f"""
|
||||
SELECT DISTINCT ON (sha256) sha256, path, size_bytes, mime
|
||||
FROM ({_LIBRARY}) AS lib ORDER BY sha256, path
|
||||
""")).all():
|
||||
if r.sha256 in kept:
|
||||
continue
|
||||
blob = Blob(r.sha256, r.path, r.size_bytes, r.mime)
|
||||
key = blob_key(blob.sha256, blob.path)
|
||||
if held.get(key) == blob.size_bytes:
|
||||
found.append((blob, key))
|
||||
for at in range(0, len(found), 1000):
|
||||
record(session, tid, found[at:at + 1000])
|
||||
session.commit()
|
||||
return {"listed": len(held), "dropped": len(gone), "adopted": len(found)}
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Restoring from the off-site bucket: the database from one of its dated
|
||||
dumps, then every library file the restored rows name, fetched back to the
|
||||
path each row records.
|
||||
|
||||
The database comes first because it is the map: each image and attachment row
|
||||
holds its file's path and sha256, which is also the file's name in the bucket
|
||||
(`media/ab/cd/<sha256><ext>`). A file is checked against that sha256 on
|
||||
arrival and only then moved into place, so a restore never leaves a wrong or
|
||||
half-written file in the library. A file already on disk at the right size is
|
||||
left alone, which makes a restore safe to run again and lets it bring back
|
||||
only what a damaged library is missing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from ..models import BackupBlob, ImportSettings
|
||||
from . import backup_destination
|
||||
from .backup_media import _LIBRARY, blob_key
|
||||
from .backup_s3 import Pacer, S3Error, S3Target, get_file, list_objects
|
||||
|
||||
DUMP_PREFIX = "database/"
|
||||
_DUMP_NAME = re.compile(r"^database/fabledcurator-(\d{8}T\d{6}Z)\.dump$")
|
||||
_ALEMBIC_INI = Path(__file__).resolve().parents[3] / "alembic.ini"
|
||||
_MIGRATE_TIMEOUT_S = 1800
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dump:
|
||||
key: str
|
||||
ts: str # 20261107T134258Z, UTC
|
||||
size_bytes: int
|
||||
counted: bool = False # its row counts sit beside it, for a test restore
|
||||
|
||||
|
||||
def list_dumps(target: S3Target) -> list[Dump]:
|
||||
"""Every database dump in the bucket, newest first."""
|
||||
found, keys, token = [], set(), None
|
||||
while True:
|
||||
page = list_objects(target, DUMP_PREFIX, 1000, continuation=token)
|
||||
for o in page.objects:
|
||||
keys.add(o.key)
|
||||
if m := _DUMP_NAME.match(o.key):
|
||||
found.append((o.key, m.group(1), o.size))
|
||||
token = page.next_token
|
||||
if not token:
|
||||
break
|
||||
dumps = [Dump(k, ts, size, counted=k.removesuffix(".dump") + ".counts.json" in keys)
|
||||
for k, ts, size in found]
|
||||
return sorted(dumps, key=lambda d: d.ts, reverse=True)
|
||||
|
||||
|
||||
def find_dump(target: S3Target, key: str | None) -> Dump:
|
||||
"""The dump at `key`, or the newest when `key` is None or "latest"."""
|
||||
dumps = list_dumps(target)
|
||||
if not dumps:
|
||||
raise S3Error(f"The {target.bucket} bucket holds no database dumps.")
|
||||
if key in (None, "", "latest"):
|
||||
return dumps[0]
|
||||
for d in dumps:
|
||||
if d.key == key:
|
||||
return d
|
||||
raise S3Error(f"{key} is not among the dumps in the {target.bucket} bucket.")
|
||||
|
||||
|
||||
def fetch_dump(target: S3Target, dump: Dump, into: Path, pacer: Pacer | None) -> Path:
|
||||
"""Download a dump into the folder `into`, checked against its listed
|
||||
size. pg_restore reads it from there."""
|
||||
into.mkdir(parents=True, exist_ok=True)
|
||||
path = into / Path(dump.key).name
|
||||
get_file(target, dump.key, path, size=dump.size_bytes, pacer=pacer)
|
||||
return path
|
||||
|
||||
|
||||
def migrate(db_name: str | None = None) -> None:
|
||||
"""Bring a restored database up to this install's schema. A dump taken by
|
||||
an older FabledCurator gains the migrations it predates; one from a newer
|
||||
FabledCurator fails here, with alembic's reason, because this install
|
||||
doesn't know its schema. `db_name` names another database on the same
|
||||
server to bring up instead of this install's own."""
|
||||
env = {**os.environ, "DB_NAME": db_name} if db_name else None
|
||||
with tempfile.TemporaryFile() as err:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "-c", str(_ALEMBIC_INI), "upgrade", "head"],
|
||||
cwd=_ALEMBIC_INI.parent, stdout=subprocess.DEVNULL, stderr=err,
|
||||
timeout=_MIGRATE_TIMEOUT_S, env=env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err.seek(0)
|
||||
lines = err.read().decode(errors="replace").strip().splitlines()
|
||||
raise S3Error("The restored database couldn't be brought up to this "
|
||||
f"version: {lines[-1] if lines else 'alembic failed'}")
|
||||
|
||||
|
||||
def keep_destination(session, target: S3Target, limit_mbps: int | None) -> None:
|
||||
"""Write this install's destination into the restored settings. The
|
||||
dump's own copy of the secret is sealed with the old server's credential
|
||||
key, which never leaves that server, so it can't be opened here."""
|
||||
settings = ImportSettings.load_sync(session)
|
||||
settings.backup_s3_endpoint = target.endpoint
|
||||
settings.backup_s3_bucket = target.bucket
|
||||
settings.backup_s3_access_key = target.access_key
|
||||
settings.backup_s3_region = target.region
|
||||
settings.backup_s3_secret_sealed = backup_destination.seal(target.secret_key)
|
||||
settings.backup_upload_limit_mbps = limit_mbps
|
||||
session.commit()
|
||||
|
||||
|
||||
# ── files ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LibraryFile:
|
||||
sha256: str
|
||||
paths: tuple[str, ...] # every row's path for this sha, sorted
|
||||
size_bytes: int
|
||||
|
||||
|
||||
def library_files(session, *, after: str = "", limit: int = 200) -> list[LibraryFile]:
|
||||
"""The next `limit` distinct files the library names, in sha order after
|
||||
`after`, each with every path a row gives it."""
|
||||
rows = session.execute(text(f"""
|
||||
SELECT lib.sha256, array_agg(DISTINCT lib.path ORDER BY lib.path) AS paths,
|
||||
max(lib.size_bytes) AS size_bytes
|
||||
FROM ({_LIBRARY}) AS lib
|
||||
WHERE lib.sha256 > :after
|
||||
GROUP BY lib.sha256
|
||||
ORDER BY lib.sha256
|
||||
LIMIT :limit
|
||||
"""), {"after": after, "limit": limit}).all()
|
||||
return [LibraryFile(r.sha256, tuple(r.paths), r.size_bytes) for r in rows]
|
||||
|
||||
|
||||
def sent_keys(session, tid: str, shas: list[str]) -> dict[str, str]:
|
||||
"""The bucket key recorded for each sha that was sent to this destination."""
|
||||
if not shas:
|
||||
return {}
|
||||
return dict(session.execute(
|
||||
select(BackupBlob.sha256, BackupBlob.key)
|
||||
.where(BackupBlob.target == tid, BackupBlob.sha256.in_(shas))
|
||||
).all())
|
||||
|
||||
|
||||
def _resolve(path: str, images_root: Path) -> Path:
|
||||
"""Where a row's path lands on this server. A path outside the library
|
||||
folder is refused: a restore writes only inside it."""
|
||||
p = Path(path)
|
||||
if not p.is_absolute():
|
||||
p = images_root / p
|
||||
root = Path(*images_root.parts)
|
||||
if ".." in p.parts or not (p == root or root in p.parents):
|
||||
raise S3Error(f"{path} is outside {images_root}, so it isn't restored there.")
|
||||
return p
|
||||
|
||||
|
||||
def _present(p: Path, size: int) -> bool:
|
||||
try:
|
||||
return p.is_file() and p.stat().st_size == size
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def restore_file(target: S3Target, f: LibraryFile, key: str | None,
|
||||
images_root: Path, pacer: Pacer | None) -> str:
|
||||
"""Put one file back at every path the library gives it. Returns
|
||||
"present" (nothing was missing), "copied" (a path already on disk
|
||||
supplied the others) or "restored" (fetched from the bucket). Raises
|
||||
ObjectMissing when the bucket doesn't hold it, S3Error or OSError
|
||||
otherwise."""
|
||||
places = [_resolve(p, images_root) for p in f.paths]
|
||||
have = [p for p in places if _present(p, f.size_bytes)]
|
||||
need = [p for p in places if p not in have]
|
||||
if not need:
|
||||
return "present"
|
||||
outcome = "copied"
|
||||
if not have:
|
||||
first = need.pop(0)
|
||||
first.parent.mkdir(parents=True, exist_ok=True)
|
||||
get_file(target, key or blob_key(f.sha256, f.paths[0]), first,
|
||||
sha256=f.sha256, size=f.size_bytes, pacer=pacer)
|
||||
have.append(first)
|
||||
outcome = "restored"
|
||||
for p in need:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
partial = p.with_name(p.name + ".partial")
|
||||
shutil.copyfile(have[0], partial)
|
||||
partial.replace(p)
|
||||
return outcome
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
"""A small S3 client for sending backups off the box: AWS Signature Version 4,
|
||||
path-style addressing (endpoint/bucket/key), and the calls a backup needs —
|
||||
put an object (whole, or in parts when its size isn't known up front), read
|
||||
one back, list a prefix.
|
||||
|
||||
FabledCurator puts, lists and reads; it never deletes. The bucket keeps the
|
||||
history and thins it on its own policy, so a key that may only add and read
|
||||
objects is enough, and is the safer key to hand a service: a stolen one cannot
|
||||
wipe the history it writes into.
|
||||
|
||||
Built on the standard library's http.client rather than boto3: three calls
|
||||
don't justify a large dependency, and signing is a pure function the tests
|
||||
hold to AWS's own published examples. Every call is synchronous, because the
|
||||
uploads run in Celery workers; the API runs the Test destination call in a
|
||||
thread.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
|
||||
|
||||
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
|
||||
TIMEOUT_SECONDS = 30.0
|
||||
# A single object can be a multi-gigabyte video or archive; a slow link still
|
||||
# gets this long between bytes before the call gives up.
|
||||
TRANSFER_TIMEOUT_SECONDS = 15 * 60
|
||||
CHUNK = 1 << 20
|
||||
|
||||
|
||||
class S3Error(Exception):
|
||||
"""Why a call to the bucket failed, said to the person."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class S3Target:
|
||||
endpoint: str # http(s)://host[:port][/prefix], no bucket
|
||||
bucket: str
|
||||
access_key: str
|
||||
secret_key: str
|
||||
region: str = "us-east-1"
|
||||
verify_tls: bool = True
|
||||
|
||||
def url(self, key: str = "") -> str:
|
||||
base = f"{self.endpoint.rstrip('/')}/{quote(self.bucket, safe='')}"
|
||||
return f"{base}/{quote(key, safe='/-_.~')}" if key else f"{base}/"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredObject:
|
||||
key: str
|
||||
size: int
|
||||
last_modified: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ListPage:
|
||||
objects: list[StoredObject]
|
||||
next_token: str | None # pass back as `continuation` for the next page
|
||||
|
||||
|
||||
# ── Signing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _encode(text: str, keep_slash: bool = False) -> str:
|
||||
return quote(text, safe="-_.~" + ("/" if keep_slash else ""))
|
||||
|
||||
|
||||
def _hmac(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def sign(method: str, url: str, headers: dict[str, str], payload_sha256: str,
|
||||
access_key: str, secret_key: str, region: str, at: datetime,
|
||||
service: str = "s3") -> dict[str, str]:
|
||||
"""The request's headers with Host, x-amz-date, x-amz-content-sha256 and
|
||||
Authorization added. Every header given is signed, Host always."""
|
||||
u = urlsplit(url)
|
||||
stamp = at.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
day = stamp[:8]
|
||||
out = dict(headers)
|
||||
out["Host"] = u.netloc
|
||||
out["x-amz-date"] = stamp
|
||||
out["x-amz-content-sha256"] = payload_sha256
|
||||
signed = {k.lower(): " ".join(str(v).split()) for k, v in out.items()}
|
||||
names = sorted(signed)
|
||||
|
||||
query = sorted(
|
||||
(_encode(k), _encode(v)) for k, v in parse_qsl(u.query, keep_blank_values=True)
|
||||
)
|
||||
canonical = "\n".join([
|
||||
method.upper(),
|
||||
_encode(unquote(u.path) or "/", keep_slash=True),
|
||||
"&".join(f"{k}={v}" for k, v in query),
|
||||
"".join(f"{n}:{signed[n]}\n" for n in names),
|
||||
";".join(names),
|
||||
payload_sha256,
|
||||
])
|
||||
scope = f"{day}/{region}/{service}/aws4_request"
|
||||
to_sign = "\n".join(["AWS4-HMAC-SHA256", stamp, scope,
|
||||
hashlib.sha256(canonical.encode()).hexdigest()])
|
||||
key = _hmac(_hmac(_hmac(_hmac(f"AWS4{secret_key}".encode(), day), region),
|
||||
service), "aws4_request")
|
||||
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
|
||||
out["Authorization"] = (f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
|
||||
f"SignedHeaders={';'.join(names)}, Signature={signature}")
|
||||
return out
|
||||
|
||||
|
||||
# ── Transport ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _connect(target: S3Target, url: str, timeout: float) -> http.client.HTTPConnection:
|
||||
u = urlsplit(url)
|
||||
if u.scheme == "https":
|
||||
ctx = ssl.create_default_context()
|
||||
if not target.verify_tls:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return http.client.HTTPSConnection(
|
||||
u.hostname, u.port, timeout=timeout, context=ctx, blocksize=CHUNK,
|
||||
)
|
||||
if u.scheme != "http":
|
||||
raise S3Error(f"{target.endpoint} isn't an http:// or https:// address.")
|
||||
return http.client.HTTPConnection(u.hostname, u.port, timeout=timeout, blocksize=CHUNK)
|
||||
|
||||
|
||||
def _request_target(url: str) -> str:
|
||||
u = urlsplit(url)
|
||||
return (u.path or "/") + (f"?{u.query}" if u.query else "")
|
||||
|
||||
|
||||
def _reach_error(target: S3Target, exc: Exception) -> S3Error:
|
||||
if isinstance(exc, TimeoutError):
|
||||
return S3Error(f"{target.endpoint} didn't answer in time.")
|
||||
if isinstance(exc, socket.gaierror):
|
||||
return S3Error(f"Couldn't reach {target.endpoint}: the host name doesn't resolve.")
|
||||
if isinstance(exc, ConnectionRefusedError):
|
||||
return S3Error(
|
||||
f"Couldn't reach {target.endpoint}: nothing is listening there "
|
||||
"(check the port)."
|
||||
)
|
||||
if isinstance(exc, ssl.SSLError):
|
||||
return S3Error(f"Couldn't reach {target.endpoint}: TLS failed ({exc.reason}).")
|
||||
return S3Error(f"Couldn't reach {target.endpoint}: {exc}.")
|
||||
|
||||
|
||||
def _says(status: int, reason: str, content: bytes) -> str:
|
||||
"""An S3 error response in words: its Code and Message when it has them."""
|
||||
try:
|
||||
root = ET.fromstring(content) if content else None
|
||||
code = (root.findtext("Code") or "") if root is not None else ""
|
||||
message = (root.findtext("Message") or "") if root is not None else ""
|
||||
except ET.ParseError:
|
||||
code, message = "", ""
|
||||
hints = {
|
||||
"SignatureDoesNotMatch": "the secret key is wrong",
|
||||
"InvalidAccessKeyId": "the access key isn't known there",
|
||||
"NoSuchBucket": "there is no such bucket",
|
||||
"AccessDenied": "the key isn't allowed to do that",
|
||||
"XAmzContentSHA256Mismatch": "the file's bytes don't match its recorded checksum",
|
||||
}
|
||||
said = hints.get(code) or message or reason
|
||||
if not code and 400 <= status < 500:
|
||||
# S3 refuses with an <Error><Code> body. A 4xx without one came from
|
||||
# something else at that address — most often a web server on the
|
||||
# default port, when the S3 service listens on a port of its own.
|
||||
said = ("the address answered, but not as S3 — check the scheme and "
|
||||
"the port (an S3 service usually listens on its own port)")
|
||||
return f"HTTP {status}{f' {code}' if code else ''}: {said}."
|
||||
|
||||
|
||||
# ── Calls ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(CHUNK):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def put_file(target: S3Target, key: str, path: Path, *,
|
||||
content_type: str = "application/octet-stream",
|
||||
payload_sha256: str | None = None,
|
||||
pacer: Pacer | None = None,
|
||||
now: datetime | None = None) -> None:
|
||||
"""Upload one file as one object, streamed from disk. `payload_sha256` is
|
||||
the file's sha256 when the caller already knows it (the library stores
|
||||
every file's), which saves reading the file twice; the bucket checks the
|
||||
bytes against it, so a file that changed on disk is refused rather than
|
||||
stored under the wrong name."""
|
||||
digest = payload_sha256 or file_sha256(path)
|
||||
size = path.stat().st_size
|
||||
url = target.url(key)
|
||||
headers = sign("PUT", url, {"Content-Length": str(size), "Content-Type": content_type},
|
||||
digest, target.access_key, target.secret_key, target.region,
|
||||
now or datetime.now(UTC))
|
||||
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
conn.request("PUT", _request_target(url),
|
||||
body=pacer.file_chunks(f) if pacer else f, headers=headers)
|
||||
response = conn.getresponse()
|
||||
content = response.read()
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
raise _reach_error(target, exc) from None
|
||||
finally:
|
||||
conn.close()
|
||||
if response.status // 100 != 2:
|
||||
raise S3Error(f"Upload of {key} refused: "
|
||||
f"{_says(response.status, response.reason, content)}")
|
||||
|
||||
|
||||
class ObjectMissing(S3Error):
|
||||
"""The bucket holds nothing at the key asked for."""
|
||||
|
||||
|
||||
def get_file(target: S3Target, key: str, path: Path, *,
|
||||
sha256: str | None = None, size: int | None = None,
|
||||
pacer: Pacer | None = None,
|
||||
now: datetime | None = None) -> None:
|
||||
"""Download one object to `path`, streamed to disk. The bytes land under a
|
||||
`.partial` name and are renamed into place when complete, so a broken
|
||||
transfer never leaves a truncated file at `path`. With `sha256` (and
|
||||
`size`) given, the bytes are checked on arrival and a copy that doesn't
|
||||
match is thrown away and reported. Raises ObjectMissing when the bucket
|
||||
has no such object."""
|
||||
url = target.url(key)
|
||||
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
|
||||
target.region, now or datetime.now(UTC))
|
||||
partial = path.with_name(path.name + ".partial")
|
||||
digest = hashlib.sha256()
|
||||
got = 0
|
||||
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
|
||||
try:
|
||||
conn.request("GET", _request_target(url), headers=headers)
|
||||
response = conn.getresponse()
|
||||
if response.status == 404:
|
||||
response.read()
|
||||
raise ObjectMissing(f"{key} is not in the {target.bucket} bucket.")
|
||||
if response.status != 200:
|
||||
raise S3Error(f"Reading {key} refused: "
|
||||
f"{_says(response.status, response.reason, response.read())}")
|
||||
with open(partial, "wb") as f:
|
||||
while chunk := response.read(CHUNK):
|
||||
if pacer:
|
||||
pacer.take(len(chunk))
|
||||
digest.update(chunk)
|
||||
got += len(chunk)
|
||||
f.write(chunk)
|
||||
if size is not None and got != size:
|
||||
raise S3Error(f"{key} arrived as {got} bytes, not the {size} expected.")
|
||||
if sha256 and digest.hexdigest() != sha256:
|
||||
raise S3Error(f"{key} arrived with a different fingerprint "
|
||||
f"({digest.hexdigest()[:12]}…), so it wasn't kept.")
|
||||
os.replace(partial, path)
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
partial.unlink(missing_ok=True)
|
||||
raise _reach_error(target, exc) from None
|
||||
except S3Error:
|
||||
partial.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_objects(target: S3Target, prefix: str = "", limit: int = 1000, *,
|
||||
continuation: str | None = None,
|
||||
now: datetime | None = None) -> ListPage:
|
||||
"""One page (up to `limit`) of the objects under `prefix`, ListObjectsV2."""
|
||||
params = {"list-type": "2", "max-keys": str(limit)}
|
||||
if prefix:
|
||||
params["prefix"] = prefix
|
||||
if continuation:
|
||||
params["continuation-token"] = continuation
|
||||
url = f"{target.url()}?{urlencode(params, quote_via=quote)}"
|
||||
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
|
||||
target.region, now or datetime.now(UTC))
|
||||
conn = _connect(target, url, TIMEOUT_SECONDS)
|
||||
try:
|
||||
conn.request("GET", _request_target(url), headers=headers)
|
||||
response = conn.getresponse()
|
||||
content = response.read()
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
raise _reach_error(target, exc) from None
|
||||
finally:
|
||||
conn.close()
|
||||
if response.status != 200:
|
||||
raise S3Error(f"Listing {target.bucket} refused: "
|
||||
f"{_says(response.status, response.reason, content)}")
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except ET.ParseError:
|
||||
raise S3Error(
|
||||
f"{target.endpoint} answered a listing with something that isn't S3's XML."
|
||||
) from None
|
||||
objects, next_token = [], None
|
||||
for item in root.iter():
|
||||
tag = item.tag.rsplit("}", 1)[-1]
|
||||
if tag == "NextContinuationToken":
|
||||
next_token = item.text or None
|
||||
if tag != "Contents":
|
||||
continue
|
||||
fields = {child.tag.rsplit("}", 1)[-1]: (child.text or "") for child in item}
|
||||
objects.append(StoredObject(fields.get("Key", ""), int(fields.get("Size") or 0),
|
||||
fields.get("LastModified", "")))
|
||||
return ListPage(objects, next_token)
|
||||
|
||||
|
||||
# ── Multipart: one object sent in parts ─────────────────────────────────────
|
||||
|
||||
# Each part is held in memory while it is sent, so this bounds the memory a
|
||||
# streamed upload uses. S3 allows 10,000 parts, which at this size is 640 GiB
|
||||
# for one object, and every part but the last must be at least 5 MiB.
|
||||
PART_SIZE = 64 * CHUNK
|
||||
# A part the bucket couldn't take (it didn't answer, or said it was busy) is
|
||||
# sent again after a rest that grows each time: 15 s, then 60 s.
|
||||
PART_RETRY_WAITS = (15, 60)
|
||||
_BUSY = frozenset({429, 500, 502, 503, 504})
|
||||
_MAX_RETRY_AFTER = 300
|
||||
|
||||
|
||||
class Pacer:
|
||||
"""Holds the bytes a job moves to an average rate, across every request
|
||||
it makes, so a backup or a restore leaves room on the link for everything
|
||||
else."""
|
||||
|
||||
def __init__(self, mbps: int | None):
|
||||
self.rate = mbps * 1_000_000 / 8 if mbps else None # bytes per second
|
||||
self.sent = 0
|
||||
self.started = time.monotonic()
|
||||
|
||||
def take(self, n: int) -> None:
|
||||
"""Count `n` bytes moved, pausing while the job is ahead of its rate."""
|
||||
if self.rate:
|
||||
self.sent += n
|
||||
ahead = self.sent / self.rate - (time.monotonic() - self.started)
|
||||
if ahead > 0:
|
||||
time.sleep(ahead)
|
||||
|
||||
def chunks(self, data: bytes) -> Iterator[memoryview]:
|
||||
view = memoryview(data)
|
||||
for at in range(0, len(view), CHUNK):
|
||||
piece = view[at:at + CHUNK]
|
||||
self.take(len(piece))
|
||||
yield piece
|
||||
|
||||
def file_chunks(self, f) -> Iterator[bytes]:
|
||||
while piece := f.read(CHUNK):
|
||||
self.take(len(piece))
|
||||
yield piece
|
||||
|
||||
|
||||
def _send(target: S3Target, method: str, url: str, body: bytes = b"",
|
||||
headers: dict[str, str] | None = None, *, timeout: float = TIMEOUT_SECONDS,
|
||||
pacer: Pacer | None = None,
|
||||
now: datetime | None = None) -> tuple[int, str, http.client.HTTPMessage, bytes]:
|
||||
"""Sign and send one request with an in-memory body, paced when a pacer
|
||||
is given. Returns (status, reason, headers, content)."""
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
signed = sign(method, url, {"Content-Length": str(len(body)), **(headers or {})},
|
||||
digest, target.access_key, target.secret_key, target.region,
|
||||
now or datetime.now(UTC))
|
||||
conn = _connect(target, url, timeout)
|
||||
try:
|
||||
conn.request(method, _request_target(url),
|
||||
body=pacer.chunks(body) if pacer and body else body, headers=signed)
|
||||
response = conn.getresponse()
|
||||
content = response.read()
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
raise _reach_error(target, exc) from None
|
||||
finally:
|
||||
conn.close()
|
||||
return response.status, response.reason, response.headers, content
|
||||
|
||||
|
||||
def _xml_field(content: bytes, name: str) -> str | None:
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except ET.ParseError:
|
||||
return None
|
||||
for item in root.iter():
|
||||
if item.tag.rsplit("}", 1)[-1] == name:
|
||||
return item.text
|
||||
return None
|
||||
|
||||
|
||||
def _is_error_body(content: bytes) -> bool:
|
||||
try:
|
||||
return bool(content) and ET.fromstring(content).tag.rsplit("}", 1)[-1] == "Error"
|
||||
except ET.ParseError:
|
||||
return False
|
||||
|
||||
|
||||
def _retry_after(headers: http.client.HTTPMessage | None, default: int) -> int:
|
||||
try:
|
||||
asked = int((headers or {}).get("Retry-After") or default)
|
||||
except ValueError:
|
||||
asked = default
|
||||
return max(0, min(asked, _MAX_RETRY_AFTER))
|
||||
|
||||
|
||||
class Upload:
|
||||
"""One object written as a series of parts, for data whose size isn't known
|
||||
when it starts, such as a database dump streamed from pg_dump.
|
||||
|
||||
The object appears in the bucket only when `complete()` succeeds. Until
|
||||
then the parts are invisible, so a stream that fails partway leaves
|
||||
nothing behind that looks like a finished backup. `abort()` asks the
|
||||
bucket to drop the parts at once; if the abort is refused or never
|
||||
arrives, S3 services expire an unfinished upload on their own (Fabled
|
||||
Archives after a day)."""
|
||||
|
||||
def __init__(self, target: S3Target, key: str, *,
|
||||
content_type: str = "application/octet-stream",
|
||||
pacer: Pacer | None = None):
|
||||
self.target = target
|
||||
self.key = key
|
||||
self.size = 0
|
||||
self.pacer = pacer
|
||||
self._etags: list[str] = []
|
||||
status, reason, _, content = _send(
|
||||
target, "POST", f"{target.url(key)}?uploads", headers={"Content-Type": content_type},
|
||||
)
|
||||
upload_id = _xml_field(content, "UploadId") if status == 200 else None
|
||||
if not upload_id:
|
||||
raise S3Error(f"Starting the upload of {key} refused: "
|
||||
f"{_says(status, reason, content)}")
|
||||
self.upload_id = upload_id
|
||||
|
||||
def _url(self, **params: str) -> str:
|
||||
query = urlencode({**params, "uploadId": self.upload_id}, quote_via=quote)
|
||||
return f"{self.target.url(self.key)}?{query}"
|
||||
|
||||
@property
|
||||
def parts(self) -> int:
|
||||
return len(self._etags)
|
||||
|
||||
def add(self, data: bytes) -> None:
|
||||
"""Send the next part, resting and sending it again when the bucket
|
||||
doesn't answer or says it is busy."""
|
||||
n = len(self._etags) + 1
|
||||
for wait in (*PART_RETRY_WAITS, None):
|
||||
headers = None
|
||||
try:
|
||||
status, reason, headers, content = _send(
|
||||
self.target, "PUT", self._url(partNumber=str(n)), data,
|
||||
timeout=TRANSFER_TIMEOUT_SECONDS, pacer=self.pacer,
|
||||
)
|
||||
except S3Error as exc:
|
||||
if wait is None:
|
||||
raise S3Error(f"Part {n} of {self.key}: {exc}") from None
|
||||
time.sleep(wait)
|
||||
continue
|
||||
etag = headers.get("ETag") if status == 200 else None
|
||||
if etag:
|
||||
self._etags.append(etag)
|
||||
self.size += len(data)
|
||||
return
|
||||
if status not in _BUSY or wait is None:
|
||||
raise S3Error(f"Part {n} of {self.key} refused: "
|
||||
f"{_says(status, reason, content)}")
|
||||
time.sleep(_retry_after(headers, wait))
|
||||
|
||||
def complete(self) -> None:
|
||||
"""Join the parts into the object. S3 can refuse a completion with an
|
||||
error body under a 200 status, so the body is read too."""
|
||||
if not self._etags:
|
||||
raise S3Error(f"Nothing was sent for {self.key}.")
|
||||
body = "<CompleteMultipartUpload>" + "".join(
|
||||
f"<Part><PartNumber>{n}</PartNumber><ETag>{etag}</ETag></Part>"
|
||||
for n, etag in enumerate(self._etags, start=1)
|
||||
) + "</CompleteMultipartUpload>"
|
||||
status, reason, _, content = _send(
|
||||
self.target, "POST", self._url(), body.encode(),
|
||||
headers={"Content-Type": "application/xml"}, timeout=TRANSFER_TIMEOUT_SECONDS,
|
||||
)
|
||||
if status != 200 or _is_error_body(content):
|
||||
raise S3Error(f"Finishing the upload of {self.key} refused: "
|
||||
f"{_says(status, reason, content)}")
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Drop the parts sent so far. Never raises: it runs while another
|
||||
failure is already being reported, and the bucket expires the parts on
|
||||
its own if this doesn't reach it."""
|
||||
try:
|
||||
_send(self.target, "DELETE", self._url())
|
||||
except Exception: # noqa: BLE001 — best effort, see above
|
||||
pass
|
||||
|
||||
|
||||
def put_file_in_parts(target: S3Target, key: str, path: Path, *, sha256: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
pacer: Pacer | None = None) -> int:
|
||||
"""Upload a large file as a multipart object, read from disk a part at a
|
||||
time. The whole file's sha256 is computed on the way and must equal
|
||||
`sha256` before the upload is completed; otherwise it is aborted, so a file
|
||||
that changed on disk is never stored under its old name. Returns the
|
||||
number of bytes sent."""
|
||||
upload = Upload(target, key, content_type=content_type, pacer=pacer)
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
while part := f.read(PART_SIZE):
|
||||
digest.update(part)
|
||||
upload.add(part)
|
||||
if digest.hexdigest() != sha256:
|
||||
raise S3Error(f"{path} no longer matches its recorded checksum; not stored.")
|
||||
upload.complete()
|
||||
except BaseException:
|
||||
upload.abort()
|
||||
raise
|
||||
return upload.size
|
||||
|
||||
|
||||
def object_present(target: S3Target, key: str, size: int) -> bool:
|
||||
"""Whether `key` is already in the bucket at `size` bytes. Used when a put
|
||||
is refused: an append-only bucket refuses to replace an object that is
|
||||
already there, which for a content-addressed key means the work is done."""
|
||||
try:
|
||||
page = list_objects(target, prefix=key, limit=1)
|
||||
except S3Error:
|
||||
return False
|
||||
return any(o.key == key and o.size == size for o in page.objects)
|
||||
@@ -1,12 +1,11 @@
|
||||
"""FC-3h: first-class backup/restore service for FC.
|
||||
|
||||
Two independent backup kinds:
|
||||
- 'db' — pg_dump only; fast; nightly via Beat (settings-gated)
|
||||
- 'images' — tar+zstd of /images; slow; manual trigger only
|
||||
|
||||
Files live under <images_root>/_backups/. Each backup writes:
|
||||
fc_<kind>_<ts>.{sql|tar.zst} — the artifact
|
||||
fc_<kind>_<ts>.json — manifest (kind/tag/triggered_by)
|
||||
The database dump (pg_dump), nightly via Beat when the setting is on. With an
|
||||
off-site destination set up, the dump streams straight to that bucket
|
||||
(backup_db_offsite), and images and attachments go there too (backup_media).
|
||||
Without one, the dump is written under <images_root>/_backups/:
|
||||
fc_db_<ts>.dump — the dump (custom format)
|
||||
fc_db_<ts>.json — manifest (tag, trigger, row counts)
|
||||
|
||||
Service functions are sync (subprocess-bound). Celery tasks in
|
||||
backend.app.tasks.backup wrap each one with task_run-tracked
|
||||
@@ -15,33 +14,19 @@ lifecycle + soft/hard time limits + retention bookkeeping.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
_BACKUPS_DIRNAME = "_backups"
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Excluded from the images tarball, and each for its own reason (#4233, #4234):
|
||||
#
|
||||
# _backups — the archive would otherwise contain every previous archive.
|
||||
# This is not hypothetical: the 2026-05-23/24 runs, taken before
|
||||
# this exclude existed, grew 43G -> 107G -> ... -> 2123G as each
|
||||
# swallowed its predecessors, and cost 4.3T of the images
|
||||
# filesystem until they were reclaimed on 2026-09-21.
|
||||
# _quarantine — holds files deliberately pulled OUT of the library.
|
||||
# secrets — `credential_key.b64`, the key that decrypts the stored
|
||||
# Patreon/SubscribeStar session credentials.
|
||||
# cookies — those session cookies themselves.
|
||||
#
|
||||
# The last two are the ones worth stating plainly: an images tarball is a media
|
||||
# archive, and a media archive that carries the key to the operator's accounts
|
||||
# is a credential leak wearing a backup's name. Encryption at rest buys nothing
|
||||
# when the key rides along in the same file. A restore therefore does NOT
|
||||
# re-establish credentials — you sign in again, which is the correct outcome.
|
||||
_IMAGES_EXCLUDED_DIRNAMES = ("_backups", "_quarantine", "secrets", "cookies")
|
||||
_BACKUPS_DIRNAME = "_backups"
|
||||
|
||||
# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The Celery
|
||||
# soft limit signals the Python process; subprocess.Popen in a blocking syscall
|
||||
@@ -51,9 +36,7 @@ _IMAGES_EXCLUDED_DIRNAMES = ("_backups", "_quarantine", "secrets", "cookies")
|
||||
# matters because an NFS D-state hang defeats even Celery's SIGKILL (the failure
|
||||
# that wedged the maintenance lane for hours, #739).
|
||||
# backup_db_task: soft=1800s / hard=2100s → 1700s
|
||||
# backup_images_task: soft=21600s / hard=23400s → 21000s
|
||||
_DB_SUBPROCESS_TIMEOUT_S = 1700 # ~28 min, under the 30-min DB soft limit
|
||||
_IMAGES_SUBPROCESS_TIMEOUT_S = 21000 # ~5.8 hr, under the 6-hr images soft limit
|
||||
# Grace after SIGKILL to reap the child. If it can't be reaped in this window
|
||||
# (an uninterruptible NFS D-state — the failure mode that wedged the
|
||||
# concurrency-1 maintenance lane for hours, operator-flagged 2026-06-07), we
|
||||
@@ -62,6 +45,85 @@ _IMAGES_SUBPROCESS_TIMEOUT_S = 21000 # ~5.8 hr, under the 6-hr images soft
|
||||
_KILL_REAP_GRACE_S = 10
|
||||
|
||||
|
||||
# Tables whose rows stay out of every dump. `credential` holds account logins,
|
||||
# sealed with the key in /images/secrets: a restored install keeps the table,
|
||||
# empty, and you sign in to each account again.
|
||||
DATA_LEFT_OUT = ("credential",)
|
||||
|
||||
# Every database dump, local or off-site: custom format (-Fc, compressed and
|
||||
# restored with pg_restore), without ownership or grants so it loads into any
|
||||
# install.
|
||||
_PG_DUMP = ["pg_dump", "--no-owner", "--no-acl", "-Fc",
|
||||
*(f"--exclude-table-data={t}" for t in DATA_LEFT_OUT)]
|
||||
|
||||
|
||||
def table_counts(conn) -> dict[str, int]:
|
||||
"""How many rows each table in the public schema holds, read on a psycopg
|
||||
connection, in the order of the tables' names."""
|
||||
from psycopg import sql
|
||||
|
||||
names = [r[0] for r in conn.execute(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")]
|
||||
return {
|
||||
n: conn.execute(sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(n))).fetchone()[0]
|
||||
for n in names
|
||||
}
|
||||
|
||||
|
||||
def database_counts(conn) -> dict:
|
||||
"""{"alembic": the schema's revision, "tables": {name: rows}}."""
|
||||
has_version = conn.execute("SELECT to_regclass('public.alembic_version')").fetchone()[0]
|
||||
alembic = (conn.execute("SELECT version_num FROM alembic_version").fetchone()
|
||||
if has_version else None)
|
||||
return {"alembic": alembic[0] if alembic else None, "tables": table_counts(conn)}
|
||||
|
||||
|
||||
def read_counts(db_url: str) -> dict:
|
||||
"""database_counts for the database at `db_url`, as it is now."""
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(_libpq_url(db_url)) as conn:
|
||||
return database_counts(conn)
|
||||
|
||||
|
||||
class DumpSnapshot:
|
||||
"""A read-only transaction whose snapshot pg_dump dumps from (it is handed
|
||||
over with --snapshot), so rows counted here are exactly the rows the dump
|
||||
holds. A test restore checks every table against these counts."""
|
||||
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
self.id = conn.execute("SELECT pg_export_snapshot()").fetchone()[0]
|
||||
|
||||
def counts(self) -> dict | None:
|
||||
"""The counts, or None when they can't be read: the dump is good
|
||||
without them, and a test restore then says none were recorded."""
|
||||
try:
|
||||
return self._counts()
|
||||
except Exception: # noqa: BLE001 — see above
|
||||
log.exception("backup: the dump's row counts couldn't be read")
|
||||
return None
|
||||
|
||||
def _counts(self) -> dict:
|
||||
counts = database_counts(self.conn)
|
||||
for t in DATA_LEFT_OUT:
|
||||
if t in counts["tables"]:
|
||||
counts["tables"][t] = 0 # the dump holds the table, not its rows
|
||||
return counts
|
||||
|
||||
|
||||
@contextmanager
|
||||
def dump_snapshot(db_url: str):
|
||||
"""Open the snapshot a dump is taken from. It stays open until the dump
|
||||
is finished: pg_dump can only take it up while it is."""
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(_libpq_url(db_url)) as conn:
|
||||
conn.isolation_level = psycopg.IsolationLevel.REPEATABLE_READ
|
||||
conn.read_only = True
|
||||
yield DumpSnapshot(conn)
|
||||
|
||||
|
||||
def _run_bounded(cmd: list[str], timeout: int) -> None:
|
||||
"""subprocess.run(check=True, timeout) whose reaper can't itself hang.
|
||||
|
||||
@@ -117,7 +179,7 @@ def _file_size_or_none(path: Path) -> int | None:
|
||||
def _write_manifest(
|
||||
out_dir: Path, *, kind: str, ts: str,
|
||||
tag: str | None, triggered_by: str,
|
||||
artifact_path: Path,
|
||||
artifact_path: Path, counts: dict | None = None,
|
||||
) -> Path:
|
||||
manifest = {
|
||||
"kind": kind,
|
||||
@@ -127,6 +189,8 @@ def _write_manifest(
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"artifact_path": str(artifact_path),
|
||||
}
|
||||
if counts is not None:
|
||||
manifest["counts"] = counts
|
||||
mf = out_dir / f"fc_{kind}_{ts}.json"
|
||||
mf.write_text(json.dumps(manifest, indent=2))
|
||||
return mf
|
||||
@@ -152,104 +216,215 @@ def backup_db(
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
_run_bounded(
|
||||
[
|
||||
"pg_dump", "--no-owner", "--no-acl", "-Fc",
|
||||
"-f", str(tmp_path), _libpq_url(db_url),
|
||||
],
|
||||
_DB_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
with dump_snapshot(db_url) as snap:
|
||||
_run_bounded(
|
||||
_gently([*_PG_DUMP, f"--snapshot={snap.id}", "-f", str(tmp_path),
|
||||
_libpq_url(db_url)]),
|
||||
_DB_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
counts = snap.counts()
|
||||
shutil.move(str(tmp_path), str(sql_path))
|
||||
finally:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
manifest_path = _write_manifest(
|
||||
out_dir, kind="db", ts=ts, tag=tag, triggered_by=triggered_by,
|
||||
artifact_path=sql_path,
|
||||
artifact_path=sql_path, counts=counts,
|
||||
)
|
||||
return {
|
||||
"kind": "db",
|
||||
"ts": ts,
|
||||
"sql_path": str(sql_path),
|
||||
"tar_path": None,
|
||||
"manifest_path": str(manifest_path),
|
||||
"size_bytes": _file_size_or_none(sql_path),
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
|
||||
def backup_images(
|
||||
*, images_root: Path,
|
||||
tag: str | None = None, triggered_by: str = "manual",
|
||||
) -> dict:
|
||||
"""Run tar --zstd over images_root; write .tar.zst + manifest."""
|
||||
class DumpFailed(Exception):
|
||||
"""pg_dump exited with an error; the message is its last line of output."""
|
||||
|
||||
|
||||
def offsite_dump_key(ts: str) -> str:
|
||||
"""Where a database dump lands in the off-site bucket. Dated names sit
|
||||
side by side under one prefix, which is what lets the bucket's retention
|
||||
thin them by age."""
|
||||
return f"database/fabledcurator-{ts}.dump"
|
||||
|
||||
|
||||
def _gently(cmd: list[str]) -> list[str]:
|
||||
"""`cmd` at low CPU priority, and low disk priority where `ionice` is
|
||||
installed, so a long dump leaves the server responsive."""
|
||||
io = ["ionice", "-c", "2", "-n", "7"] if shutil.which("ionice") else []
|
||||
return ["nice", "-n", "10", *io, *cmd]
|
||||
|
||||
|
||||
def _last_line(f) -> str | None:
|
||||
f.seek(0)
|
||||
lines = f.read().decode(errors="replace").strip().splitlines()
|
||||
return lines[-1].strip() if lines else None
|
||||
|
||||
|
||||
def backup_db_offsite(*, db_url: str, target, limit_mbps: int | None = None) -> dict:
|
||||
"""Stream `pg_dump` straight into the off-site bucket, in parts, with no
|
||||
copy staged on this server. The object appears only once pg_dump has
|
||||
exited cleanly and every part has arrived; a dump that fails partway
|
||||
leaves nothing in the bucket, and raises DumpFailed with pg_dump's own
|
||||
last line. Returns a dict for the caller to record in BackupRun, with
|
||||
`counts`: each table's rows in the dump, for the caller to store beside
|
||||
it."""
|
||||
ts = _now_ts()
|
||||
out_dir = _backups_dir(images_root)
|
||||
tar_path = out_dir / f"fc_images_{ts}.tar.zst"
|
||||
# No local-temp here (the archive is hundreds of GB — it can't stage in
|
||||
# /tmp), but bounded-kill still applies so a tar wedged on NFS fails fast
|
||||
# rather than holding the lane for hours.
|
||||
_run_bounded(
|
||||
[
|
||||
"tar", "--zstd", "-cf", str(tar_path),
|
||||
"-C", str(images_root.parent), images_root.name,
|
||||
*(
|
||||
f"--exclude={images_root.name}/{name}"
|
||||
for name in _IMAGES_EXCLUDED_DIRNAMES
|
||||
),
|
||||
],
|
||||
_IMAGES_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
manifest_path = _write_manifest(
|
||||
out_dir, kind="images", ts=ts, tag=tag, triggered_by=triggered_by,
|
||||
artifact_path=tar_path,
|
||||
)
|
||||
with dump_snapshot(db_url) as snap:
|
||||
result = _stream_dump(db_url, target, ts, limit_mbps, snap.id)
|
||||
result["counts"] = snap.counts()
|
||||
return result
|
||||
|
||||
|
||||
def _stream_dump(db_url: str, target, ts: str, limit_mbps: int | None, snapshot: str) -> dict:
|
||||
from .backup_s3 import PART_SIZE, Pacer, Upload
|
||||
|
||||
key = offsite_dump_key(ts)
|
||||
deadline = time.monotonic() + _DB_SUBPROCESS_TIMEOUT_S
|
||||
with tempfile.TemporaryFile() as err:
|
||||
proc = subprocess.Popen(
|
||||
_gently([*_PG_DUMP, f"--snapshot={snapshot}", _libpq_url(db_url)]),
|
||||
stdout=subprocess.PIPE, stderr=err,
|
||||
)
|
||||
upload = None
|
||||
try:
|
||||
upload = Upload(target, key, pacer=Pacer(limit_mbps))
|
||||
while part := proc.stdout.read(PART_SIZE):
|
||||
if time.monotonic() > deadline:
|
||||
raise subprocess.TimeoutExpired(proc.args, _DB_SUBPROCESS_TIMEOUT_S)
|
||||
upload.add(part)
|
||||
proc.stdout.close()
|
||||
proc.wait(timeout=_KILL_REAP_GRACE_S * 6)
|
||||
if proc.returncode != 0:
|
||||
raise DumpFailed(_last_line(err) or f"pg_dump exited with {proc.returncode}")
|
||||
upload.complete()
|
||||
except BaseException:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=_KILL_REAP_GRACE_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # unkillable (D-state): abandon the reap, fail fast
|
||||
if upload is not None:
|
||||
upload.abort()
|
||||
raise
|
||||
return {
|
||||
"kind": "images",
|
||||
"kind": "db",
|
||||
"ts": ts,
|
||||
"sql_path": None,
|
||||
"tar_path": str(tar_path),
|
||||
"manifest_path": str(manifest_path),
|
||||
"size_bytes": _file_size_or_none(tar_path),
|
||||
"key": key,
|
||||
"bucket": target.bucket,
|
||||
"endpoint": target.endpoint,
|
||||
"parts": upload.parts,
|
||||
"size_bytes": upload.size,
|
||||
}
|
||||
|
||||
|
||||
class RestoreFailed(Exception):
|
||||
"""psql or pg_restore exited with an error; the message is its last line."""
|
||||
|
||||
|
||||
# A restore waits this long for other connections to let go of the tables it
|
||||
# replaces, then gives up with Postgres' own reason instead of queueing every
|
||||
# other query behind it.
|
||||
_RESTORE_LOCK_TIMEOUT = "60s"
|
||||
# pg_restore of a large library (vector tables, rebuilt indexes) runs well past
|
||||
# a dump's length; it is bounded on its own.
|
||||
_RESTORE_SUBPROCESS_TIMEOUT_S = 3 * 3600
|
||||
|
||||
|
||||
def _run_reporting(cmd: list[str], timeout: int, env: dict | None = None) -> None:
|
||||
"""Run `cmd`; raise RestoreFailed with the last line it printed when it
|
||||
exits non-zero."""
|
||||
with tempfile.TemporaryFile() as err:
|
||||
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=err, timeout=timeout,
|
||||
env=env)
|
||||
if proc.returncode != 0:
|
||||
raise RestoreFailed(_last_line(err) or f"{cmd[0]} exited with {proc.returncode}")
|
||||
|
||||
|
||||
# Every other connection to this database: the web server's, the other
|
||||
# workers'. Ended before the tables are dropped, so none holds a lock the drop
|
||||
# waits on, and again once the restore is in, so none keeps statements it
|
||||
# prepared against the old tables. Each reconnects on its next query.
|
||||
_END_OTHER_CONNECTIONS = (
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
||||
"WHERE datname = current_database() AND pid <> pg_backend_pid();"
|
||||
)
|
||||
|
||||
|
||||
def end_other_connections(*, db_url: str) -> None:
|
||||
_run_reporting(["psql", _libpq_url(db_url), "-v", "ON_ERROR_STOP=1",
|
||||
"-c", _END_OTHER_CONNECTIONS], 60)
|
||||
|
||||
|
||||
def wipe_db(*, db_url: str) -> None:
|
||||
"""Drop and recreate the public schema: an empty database, ready for a
|
||||
dump or for `alembic upgrade head`."""
|
||||
_run_reporting(
|
||||
[
|
||||
"psql", _libpq_url(db_url), "-v", "ON_ERROR_STOP=1", "-c",
|
||||
f"{_END_OTHER_CONNECTIONS} DROP SCHEMA IF EXISTS public CASCADE; "
|
||||
"CREATE SCHEMA public;",
|
||||
],
|
||||
120,
|
||||
# Set for the session, not as a SET inside the same -c string.
|
||||
env={**os.environ, "PGOPTIONS": f"-c lock_timeout={_RESTORE_LOCK_TIMEOUT}"},
|
||||
)
|
||||
|
||||
|
||||
# A pg_restore newer than the server sets parameters the server doesn't have
|
||||
# (pg_restore 17 against Postgres 16: `SET transaction_timeout = 0`). The
|
||||
# statement fails and nothing depends on it, so its error is not a failed
|
||||
# restore.
|
||||
_HARMLESS_RESTORE_ERRORS = (
|
||||
'unrecognized configuration parameter "transaction_timeout"',
|
||||
)
|
||||
|
||||
|
||||
def _restore_errors(stderr: str) -> list[str]:
|
||||
"""pg_restore's error lines, less the harmless ones."""
|
||||
return [
|
||||
line.strip() for line in stderr.splitlines()
|
||||
if "error:" in line and not any(h in line for h in _HARMLESS_RESTORE_ERRORS)
|
||||
]
|
||||
|
||||
|
||||
def restore_db(*, db_url: str, sql_path: Path) -> None:
|
||||
"""Wipe public schema, then load from the custom-format dump. Raises on
|
||||
subprocess failure; partial-restore state is the caller's concern."""
|
||||
libpq = _libpq_url(db_url)
|
||||
subprocess.run(
|
||||
[
|
||||
"psql", libpq, "-c",
|
||||
"DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;",
|
||||
],
|
||||
capture_output=True, check=True, timeout=120,
|
||||
)
|
||||
# Custom-format (-Fc) dumps are restored with pg_restore, not psql.
|
||||
subprocess.run(
|
||||
["pg_restore", "--no-owner", "--no-acl", "-d", libpq, str(sql_path)],
|
||||
capture_output=True, check=True,
|
||||
timeout=_DB_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
|
||||
|
||||
def restore_images(*, images_root: Path, tar_path: Path) -> None:
|
||||
"""Untar over images_root.parent. Additive — files NOT in the
|
||||
tarball are NOT removed. Caller wipes first if a clean restore
|
||||
is needed."""
|
||||
subprocess.run(
|
||||
[
|
||||
"tar", "--zstd", "-xf", str(tar_path),
|
||||
"-C", str(images_root.parent),
|
||||
],
|
||||
capture_output=True, check=True,
|
||||
timeout=_IMAGES_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
"""Wipe the public schema, then load the custom-format dump. Any error
|
||||
pg_restore reports, other than a harmless one, raises RestoreFailed with
|
||||
the first; the database is then partly loaded, and the caller decides what
|
||||
to put back."""
|
||||
wipe_db(db_url=db_url)
|
||||
# Custom-format (-Fc) dumps are restored with pg_restore, not psql. It runs
|
||||
# on past errors (it can't skip the harmless one and stop at the rest), so
|
||||
# its errors are judged once it is done.
|
||||
cmd = _gently(["pg_restore", "--no-owner", "--no-acl",
|
||||
"-d", _libpq_url(db_url), str(sql_path)])
|
||||
with tempfile.TemporaryFile() as err:
|
||||
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=err,
|
||||
timeout=_RESTORE_SUBPROCESS_TIMEOUT_S)
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
err.seek(0)
|
||||
said = err.read().decode(errors="replace")
|
||||
errors = _restore_errors(said)
|
||||
if errors:
|
||||
raise RestoreFailed(errors[0] if len(errors) == 1
|
||||
else f"{errors[0]} ({len(errors)} errors in all)")
|
||||
if not any(h in said for h in _HARMLESS_RESTORE_ERRORS):
|
||||
# A failure with no error line to show for it.
|
||||
lines = said.strip().splitlines()
|
||||
raise RestoreFailed(lines[-1].strip() if lines
|
||||
else f"pg_restore exited with {proc.returncode}")
|
||||
|
||||
|
||||
def unlink_artifact_files(
|
||||
*,
|
||||
sql_path: str | None,
|
||||
tar_path: str | None,
|
||||
manifest_path: str | None,
|
||||
) -> dict:
|
||||
"""Best-effort unlink of all on-disk files for a BackupRun row.
|
||||
@@ -258,7 +433,6 @@ def unlink_artifact_files(
|
||||
deleted: dict = {}
|
||||
for label, p in (
|
||||
("sql", sql_path),
|
||||
("tar", tar_path),
|
||||
("manifest", manifest_path),
|
||||
):
|
||||
if not p:
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Testing a database dump without touching this install.
|
||||
|
||||
The dump is loaded into a separate, temporary database on the same Postgres
|
||||
server, using the same steps a real restore takes. Every table is counted
|
||||
there and compared, the copy is brought up to this version, and the copy is
|
||||
then dropped. The live database is only read, to show its counts alongside.
|
||||
|
||||
Every dump records how many rows each table held, counted in the same
|
||||
snapshot pg_dump dumped from, so the comparison is exact. For a dump in the
|
||||
bucket the counts sit beside it as `<dump name>.counts.json`; for a dump on
|
||||
this server they are in its manifest file. A dump taken before counts were
|
||||
recorded can still be tested: it is then compared with the live database,
|
||||
where a difference is what changed after the dump was taken, not a fault.
|
||||
|
||||
The temporary database needs about as much free space on the database's disk
|
||||
as the live one takes, and only for the length of the test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from . import backup_restore, backup_service
|
||||
from .backup_s3 import ObjectMissing, S3Target, get_file, put_file
|
||||
|
||||
SCRATCH_SUFFIX = "_restore_test"
|
||||
|
||||
|
||||
def counts_key(dump_key: str) -> str:
|
||||
"""Where a dump's row counts sit in the bucket, beside the dump."""
|
||||
return dump_key.removesuffix(".dump") + ".counts.json"
|
||||
|
||||
|
||||
def store_counts(target: S3Target, dump_key: str, counts: dict) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d) / "counts.json"
|
||||
path.write_text(json.dumps(counts))
|
||||
put_file(target, counts_key(dump_key), path, content_type="application/json")
|
||||
|
||||
|
||||
def fetch_counts(target: S3Target, dump_key: str) -> dict | None:
|
||||
"""The counts recorded with a dump in the bucket, or None for a dump that
|
||||
has none."""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d) / "counts.json"
|
||||
try:
|
||||
get_file(target, counts_key(dump_key), path)
|
||||
except ObjectMissing:
|
||||
return None
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def local_counts(manifest_path: str | None) -> dict | None:
|
||||
"""The counts in a dump's manifest file on this server, or None."""
|
||||
try:
|
||||
return json.loads(Path(manifest_path).read_text()).get("counts")
|
||||
except (TypeError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def scratch_name(db_url: str) -> str:
|
||||
"""The temporary database's name: the live one's, plus SCRATCH_SUFFIX."""
|
||||
return f"{make_url(db_url).database}{SCRATCH_SUFFIX}"
|
||||
|
||||
|
||||
def scratch_url(db_url: str) -> str:
|
||||
return make_url(db_url).set(database=scratch_name(db_url)).render_as_string(
|
||||
hide_password=False)
|
||||
|
||||
|
||||
def _on_server(db_url: str, statement) -> None:
|
||||
"""Run a CREATE or DROP DATABASE, which can't run inside a transaction."""
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(backup_service._libpq_url(db_url), autocommit=True) as conn:
|
||||
conn.execute(statement)
|
||||
|
||||
|
||||
def drop_scratch(db_url: str) -> None:
|
||||
"""Drop the temporary database if it is there, ending any connection to it."""
|
||||
from psycopg import sql
|
||||
|
||||
_on_server(db_url, sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
|
||||
sql.Identifier(scratch_name(db_url))))
|
||||
|
||||
|
||||
def _create_scratch(db_url: str) -> None:
|
||||
from psycopg import sql
|
||||
|
||||
_on_server(db_url, sql.SQL("CREATE DATABASE {} TEMPLATE template0").format(
|
||||
sql.Identifier(scratch_name(db_url))))
|
||||
|
||||
|
||||
def trial_restore(*, db_url: str, dump_path: Path, recorded: dict | None,
|
||||
note: Callable[[str], None] = lambda phase: None) -> dict:
|
||||
"""Load the dump at `dump_path` into the temporary database, compare it,
|
||||
bring it up to this version, and drop it. `note` is told each phase as it
|
||||
starts: "loading", "comparing", "upgrading". Raises RestoreFailed (or
|
||||
S3Error from the upgrade) when the dump doesn't load or can't be brought
|
||||
up; the temporary database is dropped either way."""
|
||||
scratch = scratch_url(db_url)
|
||||
if make_url(scratch).database == make_url(db_url).database:
|
||||
raise ValueError("The test database would be the live one.")
|
||||
drop_scratch(db_url) # one left by a test cut off by a restart
|
||||
_create_scratch(db_url)
|
||||
try:
|
||||
note("loading")
|
||||
backup_service.restore_db(db_url=scratch, sql_path=dump_path)
|
||||
note("comparing")
|
||||
restored = backup_service.read_counts(scratch)
|
||||
live = backup_service.read_counts(db_url)
|
||||
note("upgrading")
|
||||
backup_restore.migrate(db_name=scratch_name(db_url))
|
||||
upgraded = backup_service.read_counts(scratch)["alembic"]
|
||||
finally:
|
||||
drop_scratch(db_url)
|
||||
return {**compare(recorded, restored, live),
|
||||
"alembic_dump": restored["alembic"], "alembic_now": upgraded}
|
||||
|
||||
|
||||
def compare(recorded: dict | None, restored: dict, live: dict) -> dict:
|
||||
"""Each table's rows in the dump (as recorded), in the restored copy, and
|
||||
in the live database. With counts recorded, every table must come back
|
||||
with exactly its count; `mismatched` names those that didn't."""
|
||||
rec = (recorded or {}).get("tables")
|
||||
names = sorted(set(restored["tables"]) | set(rec or {}) | set(live["tables"]))
|
||||
tables = [
|
||||
{"table": n, "dump": rec.get(n) if rec is not None else None,
|
||||
"restored": restored["tables"].get(n), "live": live["tables"].get(n)}
|
||||
for n in names
|
||||
]
|
||||
mismatched = [t["table"] for t in tables
|
||||
if rec is not None and t["table"] in rec and t["restored"] != t["dump"]]
|
||||
return {
|
||||
"counted": rec is not None,
|
||||
"tables": tables,
|
||||
"mismatched": mismatched,
|
||||
"rows_restored": sum(v for v in restored["tables"].values()),
|
||||
}
|
||||
|
||||
|
||||
def summary(result: dict) -> str:
|
||||
"""One sentence on how a test went."""
|
||||
n = len([t for t in result["tables"] if t["restored"] is not None])
|
||||
rows = f"{result['rows_restored']:,}"
|
||||
if result["mismatched"]:
|
||||
shown = ", ".join(
|
||||
f"{t['table']} ({t['dump']:,} in the dump, "
|
||||
+ ("table missing)" if t["restored"] is None else f"{t['restored']:,} restored)")
|
||||
for t in result["tables"] if t["table"] in result["mismatched"][:5]
|
||||
)
|
||||
more = len(result["mismatched"]) - 5
|
||||
return (f"{len(result['mismatched'])} table(s) came back different: {shown}"
|
||||
+ (f", and {more} more" if more > 0 else "") + ".")
|
||||
if result["counted"]:
|
||||
return f"All {n} tables came back with the {rows} rows the dump recorded."
|
||||
return (f"The dump loaded cleanly: {n} tables, {rows} rows. It was taken before "
|
||||
"dumps recorded their counts, so it is shown beside the live database instead.")
|
||||
@@ -1044,45 +1044,75 @@ def cancel_audit_run(session: Session, *, audit_id: int) -> None:
|
||||
_ARCHIVE_EXT_FOR_FORMAT = {"zip": ".zip", "rar": ".rar", "7z": ".7z"}
|
||||
|
||||
|
||||
def _stage_archive(archive_path: Path, staged: Path) -> None:
|
||||
"""Put the stored archive at `staged` for the importer to read: a symlink,
|
||||
so a multi-gigabyte archive isn't copied, or a copy where links aren't
|
||||
possible."""
|
||||
import shutil
|
||||
|
||||
staged.unlink(missing_ok=True)
|
||||
try:
|
||||
staged.symlink_to(archive_path)
|
||||
except OSError:
|
||||
shutil.copy2(archive_path, staged)
|
||||
|
||||
|
||||
def _archive_folder_name(original_filename: str | None, attachment_id: int) -> str:
|
||||
"""The library folder a post-less archive's members land in: the archive's
|
||||
own name without its extension, made safe for a path."""
|
||||
stem = Path(original_filename or "").stem.strip().strip(".")
|
||||
stem = "".join(c if c not in '/\\:*?"<>|' else "_" for c in stem)[:120].strip()
|
||||
return stem or f"archive-{attachment_id}"
|
||||
|
||||
|
||||
def _reextract_archive_to_post(
|
||||
importer, archive_path: Path, post, source_row, artist, images_root: Path,
|
||||
*, folder: str = "",
|
||||
) -> list[int]:
|
||||
"""Extract one stored archive and link its members to `post`.
|
||||
"""Extract one stored archive and import its members.
|
||||
|
||||
The stored attachment has no adjacent sidecar (it lives in the sha-addressed
|
||||
attachment store). Stage a copy + a reconstructed sidecar UNDER the artist's
|
||||
library dir (`images_root/<slug>/<platform>/<post>/`) — the importer
|
||||
re-derives the artist from the path AND copies members relative to it, so the
|
||||
members land in the real library and resolve to the right artist — then re-run
|
||||
`attach_in_place`: the archive extracts and `find_or_create_post` re-attaches
|
||||
the members to the SAME Post (source_id + external_post_id). Removes only the
|
||||
staged archive + sidecar afterward; the imported member files stay. Returns
|
||||
the new member image ids.
|
||||
attachment store), so the archive is staged under the artist's library dir
|
||||
and `attach_in_place` re-run on it: the importer derives the artist from the
|
||||
path and copies members relative to it, so they land in the real library.
|
||||
|
||||
- With a `post`, the stage is `images_root/<slug>/<platform>/<post>/` beside
|
||||
a reconstructed sidecar, so `find_or_create_post` re-attaches the members
|
||||
to that same Post (source_id + external_post_id).
|
||||
- Without one (a filesystem import that had no sidecar), the stage is
|
||||
`images_root/<slug>/<folder>/` with no sidecar, and the members belong to
|
||||
the artist alone, as a filesystem import of the archive would have left
|
||||
them.
|
||||
|
||||
Only the staged archive and sidecar are removed afterward; the imported
|
||||
member files stay. Returns the new member image ids.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
|
||||
from .archive_extractor import detect_archive_format
|
||||
|
||||
fmt = detect_archive_format(archive_path)
|
||||
ext = _ARCHIVE_EXT_FOR_FORMAT.get(fmt or "", ".zip")
|
||||
platform = source_row.platform if source_row is not None else "imported"
|
||||
sidecar = {
|
||||
"category": source_row.platform if source_row is not None
|
||||
else (post.raw_metadata or {}).get("category"),
|
||||
"id": post.external_post_id,
|
||||
"title": post.post_title or "",
|
||||
"content": post.description or "",
|
||||
"published_at": post.post_date.isoformat() if post.post_date else None,
|
||||
"url": post.post_url,
|
||||
}
|
||||
work = images_root / artist.slug / platform / str(post.external_post_id)
|
||||
if post is not None:
|
||||
platform = source_row.platform if source_row is not None else "imported"
|
||||
work = images_root / artist.slug / platform / str(post.external_post_id)
|
||||
else:
|
||||
work = images_root / artist.slug / folder
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
staged = work / f"archive{ext}" # clean ext → is_archive + find_sidecar
|
||||
sidecar_path = staged.with_suffix(".json")
|
||||
try:
|
||||
shutil.copy2(archive_path, staged)
|
||||
sidecar_path.write_text(json.dumps(sidecar))
|
||||
_stage_archive(archive_path, staged)
|
||||
if post is not None:
|
||||
sidecar_path.write_text(json.dumps({
|
||||
"category": source_row.platform if source_row is not None
|
||||
else (post.raw_metadata or {}).get("category"),
|
||||
"id": post.external_post_id,
|
||||
"title": post.post_title or "",
|
||||
"content": post.description or "",
|
||||
"published_at": post.post_date.isoformat() if post.post_date else None,
|
||||
"url": post.post_url,
|
||||
}))
|
||||
res = importer.attach_in_place(staged, artist=artist, source=source_row)
|
||||
return list(res.member_image_ids or [])
|
||||
finally:
|
||||
@@ -1124,8 +1154,9 @@ def reextract_archive_attachments(
|
||||
|
||||
summary = {
|
||||
"scanned": 0, "archives": 0, "members_imported": 0,
|
||||
"posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0,
|
||||
"errors": 0, "partial": False, "resume_after_id": after_id,
|
||||
"posts_touched": 0, "without_post": 0, "already_extracted": 0,
|
||||
"skipped_no_artist": 0, "errors": 0, "partial": False,
|
||||
"resume_after_id": after_id,
|
||||
}
|
||||
settings = ImportSettings.load_sync(session)
|
||||
importer = Importer(
|
||||
@@ -1150,15 +1181,17 @@ def reextract_archive_attachments(
|
||||
except OSError:
|
||||
continue
|
||||
summary["archives"] += 1
|
||||
if att.post_id is None:
|
||||
summary["skipped_no_post"] += 1
|
||||
continue
|
||||
post = session.get(Post, att.post_id)
|
||||
if post is None:
|
||||
summary["skipped_no_post"] += 1
|
||||
# Members already recorded as coming from this archive: done before.
|
||||
if session.execute(
|
||||
select(ImageProvenance.id)
|
||||
.where(ImageProvenance.from_attachment_id == att.id)
|
||||
.limit(1)
|
||||
).first() is not None:
|
||||
summary["already_extracted"] += 1
|
||||
continue
|
||||
post = session.get(Post, att.post_id) if att.post_id else None
|
||||
artist = session.get(Artist, att.artist_id) if att.artist_id else None
|
||||
if artist is None and post.artist_id:
|
||||
if artist is None and post is not None and post.artist_id:
|
||||
artist = session.get(Artist, post.artist_id)
|
||||
if artist is None or not artist.slug:
|
||||
# The importer re-derives the artist from the staged path, so we need
|
||||
@@ -1166,20 +1199,35 @@ def reextract_archive_attachments(
|
||||
# subscription posts; skip rather than orphan the members.)
|
||||
summary["skipped_no_artist"] += 1
|
||||
continue
|
||||
source_row = session.get(Source, post.source_id) if post.source_id else None
|
||||
source_row = (
|
||||
session.get(Source, post.source_id)
|
||||
if post is not None and post.source_id else None
|
||||
)
|
||||
if post is None:
|
||||
summary["without_post"] += 1
|
||||
# Logged on the way in as well as out, so an archive that stalls a
|
||||
# chunk is named in the log even if the worker dies holding it.
|
||||
size_mb = (att.size_bytes or 0) / 1e6
|
||||
log.info("re-extract: attachment %s (%.0f MB) starting", att.id, size_mb)
|
||||
archive_start = time.monotonic()
|
||||
try:
|
||||
ids = _reextract_archive_to_post(
|
||||
importer, stored, post, source_row, artist, images_root,
|
||||
folder=_archive_folder_name(att.original_filename, att.id),
|
||||
)
|
||||
session.commit()
|
||||
except Exception as exc: # one bad archive must not strand the rest
|
||||
session.rollback()
|
||||
summary["errors"] += 1
|
||||
log.warning("re-extract failed for attachment %s: %s", att.id, exc)
|
||||
log.warning("re-extract failed for attachment %s after %.1fs: %s",
|
||||
att.id, time.monotonic() - archive_start, exc)
|
||||
continue
|
||||
log.info("re-extract: attachment %s done in %.1fs, %d new image(s)",
|
||||
att.id, time.monotonic() - archive_start, len(ids))
|
||||
if ids:
|
||||
summary["members_imported"] += len(ids)
|
||||
summary["posts_touched"] += 1
|
||||
if post is not None:
|
||||
summary["posts_touched"] += 1
|
||||
enqueue_ids.extend(ids)
|
||||
|
||||
# Time-box the chunk. resume_after_id already points at this attachment,
|
||||
|
||||
@@ -24,6 +24,7 @@ rows undecryptable (recovery = delete the rows and re-upload).
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
@@ -80,10 +81,59 @@ class CredentialCrypto:
|
||||
parent = self._key_path.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(parent, 0o700)
|
||||
|
||||
# Written to a temp file and LINKED into place, not written directly.
|
||||
#
|
||||
# hypercorn starts several worker processes and each one builds the
|
||||
# app, so on a first boot they all reach this at once. A plain
|
||||
# `write_bytes` creates the file at size zero and fills it a moment
|
||||
# later, which gives a second process an `exists()` of True and a
|
||||
# `read_bytes()` of b"" — and the app dies with
|
||||
#
|
||||
# ValueError: Fernet key must be 32 url-safe base64-encoded bytes.
|
||||
#
|
||||
# Seen on run 7368's smoke, and it is a race rather than a certainty:
|
||||
# the same image had booted cleanly on the three runs before it. A
|
||||
# first boot that fails one time in five is worse than one that fails
|
||||
# every time, because it looks like the deployment rather than the code.
|
||||
#
|
||||
# `os.link` is the atomic part: it either creates the name or raises
|
||||
# FileExistsError, and it cannot expose a half-written file. NOT
|
||||
# `os.replace`, which would succeed — so two processes that both
|
||||
# generated a key would each think they had won, and the loser's key
|
||||
# would overwrite the one the winner had already handed to Fernet.
|
||||
# `mkstemp`, not a pid-derived name. The first cut spelled the temp
|
||||
# file `.credential_key.b64.<pid>.tmp`, which assumes one bootstrap per
|
||||
# process — and the test that exercises this with eight THREADS shares
|
||||
# one pid, so all eight raced the same filename and six died with
|
||||
# FileNotFoundError when another had already unlinked it. The
|
||||
# assumption held for hypercorn's workers and would have held in
|
||||
# production; it was still an assumption the code did not need to make.
|
||||
key = Fernet.generate_key()
|
||||
self._key_path.write_bytes(key)
|
||||
os.chmod(self._key_path, 0o600)
|
||||
return key
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=parent, prefix=f".{self._key_path.name}.", suffix=".tmp",
|
||||
)
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(key)
|
||||
os.chmod(tmp, 0o600)
|
||||
try:
|
||||
os.link(tmp, self._key_path)
|
||||
except FileExistsError:
|
||||
# Another process created it between our `exists()` check and
|
||||
# here. Theirs is as good as ours, and using it is what keeps
|
||||
# every worker on ONE key.
|
||||
log.info(
|
||||
"another process created %s first; using that key",
|
||||
self._key_path,
|
||||
)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
# Read back rather than returning `key`: on the losing branch the file
|
||||
# holds somebody else's, and returning ours would leave this worker
|
||||
# encrypting with a key no other worker can read.
|
||||
return self._key_path.read_bytes()
|
||||
|
||||
def encrypt(self, plaintext: str) -> bytes:
|
||||
return self._fernet.encrypt(plaintext.encode("utf-8"))
|
||||
|
||||
@@ -16,12 +16,12 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import Select, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Source
|
||||
from .gallery_dl import ErrorType
|
||||
from .download_result import ErrorType
|
||||
|
||||
|
||||
async def get_or_create[T](
|
||||
@@ -67,12 +67,26 @@ async def get_or_create[T](
|
||||
|
||||
|
||||
def failing_sources_clause():
|
||||
"""A source is FAILING when its runs are actually erroring.
|
||||
"""A source is FAILING when it is ENABLED and its runs are erroring.
|
||||
|
||||
Deliberately not `last_error IS NOT NULL` — a tier-limited source clears
|
||||
last_error and keeps a chip, and must never be counted as broken.
|
||||
|
||||
The `enabled` half was folded in 2026-09-21 (#4279). A disabled source is
|
||||
one FC deliberately stopped — most often because the membership sweep saw
|
||||
`former_patron` — and "stopped because you no longer subscribe" is not
|
||||
"failing". Worse, it is a failure nobody can clear: a disabled source is
|
||||
never scheduled, so no successful run ever resets the counter, and the
|
||||
card's Retry button routes to `/check`, which refuses a disabled source
|
||||
outright. Ebi77 sat in the banner for six days with no action available.
|
||||
|
||||
This also settles a disagreement the two callers already had. The
|
||||
scheduler's status count paired this clause with `enabled.is_(True)`;
|
||||
`SourceService.list(failing=True)` did not. One counted Ebi77, the other
|
||||
did not — the exact drift the note above this function warns about, which
|
||||
is why the `enabled` test belongs IN the predicate rather than beside it.
|
||||
"""
|
||||
return Source.consecutive_failures > 0
|
||||
return and_(Source.enabled.is_(True), Source.consecutive_failures > 0)
|
||||
|
||||
|
||||
def no_access_sources_clause():
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
"""Native Discord read client — the Discord counterpart to subscribestar_client.
|
||||
|
||||
Mirrors gallery-dl 1.32.13's `extractor/discord.py` (rule 130: gallery-dl is the
|
||||
known-working base), adapted to the native core's client contract
|
||||
(`ingest_core` module docstring): `iter_posts` / `extract_media`, plus the
|
||||
post-first `post_record_key` and the `post_meta` date the revisit window reads.
|
||||
|
||||
What is mirrored exactly, because drift in any of it changes what we fetch or
|
||||
where it lands on disk:
|
||||
|
||||
- API v10, `Authorization: <user token>` (a USER token, not a bot token).
|
||||
- gallery-dl's request profile: its date-derived Firefox User-Agent,
|
||||
`Accept: */*`, `Accept-Language`, `Referer: https://discord.com/`.
|
||||
- `GET /channels/{id}/messages?limit=100&before=<last id>`, newest first,
|
||||
stopping on a short page. Message types {0, 19, 21} only.
|
||||
- The walk: a text/news channel's own messages then its threads, a forum's
|
||||
threads, a category's children, a server's text/news/forum channels.
|
||||
- Files: attachments, then embeds of type image/gifv/video (FC configures
|
||||
`embeds: all`, which for files is the same three plus rich/link embeds that
|
||||
carry an image), then forwarded `message_snapshots`, numbered from 1 across
|
||||
the lot — the `num` in `{date}_{message_id}_{num}_{filename}`.
|
||||
- Text: `content`, rich-embed author/title/description/fields/footer, poll.
|
||||
|
||||
Two deliberate departures, both about the walk order, neither about content:
|
||||
|
||||
- Threads are walked newest-CREATED first (by id), not by last-message time.
|
||||
A backfill resumes from a checkpointed channel; last-message order shifts
|
||||
between chunks whenever someone posts, which can move an unwalked thread
|
||||
above the resume point and skip it. Creation order only ever grows at the
|
||||
front, where the next tick finds it.
|
||||
- A 403 on a thread or a nested channel skips that feed instead of failing
|
||||
the walk. gallery-dl skips only nested channels; one private thread the
|
||||
token cannot read would otherwise stop every channel after it.
|
||||
|
||||
And one about content: a message is taken only if it has an image or video
|
||||
ATTACHMENT (`has_visual_attachment`). Discord is where creators chat as well
|
||||
as post, and the operator wants the art, not the conversation (2026-09-28): a
|
||||
text line, a lone archive or PSD, a pasted link's preview or a Tenor GIF is
|
||||
chat. A message that passes keeps every file gallery-dl would take, numbered
|
||||
as gallery-dl numbers them, so the on-disk names still match.
|
||||
|
||||
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import date
|
||||
from urllib.parse import unquote
|
||||
|
||||
import requests
|
||||
|
||||
from .native_ingest_common import (
|
||||
MediaItem,
|
||||
NativeAuthError,
|
||||
NativeDriftError,
|
||||
NativeIngestError,
|
||||
retry_after_seconds,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
API_ROOT = "https://discord.com/api/v10"
|
||||
_ROOT = "https://discord.com"
|
||||
|
||||
_TIMEOUT_SECONDS = 60.0
|
||||
_MESSAGES_BATCH = 100
|
||||
_THREADS_BATCH = 25
|
||||
# gallery-dl retries a 429 up to its default 4 retries, waiting
|
||||
# `request_interval_429` (60s) between them. Discord's Retry-After is exact, so
|
||||
# it is honoured when present; 60s is the fallback and the cap.
|
||||
_MAX_429_RETRIES = 4
|
||||
_429_WAIT_SECONDS = 60.0
|
||||
# How far back the poster picker looks: two pages. Enough to see who posts in
|
||||
# a channel, few enough to answer while the operator waits (#4491).
|
||||
_POSTER_SCAN = 200
|
||||
|
||||
# https://discord.com/developers/docs/resources/message#message-object-message-types
|
||||
# DEFAULT, REPLY, CHAT_INPUT_COMMAND — the ones that carry user content.
|
||||
MESSAGE_TYPES = frozenset({0, 19, 21})
|
||||
# https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
_TEXT = frozenset({0, 5}) # text, announcement: messages + threads
|
||||
_DIRECT = frozenset({1, 3, 10, 11, 12}) # DMs and threads: messages only
|
||||
_FORUM = frozenset({15, 16}) # forum, media: threads only
|
||||
_CATEGORY = 4
|
||||
_SERVER_WALK = _TEXT | _FORUM
|
||||
_EMBED_TYPES = frozenset({"image", "gifv", "video"})
|
||||
# What makes a message art rather than chat: an attached file FC imports as an
|
||||
# image or video. Mirrors importer.ALL_EXTS (not imported: that module is the
|
||||
# import pipeline, and this one is a network client).
|
||||
_VISUAL_EXTS = frozenset({
|
||||
"png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff",
|
||||
"mp4", "mov", "avi", "mkv", "webm", "m4v", "wmv", "flv",
|
||||
})
|
||||
|
||||
_URL_RE = re.compile(
|
||||
r"^(?:https?://)?(?:www\.|ptb\.|canary\.)?discord(?:app)?\.com/channels/"
|
||||
r"(?P<server>@me|\d+)(?:/(?:\d+/threads/)?(?P<channel>\d+))?(?P<rest>/.*)?/?$"
|
||||
)
|
||||
|
||||
|
||||
class DiscordAPIError(NativeIngestError):
|
||||
"""Base for native Discord client failures."""
|
||||
|
||||
|
||||
class DiscordAuthError(DiscordAPIError, NativeAuthError):
|
||||
"""401 (the token is invalid or expired) or a 403 on the channel the
|
||||
source names. The fix is a new token, not a new client."""
|
||||
|
||||
|
||||
class DiscordDriftError(DiscordAPIError, NativeDriftError):
|
||||
"""A response did not have the shape the walk depends on."""
|
||||
|
||||
|
||||
def firefox_user_agent(today: date | None = None) -> str:
|
||||
"""gallery-dl's default User-Agent: a Firefox whose version advances every
|
||||
four weeks (`util._ff_ver`, "147 on 2026-01-13"). Computed the same way so
|
||||
the profile keeps matching the gallery-dl this replaced."""
|
||||
ver = ((today or date.today()).toordinal() - 735_513) // 28
|
||||
return (
|
||||
f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{ver}.0) "
|
||||
f"Gecko/20100101 Firefox/{ver}.0"
|
||||
)
|
||||
|
||||
|
||||
def nameext_from_url(url: str) -> tuple[str, str]:
|
||||
"""gallery-dl's `text.nameext_from_url`: the URL's last path segment,
|
||||
unquoted, split at the last dot when the extension is at most 16 chars
|
||||
(lowercased); otherwise the whole name and no extension."""
|
||||
filename = unquote(url.partition("?")[0].rpartition("/")[2])
|
||||
name, _, ext = filename.rpartition(".")
|
||||
if name and len(ext) <= 16:
|
||||
return name, ext.lower()
|
||||
return filename, ""
|
||||
|
||||
|
||||
def parse_source_url(url: str) -> tuple[str | None, str | None]:
|
||||
"""`(server_id, channel_id)` from a Discord channel/server URL. `server_id`
|
||||
is None for a DM (`@me`); `channel_id` is None for a whole server. Raises
|
||||
DiscordAPIError for anything else, including a link to a single message —
|
||||
a message is not something a source can subscribe to."""
|
||||
m = _URL_RE.match((url or "").strip())
|
||||
if not m or (m.group("rest") or "").strip("/"):
|
||||
raise DiscordAPIError(
|
||||
f"Not a Discord channel or server link: {url!r} "
|
||||
"(expected https://discord.com/channels/<server>[/<channel>])"
|
||||
)
|
||||
server = m.group("server")
|
||||
channel = m.group("channel")
|
||||
if server == "@me":
|
||||
if not channel:
|
||||
raise DiscordAPIError(f"A DM link needs a channel id: {url!r}")
|
||||
return None, channel
|
||||
return server, channel
|
||||
|
||||
|
||||
def message_text(message: dict) -> str:
|
||||
"""gallery-dl's `extract_message_text`: the body plus the text of rich
|
||||
embeds and polls, newline-joined, empties dropped."""
|
||||
parts = [message.get("content") or ""]
|
||||
for embed in message.get("embeds") or []:
|
||||
if embed.get("type") != "rich":
|
||||
continue
|
||||
parts.append((embed.get("author") or {}).get("name") or "")
|
||||
parts.append(embed.get("title") or "")
|
||||
parts.append(embed.get("description") or "")
|
||||
for fld in embed.get("fields") or []:
|
||||
parts.append(fld.get("name") or "")
|
||||
parts.append(fld.get("value") or "")
|
||||
parts.append((embed.get("footer") or {}).get("text") or "")
|
||||
poll = message.get("poll")
|
||||
if poll:
|
||||
parts.append(((poll.get("question") or {}).get("text")) or "")
|
||||
for answer in poll.get("answers") or []:
|
||||
parts.append(((answer.get("poll_media") or {}).get("text")) or "")
|
||||
return "\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _message_and_snapshots(message: dict) -> list[dict]:
|
||||
"""The message itself, then each forwarded message it carries."""
|
||||
return [message] + [
|
||||
(s or {}).get("message") or {}
|
||||
for s in message.get("message_snapshots") or []
|
||||
if ((s or {}).get("message") or {}).get("type", 0) in MESSAGE_TYPES
|
||||
]
|
||||
|
||||
|
||||
def _is_visual(attachment: dict) -> bool:
|
||||
ctype = (attachment.get("content_type") or "").lower()
|
||||
if ctype.startswith(("image/", "video/")):
|
||||
return True
|
||||
name = attachment.get("filename") or attachment.get("url") or ""
|
||||
return nameext_from_url(name)[1] in _VISUAL_EXTS
|
||||
|
||||
|
||||
def has_visual_attachment(message: dict) -> bool:
|
||||
"""Does this message, or a message it forwards, have an image or video
|
||||
attached? The one test for "art, not chat" — see the module docstring."""
|
||||
return any(
|
||||
att.get("url") and _is_visual(att)
|
||||
for snap in _message_and_snapshots(message)
|
||||
for att in snap.get("attachments") or []
|
||||
)
|
||||
|
||||
|
||||
_SQUASH = re.compile(r"[\W_]+", re.UNICODE)
|
||||
# Words a server name wraps around its creator's: "Todding's Server",
|
||||
# "The Official Todding Discord".
|
||||
_SERVER_WORDS = frozenset({"server", "discord", "the", "official", "community", "s"})
|
||||
|
||||
|
||||
def _squash(text: str | None) -> str:
|
||||
return _SQUASH.sub("", (text or "").lower())
|
||||
|
||||
|
||||
def _server_tokens(server_name: str | None) -> list[str]:
|
||||
name = (server_name or "").lower().replace("'s", " ").replace("\u2019s", " ")
|
||||
words = [w for w in _SQUASH.split(name) if w and w not in _SERVER_WORDS]
|
||||
tokens = [w for w in words if len(w) >= 3]
|
||||
joined = "".join(words)
|
||||
if len(joined) >= 3 and joined not in tokens:
|
||||
tokens.append(joined)
|
||||
return tokens
|
||||
|
||||
|
||||
def rank_posters(server_name: str | None, owner_id, posters: list[dict]) -> list[dict]:
|
||||
"""Mark who is probably the creator a server is named for, and order the
|
||||
list for picking: suggested first, then by images posted, then messages.
|
||||
|
||||
Two signals, each a reason the picker shows beside the name:
|
||||
- they own the server;
|
||||
- their username or display name matches the server's name, once
|
||||
"'s Server", "Official", "Discord" and punctuation are taken off
|
||||
("Todding's Server" and "todding").
|
||||
Neither signal alone is proof, which is why this suggests and never
|
||||
decides: the operator ticks the list. Bots are never suggested."""
|
||||
tokens = _server_tokens(server_name)
|
||||
owner = str(owner_id) if owner_id else None
|
||||
ranked = []
|
||||
for p in posters:
|
||||
reasons = []
|
||||
if owner and p.get("id") == owner:
|
||||
reasons.append("owns the server")
|
||||
names = [n for n in (_squash(p.get("username")), _squash(p.get("global_name"))) if len(n) >= 3]
|
||||
if any(t in n or n in t for t in tokens for n in names):
|
||||
reasons.append("name matches the server")
|
||||
ranked.append({**p, "reasons": reasons, "suggested": False})
|
||||
best = max((len(r["reasons"]) for r in ranked if not r.get("bot")), default=0)
|
||||
for r in ranked:
|
||||
r["suggested"] = best > 0 and not r.get("bot") and len(r["reasons"]) == best
|
||||
ranked.sort(key=lambda r: (not r["suggested"], -r.get("images", 0), -r.get("messages", 0)))
|
||||
return ranked
|
||||
|
||||
|
||||
class DiscordClient:
|
||||
"""Synchronous Discord API v10 read client for one user token."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str | None,
|
||||
*,
|
||||
request_sleep: float = 0.0,
|
||||
max_retries: int = _MAX_429_RETRIES,
|
||||
session: requests.Session | None = None,
|
||||
):
|
||||
self._session = session or requests.Session()
|
||||
self._session.headers.update({
|
||||
"User-Agent": firefox_user_agent(),
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Referer": _ROOT + "/",
|
||||
})
|
||||
if token:
|
||||
self._session.headers["Authorization"] = token
|
||||
self._token = token
|
||||
self._request_sleep = request_sleep or 0.0
|
||||
self._max_retries = max_retries
|
||||
self._server: dict = {}
|
||||
self._channels: dict[str, dict] = {}
|
||||
self._skip_feed = False
|
||||
# Whose messages the walk takes (`only_from`); empty takes everyone's.
|
||||
self._authors: frozenset[str] = frozenset()
|
||||
|
||||
# -- request -----------------------------------------------------------
|
||||
|
||||
def _get(self, endpoint: str, params: dict | None = None):
|
||||
if not self._token:
|
||||
raise DiscordAuthError("No Discord token is configured for this source")
|
||||
if self._request_sleep > 0:
|
||||
time.sleep(self._request_sleep)
|
||||
url = API_ROOT + endpoint
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
resp = self._session.get(url, params=params, timeout=_TIMEOUT_SECONDS)
|
||||
except requests.RequestException as exc:
|
||||
raise DiscordAPIError(f"Discord request failed ({endpoint}): {exc}") from exc
|
||||
if resp.status_code == 429 and attempt < self._max_retries:
|
||||
attempt += 1
|
||||
delay = retry_after_seconds(
|
||||
resp, attempt, base=_429_WAIT_SECONDS, cap=_429_WAIT_SECONDS,
|
||||
)
|
||||
log.warning(
|
||||
"Discord 429 (%s) — waiting %.1fs (retry %d/%d)",
|
||||
endpoint, delay, attempt, self._max_retries,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
break
|
||||
if resp.status_code == 401:
|
||||
raise DiscordAuthError(
|
||||
"Discord rejected the token (HTTP 401) — it is invalid or has "
|
||||
"expired; copy a fresh one from the browser",
|
||||
status_code=401,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise DiscordAPIError(
|
||||
f"Discord returned HTTP {resp.status_code} ({endpoint})",
|
||||
status_code=resp.status_code,
|
||||
retry_after=_retry_after(resp),
|
||||
)
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
raise DiscordDriftError(
|
||||
f"Discord returned non-JSON for {endpoint} ({len(resp.content)} bytes)"
|
||||
) from exc
|
||||
|
||||
# -- metadata (gallery-dl parse_server / parse_channel) -----------------
|
||||
|
||||
def _load_server(self, server_id: str) -> None:
|
||||
server = self._get(f"/guilds/{server_id}")
|
||||
if not isinstance(server, dict) or "id" not in server:
|
||||
raise DiscordDriftError(f"Discord server {server_id} came back without an id")
|
||||
self._server = {
|
||||
"server": server.get("name") or "",
|
||||
"server_id": str(server["id"]),
|
||||
"owner_id": server.get("owner_id"),
|
||||
}
|
||||
channels = self._get(f"/guilds/{server_id}/channels")
|
||||
if not isinstance(channels, list):
|
||||
raise DiscordDriftError(f"Discord server {server_id} channel list is not a list")
|
||||
# Categories first, so every child can name its parent.
|
||||
for channel in sorted(channels, key=lambda ch: ch.get("type") != _CATEGORY):
|
||||
self._parse_channel(channel)
|
||||
|
||||
def _parse_channel(self, channel: dict) -> dict:
|
||||
parent_id = channel.get("parent_id")
|
||||
meta = {
|
||||
"channel": channel.get("name") or "",
|
||||
"channel_id": str(channel.get("id")),
|
||||
"channel_type": channel.get("type"),
|
||||
"channel_topic": channel.get("topic") or "",
|
||||
"parent_id": parent_id,
|
||||
"is_thread": "thread_metadata" in channel,
|
||||
}
|
||||
parent = self._channels.get(parent_id) if parent_id else None
|
||||
if parent:
|
||||
meta["parent"] = parent["channel"]
|
||||
meta["parent_type"] = parent["channel_type"]
|
||||
if meta["channel_type"] in {1, 3}:
|
||||
recipients = channel.get("recipients") or []
|
||||
meta["channel"] = "DMs"
|
||||
meta["recipients"] = [u.get("username") for u in recipients]
|
||||
meta["recipients_id"] = [u.get("id") for u in recipients]
|
||||
self._channels[meta["channel_id"]] = meta
|
||||
return meta
|
||||
|
||||
def _channel_meta(self, channel_id: str) -> dict:
|
||||
if channel_id not in self._channels:
|
||||
self._parse_channel(self._get(f"/channels/{channel_id}"))
|
||||
return self._channels[channel_id]
|
||||
|
||||
def _threads(self, channel_id: str) -> list[dict]:
|
||||
"""Every thread of a channel or forum, newest-created first (see the
|
||||
module docstring for why not last-message order)."""
|
||||
threads: list[dict] = []
|
||||
offset = 0
|
||||
while True:
|
||||
data = self._get(f"/channels/{channel_id}/threads/search", {
|
||||
"sort_by": "last_message_time",
|
||||
"sort_order": "desc",
|
||||
"limit": _THREADS_BATCH,
|
||||
"offset": offset,
|
||||
})
|
||||
batch = (data.get("threads") or []) if isinstance(data, dict) else []
|
||||
threads.extend(batch)
|
||||
if len(batch) < _THREADS_BATCH:
|
||||
break
|
||||
offset += len(batch)
|
||||
threads.sort(key=lambda t: int(t.get("id") or 0), reverse=True)
|
||||
return threads
|
||||
|
||||
# -- the walk ------------------------------------------------------------
|
||||
|
||||
def _feeds(self, channel_id: str, *, safe: bool) -> Iterator[tuple[str, bool]]:
|
||||
"""`(channel_id, safe)` for every message feed under `channel_id`, in
|
||||
gallery-dl's order. `safe` feeds are skipped on a 403."""
|
||||
try:
|
||||
ctype = self._channel_meta(channel_id)["channel_type"]
|
||||
except DiscordAPIError as exc:
|
||||
if exc.status_code != 403:
|
||||
raise
|
||||
if not safe:
|
||||
raise DiscordAuthError(
|
||||
f"The Discord token cannot see channel {channel_id} (HTTP 403)",
|
||||
status_code=403,
|
||||
) from exc
|
||||
log.info("Discord: no access to channel %s — skipped", channel_id)
|
||||
return
|
||||
if ctype in _TEXT or ctype in _DIRECT:
|
||||
yield channel_id, safe
|
||||
if ctype in _TEXT or ctype in _FORUM:
|
||||
try:
|
||||
threads = self._threads(channel_id)
|
||||
except DiscordAPIError as exc:
|
||||
if exc.status_code != 403:
|
||||
raise
|
||||
log.info("Discord: cannot list threads of %s — skipped", channel_id)
|
||||
threads = []
|
||||
for thread in threads:
|
||||
yield self._parse_channel(thread)["channel_id"], True
|
||||
elif ctype == _CATEGORY:
|
||||
for child in list(self._channels.values()):
|
||||
if child.get("parent_id") == channel_id:
|
||||
yield from self._feeds(child["channel_id"], safe=True)
|
||||
elif ctype not in _DIRECT and not safe:
|
||||
raise DiscordAPIError(
|
||||
f"Discord channel {channel_id} is of type {ctype}, which has no messages"
|
||||
)
|
||||
|
||||
def _source_feeds(self, url: str) -> Iterator[tuple[str, bool]]:
|
||||
server_id, channel_id = parse_source_url(url)
|
||||
self._server, self._channels = {}, {}
|
||||
if server_id is not None:
|
||||
self._load_server(server_id)
|
||||
if channel_id is not None:
|
||||
yield from self._feeds(channel_id, safe=False)
|
||||
return
|
||||
for meta in list(self._channels.values()):
|
||||
if meta["channel_type"] in _SERVER_WALK:
|
||||
yield from self._feeds(meta["channel_id"], safe=True)
|
||||
|
||||
def only_from(self, authors) -> None:
|
||||
"""Take only messages posted by these people: user ids, usernames or
|
||||
display names, any case. A creator's server is full of other members
|
||||
posting their own pictures; the source is subscribed to the creator.
|
||||
Empty or None takes everyone's, as before."""
|
||||
self._authors = frozenset(
|
||||
str(a).strip().lower() for a in authors or () if str(a).strip()
|
||||
)
|
||||
|
||||
def _author_wanted(self, message: dict) -> bool:
|
||||
if not self._authors:
|
||||
return True
|
||||
author = message.get("author") or {}
|
||||
names = (author.get("id"), author.get("username"), author.get("global_name"))
|
||||
return any(str(n).lower() in self._authors for n in names if n)
|
||||
|
||||
def source_label(self, url: str) -> str | None:
|
||||
"""What the source is called in Discord — `Server · #channel`, a
|
||||
thread as `#parent › thread`, a whole server by its name — from the
|
||||
metadata the walk loaded. None when the walk never got that far."""
|
||||
try:
|
||||
_, channel_id = parse_source_url(url)
|
||||
except DiscordAPIError:
|
||||
return None
|
||||
server = self._server.get("server") or None
|
||||
if channel_id is None:
|
||||
return server
|
||||
meta = self._channels.get(channel_id) or {}
|
||||
name = meta.get("channel")
|
||||
if not name:
|
||||
return None
|
||||
if name != "DMs":
|
||||
name = f"#{name}"
|
||||
if meta.get("is_thread") and meta.get("parent"):
|
||||
name = f"#{meta['parent']} › {meta['channel']}"
|
||||
return f"{server} · {name}" if server else name
|
||||
|
||||
def skip_feed(self) -> None:
|
||||
"""Optional core seam (#4413): end the current channel and go on to the
|
||||
next one. A tick's early-out means THIS channel has nothing new, not
|
||||
that the server has nothing new."""
|
||||
self._skip_feed = True
|
||||
|
||||
def iter_posts(
|
||||
self, campaign_id: str, cursor: str | None = None
|
||||
) -> Iterator[tuple[dict, dict, str | None]]:
|
||||
"""Yield `(message, channel_meta, page_cursor)` for every content
|
||||
message the source reaches, channel by channel, each newest first.
|
||||
|
||||
`campaign_id` is the source URL. The cursor is `<channel_id>:<before>`
|
||||
— the channel and the `before` id that fetched the page (empty for a
|
||||
channel's first page) — so a backfill resumes inside the right channel
|
||||
and re-fetches the page it was cut in. A cursor naming a channel the
|
||||
walk no longer reaches (a deleted thread) restarts from the top rather
|
||||
than walking nothing.
|
||||
"""
|
||||
resume_channel, _, resume_before = (cursor or "").partition(":")
|
||||
resuming = bool(resume_channel)
|
||||
feeds = list(self._source_feeds(campaign_id)) if resuming else None
|
||||
if feeds is not None and resume_channel not in {cid for cid, _ in feeds}:
|
||||
log.warning(
|
||||
"Discord: resume channel %s is no longer in %s — restarting",
|
||||
resume_channel, campaign_id,
|
||||
)
|
||||
resuming = False
|
||||
for channel_id, safe in feeds if feeds is not None else self._source_feeds(campaign_id):
|
||||
before = None
|
||||
if resuming:
|
||||
if channel_id != resume_channel:
|
||||
continue
|
||||
resuming = False
|
||||
before = resume_before or None
|
||||
yield from self._iter_channel(channel_id, before, safe=safe)
|
||||
|
||||
def _iter_channel(
|
||||
self, channel_id: str, before: str | None, *, safe: bool
|
||||
) -> Iterator[tuple[dict, dict, str | None]]:
|
||||
self._skip_feed = False
|
||||
meta = {**self._server, **self._channels.get(channel_id, {})}
|
||||
while True:
|
||||
page_cursor = f"{channel_id}:{before or ''}"
|
||||
try:
|
||||
messages = self._get(
|
||||
f"/channels/{channel_id}/messages",
|
||||
{"limit": _MESSAGES_BATCH, "before": before},
|
||||
)
|
||||
except DiscordAPIError as exc:
|
||||
if exc.status_code != 403:
|
||||
raise
|
||||
if not safe:
|
||||
raise DiscordAuthError(
|
||||
f"The Discord token cannot read channel {channel_id} (HTTP 403)",
|
||||
status_code=403,
|
||||
) from exc
|
||||
log.info("Discord: no access to messages of %s — skipped", channel_id)
|
||||
return
|
||||
if not isinstance(messages, list):
|
||||
raise DiscordDriftError(
|
||||
f"Discord messages of {channel_id} came back as "
|
||||
f"{type(messages).__name__}, not a list"
|
||||
)
|
||||
for message in messages:
|
||||
if message.get("type") not in MESSAGE_TYPES:
|
||||
continue
|
||||
if not self._author_wanted(message):
|
||||
continue
|
||||
message["_meta"] = meta
|
||||
yield message, meta, page_cursor
|
||||
if self._skip_feed:
|
||||
return
|
||||
if len(messages) < _MESSAGES_BATCH:
|
||||
return
|
||||
before = str(messages[-1]["id"])
|
||||
|
||||
# -- per-message -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def extract_media(post: dict, included: dict | None = None) -> list[MediaItem]:
|
||||
"""gallery-dl's file list for one message: attachments, then the first
|
||||
of video/image/thumbnail `proxy_url` of each file-bearing embed, then
|
||||
the same for every forwarded snapshot; numbered from 1 across them.
|
||||
|
||||
Empty for a message with no image or video attached: that is chat,
|
||||
whatever else it carries (`has_visual_attachment`)."""
|
||||
if not has_visual_attachment(post):
|
||||
return []
|
||||
mid = str(post.get("id") or "")
|
||||
snapshots = _message_and_snapshots(post)
|
||||
found: list[tuple[str, str, str | None]] = []
|
||||
for snap in snapshots:
|
||||
for att in snap.get("attachments") or []:
|
||||
if att.get("url"):
|
||||
aid = att.get("id")
|
||||
found.append((att["url"], "attachment", str(aid) if aid else None))
|
||||
for embed in snap.get("embeds") or []:
|
||||
if embed.get("type") not in _EMBED_TYPES:
|
||||
continue
|
||||
for fld in ("video", "image", "thumbnail"):
|
||||
url = (embed.get(fld) or {}).get("proxy_url")
|
||||
if url:
|
||||
found.append((url, "embed", None))
|
||||
break
|
||||
items = []
|
||||
for num, (url, kind, fid) in enumerate(found, start=1):
|
||||
name, ext = nameext_from_url(url)
|
||||
if fid is None:
|
||||
path = url.partition("?")[0].encode()
|
||||
fid = "u" + hashlib.sha1(path, usedforsecurity=False).hexdigest()[:32]
|
||||
items.append(MediaItem(
|
||||
url=url, filename=name, extension=ext, kind=kind, post_id=mid,
|
||||
num=num, media_id=fid,
|
||||
))
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def post_meta(post: dict) -> dict:
|
||||
"""No title (Discord has none); `date` is the message timestamp, ISO
|
||||
with an offset — what the core's revisit window reads."""
|
||||
return {"title": None, "date": post.get("timestamp")}
|
||||
|
||||
@classmethod
|
||||
def post_record_key(cls, post: dict) -> tuple[str, str] | None:
|
||||
"""`(message:<id>, <id>)` — gates the message record through the seen
|
||||
ledger, like `post:<id>` on the other platforms.
|
||||
|
||||
None for a message `extract_media` takes nothing from, i.e. one with
|
||||
no image or video attached. The drop grouping (discord_grouping) is
|
||||
built on that: a channel's chatter recorded as posts would bury the
|
||||
drops it exists to surface."""
|
||||
mid = post.get("id")
|
||||
mid = str(mid) if mid is not None else ""
|
||||
if not mid or not cls.extract_media(post):
|
||||
return None
|
||||
return (f"message:{mid}", mid)
|
||||
|
||||
# -- verify ------------------------------------------------------------
|
||||
|
||||
def describe(self, server_id: str | None, channel_id: str | None) -> dict:
|
||||
"""The display names behind a server/channel pair, for the browser
|
||||
extension's Add panel. Best-effort per name: one that can't be read
|
||||
comes back None, and the other is still returned."""
|
||||
out: dict = {"server": None, "channel": None, "parent": None}
|
||||
if server_id:
|
||||
try:
|
||||
out["server"] = (self._get(f"/guilds/{server_id}") or {}).get("name") or None
|
||||
except DiscordAPIError:
|
||||
pass
|
||||
if channel_id:
|
||||
try:
|
||||
meta = self._parse_channel(self._get(f"/channels/{channel_id}"))
|
||||
out["channel"] = meta.get("channel") or None
|
||||
except (DiscordAPIError, AttributeError):
|
||||
pass
|
||||
return out
|
||||
|
||||
def recent_posters(
|
||||
self, server_id: str | None, channel_id: str, *, max_messages: int = _POSTER_SCAN,
|
||||
) -> dict:
|
||||
"""Who has posted lately in a channel, for picking a source's poster
|
||||
list (#4491): the newest `max_messages` messages, tallied per author.
|
||||
Deliberately shallow — it answers "who posts here", not "who ever did".
|
||||
Every poster comes back with their stable id, which is what the list
|
||||
stores: a username can change on a whim, an id can't."""
|
||||
server: dict = {}
|
||||
if server_id:
|
||||
try:
|
||||
server = self._get(f"/guilds/{server_id}") or {}
|
||||
except DiscordAPIError:
|
||||
server = {}
|
||||
tally: dict[str, dict] = {}
|
||||
before = None
|
||||
seen = 0
|
||||
while seen < max_messages:
|
||||
page = self._get(
|
||||
f"/channels/{channel_id}/messages",
|
||||
{"limit": min(_MESSAGES_BATCH, max_messages - seen), "before": before},
|
||||
)
|
||||
if not isinstance(page, list) or not page:
|
||||
break
|
||||
for message in page:
|
||||
seen += 1
|
||||
if message.get("type") not in MESSAGE_TYPES:
|
||||
continue
|
||||
author = message.get("author") or {}
|
||||
aid = str(author.get("id") or "")
|
||||
if not aid:
|
||||
continue
|
||||
row = tally.setdefault(aid, {
|
||||
"id": aid,
|
||||
"username": author.get("username"),
|
||||
"global_name": author.get("global_name"),
|
||||
"bot": bool(author.get("bot")),
|
||||
"messages": 0,
|
||||
"images": 0,
|
||||
})
|
||||
row["messages"] += 1
|
||||
if has_visual_attachment(message):
|
||||
row["images"] += 1
|
||||
if len(page) < _MESSAGES_BATCH:
|
||||
break
|
||||
before = str(page[-1]["id"])
|
||||
return {
|
||||
"server": server.get("name") or None,
|
||||
"owner_id": str(server["owner_id"]) if server.get("owner_id") else None,
|
||||
"scanned": seen,
|
||||
"posters": rank_posters(
|
||||
server.get("name"), server.get("owner_id"), list(tally.values()),
|
||||
),
|
||||
}
|
||||
|
||||
def verify_auth(self, url: str) -> tuple[bool | None, str]:
|
||||
"""Is the token valid, and can it see what the source names?"""
|
||||
try:
|
||||
server_id, channel_id = parse_source_url(url)
|
||||
except DiscordAPIError as exc:
|
||||
return None, str(exc)
|
||||
try:
|
||||
me = self._get("/users/@me")
|
||||
if channel_id is not None:
|
||||
self._get(f"/channels/{channel_id}")
|
||||
elif server_id is not None:
|
||||
self._get(f"/guilds/{server_id}")
|
||||
except DiscordAuthError as exc:
|
||||
return False, f"Discord rejected the token — {exc}"
|
||||
except DiscordAPIError as exc:
|
||||
if exc.status_code in (403, 404):
|
||||
return False, (
|
||||
"The token is valid, but its account cannot see "
|
||||
f"{'this channel' if channel_id else 'this server'} "
|
||||
f"(HTTP {exc.status_code})"
|
||||
)
|
||||
return None, f"Couldn't verify (network/HTTP issue): {exc}"
|
||||
who = (me or {}).get("username") if isinstance(me, dict) else None
|
||||
return True, f"Token valid{f' ({who})' if who else ''} — the source is readable."
|
||||
|
||||
|
||||
def _retry_after(resp: requests.Response) -> float | None:
|
||||
hdr = resp.headers.get("Retry-After")
|
||||
try:
|
||||
return float(hdr) if hdr else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Native Discord media downloader — the Discord counterpart to
|
||||
subscribestar_downloader.
|
||||
|
||||
Writes files exactly where gallery-dl wrote them, so a cutover finds every
|
||||
existing file on disk (`skipped_disk`) instead of fetching it again:
|
||||
|
||||
<images>/<artist>/discord/<channel>/<YYYYMMDD>_<message_id>_<NN>_<name>.<ext>
|
||||
|
||||
That is what FC's gallery-dl config produced (directory `{channel}`, filename
|
||||
`{date:%Y%m%d}_{message_id}_{num:>02}_{filename}.{extension}`, under the
|
||||
per-source base directory `<images>/<artist>/<platform>`), retired from that
|
||||
config once Discord moved here; tests/test_discord_naming.py pins the match
|
||||
against a real gallery-dl sidecar. The name is cleaned the way gallery-dl cleans it
|
||||
on Linux — `/` becomes `_` and control characters are removed, nothing else
|
||||
(`path-restrict: auto`, `path-remove` defaults). It is NOT `sanitize_segment`,
|
||||
whose Windows set would turn a `:` in a channel or file name into `_` and miss
|
||||
the file gallery-dl wrote.
|
||||
|
||||
Post-first (rule 120): each file gets a minimal sidecar named like it minus the
|
||||
extension (what `find_sidecar` pairs first), and the message itself gets one
|
||||
record, `<YYYYMMDD>_<message_id>_post.json`, carrying gallery-dl's metadata keys
|
||||
— `message_id` for the post id, `server_id`/`channel_id` for the permalink,
|
||||
`message` for the body, `date` — so `parse_sidecar` reads it exactly as it read
|
||||
the gallery-dl sidecars. Neither file carries an `id` or `post_id` key: both
|
||||
outrank `message_id` in the post-id chain (`platforms.base`).
|
||||
|
||||
PURE: no DB; the seen-skip is an injected predicate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from .discord_client import firefox_user_agent, message_text
|
||||
from .native_ingest_common import (
|
||||
BaseNativeDownloader,
|
||||
MediaItem,
|
||||
PostRecordOutcome,
|
||||
make_session,
|
||||
)
|
||||
|
||||
PLATFORM = "discord"
|
||||
_CONTROL = re.compile("[\x00-\x1f\x7f]")
|
||||
# gallery-dl falls back to the response's type for a URL with no extension;
|
||||
# we never see the response before naming, and such URLs do not occur for
|
||||
# Discord attachments or embed proxies in practice.
|
||||
_NO_EXTENSION = "bin"
|
||||
|
||||
|
||||
def gdl_clean(segment: str) -> str:
|
||||
"""One path segment as gallery-dl writes it on Linux."""
|
||||
return _CONTROL.sub("", segment.replace("/", "_"))
|
||||
|
||||
|
||||
def message_date(post: dict) -> datetime | None:
|
||||
raw = post.get("timestamp")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return (dt if dt.tzinfo else dt.replace(tzinfo=UTC)).astimezone(UTC)
|
||||
|
||||
|
||||
def channel_dir(images_root: Path, artist_slug: str, post: dict) -> Path:
|
||||
"""gallery-dl's `{channel}` directory; an empty name adds no segment."""
|
||||
base = Path(images_root) / artist_slug / PLATFORM
|
||||
channel = gdl_clean(((post.get("_meta") or {}).get("channel") or "").strip())
|
||||
return base / channel if channel else base
|
||||
|
||||
|
||||
def media_stem(post: dict, media) -> str:
|
||||
"""`<YYYYMMDD>_<message_id>_<NN>_<name>` — the file's name minus `.<ext>`."""
|
||||
when = message_date(post)
|
||||
day = f"{when:%Y%m%d}" if when else "None"
|
||||
return gdl_clean(f"{day}_{post.get('id')}_{media.num:>02}_{media.filename}")
|
||||
|
||||
|
||||
class DiscordDownloader(BaseNativeDownloader):
|
||||
"""Download a message's files to gallery-dl's layout. The CDN gets
|
||||
gallery-dl's browser profile and no token — gallery-dl sends the token only
|
||||
to the API, and the CDN URLs are pre-signed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
images_root: Path,
|
||||
cookies_path: str | None = None,
|
||||
*,
|
||||
validate: bool = True,
|
||||
rate_limit: float = 0.0,
|
||||
session: requests.Session | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
images_root, None, platform=PLATFORM,
|
||||
validate=validate, rate_limit=rate_limit,
|
||||
session=session if session is not None else make_session(None, extra_headers={
|
||||
"User-Agent": firefox_user_agent(),
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Referer": "https://discord.com/",
|
||||
}),
|
||||
)
|
||||
|
||||
# The download loop is BaseNativeDownloader's; these seams give it
|
||||
# gallery-dl's Discord layout.
|
||||
|
||||
def _post_folder(self, post: dict, artist_slug: str) -> Path:
|
||||
return channel_dir(self.images_root, artist_slug, post)
|
||||
|
||||
def _media_name(self, post: dict, media: MediaItem, index: int) -> str:
|
||||
return f"{media_stem(post, media)}.{media.extension or _NO_EXTENSION}"
|
||||
|
||||
def _media_sidecar(self, post: dict, media: MediaItem) -> dict:
|
||||
# `message_id`, never `id`: `id` outranks it in the post-id chain.
|
||||
return {"category": PLATFORM, "message_id": str(post.get("id") or ""),
|
||||
"source_url": media.url}
|
||||
|
||||
def write_post_record(
|
||||
self, post: dict, artist_slug: str, *, revisit: bool = False,
|
||||
) -> PostRecordOutcome:
|
||||
"""The message record — the one writer of a Discord post's body, date
|
||||
and permalink ids. `revisit` re-reads a message already captured (an
|
||||
edit); an empty re-read writes nothing, so it never blanks a body."""
|
||||
mid = str(post.get("id") or "")
|
||||
body = message_text(post)
|
||||
if not mid or (revisit and not body.strip()):
|
||||
return PostRecordOutcome(path=None, post_type=None, title=None, body_chars=0)
|
||||
meta = post.get("_meta") or {}
|
||||
author = post.get("author") or {}
|
||||
record = {
|
||||
"category": PLATFORM,
|
||||
"message_id": mid,
|
||||
"server": meta.get("server"),
|
||||
"server_id": meta.get("server_id"),
|
||||
"channel": meta.get("channel"),
|
||||
"channel_id": meta.get("channel_id") or post.get("channel_id"),
|
||||
"parent": meta.get("parent"),
|
||||
"is_thread": meta.get("is_thread"),
|
||||
"author": author.get("username"),
|
||||
"author_name": author.get("global_name"),
|
||||
"author_id": author.get("id"),
|
||||
"message": body,
|
||||
"date": post.get("timestamp"),
|
||||
}
|
||||
folder = channel_dir(self.images_root, artist_slug, post)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
when = message_date(post)
|
||||
day = f"{when:%Y%m%d}" if when else "None"
|
||||
path = folder / f"{day}_{mid}_post.json"
|
||||
path.write_text(json.dumps(
|
||||
{k: v for k, v in record.items() if v is not None}, indent=2,
|
||||
))
|
||||
return PostRecordOutcome(
|
||||
path=path, post_type=None, title=None, body_chars=len(body),
|
||||
)
|
||||
@@ -59,14 +59,23 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import Select, func, select, update
|
||||
from sqlalchemy import Select, delete, func, select, union, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import ImageProvenance, ImageRecord, MLSettings, Post, Source
|
||||
from ..models import (
|
||||
ImageProvenance,
|
||||
ImageRecord,
|
||||
MLSettings,
|
||||
Post,
|
||||
PostAssociation,
|
||||
Source,
|
||||
)
|
||||
from .post_naming import FAMILY_MAX_POSTS, leading_name, rarity, token_frequencies
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -117,6 +126,31 @@ def cosine_distance(a, b) -> float:
|
||||
return 1.0 - (dot / (na * nb))
|
||||
|
||||
|
||||
def _message_images(posts):
|
||||
"""(post_id, image_id) for every image a message carries — owned AND re-posted.
|
||||
|
||||
A message owns an image through `primary_post_id`, but only the FIRST
|
||||
message imported with a given file does. The same file posted again is a
|
||||
provenance link, and the backfill runs newest-first, so it is usually the
|
||||
ORIGINAL message that ends up owning nothing. Reading ownership alone left
|
||||
101 of Yellowroom's messages (2018–2020 mostly) ungroupable: every image
|
||||
they carried also sat in another message.
|
||||
|
||||
`posts` is a list of ids or a select of them; filtering both branches by it
|
||||
keeps the union to the messages in hand rather than the whole library.
|
||||
Callers pass MESSAGE posts only — a drop's own provenance rows would read
|
||||
as images it carries.
|
||||
"""
|
||||
owned = select(
|
||||
ImageRecord.primary_post_id.label("post_id"), ImageRecord.id.label("image_id"),
|
||||
).where(ImageRecord.primary_post_id.in_(posts))
|
||||
reposted = select(
|
||||
ImageProvenance.post_id.label("post_id"),
|
||||
ImageProvenance.image_record_id.label("image_id"),
|
||||
).where(ImageProvenance.post_id.in_(posts))
|
||||
return union(owned, reposted).subquery()
|
||||
|
||||
|
||||
def _candidate_stmt(source_id: int, *, not_after: datetime) -> Select:
|
||||
"""Ungrouped Discord message-posts, one representative image each, OLDEST
|
||||
FIRST — which is the order `build_groups` requires.
|
||||
@@ -134,13 +168,15 @@ def _candidate_stmt(source_id: int, *, not_after: datetime) -> Select:
|
||||
take the OLDEST candidates instead of the lowest-numbered ones.
|
||||
"""
|
||||
sort_key = func.coalesce(Post.post_date, Post.downloaded_at)
|
||||
carried = _message_images(select(Post.id).where(Post.source_id == source_id))
|
||||
inner = (
|
||||
select(
|
||||
Post.id.label("post_id"),
|
||||
sort_key.label("occurred_at"),
|
||||
ImageRecord.siglip_embedding.label("embedding"),
|
||||
)
|
||||
.join(ImageRecord, ImageRecord.primary_post_id == Post.id)
|
||||
.join(carried, carried.c.post_id == Post.id)
|
||||
.join(ImageRecord, ImageRecord.id == carried.c.image_id)
|
||||
.where(
|
||||
Post.source_id == source_id,
|
||||
# Never absorb a post FC wrote, and never re-absorb one already
|
||||
@@ -286,9 +322,10 @@ async def _link_member_images(
|
||||
"""
|
||||
if not member_ids:
|
||||
return 0
|
||||
image_rows = (await session.execute(
|
||||
select(ImageRecord.id).where(ImageRecord.primary_post_id.in_(member_ids))
|
||||
)).scalars().all()
|
||||
carried = _message_images(member_ids)
|
||||
image_rows = sorted(set((await session.execute(
|
||||
select(carried.c.image_id)
|
||||
)).scalars().all()))
|
||||
if not image_rows:
|
||||
return 0
|
||||
await session.execute(
|
||||
@@ -412,9 +449,11 @@ async def _group_seed(session: AsyncSession, post_id: int) -> list[float] | None
|
||||
free to disagree; this way there is one.
|
||||
"""
|
||||
sort_key = func.coalesce(Post.post_date, Post.downloaded_at)
|
||||
carried = _message_images(select(Post.id).where(Post.absorbed_by_post_id == post_id))
|
||||
return (await session.execute(
|
||||
select(ImageRecord.siglip_embedding)
|
||||
.join(Post, ImageRecord.primary_post_id == Post.id)
|
||||
.join(carried, carried.c.image_id == ImageRecord.id)
|
||||
.join(Post, Post.id == carried.c.post_id)
|
||||
.where(
|
||||
Post.absorbed_by_post_id == post_id,
|
||||
ImageRecord.siglip_embedding.is_not(None),
|
||||
@@ -600,6 +639,352 @@ async def join_open_groups(
|
||||
return joined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #4390: a trickle is one drop — later drops of the same piece merge in.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Operator, 2026-09-24, on a feed of "Grouped from 1 Discord message" cards:
|
||||
# *"the groups are still single image even when they can clearly be seen as
|
||||
# group"*. 665 of Yellowroom's 714 drops were one message.
|
||||
#
|
||||
# Both paths above join on cosine distance to the group's SEED, and the stages
|
||||
# of one piece fail it: `svtt_wip4` did not join `svtt_wip3` from the day
|
||||
# before. A creator trickles a piece out as sketch -> wip -> wip -> release, and
|
||||
# each stage is nearest to the one before it, not to the first.
|
||||
#
|
||||
# Measured on artist 8 before any of this was written (#4390 log):
|
||||
#
|
||||
# * phash cannot see it. Stages of one piece sit 68-134 bits apart; unrelated
|
||||
# pieces by the same artist sit at a median of 126, p5 110. Lesson #4400.
|
||||
# * The embedding's NEAREST neighbour can. Every stage of three real trickles
|
||||
# had a sibling stage as its single nearest image in the artist's whole
|
||||
# library, while siblings further along ranked 40-100 — which is exactly why
|
||||
# seed distance fails. Negative control over all 137 recent Discord images:
|
||||
# where the nearest neighbour was another message within 7 days, the two
|
||||
# carried the same working name 53 times out of 53. Disagreements start past
|
||||
# 7 days.
|
||||
# * The working name sees it directly, when there is one.
|
||||
#
|
||||
# So a later drop merges into an earlier one when the two are within
|
||||
# `discord_group_close_after_hours` of each other (168h — the measured 7 days)
|
||||
# AND either they share a gated LEADING working name, or one's image has the
|
||||
# other's image as its nearest neighbour. A drop reaching SEVERAL earlier drops
|
||||
# pulls them all together — unless two of them are named as different pieces,
|
||||
# in which case nothing moves (see `_compatible`): leaving a drop alone is
|
||||
# recoverable, a wrong merge asserts that unrelated art belongs together.
|
||||
#
|
||||
# Chaining is permitted here and was forbidden above, deliberately. The seed
|
||||
# rule exists because tiny steps can drift from one piece to another; the
|
||||
# measured precision of nearest-neighbour inside 7 days is what bounds drift
|
||||
# for this route, and each link is between neighbours in time, never across a
|
||||
# quiet week.
|
||||
|
||||
# How many unchecked drops one sweep examines per source. A first run over an
|
||||
# established library drains over successive sweeps, oldest first, rather than
|
||||
# issuing one nearest-neighbour query per image of the whole history at once.
|
||||
TRICKLE_BATCH = 300
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Drop:
|
||||
post: Post
|
||||
members: set[int]
|
||||
first_at: datetime
|
||||
last_at: datetime
|
||||
names: set[str]
|
||||
images: list[int]
|
||||
nearest: set[int] | None
|
||||
|
||||
|
||||
async def _nearest_message(
|
||||
session: AsyncSession, *, artist_id: int, image_id: int, exclude: set[int],
|
||||
) -> int | None:
|
||||
"""The post that owns the nearest image in the artist's whole library.
|
||||
|
||||
The whole LIBRARY, not this source, because that is what was measured: a
|
||||
neighbour that turns out to be a Patreon re-post simply yields no Discord
|
||||
drop to merge into, which errs toward leaving things alone. `exclude` is
|
||||
the drop's own messages — an image is always nearest to its own siblings
|
||||
in the same drop, which says nothing.
|
||||
"""
|
||||
embedding = (await session.execute(
|
||||
select(ImageRecord.siglip_embedding).where(ImageRecord.id == image_id)
|
||||
)).scalar_one_or_none()
|
||||
if embedding is None:
|
||||
return None
|
||||
stmt = (
|
||||
select(ImageRecord.primary_post_id)
|
||||
.where(
|
||||
ImageRecord.artist_id == artist_id,
|
||||
ImageRecord.id != image_id,
|
||||
ImageRecord.siglip_embedding.is_not(None),
|
||||
ImageRecord.primary_post_id.is_not(None),
|
||||
)
|
||||
.order_by(ImageRecord.siglip_embedding.cosine_distance(embedding))
|
||||
.limit(1)
|
||||
)
|
||||
if exclude:
|
||||
stmt = stmt.where(ImageRecord.primary_post_id.not_in(exclude))
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def _load_drops(session: AsyncSession, source: Source) -> list[_Drop]:
|
||||
"""Every live drop of this source, with what the merge rule reads, oldest first."""
|
||||
posts = (await session.execute(
|
||||
select(Post).where(
|
||||
Post.source_id == source.id,
|
||||
Post.synthesized_by == DROP_GROUPER,
|
||||
Post.absorbed_by_post_id.is_(None),
|
||||
)
|
||||
)).scalars().all()
|
||||
if not posts:
|
||||
return []
|
||||
by_id = {p.id: p for p in posts}
|
||||
|
||||
msg_at = func.coalesce(Post.post_date, Post.downloaded_at)
|
||||
members: dict[int, set[int]] = {pid: set() for pid in by_id}
|
||||
times: dict[int, list[datetime]] = {pid: [] for pid in by_id}
|
||||
for mid, owner, at in (await session.execute(
|
||||
select(Post.id, Post.absorbed_by_post_id, msg_at)
|
||||
.where(Post.absorbed_by_post_id.in_(list(by_id)))
|
||||
)).all():
|
||||
members[owner].add(mid)
|
||||
times[owner].append(at)
|
||||
|
||||
owner_of = {m: d for d, ms in members.items() for m in ms}
|
||||
names: dict[int, set[str]] = {pid: set() for pid in by_id}
|
||||
images: dict[int, list[int]] = {pid: [] for pid in by_id}
|
||||
if owner_of:
|
||||
carried = _message_images(list(owner_of))
|
||||
for iid, message, path in (await session.execute(
|
||||
select(ImageRecord.id, carried.c.post_id, ImageRecord.path)
|
||||
.join(carried, carried.c.image_id == ImageRecord.id)
|
||||
.order_by(ImageRecord.id)
|
||||
)).all():
|
||||
drop = owner_of[message]
|
||||
if iid in images[drop]:
|
||||
continue # one file carried by two of the drop's messages
|
||||
images[drop].append(iid)
|
||||
if (name := leading_name(path)) is not None:
|
||||
names[drop].add(name)
|
||||
|
||||
out = []
|
||||
for pid, post in by_id.items():
|
||||
if not times[pid]:
|
||||
continue
|
||||
stored = (post.synthesis_details or {}).get("nearest_message_ids")
|
||||
out.append(_Drop(
|
||||
post=post, members=members[pid],
|
||||
first_at=min(times[pid]), last_at=max(times[pid]),
|
||||
names=names[pid], images=images[pid],
|
||||
nearest=set(stored) if stored is not None else None,
|
||||
))
|
||||
return sorted(out, key=lambda d: (d.first_at, d.post.id))
|
||||
|
||||
|
||||
async def _name_posts(session: AsyncSession, artist_id: int) -> Counter[str]:
|
||||
"""Post-span counts of the artist's working names — the same corpus the
|
||||
teaser card and the announcement matcher count against."""
|
||||
by_post: dict[int, list[str]] = {}
|
||||
for pid, path in (await session.execute(
|
||||
select(ImageRecord.primary_post_id, ImageRecord.path).where(
|
||||
ImageRecord.artist_id == artist_id,
|
||||
ImageRecord.primary_post_id.is_not(None),
|
||||
)
|
||||
)).all():
|
||||
by_post.setdefault(pid, []).append(path)
|
||||
return token_frequencies(by_post.values())
|
||||
|
||||
|
||||
async def _repoint_associations(
|
||||
session: AsyncSession, *, from_id: int, to_id: int,
|
||||
) -> None:
|
||||
"""Move announcement links from a drop about to merge onto the one it joins.
|
||||
|
||||
Without this the merge would silently undo a teaser link: the association's
|
||||
payload FK cascades on delete. Where the teaser already points at the
|
||||
surviving drop, the stronger claim is kept — a link over a proposal over a
|
||||
dismissal — and the duplicate goes.
|
||||
"""
|
||||
rank = {"linked": 2, "pending": 1, "dismissed": 0}
|
||||
moving = (await session.execute(
|
||||
select(PostAssociation).where(PostAssociation.payload_post_id == from_id)
|
||||
)).scalars().all()
|
||||
for a in moving:
|
||||
existing = (await session.execute(
|
||||
select(PostAssociation).where(
|
||||
PostAssociation.announcement_post_id == a.announcement_post_id,
|
||||
PostAssociation.payload_post_id == to_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
a.payload_post_id = to_id
|
||||
continue
|
||||
if rank.get(a.status, 0) > rank.get(existing.status, 0):
|
||||
existing.status = a.status
|
||||
existing.linked_by = a.linked_by
|
||||
await session.delete(a)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def merge_trickles(
|
||||
session: AsyncSession,
|
||||
source: Source,
|
||||
*,
|
||||
gap: timedelta,
|
||||
min_images: int,
|
||||
cooldown: timedelta,
|
||||
batch: int = TRICKLE_BATCH,
|
||||
) -> int:
|
||||
"""Fold later drops of the same piece into the earlier one. Returns merges."""
|
||||
drops = await _load_drops(session, source)
|
||||
if len(drops) < 2:
|
||||
return 0
|
||||
name_posts = await _name_posts(session, source.artist_id)
|
||||
|
||||
def gated(names: set[str]) -> set[str]:
|
||||
return {n for n in names if rarity(name_posts.get(n, 0), FAMILY_MAX_POSTS) > 0}
|
||||
|
||||
alive: list[_Drop] = []
|
||||
merged = 0
|
||||
checked = 0
|
||||
for drop in drops:
|
||||
details = drop.post.synthesis_details or {}
|
||||
if details.get("trickle_checked"):
|
||||
alive.append(drop)
|
||||
continue
|
||||
if checked >= batch:
|
||||
# Unchecked and out of budget: still a candidate for LATER drops'
|
||||
# reverse edges, just not examined itself this run.
|
||||
alive.append(drop)
|
||||
continue
|
||||
checked += 1
|
||||
|
||||
if drop.nearest is None:
|
||||
found: set[int] = set()
|
||||
for iid in drop.images:
|
||||
pid = await _nearest_message(
|
||||
session, artist_id=source.artist_id, image_id=iid,
|
||||
exclude=drop.members,
|
||||
)
|
||||
if pid is not None:
|
||||
found.add(pid)
|
||||
drop.nearest = found
|
||||
|
||||
mine = gated(drop.names)
|
||||
targets: dict[int, tuple[_Drop, str]] = {}
|
||||
for earlier in alive:
|
||||
if drop.first_at - earlier.last_at > gap:
|
||||
continue
|
||||
shared = mine & gated(earlier.names)
|
||||
if set(drop.images) & set(earlier.images):
|
||||
# The creator posted the very same file again — the strongest
|
||||
# evidence there is, and one nearest-neighbour cannot see: it
|
||||
# skips the image itself, which is the one they share.
|
||||
targets[earlier.post.id] = (earlier, "same_image")
|
||||
elif shared:
|
||||
targets[earlier.post.id] = (earlier, f"name:{min(shared)}")
|
||||
elif drop.nearest & earlier.members or (earlier.nearest or set()) & drop.members:
|
||||
targets[earlier.post.id] = (earlier, "nearest")
|
||||
|
||||
record = dict(details)
|
||||
record["nearest_message_ids"] = sorted(drop.nearest)
|
||||
record["trickle_checked"] = True
|
||||
drop.post.synthesis_details = record
|
||||
|
||||
if not targets or not _compatible(
|
||||
[gated(t.names) for t, _route in targets.values()] + [mine]
|
||||
):
|
||||
alive.append(drop)
|
||||
continue
|
||||
|
||||
# Every target is the same piece as this drop, so they are the same
|
||||
# piece as each other: fold them all into the earliest, then this drop.
|
||||
ordered = sorted(targets.values(), key=lambda tr: (tr[0].first_at, tr[0].post.id))
|
||||
into = ordered[0][0]
|
||||
for other, route in ordered[1:]:
|
||||
await _merge_drop(
|
||||
session, into=into, drop=other, route=route,
|
||||
min_images=min_images, cooldown=cooldown,
|
||||
)
|
||||
alive.remove(other)
|
||||
merged += 1
|
||||
await _merge_drop(
|
||||
session, into=into, drop=drop, route=ordered[0][1],
|
||||
min_images=min_images, cooldown=cooldown,
|
||||
)
|
||||
merged += 1
|
||||
return merged
|
||||
|
||||
|
||||
def _compatible(name_sets: list[set[str]]) -> bool:
|
||||
"""May drops carrying these working names become one post?
|
||||
|
||||
Refused only when two of them are NAMED AS DIFFERENT PIECES — both carry a
|
||||
gated name, and they share none. An unnamed drop (a canvas screenshot)
|
||||
fits anywhere, which is the whole of the Marin case: two early stages both
|
||||
nearest to the same later one are one trickle, not an ambiguity.
|
||||
|
||||
What it does NOT refuse is a drop the creator made two pieces in
|
||||
themselves. Measured on artist 8: one November message carries both
|
||||
`AdL01_wip4` and `Year_20k_wip_z4`, so its drop holds both names, and a
|
||||
later drop of either piece joins it on its own name. That is the creator's
|
||||
co-posting carried forward — Discord shows those two together too — not a
|
||||
bridge FC built.
|
||||
"""
|
||||
named = [n for n in name_sets if n]
|
||||
return all(a & b for i, a in enumerate(named) for b in named[i + 1:])
|
||||
|
||||
|
||||
async def _merge_drop(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
into: _Drop,
|
||||
drop: _Drop,
|
||||
route: str,
|
||||
min_images: int,
|
||||
cooldown: timedelta,
|
||||
) -> None:
|
||||
"""Absorb `drop`'s messages into `into`, carry its links over, delete it.
|
||||
|
||||
Growth is stamped at the merged messages' OWN time, not the wall clock.
|
||||
Merging history must not drag a two-year-old drop to the top of the feed,
|
||||
and the time the group actually grew is when those messages arrived.
|
||||
"""
|
||||
await _repoint_associations(session, from_id=drop.post.id, to_id=into.post.id)
|
||||
grew_before = into.post.last_grew_at
|
||||
await _absorb_into(
|
||||
session, group=into.post, member_ids=sorted(drop.members),
|
||||
source_id=into.post.source_id, now=drop.last_at,
|
||||
min_images=min_images, cooldown=cooldown,
|
||||
)
|
||||
# Never backwards: a group that already grew later than these messages
|
||||
# keeps that later date.
|
||||
if grew_before is not None and grew_before > drop.last_at:
|
||||
into.post.last_grew_at = grew_before
|
||||
details = dict(into.post.synthesis_details or {})
|
||||
if grew_before is not None and grew_before > drop.last_at:
|
||||
details["last_grew_at"] = grew_before.isoformat()
|
||||
# The honesty rule, extended: a grouping FC invented says what it was
|
||||
# built from, and a merge says WHY — "name:svtt" or "nearest".
|
||||
details["merged"] = [
|
||||
*details.get("merged", []),
|
||||
{"post_id": drop.post.id, "route": route, "message_ids": sorted(drop.members)},
|
||||
]
|
||||
details["nearest_message_ids"] = sorted((into.nearest or set()) | (drop.nearest or set()))
|
||||
into.post.synthesis_details = details
|
||||
|
||||
into.members |= drop.members
|
||||
into.names |= drop.names
|
||||
into.images += drop.images
|
||||
into.nearest = (into.nearest or set()) | (drop.nearest or set())
|
||||
into.last_at = max(into.last_at, drop.last_at)
|
||||
|
||||
await session.execute(delete(ImageProvenance).where(ImageProvenance.post_id == drop.post.id))
|
||||
await session.delete(drop.post)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict:
|
||||
"""Group every enabled Discord source. No-op when the switch is off.
|
||||
|
||||
@@ -612,6 +997,7 @@ async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict:
|
||||
if not settings.discord_grouping_enabled:
|
||||
return {
|
||||
"enabled": False, "sources": 0, "posts_created": 0, "images_joined": 0,
|
||||
"drops_merged": 0,
|
||||
}
|
||||
|
||||
sources = (await session.execute(
|
||||
@@ -626,6 +1012,7 @@ async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict:
|
||||
|
||||
created = 0
|
||||
joined = 0
|
||||
merged = 0
|
||||
for source in sources:
|
||||
joined += await join_open_groups(
|
||||
session, source,
|
||||
@@ -644,12 +1031,20 @@ async def sweep(session: AsyncSession, *, now: datetime | None = None) -> dict:
|
||||
window_minutes=window_minutes,
|
||||
now=now,
|
||||
)
|
||||
# Last, so the drops the two passes above just wrote are merged in
|
||||
# the same sweep rather than showing as singletons for an hour.
|
||||
merged += await merge_trickles(
|
||||
session, source,
|
||||
gap=timedelta(hours=float(settings.discord_group_close_after_hours)),
|
||||
min_images=int(settings.discord_group_resurface_min_images),
|
||||
cooldown=timedelta(hours=float(settings.discord_group_resurface_cooldown_hours)),
|
||||
)
|
||||
log.info(
|
||||
"discord drop grouping: %d source(s), %d synthetic post(s) created, "
|
||||
"%d image(s) joined to open groups",
|
||||
len(sources), created, joined,
|
||||
"%d image(s) joined to open groups, %d trickle drop(s) merged",
|
||||
len(sources), created, joined, merged,
|
||||
)
|
||||
return {
|
||||
"enabled": True, "sources": len(sources),
|
||||
"posts_created": created, "images_joined": joined,
|
||||
"posts_created": created, "images_joined": joined, "drops_merged": merged,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Native Discord ingester — the Discord ADAPTER over `ingest_core.Ingester`.
|
||||
|
||||
Thin counterpart to subscribestar_ingester (milestone 428). The walk's modes,
|
||||
both ledgers, cursor checkpointing and the post-first capture live in the core;
|
||||
this wires in the Discord client, downloader, ledger models and key.
|
||||
|
||||
Two things differ from the cookie platforms:
|
||||
|
||||
- Discord authenticates with a user TOKEN, so `auth_token` is the credential
|
||||
here rather than an argument accepted and ignored.
|
||||
- The body canary is off. It fails a walk whose first 30+ captured posts all
|
||||
came back without text, on the theory that a creator nearly always writes
|
||||
something; a Discord drop is routinely files and nothing else, so on
|
||||
Discord that is an ordinary backfill, not a broken parser.
|
||||
|
||||
And two things only a Discord source has: `discord_authors` in its
|
||||
config_overrides limits it to the creator's own messages (a server's other
|
||||
members post pictures too), and it learns its own name — the server and
|
||||
channel it walks — into `source.display_name`.
|
||||
|
||||
`campaign_id` is the source URL (a server, channel, thread or category link).
|
||||
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from ..models import DiscordFailedMedia, DiscordSeenMedia, Source
|
||||
from .discord_client import DiscordAPIError, DiscordClient
|
||||
from .discord_downloader import DiscordDownloader
|
||||
from .ingest_core import Ingester
|
||||
from .native_ingest_common import ledger_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# `config_overrides` key: the people whose messages this source takes (user
|
||||
# ids, usernames or display names). Absent or empty takes everyone's.
|
||||
AUTHORS_KEY = "discord_authors"
|
||||
|
||||
|
||||
class DiscordIngester(Ingester):
|
||||
"""Walk a Discord source's channels, download unseen files, return a
|
||||
`DownloadResult`. `client` / `downloader` are injectable for tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
images_root: Path,
|
||||
cookies_path: str | None,
|
||||
session_factory: Callable[[], object],
|
||||
*,
|
||||
validate: bool = True,
|
||||
rate_limit: float = 0.0,
|
||||
request_sleep: float = 0.0,
|
||||
auth_token: str | None = None,
|
||||
client: DiscordClient | None = None,
|
||||
downloader: DiscordDownloader | None = None,
|
||||
):
|
||||
del cookies_path # Discord authenticates by token (uniform signature)
|
||||
self.images_root = Path(images_root)
|
||||
super().__init__(
|
||||
client=client if client is not None else DiscordClient(
|
||||
auth_token, request_sleep=request_sleep,
|
||||
),
|
||||
downloader=downloader if downloader is not None else DiscordDownloader(
|
||||
self.images_root, validate=validate, rate_limit=rate_limit,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
seen_model=DiscordSeenMedia,
|
||||
failed_model=DiscordFailedMedia,
|
||||
seen_constraint="uq_discord_seen_media_source_id",
|
||||
failed_constraint="uq_discord_failed_media_source_id",
|
||||
ledger_key=ledger_key,
|
||||
platform="discord",
|
||||
error_base=DiscordAPIError,
|
||||
drift_label="Discord API",
|
||||
body_canary=False,
|
||||
)
|
||||
|
||||
def run(self, **kwargs):
|
||||
"""The core walk, bracketed by the two things only a Discord source
|
||||
has: whose messages it takes, read before the walk, and the name of
|
||||
what it walks, written after it (the walk is what loads that name)."""
|
||||
source_id = kwargs["source_id"]
|
||||
self.client.only_from(self._source_authors(source_id))
|
||||
try:
|
||||
return super().run(**kwargs)
|
||||
finally:
|
||||
self._record_display_name(source_id, kwargs["campaign_id"])
|
||||
|
||||
def _source_authors(self, source_id: int) -> list:
|
||||
if self.session_factory is None:
|
||||
return []
|
||||
with self.session_factory() as session:
|
||||
overrides = session.execute(
|
||||
select(Source.config_overrides).where(Source.id == source_id)
|
||||
).scalar_one_or_none() or {}
|
||||
authors = overrides.get(AUTHORS_KEY) or []
|
||||
return authors if isinstance(authors, list) else []
|
||||
|
||||
def _record_display_name(self, source_id: int, url: str) -> None:
|
||||
"""Refreshed on every walk, never written once: a renamed channel
|
||||
should read as its new name. A name the walk couldn't read leaves the
|
||||
stored one alone, and a failure here never fails the walk."""
|
||||
label = self.client.source_label(url)
|
||||
if not label or self.session_factory is None:
|
||||
return
|
||||
try:
|
||||
with self.session_factory() as session:
|
||||
session.execute(
|
||||
update(Source)
|
||||
.where(Source.id == source_id)
|
||||
.where(Source.display_name.is_distinct_from(label))
|
||||
.values(display_name=label)
|
||||
)
|
||||
session.commit()
|
||||
except Exception as exc: # a name is decoration — never fail the walk
|
||||
log.warning("Discord: couldn't record source %s's name: %s", source_id, exc)
|
||||
|
||||
|
||||
async def verify_discord_credential(url: str, auth_token: str | None) -> tuple[bool | None, str]:
|
||||
"""The uniform `(ok, message)` probe: is the token valid, and can its
|
||||
account see the channel or server the source names?"""
|
||||
if not auth_token:
|
||||
return False, "No Discord token is saved — add one under Credentials."
|
||||
client = DiscordClient(auth_token)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, client.verify_auth, url)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Remove a Discord source's posts by people outside its poster list (#4486).
|
||||
|
||||
A creator's server is full of other members posting their own pictures. The
|
||||
poster list (`discord_authors`, #4481) stops a walk taking them; this removes
|
||||
the ones taken before the list existed. The operator sets the list first, then
|
||||
previews what would go, then applies.
|
||||
|
||||
## What goes
|
||||
|
||||
A post of this source whose record names its poster (`author_id` / `author`,
|
||||
written by `DiscordDownloader.write_post_record`) and names someone NOT on the
|
||||
list. Two kinds of post are never touched:
|
||||
|
||||
* a post whose record names no poster — a message recorded before the native
|
||||
ingester, whose poster FC cannot tell. Counted as `unknown`, never guessed;
|
||||
* a synthetic drop post (`synthesized_by`) — FC authored it, and it is handled
|
||||
below as a consequence, not matched as a poster's post.
|
||||
|
||||
An image goes when every REAL post it belongs to is going. A link to a
|
||||
synthetic drop does not keep an image: the drop only references its members'
|
||||
images. An image also on a kept post — the creator re-posting a piece someone
|
||||
else shared — stays.
|
||||
|
||||
A drop that absorbed a removed message is deleted outright. Deleting a drop is
|
||||
its undo (discord_grouping's honesty rule): its remaining members return to
|
||||
the feed and the grouping sweep regroups them on its next run, so no half-
|
||||
rebuilt drop keeps text or thumbnails from someone it no longer contains.
|
||||
|
||||
Preview and apply spread the same predicates (rule 93, snippet #3087).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import and_, delete, exists, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from ..models import ImageProvenance, ImageRecord, Post, PostAttachment, Source
|
||||
from .cleanup_service import delete_images
|
||||
from .discord_ingester import AUTHORS_KEY
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM = "discord"
|
||||
# The record keys that name a message's poster. `author_name` (the display
|
||||
# name) is only on records written after 2026-09-28.
|
||||
_POSTER_KEYS = ("author_id", "author", "author_name")
|
||||
|
||||
|
||||
class PosterCleanupError(ValueError):
|
||||
"""The source can't be cleaned this way (not Discord, or no list)."""
|
||||
|
||||
|
||||
def source_authors(source: Source) -> list[str]:
|
||||
"""The source's poster list, lowercased; empty means everyone's wanted."""
|
||||
authors = (source.config_overrides or {}).get(AUTHORS_KEY) or []
|
||||
if not isinstance(authors, list):
|
||||
return []
|
||||
return sorted({str(a).strip().lower() for a in authors if str(a).strip()})
|
||||
|
||||
|
||||
def _poster(key: str):
|
||||
return func.lower(Post.raw_metadata[key].as_string())
|
||||
|
||||
|
||||
def _has_poster():
|
||||
return or_(*(Post.raw_metadata[k].as_string().isnot(None) for k in ("author_id", "author")))
|
||||
|
||||
|
||||
def _real_post_conditions(source_id: int) -> list:
|
||||
return [Post.source_id == source_id, Post.synthesized_by.is_(None)]
|
||||
|
||||
|
||||
def _named_poster_conditions(source_id: int) -> list:
|
||||
"""A real post of this source whose record says who posted it."""
|
||||
return [*_real_post_conditions(source_id), _has_poster()]
|
||||
|
||||
|
||||
def _other_poster_post_conditions(source_id: int, authors: list[str]) -> list:
|
||||
"""The posts that go: a named poster none of whose names is on the list."""
|
||||
return [
|
||||
*_named_poster_conditions(source_id),
|
||||
*(func.coalesce(_poster(k), "").notin_(authors) for k in _POSTER_KEYS),
|
||||
]
|
||||
|
||||
|
||||
def _doomed_post_ids(source_id: int, authors: list[str]):
|
||||
# correlate(None): used inside queries that are themselves over `post` (the
|
||||
# drops, the delete), where auto-correlation would bind this to the outer
|
||||
# row instead of scanning the table.
|
||||
return (
|
||||
select(Post.id)
|
||||
.where(*_other_poster_post_conditions(source_id, authors))
|
||||
.correlate(None)
|
||||
)
|
||||
|
||||
|
||||
def _image_conditions(source_id: int, authors: list[str]) -> list:
|
||||
"""Images that belong to a removed post and to no kept real post."""
|
||||
doomed = _doomed_post_ids(source_id, authors)
|
||||
keeper = aliased(Post)
|
||||
on_doomed = or_(
|
||||
ImageRecord.primary_post_id.in_(doomed),
|
||||
exists().where(
|
||||
ImageProvenance.image_record_id == ImageRecord.id,
|
||||
ImageProvenance.post_id.in_(doomed),
|
||||
),
|
||||
)
|
||||
on_kept = or_(
|
||||
and_(
|
||||
ImageRecord.primary_post_id.isnot(None),
|
||||
ImageRecord.primary_post_id.notin_(doomed),
|
||||
),
|
||||
exists().where(
|
||||
ImageProvenance.image_record_id == ImageRecord.id,
|
||||
ImageProvenance.post_id == keeper.id,
|
||||
keeper.synthesized_by.is_(None),
|
||||
keeper.id.notin_(doomed),
|
||||
),
|
||||
)
|
||||
return [on_doomed, ~on_kept]
|
||||
|
||||
|
||||
def _drop_conditions(source_id: int, authors: list[str]) -> list:
|
||||
"""The synthetic drops that absorbed a removed message."""
|
||||
member = aliased(Post)
|
||||
return [
|
||||
Post.source_id == source_id,
|
||||
Post.synthesized_by.isnot(None),
|
||||
exists().where(
|
||||
member.absorbed_by_post_id == Post.id,
|
||||
member.id.in_(_doomed_post_ids(source_id, authors)),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _attachment_conditions(source_id: int, authors: list[str]) -> list:
|
||||
return [PostAttachment.post_id.in_(_doomed_post_ids(source_id, authors))]
|
||||
|
||||
|
||||
def _load(session: Session, source_id: int) -> tuple[Source, list[str]]:
|
||||
source = session.get(Source, source_id)
|
||||
if source is None:
|
||||
raise LookupError(source_id)
|
||||
if source.platform != PLATFORM:
|
||||
raise PosterCleanupError("Only a Discord source has posters to filter by.")
|
||||
authors = source_authors(source)
|
||||
if not authors:
|
||||
raise PosterCleanupError(
|
||||
"Set this source's 'Only posts by' list first — with no list, "
|
||||
"every poster is wanted and nothing would be removed."
|
||||
)
|
||||
return source, authors
|
||||
|
||||
|
||||
def _count(session: Session, stmt) -> int:
|
||||
return session.execute(stmt).scalar_one()
|
||||
|
||||
|
||||
def preview(session: Session, *, source_id: int) -> dict:
|
||||
"""What `apply` would remove, per poster, without touching anything."""
|
||||
_, authors = _load(session, source_id)
|
||||
who = func.coalesce(
|
||||
Post.raw_metadata["author_name"].as_string(),
|
||||
Post.raw_metadata["author"].as_string(),
|
||||
Post.raw_metadata["author_id"].as_string(),
|
||||
)
|
||||
rows = session.execute(
|
||||
select(who, func.count(Post.id))
|
||||
.where(*_other_poster_post_conditions(source_id, authors))
|
||||
.group_by(who)
|
||||
.order_by(func.count(Post.id).desc())
|
||||
).all()
|
||||
posters = [{"poster": name, "posts": n} for name, n in rows]
|
||||
unknown = _count(session, select(func.count(Post.id)).where(
|
||||
*_real_post_conditions(source_id), ~_has_poster(),
|
||||
))
|
||||
return {
|
||||
"authors": authors,
|
||||
"posters": posters,
|
||||
"posts": sum(p["posts"] for p in posters),
|
||||
"images": _count(session, select(func.count(ImageRecord.id)).where(
|
||||
*_image_conditions(source_id, authors))),
|
||||
"attachments": _count(session, select(func.count(PostAttachment.id)).where(
|
||||
*_attachment_conditions(source_id, authors))),
|
||||
"drops": _count(session, select(func.count(Post.id)).where(
|
||||
*_drop_conditions(source_id, authors))),
|
||||
"unknown_posts": unknown,
|
||||
}
|
||||
|
||||
|
||||
def confirm_token(projection: dict) -> str:
|
||||
"""What the operator's apply must echo: the preview it was shown, so a
|
||||
list edited between preview and apply can't delete an unseen set."""
|
||||
return f"remove-{projection['posts']}-posts-{projection['images']}-images"
|
||||
|
||||
|
||||
def apply(session: Session, *, source_id: int, images_root: Path) -> dict:
|
||||
"""Remove them. Counts are taken before the deletes they describe — once
|
||||
the rows are gone there is nothing left to count (snippet #3087 note 2)."""
|
||||
projection = preview(session, source_id=source_id)
|
||||
_, authors = _load(session, source_id)
|
||||
|
||||
image_ids = session.execute(
|
||||
select(ImageRecord.id).where(*_image_conditions(source_id, authors))
|
||||
).scalars().all()
|
||||
# Drops first-class: found BEFORE their members go, since the link that
|
||||
# finds them is the member's absorbed_by_post_id.
|
||||
drop_ids = session.execute(
|
||||
select(Post.id).where(*_drop_conditions(source_id, authors))
|
||||
).scalars().all()
|
||||
|
||||
deleted = delete_images(session, image_ids=list(image_ids), images_root=images_root)
|
||||
# Attachments before posts: post_attachment.post_id is SET NULL, and a
|
||||
# NULL-post attachment collides on its partial unique index (see
|
||||
# cleanup_service.delete_artist_cascade).
|
||||
attachments = session.execute(
|
||||
delete(PostAttachment).where(*_attachment_conditions(source_id, authors))
|
||||
).rowcount or 0
|
||||
posts = session.execute(
|
||||
Post.__table__.delete().where(Post.id.in_(_doomed_post_ids(source_id, authors)))
|
||||
).rowcount or 0
|
||||
drops = 0
|
||||
if drop_ids:
|
||||
drops = session.execute(
|
||||
Post.__table__.delete().where(Post.id.in_(drop_ids))
|
||||
).rowcount or 0
|
||||
session.commit()
|
||||
log.info(
|
||||
"discord poster cleanup (source %s, keeping %s): %d post(s), %d image(s), "
|
||||
"%d attachment(s), %d drop(s) removed",
|
||||
source_id, authors, posts, deleted["images_deleted"], attachments, drops,
|
||||
)
|
||||
return {
|
||||
**projection,
|
||||
"posts_deleted": posts,
|
||||
"images_deleted": deleted["images_deleted"],
|
||||
"files_deleted": deleted["files_deleted"],
|
||||
"attachments_deleted": attachments,
|
||||
"drops_deleted": drops,
|
||||
}
|
||||
@@ -23,18 +23,22 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from .gallery_dl import DownloadResult, ErrorType
|
||||
from .discord_ingester import DiscordIngester
|
||||
from .download_result import DownloadResult, ErrorType
|
||||
from .ingest_core import DEFAULT_REVISIT_DAYS
|
||||
from .patreon_ingester import PatreonIngester
|
||||
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
|
||||
from .pixiv_client import user_id_from_url
|
||||
from .pixiv_ingester import PixivIngester
|
||||
from .platforms import known_platform_keys
|
||||
from .subscribestar_ingester import SubscribeStarIngester
|
||||
|
||||
# Platforms whose download + verify go through the native ingester rather than
|
||||
# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord) until
|
||||
# they migrate too. pixiv left this set when it was retired (milestone #406).
|
||||
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar"})
|
||||
# gallery-dl. gallery-dl still serves the rest (hentaifoundry) until it
|
||||
# migrates too. Discord joined in milestone 428.
|
||||
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "discord"})
|
||||
|
||||
# Native platforms whose feed id IS the source URL, so there is nothing to
|
||||
# resolve: SubscribeStar's creator page, Discord's server/channel link.
|
||||
_URL_IS_FEED = frozenset({"subscribestar", "discord"})
|
||||
|
||||
|
||||
def _unsupported_platform_message(platform: str) -> str | None:
|
||||
@@ -67,8 +71,8 @@ def _native_ingester_cls(platform: str):
|
||||
dispatch pick up the replacement."""
|
||||
return {
|
||||
"patreon": PatreonIngester,
|
||||
"pixiv": PixivIngester,
|
||||
"subscribestar": SubscribeStarIngester,
|
||||
"discord": DiscordIngester,
|
||||
}[platform]
|
||||
|
||||
|
||||
@@ -86,6 +90,7 @@ async def run_download(
|
||||
mode: str | None,
|
||||
gdl,
|
||||
sync_session_factory,
|
||||
revisit_days: int = DEFAULT_REVISIT_DAYS,
|
||||
) -> tuple[DownloadResult, str | None]:
|
||||
"""Uniform download across backends — the download counterpart to
|
||||
`verify_source_credential`, so this module is the ONE place that knows how
|
||||
@@ -109,7 +114,7 @@ async def run_download(
|
||||
), None
|
||||
if uses_native_ingester(platform):
|
||||
return await _run_native_ingester(
|
||||
ctx, source_config, mode, gdl, sync_session_factory
|
||||
ctx, source_config, mode, gdl, sync_session_factory, revisit_days
|
||||
)
|
||||
result = await gdl.download(
|
||||
url=ctx["url"],
|
||||
@@ -127,25 +132,17 @@ async def _resolve_native_campaign_id(
|
||||
platform: str, url: str, cookies_path: str | None, overrides: dict,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""`(campaign_id, resolved_campaign_id)` for a native source. SubscribeStar's
|
||||
feed id IS the creator URL; Pixiv's is the numeric user id parsed straight
|
||||
from it (no lookup → resolved None either way). Patreon resolves the
|
||||
campaign id from the vanity URL (resolved non-None when a lookup actually ran,
|
||||
so phase 3 caches it)."""
|
||||
if platform == "subscribestar":
|
||||
and Discord's feed id IS the source URL (no lookup → resolved None). Patreon
|
||||
resolves the campaign id from the vanity URL (resolved non-None when a lookup
|
||||
actually ran, so phase 3 caches it)."""
|
||||
if platform in _URL_IS_FEED:
|
||||
return url, None
|
||||
if platform == "pixiv":
|
||||
return user_id_from_url(url), None
|
||||
return await resolve_campaign_id_for_source(url, cookies_path, overrides)
|
||||
|
||||
|
||||
def _campaign_resolution_error(platform: str, url: str) -> str:
|
||||
"""Operator-facing message for a native source whose campaign id could not
|
||||
be resolved — names the platform's own lookup mechanism."""
|
||||
if platform == "pixiv":
|
||||
return (
|
||||
f"Could not extract a pixiv user id. source_url={url!r} — expected "
|
||||
"a URL like https://www.pixiv.net/users/<id>."
|
||||
)
|
||||
vanity = extract_vanity(url)
|
||||
return (
|
||||
f"Could not resolve Patreon campaign id. source_url={url!r}; "
|
||||
@@ -157,6 +154,7 @@ def _campaign_resolution_error(platform: str, url: str) -> str:
|
||||
|
||||
async def _run_native_ingester(
|
||||
ctx: dict, source_config, mode: str | None, gdl, sync_session_factory,
|
||||
revisit_days: int = DEFAULT_REVISIT_DAYS,
|
||||
) -> tuple[DownloadResult, str | None]:
|
||||
"""Run the native ingester for a native platform in a worker thread (sync
|
||||
requests/subprocess). Patreon resolves a campaign id from the vanity URL;
|
||||
@@ -172,8 +170,8 @@ async def _run_native_ingester(
|
||||
platform, ctx["url"], ctx["cookies_path"], overrides
|
||||
)
|
||||
if not campaign_id:
|
||||
# Patreon: vanity lookup failed. Pixiv: no numeric user id in the URL.
|
||||
# (SubscribeStar's campaign id is the URL itself — never lands here.)
|
||||
# Patreon: vanity lookup failed. (SubscribeStar's campaign id is the
|
||||
# URL itself — never lands here.)
|
||||
url = ctx["url"]
|
||||
return (
|
||||
DownloadResult(
|
||||
@@ -205,7 +203,7 @@ async def _run_native_ingester(
|
||||
validate=gdl._validate_files,
|
||||
rate_limit=rate_limit,
|
||||
request_sleep=request_sleep,
|
||||
# Uniform across adapters: token platforms (pixiv) authenticate with
|
||||
# Uniform across adapters: a token platform would authenticate with
|
||||
# it, cookie platforms accept-and-ignore — so this construction stays
|
||||
# platform-agnostic.
|
||||
auth_token=ctx["auth_token"],
|
||||
@@ -221,6 +219,9 @@ async def _run_native_ingester(
|
||||
mode=mode,
|
||||
resume_cursor=source_config.resume_cursor,
|
||||
time_budget_seconds=source_config.timeout,
|
||||
# How far back a tick keeps looking for EDITED posts. The ingester
|
||||
# applies it to ticks only; a backfill ignores it.
|
||||
revisit_days=revisit_days,
|
||||
posts_base=int(overrides.get("_backfill_posts", 0)),
|
||||
# plan #709: live progress writes to this running event mid-walk.
|
||||
event_id=ctx.get("event_id"),
|
||||
@@ -252,16 +253,15 @@ async def verify_source_credential(
|
||||
if uses_native_ingester(platform):
|
||||
# Native ingester platforms verify via their own lightweight auth probe.
|
||||
# SubscribeStar's probe takes the creator URL directly; Patreon's
|
||||
# resolves the campaign id first; Pixiv's is one OAuth refresh (the
|
||||
# exact call that fails when the token is bad — no feed walk).
|
||||
# resolves the campaign id first.
|
||||
if platform == "subscribestar":
|
||||
from .subscribestar_ingester import verify_subscribestar_credential
|
||||
|
||||
return await verify_subscribestar_credential(url, cookies_path, config_overrides)
|
||||
if platform == "pixiv":
|
||||
from .pixiv_ingester import verify_pixiv_credential
|
||||
if platform == "discord":
|
||||
from .discord_ingester import verify_discord_credential
|
||||
|
||||
return await verify_pixiv_credential(auth_token)
|
||||
return await verify_discord_credential(url, auth_token)
|
||||
from .patreon_ingester import verify_patreon_credential
|
||||
|
||||
return await verify_patreon_credential(url, cookies_path, config_overrides)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Closing the download runs a worker restart orphaned (#4433).
|
||||
|
||||
Its own module, importing nothing but the model, because its caller is the
|
||||
worker boot hook in `celery_signals` — which every download task imports via
|
||||
`celery_app`. Living in `tasks.maintenance` put the whole maintenance import
|
||||
graph, the membership roster included, on the fetch path, which
|
||||
`test_gated_reason` forbids.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import literal, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from ..models import DownloadEvent
|
||||
|
||||
DOWNLOAD_INTERRUPTED_MESSAGE = (
|
||||
"interrupted by a worker restart — the next check picks it up where it left off"
|
||||
)
|
||||
|
||||
|
||||
def interrupt_orphaned_download_events(session, *, booted_at: datetime) -> int:
|
||||
"""Close the download events a restart orphaned, without blaming the source.
|
||||
|
||||
Called when the download lane comes up (#4433). Anything still
|
||||
pending/running from before this boot belongs to the previous process:
|
||||
a walk that outlived the 90s stop grace was SIGKILLed, and a queued or
|
||||
serialize-deferred task is held unacked until Redis redelivers it about an
|
||||
hour later. Left alone, the 30-min stall sweep would error each one and
|
||||
bump `consecutive_failures`, backing the source off as if the platform had
|
||||
failed.
|
||||
|
||||
Instead they end as `skipped` (terminal, not a failure) and the source is
|
||||
not touched: `last_checked_at` keeps its old value, so the next tick finds
|
||||
it due and the walk resumes from its checkpoint. A redelivered message
|
||||
that arrives later finds no pending event and opens a fresh one.
|
||||
|
||||
An event promoted to running after the boot has `started_at` reset to its
|
||||
real start (download_service), so it is never caught here. Does NOT commit.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
result = session.execute(
|
||||
update(DownloadEvent)
|
||||
.where(DownloadEvent.status.in_(["pending", "running"]))
|
||||
.where(DownloadEvent.started_at < booted_at)
|
||||
.values(
|
||||
status="skipped",
|
||||
finished_at=now,
|
||||
error=DOWNLOAD_INTERRUPTED_MESSAGE,
|
||||
metadata_=DownloadEvent.metadata_.op("||")(
|
||||
literal({"error_type": "interrupted"}, JSONB)
|
||||
),
|
||||
)
|
||||
.returning(DownloadEvent.id)
|
||||
)
|
||||
return len(result.all())
|
||||
@@ -0,0 +1,222 @@
|
||||
"""What a download walk reports, whichever backend ran it.
|
||||
|
||||
The native ingesters (Patreon, SubscribeStar, Discord) and the gallery-dl
|
||||
subprocess (HentaiFoundry) both return a `DownloadResult`, classify failures
|
||||
with the same `ErrorType`s and build `run_stats` through `make_run_stats`, so
|
||||
phase 3, the source's status chip and the Logs UI read one shape from either.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ErrorType(StrEnum):
|
||||
AUTH_ERROR = "auth_error"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
NOT_FOUND = "not_found"
|
||||
ACCESS_DENIED = "access_denied"
|
||||
NETWORK_ERROR = "network_error"
|
||||
NO_NEW_CONTENT = "no_new_content"
|
||||
TIER_LIMITED = "tier_limited"
|
||||
TIMEOUT = "timeout"
|
||||
HTTP_ERROR = "http_error"
|
||||
UNSUPPORTED_URL = "unsupported_url"
|
||||
VALIDATION_FAILED = "validation_failed"
|
||||
# Native Patreon ingester only (plan #697): a response parsed as JSON but
|
||||
# didn't match the JSON:API shape the ingester depends on (missing `data`,
|
||||
# media lacking `file_name`/`url`, etc.). Distinct from AUTH_ERROR — the fix
|
||||
# is updating the ingester's field-set/parser, not rotating credentials. The
|
||||
# contract test guards against silently shipping this.
|
||||
API_DRIFT = "api_drift"
|
||||
# Run made real progress (downloaded ≥1 file) but did not finish in the
|
||||
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
|
||||
# mapping classifies this as "ok" because the next tick continues.
|
||||
PARTIAL = "partial"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadResult:
|
||||
success: bool
|
||||
url: str
|
||||
artist_slug: str
|
||||
platform: str
|
||||
files_downloaded: int = 0
|
||||
files_quarantined: int = 0
|
||||
quarantined_paths: list[str] = field(default_factory=list)
|
||||
written_paths: list[str] = field(default_factory=list)
|
||||
# Native ingester only: post-only sidecar paths for posts that have NO
|
||||
# downloadable media (pure-text posts), so the importer can still upsert the
|
||||
# Post + its body. Empty on the gallery-dl path. Phase 3 imports these via
|
||||
# Importer.upsert_post_record (keyed on external_post_id → updates, never
|
||||
# doubles, the same Post a media import would create).
|
||||
post_record_paths: list[str] = field(default_factory=list)
|
||||
# Native ingester, recapture mode only (#830): (on-disk path, CDN source_url)
|
||||
# pairs for already-present media whose ImageRecord.source_filehash should be
|
||||
# backfilled (inline-image localization) WITHOUT re-download or unlink. Empty
|
||||
# on the gallery-dl path and outside recapture.
|
||||
relink_source_paths: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
# Native ingester only: one entry per `written_paths` entry, the backfill
|
||||
# cursor of the page that file came from. Phase 3 resumes the next chunk
|
||||
# there when it leaves files unimported at its deadline. Empty on gallery-dl.
|
||||
written_cursors: list[str | None] = field(default_factory=list)
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
return_code: int = 0
|
||||
error_type: ErrorType | None = None
|
||||
error_message: str | None = None
|
||||
duration_seconds: float = 0.0
|
||||
started_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
# Plan #704 — structured fields the NATIVE ingester populates directly (None
|
||||
# on the gallery-dl path, which keeps the regex-over-stdout route). When set,
|
||||
# phase 3 reads these instead of scraping the text it would otherwise have to
|
||||
# reconstruct: `run_stats` mirrors _compute_run_stats' shape; `cursor` is the
|
||||
# backfill checkpoint the ingester knows exactly (no parse_last_cursor).
|
||||
run_stats: dict | None = None
|
||||
cursor: str | None = None
|
||||
posts_processed: int = 0
|
||||
# Plan #708 B1 — the server's Retry-After seconds on a RATE_LIMITED result, so
|
||||
# the platform cooldown matches the hint instead of a flat default. None when
|
||||
# unknown (no header, or not a rate-limit failure).
|
||||
retry_after_seconds: float | None = None
|
||||
# Native ingester only: marks this walk's fetched media seen in its ledger.
|
||||
# Phase 3 calls it AFTER the import loop, never before — a file marked seen
|
||||
# but not yet imported is invisible to every later walk, so a run killed in
|
||||
# between orphaned it for good (TamadaHeijun's 12PCG post lost 7 of 13
|
||||
# images to a stranded run, 2026-09-24). Unmarked, the next walk finds the
|
||||
# file on disk with no ImageRecord and imports it. Takes `skip_paths`: the
|
||||
# written paths phase 3 did not import, which stay unmarked for the same
|
||||
# reason. None on gallery-dl.
|
||||
mark_seen_after_import: Callable[..., None] | None = None
|
||||
|
||||
|
||||
def extract_errors_warnings(stderr: str) -> str:
|
||||
"""Keep only the `[error]`/`[warning]` lines from a captured stderr blob.
|
||||
|
||||
A generic download-log helper (module-level so the native-ingester result
|
||||
path can shape its logs without reaching through a GalleryDLService instance).
|
||||
"""
|
||||
if not stderr:
|
||||
return ""
|
||||
kept = [
|
||||
line for line in stderr.splitlines()
|
||||
if "][error]" in line.lower() or "][warning]" in line.lower()
|
||||
]
|
||||
return "\n".join(kept)
|
||||
|
||||
|
||||
def truncate_log(text: str, max_bytes: int = 500_000) -> str:
|
||||
"""Cap a captured log to `max_bytes`, eliding the middle (head + tail kept)."""
|
||||
if not text:
|
||||
return text
|
||||
encoded = text.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text
|
||||
half = max_bytes // 2
|
||||
head = encoded[:half].decode("utf-8", errors="ignore")
|
||||
tail = encoded[-half:].decode("utf-8", errors="ignore")
|
||||
head_lines = head.count("\n")
|
||||
tail_lines = tail.count("\n")
|
||||
total_lines = text.count("\n")
|
||||
elided = max(0, total_lines - head_lines - tail_lines)
|
||||
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
|
||||
return head + marker + tail
|
||||
|
||||
|
||||
def make_run_stats(
|
||||
*,
|
||||
exit_code: int = 0,
|
||||
downloaded_count: int = 0,
|
||||
skipped_count: int = 0,
|
||||
per_item_failures: int = 0,
|
||||
warning_count: int = 0,
|
||||
tier_gated_count: int = 0,
|
||||
quarantined_count: int = 0,
|
||||
dead_lettered_count: int = 0,
|
||||
) -> dict:
|
||||
"""The canonical `run_stats` dict shape, in ONE place.
|
||||
|
||||
Both result producers — gallery-dl's `_compute_run_stats` (log scrape) and the
|
||||
native ingester's per-outcome tally (ingest_core) — build through this so the
|
||||
key set can't drift between backends. Phase 3 + the Logs UI read these keys.
|
||||
"""
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"downloaded_count": downloaded_count,
|
||||
"skipped_count": skipped_count,
|
||||
"per_item_failures": per_item_failures,
|
||||
"warning_count": warning_count,
|
||||
"tier_gated_count": tier_gated_count,
|
||||
"quarantined_count": quarantined_count,
|
||||
"dead_lettered_count": dead_lettered_count,
|
||||
}
|
||||
|
||||
|
||||
# --- tier-gated classification, shared by BOTH backends ---------------------
|
||||
#
|
||||
# These three live together because the native ingester and the gallery-dl
|
||||
# subprocess must reach the same verdict from the same number. They did not:
|
||||
# gallery-dl classified TIER_LIMITED while ingest_core counted gated posts and
|
||||
# threw the count away, so the platforms FC owns reported a paywalled creator as
|
||||
# a silent one (#874 follow-up). One predicate, spread into both, rather than
|
||||
# the condition re-derived per backend.
|
||||
|
||||
|
||||
def classify_tier_gated(tier_gated_count: int) -> ErrorType | None:
|
||||
"""TIER_LIMITED when a walk saw tier-gated posts and nothing else failed.
|
||||
|
||||
Deliberately NOT conditioned on `downloaded == 0`. A creator whose top-tier
|
||||
posts we cannot see is tier-limited even in a week we did get their cheaper
|
||||
ones — the fact the operator needs ("there is content here you are not
|
||||
paying for") is true either way. gallery-dl has classified it this way since
|
||||
the paywall-as-"needs attention" complaint (see `_categorize_error`), and the
|
||||
native path now matches rather than inventing a stricter rule.
|
||||
|
||||
Callers must apply this only AFTER the real error categories (auth, rate
|
||||
limit, drift, …) have had their turn; tier-gating is the weakest signal and
|
||||
must never mask a genuine failure.
|
||||
"""
|
||||
return ErrorType.TIER_LIMITED if tier_gated_count else None
|
||||
|
||||
|
||||
def tier_gated_message(count: int) -> str:
|
||||
"""The one wording for the tier-gated verdict, so the two backends can't
|
||||
describe the same state differently in the Logs UI."""
|
||||
return (
|
||||
f"Subscription tier does not grant access to "
|
||||
f"{count} post{'s' if count != 1 else ''}"
|
||||
)
|
||||
|
||||
|
||||
# `Source.error_type` doubles as the failure-class chip, and a status of "ok"
|
||||
# CLEARS it (alembic 0032). TIER_LIMITED breaks that assumption: it rides an
|
||||
# otherwise-successful run, so without an exemption the chip is wiped the moment
|
||||
# it is set and `FailingSourcesCard`'s `tier_limited` palette entry can never
|
||||
# render. Informational classes are the exemption — they describe the source,
|
||||
# not a failure of the run.
|
||||
INFORMATIONAL_ERROR_TYPES = frozenset({ErrorType.TIER_LIMITED.value})
|
||||
|
||||
|
||||
def is_informational(error_type) -> bool:
|
||||
"""True for a class that reports a state rather than a failure. Accepts an
|
||||
ErrorType or the plain string persisted on Source.error_type."""
|
||||
return error_type is not None and str(error_type) in INFORMATIONAL_ERROR_TYPES
|
||||
|
||||
|
||||
def walk_completed(result: DownloadResult) -> bool:
|
||||
"""Did this walk reach the bottom cleanly?
|
||||
|
||||
The backfill lifecycle's completion test. An informational error_type still
|
||||
counts as complete: a fully-paywalled creator's backfill DID finish, and
|
||||
treating it as unfinished re-walks the same wall until the stall counter
|
||||
trips — the creator we can see least becoming the one we fetch most.
|
||||
"""
|
||||
return (
|
||||
result.success
|
||||
and result.return_code == 0
|
||||
and (result.error_type is None or is_informational(result.error_type))
|
||||
)
|
||||
@@ -3,8 +3,9 @@
|
||||
Three-phase pipeline:
|
||||
Phase 1 — DB setup (brief session): load source, mark event running,
|
||||
fetch credentials + settings.
|
||||
Phase 2 — Execute (no DB connection): call GalleryDLService; on
|
||||
Patreon campaign-ID failure, resolve vanity and retry once.
|
||||
Phase 2 — Execute (no DB connection): run the source's backend (a native
|
||||
ingester, or gallery-dl for HentaiFoundry); on a Patreon
|
||||
campaign-ID failure, resolve the vanity and retry once.
|
||||
Phase 3 — Import + persist (fresh session): attach_in_place each
|
||||
written file; clean up duplicates; persist rich metadata.
|
||||
"""
|
||||
@@ -13,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -25,26 +27,39 @@ from sqlalchemy.orm import joinedload
|
||||
from ..models import Artist, DownloadEvent, Source
|
||||
from .credential_service import CredentialService
|
||||
from .download_backends import run_download, uses_native_ingester
|
||||
from .gallery_dl import (
|
||||
BACKFILL_CHUNK_SECONDS,
|
||||
BACKFILL_SKIP_VALUE,
|
||||
TICK_SKIP_VALUE,
|
||||
from .download_result import (
|
||||
DownloadResult,
|
||||
ErrorType,
|
||||
GalleryDLService,
|
||||
SourceConfig,
|
||||
extract_errors_warnings,
|
||||
is_informational,
|
||||
truncate_log,
|
||||
walk_completed,
|
||||
)
|
||||
from .gallery_dl import (
|
||||
BACKFILL_CHUNK_SECONDS,
|
||||
BACKFILL_SKIP_VALUE,
|
||||
TICK_SKIP_VALUE,
|
||||
GalleryDLService,
|
||||
SourceConfig,
|
||||
)
|
||||
from .importer import Importer
|
||||
from .ingest_core import DEFAULT_REVISIT_DAYS, PHASE3_DEADLINE_SECONDS
|
||||
from .platforms import auth_type_for
|
||||
from .scheduler_service import set_platform_cooldown
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def credential_proven(ctx: dict, dl_result) -> bool:
|
||||
"""Did this walk prove the platform's stored credential still works?
|
||||
|
||||
A clean walk made with a credential is the evidence the Verify button looks
|
||||
for. A walk with any error type proves nothing, tier-limited included:
|
||||
content hidden behind a tier is also what an expired session looks like."""
|
||||
used = bool(ctx.get("cookies_path") or ctx.get("auth_token"))
|
||||
return used and bool(dl_result.success) and dl_result.error_type is None
|
||||
|
||||
|
||||
class DownloadService:
|
||||
"""Async orchestrator. The Celery task runs `asyncio.run(svc.download_source(N))`.
|
||||
|
||||
@@ -60,6 +75,7 @@ class DownloadService:
|
||||
importer: Importer,
|
||||
cred_service: CredentialService,
|
||||
sync_session_factory=None,
|
||||
revisit_days: int = DEFAULT_REVISIT_DAYS,
|
||||
):
|
||||
self.async_session = async_session
|
||||
self.sync_session = sync_session
|
||||
@@ -71,9 +87,16 @@ class DownloadService:
|
||||
# the multi-minute walk — see PatreonIngester). Only the patreon branch
|
||||
# of phase 2 uses it; gallery-dl sources leave it None.
|
||||
self.sync_session_factory = sync_session_factory
|
||||
# ImportSettings.download_revisit_days — how far back a tick keeps
|
||||
# looking for EDITED posts (ingest_core.DEFAULT_REVISIT_DAYS). Passed in
|
||||
# rather than read here because the task already loads the settings row
|
||||
# for rate_limit/validate_files, and a second load on every download
|
||||
# would be the same row twice for one number.
|
||||
self.revisit_days = revisit_days
|
||||
|
||||
async def download_source(self, source_id: int) -> int:
|
||||
"""Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is."""
|
||||
started = time.monotonic()
|
||||
setup = await self._phase1_setup(source_id)
|
||||
if setup["status"] in ("skipped", "in_flight"):
|
||||
return setup["event_id"]
|
||||
@@ -166,6 +189,7 @@ class DownloadService:
|
||||
|
||||
return await self._phase3_persist(
|
||||
ctx["event_id"], ctx, dl_result, resolved_campaign_id,
|
||||
started=started,
|
||||
)
|
||||
|
||||
async def _run_download(
|
||||
@@ -178,6 +202,7 @@ class DownloadService:
|
||||
return await run_download(
|
||||
ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode,
|
||||
gdl=self.gdl, sync_session_factory=self.sync_session_factory,
|
||||
revisit_days=self.revisit_days,
|
||||
)
|
||||
|
||||
async def _phase1_setup(self, source_id: int) -> dict[str, Any]:
|
||||
@@ -259,6 +284,8 @@ class DownloadService:
|
||||
ctx: dict,
|
||||
dl_result,
|
||||
resolved_campaign_id: str | None,
|
||||
*,
|
||||
started: float | None = None,
|
||||
) -> int:
|
||||
# Cache Patreon campaign id on the source.
|
||||
if resolved_campaign_id:
|
||||
@@ -294,7 +321,23 @@ class DownloadService:
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
for path_str in dl_result.written_paths or []:
|
||||
# Phase 3 shares the task's soft limit with the walk, and an import's
|
||||
# cost is only estimated there. Native platforms stop starting imports
|
||||
# at the deadline: what is left stays on disk with no record, which the
|
||||
# next chunk's walk imports. gallery-dl platforms have no such pickup,
|
||||
# so they import everything.
|
||||
deadline = (
|
||||
started + PHASE3_DEADLINE_SECONDS
|
||||
if started is not None and uses_native_ingester(ctx["platform"])
|
||||
else None
|
||||
)
|
||||
written_paths = list(dl_result.written_paths or [])
|
||||
deferred_from: int | None = None
|
||||
|
||||
for idx, path_str in enumerate(written_paths):
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
deferred_from = idx
|
||||
break
|
||||
if path_str in (dl_result.quarantined_paths or []):
|
||||
continue
|
||||
path = Path(path_str)
|
||||
@@ -339,11 +382,8 @@ class DownloadService:
|
||||
if do_embed:
|
||||
embed_image.delay(img_id)
|
||||
elif result.status == "attached":
|
||||
# Non-media or extracted archive captured as PostAttachment
|
||||
# (FC-2d-iii). The canonical copy lives in the attachments
|
||||
# store; the original download path is now redundant —
|
||||
# mirror duplicate_hash cleanup so we don't keep two copies.
|
||||
# Operator-flagged 2026-06-02 (Lustria OST zip).
|
||||
# Non-media, or an archive with no new images, captured as a
|
||||
# PostAttachment; the attachments store holds the copy.
|
||||
import_summary["attached"] += 1
|
||||
# An archive captured WITHOUT extracting any image carries a
|
||||
# reason on result.error — record it so the event explains the
|
||||
@@ -360,35 +400,13 @@ class DownloadService:
|
||||
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
path.unlink(missing_ok=True) # noqa: ASYNC240
|
||||
except OSError:
|
||||
pass
|
||||
elif result.status == "skipped" and result.skip_reason and result.skip_reason.value in (
|
||||
"duplicate_hash", "duplicate_phash",
|
||||
):
|
||||
import_summary["skipped"] += 1
|
||||
try:
|
||||
path.unlink(missing_ok=True) # noqa: ASYNC240
|
||||
except OSError:
|
||||
pass
|
||||
elif result.status == "skipped":
|
||||
# Soft skip (too_small, too_transparent, invalid_image) —
|
||||
# the file just didn't qualify, not a download/ingest
|
||||
# failure. Don't flag the run as error; the file stays
|
||||
# on disk for operator inspection.
|
||||
# A duplicate, or a file the import filters rejected. Neither
|
||||
# is a download failure, so the run is not flagged.
|
||||
import_summary["skipped"] += 1
|
||||
elif result.status == "failed":
|
||||
# Hard failure (today only: archive probe crash/timeout).
|
||||
# The original archive sits in /images/ as an orphan; the
|
||||
# filesystem scanner would re-import and re-crash on the
|
||||
# same file, so delete the source file and surface the
|
||||
# error in import_summary. Audit 2026-06-02.
|
||||
# Hard failure: an archive probe crashed or timed out.
|
||||
import_summary["errors"] += 1
|
||||
try:
|
||||
path.unlink(missing_ok=True) # noqa: ASYNC240
|
||||
except OSError:
|
||||
pass
|
||||
elif result.status == "refreshed":
|
||||
# Currently unreachable from attach_in_place (the download
|
||||
# path never runs in deep=True mode), but the importer's
|
||||
@@ -397,6 +415,10 @@ class DownloadService:
|
||||
import_summary["attached"] += 1
|
||||
else:
|
||||
import_summary["errors"] += 1
|
||||
# Remove the download when the library no longer needs it at this
|
||||
# path: an attachment or archive now in the store, a duplicate, a
|
||||
# rejected file. One policy for every download path.
|
||||
self.importer.discard_after_attach(path, result)
|
||||
|
||||
# Post-only records: media-less posts (pure-text) the native ingester
|
||||
# captured so the artist archive is complete. Upsert each (keyed on
|
||||
@@ -414,6 +436,21 @@ class DownloadService:
|
||||
|
||||
await loop.run_in_executor(None, _upsert)
|
||||
|
||||
# Only now is it safe to call this walk's media seen: every file above
|
||||
# has been through the importer. Had the run died before here they stay
|
||||
# unmarked, and the next walk imports them from disk (ingest_core).
|
||||
mark_seen = getattr(dl_result, "mark_seen_after_import", None)
|
||||
deferred = written_paths[deferred_from:] if deferred_from is not None else []
|
||||
if mark_seen is not None:
|
||||
if deferred:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: mark_seen(skip_paths=frozenset(deferred)),
|
||||
)
|
||||
else:
|
||||
await loop.run_in_executor(None, mark_seen)
|
||||
if deferred:
|
||||
self._defer_unimported(dl_result, deferred_from, len(deferred))
|
||||
|
||||
# #830 recapture: backfill source_filehash on EXISTING on-disk images so
|
||||
# their post-body inline <img src=CDN> remaps to the local copy. A
|
||||
# SEPARATE non-deleting channel (NOT the import list — that would unlink
|
||||
@@ -512,8 +549,39 @@ class DownloadService:
|
||||
)
|
||||
await self._apply_backfill_lifecycle(ctx, dl_result)
|
||||
await self.async_session.commit()
|
||||
if credential_proven(ctx, dl_result):
|
||||
# The credentials page then shows when the account last worked,
|
||||
# not only when someone last pressed Verify.
|
||||
await self.cred_service.mark_verified(ctx["platform"])
|
||||
return event_id
|
||||
|
||||
@staticmethod
|
||||
def _defer_unimported(dl_result, first: int, count: int) -> None:
|
||||
"""Turn this chunk into a chunk boundary at the first file phase 3 left.
|
||||
|
||||
The next chunk resumes on the page that file came from, so its walk
|
||||
meets every left file again, on disk with no record, and imports it. A
|
||||
walk that reached its end has not finished while files wait, so a clean
|
||||
result becomes PARTIAL; a real failure keeps its own error.
|
||||
"""
|
||||
cursors = getattr(dl_result, "written_cursors", None) or []
|
||||
if first < len(cursors):
|
||||
dl_result.cursor = cursors[first]
|
||||
dl_result.phase3_imported_any = first > 0
|
||||
msg = (
|
||||
f"Imports stopped at the chunk deadline: {count} file(s) left "
|
||||
"for the next chunk"
|
||||
)
|
||||
dl_result.stdout = f"{dl_result.stdout}\n{msg}" if dl_result.stdout else msg
|
||||
log.info("%s", msg)
|
||||
if dl_result.error_type in (None, ErrorType.PARTIAL, ErrorType.TIMEOUT) or (
|
||||
is_informational(dl_result.error_type)
|
||||
):
|
||||
dl_result.success = False
|
||||
dl_result.return_code = -1
|
||||
dl_result.error_type = ErrorType.PARTIAL
|
||||
dl_result.error_message = msg
|
||||
|
||||
async def _apply_backfill_lifecycle(self, ctx: dict, dl_result) -> None:
|
||||
"""Backfill state machine (plan #693, building on the cursor of #689).
|
||||
|
||||
@@ -581,9 +649,12 @@ class DownloadService:
|
||||
new_cursor = (
|
||||
dl_result.cursor if uses_native_ingester(ctx["platform"]) else None
|
||||
)
|
||||
# Imports phase 3 finished before its deadline are progress even when
|
||||
# the cursor went back to the page holding the files it left.
|
||||
advanced = bool(
|
||||
(new_cursor and new_cursor != old_cursor)
|
||||
or dl_result.files_downloaded > 0
|
||||
or getattr(dl_result, "phase3_imported_any", False)
|
||||
)
|
||||
if advanced:
|
||||
if new_cursor:
|
||||
|
||||
@@ -21,6 +21,15 @@ from .source_service import BACKFILL_MAX_CHUNKS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The probe runs while the chip is drawing; names that take longer are skipped.
|
||||
_NAME_LOOKUP_SECONDS = 6.0
|
||||
# The poster picker waits on two pages of a channel's messages (#4491).
|
||||
_POSTER_LOOKUP_SECONDS = 20.0
|
||||
|
||||
# A source's poster list (discord_ingester.AUTHORS_KEY) holds user ids; this
|
||||
# keeps the name each id was picked under, for showing the list to a person.
|
||||
AUTHOR_LABELS_KEY = "discord_author_labels"
|
||||
|
||||
|
||||
class UnknownPlatformError(Exception):
|
||||
"""URL didn't match any platform pattern."""
|
||||
@@ -30,6 +39,19 @@ class InvalidUrlError(Exception):
|
||||
"""URL was empty or missing a scheme."""
|
||||
|
||||
|
||||
class UnknownArtistError(Exception):
|
||||
"""quick-add named an `artist_id` that does not exist."""
|
||||
|
||||
|
||||
class PosterLookupError(Exception):
|
||||
"""The Discord poster picker couldn't read the channel (no token, no
|
||||
access, not a channel page, or Discord was slow)."""
|
||||
|
||||
|
||||
class UnknownSourceError(Exception):
|
||||
"""No source follows this Discord channel or its server."""
|
||||
|
||||
|
||||
# Mirrored byte-for-byte from extension/lib/platforms.js
|
||||
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
|
||||
# reviewers catch drift.
|
||||
@@ -55,39 +77,220 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
|
||||
re.IGNORECASE,
|
||||
)),
|
||||
# A Discord URL names a server or a channel, never a creator, so the slug is
|
||||
# `<server>` or `<server>/<channel>` and the artist is chosen, not derived.
|
||||
# A trailing message id (a jump link) still names its channel. DMs (`@me`)
|
||||
# are not sources; thread links (`/threads/`) are left to the manual form.
|
||||
("discord", re.compile(
|
||||
r"^https?://(?:www\.|ptb\.|canary\.)?discord\.com/channels/"
|
||||
r"(?P<slug>\d+(?:/\d+)?)(?:/\d+)?/?(?:[?#].*)?$",
|
||||
re.IGNORECASE,
|
||||
)),
|
||||
]
|
||||
|
||||
DISCORD = "discord"
|
||||
|
||||
|
||||
def canonical_source_url(platform: str, url: str, slug: str) -> str:
|
||||
"""The URL a new source is stored under. Discord's is rebuilt from the ids
|
||||
— the form the manual Add form and the ingester use — so a jump link, a
|
||||
ptb/canary host or a trailing slash never makes a second source for the
|
||||
same channel. Every other platform keeps the URL as given."""
|
||||
if platform == DISCORD:
|
||||
return f"https://discord.com/channels/{slug}"
|
||||
return url
|
||||
|
||||
|
||||
def _discord_ids(url: str) -> tuple[str | None, str | None] | None:
|
||||
"""`(server_id, channel_id)` of a stored Discord source URL, None if it
|
||||
does not parse (a DM or thread link, or an old malformed row)."""
|
||||
from .discord_client import DiscordAPIError, parse_source_url
|
||||
try:
|
||||
return parse_source_url(url)
|
||||
except DiscordAPIError:
|
||||
return None
|
||||
|
||||
|
||||
class ExtensionService:
|
||||
def __init__(self, session: AsyncSession, crypto=None) -> None:
|
||||
self.session = session
|
||||
# Optional decryptor for resolving a token-auth platform's display name
|
||||
# (pixiv) at add-time. None → skip resolution, fall back to the handle.
|
||||
# Optional decryptor for resolving a platform's display name at
|
||||
# add-time. None → skip resolution, fall back to the handle.
|
||||
self._crypto = crypto
|
||||
|
||||
async def quick_add_source(self, url: str) -> dict:
|
||||
async def quick_add_source(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
artist_id: int | None = None,
|
||||
artist_name: str | None = None,
|
||||
use_platform_name: bool = False,
|
||||
discord_authors: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Add `url` as a source. `artist_id` connects it to an existing
|
||||
artist, `artist_name` to that artist (created if new); with neither,
|
||||
the artist is resolved from the platform as before.
|
||||
|
||||
`use_platform_name` applies the operator's convention that the Patreon
|
||||
name is canon: a Patreon source added to an existing artist renames
|
||||
that artist to the creator's Patreon display name. Name only — the
|
||||
slug, and every path keyed off it, never moves (#130). Ignored on every
|
||||
other platform, and when the name can't be read.
|
||||
|
||||
`discord_authors` ([{id, name}], #4491) sets a Discord source's poster
|
||||
list in the same step, on a new source or one already there."""
|
||||
platform, raw_slug = self._derive(url)
|
||||
url = canonical_source_url(platform, url, raw_slug)
|
||||
renamed_from = None
|
||||
# Identity by SOURCE handle (#130): an existing (platform, url) source
|
||||
# keeps its artist on re-add — even if that artist was since renamed (its
|
||||
# frozen slug no longer matches the current name). Only a genuinely new
|
||||
# source resolves/creates an artist.
|
||||
existing = (await self.session.execute(
|
||||
select(Source).where(Source.platform == platform, Source.url == url)
|
||||
)).scalar_one_or_none()
|
||||
# frozen slug no longer matches the current name), and even when the
|
||||
# add named a different artist. Only a genuinely new source
|
||||
# resolves/creates an artist.
|
||||
existing = await self._existing_source(platform, url)
|
||||
if existing is not None:
|
||||
artist = (await self.session.execute(
|
||||
select(Artist).where(Artist.id == existing.artist_id)
|
||||
)).scalar_one()
|
||||
if platform == DISCORD and discord_authors is not None:
|
||||
await self._apply_posters(existing, discord_authors)
|
||||
return self._shape(existing, artist, created_source=False, created_artist=False)
|
||||
|
||||
# New source → name the artist properly by resolving the real display
|
||||
# name from the platform (falls back to the URL handle).
|
||||
name = await self._resolve_artist_name(platform, raw_slug, url)
|
||||
artist, created_artist = await self._find_or_create_artist(name)
|
||||
if artist_id is not None:
|
||||
artist = (await self.session.execute(
|
||||
select(Artist).where(Artist.id == artist_id)
|
||||
)).scalar_one_or_none()
|
||||
if artist is None:
|
||||
raise UnknownArtistError(f"no artist with id {artist_id}")
|
||||
created_artist = False
|
||||
if use_platform_name and platform == "patreon":
|
||||
renamed_from = await self._adopt_patreon_name(artist, raw_slug, url)
|
||||
else:
|
||||
name = (artist_name or "").strip()
|
||||
if not name:
|
||||
# Name the artist properly by resolving the real display name
|
||||
# from the platform (falls back to the URL handle).
|
||||
name = await self._resolve_artist_name(platform, raw_slug, url)
|
||||
artist, created_artist = await self._find_or_create_artist(name)
|
||||
source, created_source = await self._find_or_create_source(
|
||||
artist_id=artist.id, platform=platform, url=url,
|
||||
)
|
||||
return self._shape(source, artist, created_source, created_artist)
|
||||
if platform == DISCORD and discord_authors is not None:
|
||||
await self._apply_posters(source, discord_authors)
|
||||
shaped = self._shape(source, artist, created_source, created_artist)
|
||||
if renamed_from is not None:
|
||||
shaped["renamed_from"] = renamed_from
|
||||
return shaped
|
||||
|
||||
# -- Discord poster picker (#4491) ------------------------------------------
|
||||
|
||||
def _discord_page(self, url: str) -> tuple[str, str]:
|
||||
platform, raw_slug = self._derive(url)
|
||||
if platform != DISCORD:
|
||||
raise InvalidUrlError("not a Discord page")
|
||||
server_id, _, channel_id = raw_slug.partition("/")
|
||||
if not channel_id:
|
||||
raise PosterLookupError("Open a channel to see who posts in it.")
|
||||
return server_id, channel_id
|
||||
|
||||
async def _source_for_page(self, server_id: str, channel_id: str) -> Source | None:
|
||||
"""The source this channel's posts land on: the channel's own, else a
|
||||
whole-server source that walks it."""
|
||||
base = f"https://discord.com/channels/{server_id}"
|
||||
return (
|
||||
await self._existing_source(DISCORD, f"{base}/{channel_id}")
|
||||
or await self._existing_source(DISCORD, base)
|
||||
)
|
||||
|
||||
async def discord_posters(self, url: str) -> dict:
|
||||
"""Who posts in the channel `url` shows, newest `_POSTER_SCAN`
|
||||
messages deep, with the likely creator marked — and, when a source
|
||||
already follows it, the poster list that source has now."""
|
||||
server_id, channel_id = self._discord_page(url)
|
||||
import asyncio
|
||||
|
||||
from .credential_service import CredentialService
|
||||
from .discord_client import DiscordAPIError, DiscordClient
|
||||
token = None
|
||||
if self._crypto is not None:
|
||||
token = await CredentialService(self.session, self._crypto).get_token(DISCORD)
|
||||
if not token:
|
||||
raise PosterLookupError("No Discord token is saved in FabledCurator.")
|
||||
client = DiscordClient(token, max_retries=0)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
found = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, client.recent_posters, server_id, channel_id),
|
||||
timeout=_POSTER_LOOKUP_SECONDS,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
raise PosterLookupError("Discord took too long to answer; try again.") from exc
|
||||
except DiscordAPIError as exc:
|
||||
raise PosterLookupError(f"Couldn't read this channel: {exc}") from exc
|
||||
source = await self._source_for_page(server_id, channel_id)
|
||||
co = (source.config_overrides or {}) if source is not None else {}
|
||||
from .discord_ingester import AUTHORS_KEY
|
||||
return {
|
||||
**found,
|
||||
"source_id": source.id if source is not None else None,
|
||||
"selected": list(co.get(AUTHORS_KEY) or []),
|
||||
# So someone on the list who hasn't posted lately still shows by
|
||||
# name, and can be unticked.
|
||||
"selected_labels": dict(co.get(AUTHOR_LABELS_KEY) or {}),
|
||||
}
|
||||
|
||||
async def set_discord_posters(self, url: str, authors: list[dict]) -> dict:
|
||||
"""Store the picked posters on the source that follows `url`'s channel."""
|
||||
server_id, channel_id = self._discord_page(url)
|
||||
source = await self._source_for_page(server_id, channel_id)
|
||||
if source is None:
|
||||
raise UnknownSourceError("No source follows this channel yet.")
|
||||
return await self._apply_posters(source, authors)
|
||||
|
||||
async def _apply_posters(self, source: Source, authors: list[dict]) -> dict:
|
||||
"""Ids are what the list keys on — a username can change on a whim,
|
||||
an id can't — and the name each was picked under rides beside it for
|
||||
showing the list to a person. An empty pick clears the list, which
|
||||
takes everyone's posts again."""
|
||||
from .discord_ingester import AUTHORS_KEY
|
||||
picked = [a for a in authors if str(a.get("id") or "").strip()]
|
||||
co = dict(source.config_overrides or {})
|
||||
if picked:
|
||||
co[AUTHORS_KEY] = [str(a["id"]).strip() for a in picked]
|
||||
co[AUTHOR_LABELS_KEY] = {
|
||||
str(a["id"]).strip(): str(a.get("name") or a["id"]) for a in picked
|
||||
}
|
||||
else:
|
||||
co.pop(AUTHORS_KEY, None)
|
||||
co.pop(AUTHOR_LABELS_KEY, None)
|
||||
source.config_overrides = co or None
|
||||
await self.session.commit()
|
||||
return {"source_id": source.id, "discord_authors": co.get(AUTHORS_KEY, [])}
|
||||
|
||||
async def _adopt_patreon_name(self, artist, raw_slug: str, url: str) -> str | None:
|
||||
"""Rename `artist` to the Patreon display name; the old name when it
|
||||
changed, else None. Unreadable name → no rename, never the handle."""
|
||||
name = await self._platform_display_name("patreon", raw_slug, url)
|
||||
if not name or name == artist.name:
|
||||
return None
|
||||
old = artist.name
|
||||
artist.name = name
|
||||
await self.session.commit()
|
||||
return old
|
||||
|
||||
async def _existing_source(self, platform: str, url: str) -> Source | None:
|
||||
"""The source this URL already is, whichever artist owns it. Discord
|
||||
compares ids, not strings, so a row stored before canonicalisation (a
|
||||
ptb host, a trailing slash) is still found."""
|
||||
if platform != DISCORD:
|
||||
return (await self.session.execute(
|
||||
select(Source).where(Source.platform == platform, Source.url == url)
|
||||
)).scalars().first()
|
||||
want = _discord_ids(url)
|
||||
rows = (await self.session.execute(
|
||||
select(Source).where(Source.platform == DISCORD).order_by(Source.id)
|
||||
)).scalars().all()
|
||||
return next((s for s in rows if _discord_ids(s.url) == want), None)
|
||||
|
||||
@staticmethod
|
||||
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
|
||||
@@ -112,31 +315,38 @@ class ExtensionService:
|
||||
self, platform: str, raw_slug: str, url: str
|
||||
) -> str:
|
||||
"""The real display name for a new artist, resolved from the platform at
|
||||
add-time (#130). Our native platforms each have a name source — pixiv the
|
||||
app API (token), patreon the campaigns API, subscribestar the profile
|
||||
page (both cookies). Other platforms (and any failure — no credential,
|
||||
network error) fall back to the URL handle, which is already readable.
|
||||
add-time (#130). Our native platforms each have a name source — patreon
|
||||
the campaigns API, subscribestar the profile page (both cookies). Other
|
||||
platforms (and any failure — no credential, network error) fall back to
|
||||
the URL handle, which is already readable.
|
||||
The resolvers are sync, so they run in an executor."""
|
||||
if self._crypto is None or platform not in ("pixiv", "patreon", "subscribestar"):
|
||||
return raw_slug
|
||||
if platform == DISCORD:
|
||||
# The server's name: what the operator knows the community as.
|
||||
server_id = raw_slug.split("/", 1)[0]
|
||||
names = await self._discord_names(server_id, None)
|
||||
return names.get("server") or f"Discord {server_id}"
|
||||
return await self._platform_display_name(platform, raw_slug, url) or raw_slug
|
||||
|
||||
async def _platform_display_name(
|
||||
self, platform: str, raw_slug: str, url: str
|
||||
) -> str | None:
|
||||
"""The creator's display name as Patreon or SubscribeStar shows it, read
|
||||
with the stored cookies; None when it can't be read (no credential, a
|
||||
network error, a slow answer, any other platform). None, not the handle,
|
||||
so a caller can tell a real name from a fallback — a rename to the
|
||||
Patreon name must never rename to a URL handle instead."""
|
||||
if self._crypto is None or platform not in ("patreon", "subscribestar"):
|
||||
return None
|
||||
import asyncio
|
||||
|
||||
from .credential_service import CredentialService
|
||||
cred = CredentialService(self.session, self._crypto)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
if platform == "pixiv":
|
||||
token = await cred.get_token("pixiv")
|
||||
if not token:
|
||||
return raw_slug
|
||||
from .pixiv_client import PixivClient
|
||||
name = await loop.run_in_executor(
|
||||
None, PixivClient(token).resolve_display_name, raw_slug
|
||||
)
|
||||
elif platform == "patreon":
|
||||
if platform == "patreon":
|
||||
cookies = await cred.get_cookies_path("patreon")
|
||||
from .patreon_resolver import resolve_display_name
|
||||
name = await loop.run_in_executor(
|
||||
call = loop.run_in_executor(
|
||||
None, resolve_display_name, raw_slug,
|
||||
str(cookies) if cookies else None,
|
||||
)
|
||||
@@ -144,15 +354,14 @@ class ExtensionService:
|
||||
cookies = await cred.get_cookies_path("subscribestar")
|
||||
from .subscribestar_client import SubscribeStarClient
|
||||
client = SubscribeStarClient(str(cookies) if cookies else None)
|
||||
name = await loop.run_in_executor(
|
||||
None, client.resolve_display_name, url
|
||||
)
|
||||
call = loop.run_in_executor(None, client.resolve_display_name, url)
|
||||
name = await asyncio.wait_for(call, timeout=_NAME_LOOKUP_SECONDS)
|
||||
except Exception as exc: # resolution is best-effort — never block the add
|
||||
log.warning("artist display-name resolution failed (%s): %s", platform, exc)
|
||||
return raw_slug
|
||||
return name or raw_slug
|
||||
return None
|
||||
return (name or "").strip() or None
|
||||
|
||||
async def probe(self, url: str) -> dict:
|
||||
async def probe(self, url: str, *, names: bool = False) -> dict:
|
||||
"""Read-only resolution of a creator-page URL against the FC DB.
|
||||
Returns one of:
|
||||
- {state: 'unknown_platform'} — URL didn't match any
|
||||
@@ -168,21 +377,30 @@ class ExtensionService:
|
||||
— exact (artist, platform,
|
||||
url) Source already exists
|
||||
|
||||
Side-effect-free: two SELECTs at most.
|
||||
`names` (the Add panel asks, the chip does not) adds `display_name`:
|
||||
the creator's name as Patreon/SubscribeStar shows it, or None. It costs
|
||||
a request to the platform, so a plain page view never pays it.
|
||||
|
||||
Side-effect-free: two SELECTs at most, plus that one lookup.
|
||||
"""
|
||||
try:
|
||||
platform, raw_slug = self._derive(url)
|
||||
except (UnknownPlatformError, InvalidUrlError):
|
||||
return {"state": "unknown_platform"}
|
||||
if platform == DISCORD:
|
||||
return await self._probe_discord(raw_slug)
|
||||
|
||||
slug = slugify(raw_slug)
|
||||
result: dict = {"platform": platform, "slug": slug}
|
||||
if names:
|
||||
result["display_name"] = await self._platform_display_name(
|
||||
platform, raw_slug, url,
|
||||
)
|
||||
artist = (await self.session.execute(
|
||||
select(Artist).where(Artist.slug == slug)
|
||||
)).scalar_one_or_none()
|
||||
if artist is None:
|
||||
return {"state": "new", "platform": platform, "slug": slug}
|
||||
|
||||
artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||
return {"state": "new", **result}
|
||||
|
||||
source = (await self.session.execute(
|
||||
select(Source).where(
|
||||
@@ -192,26 +410,114 @@ class ExtensionService:
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if source is None:
|
||||
return {
|
||||
"state": "artist_match",
|
||||
"platform": platform,
|
||||
"slug": slug,
|
||||
"artist": artist_payload,
|
||||
}
|
||||
return {"state": "artist_match", **result, "artist": self._artist_payload(artist)}
|
||||
|
||||
return {
|
||||
"state": "source_match",
|
||||
"platform": platform,
|
||||
"slug": slug,
|
||||
"artist": artist_payload,
|
||||
"source": {
|
||||
"id": source.id,
|
||||
"artist_id": source.artist_id,
|
||||
"platform": source.platform,
|
||||
"url": source.url,
|
||||
"enabled": source.enabled,
|
||||
**result,
|
||||
"artist": self._artist_payload(artist),
|
||||
"source": self._source_payload(source),
|
||||
}
|
||||
|
||||
async def _probe_discord(self, raw_slug: str) -> dict:
|
||||
"""probe for a Discord server or channel. The states mean what they
|
||||
mean elsewhere, but the artist is never read off the URL:
|
||||
|
||||
- source_match: this channel is a source — or the whole server is
|
||||
(`covered_by_server`), which already walks every channel;
|
||||
- artist_match: another source on this server belongs to an artist,
|
||||
the one this channel most likely belongs to too (a suggestion the
|
||||
Add panel preselects, not a decision);
|
||||
- new: nothing on this server yet.
|
||||
|
||||
`discord` carries the ids, both canonical URLs and the display names,
|
||||
read with the stored token; a name that can't be read is None."""
|
||||
server_id, _, channel_id = raw_slug.partition("/")
|
||||
channel_id = channel_id or None
|
||||
rows = (await self.session.execute(
|
||||
select(Source, Artist)
|
||||
.join(Artist, Artist.id == Source.artist_id)
|
||||
.where(Source.platform == DISCORD)
|
||||
.order_by(Source.id)
|
||||
)).all()
|
||||
exact = server_whole = on_server = None
|
||||
for source, artist in rows:
|
||||
ids = _discord_ids(source.url)
|
||||
if ids is None or ids[0] != server_id:
|
||||
continue
|
||||
if ids[1] == channel_id and exact is None:
|
||||
exact = (source, artist)
|
||||
elif ids[1] is None and server_whole is None:
|
||||
server_whole = (source, artist)
|
||||
if on_server is None:
|
||||
on_server = (source, artist)
|
||||
|
||||
names = await self._discord_names(server_id, channel_id)
|
||||
base = f"https://discord.com/channels/{server_id}"
|
||||
result: dict = {
|
||||
"platform": DISCORD,
|
||||
"slug": raw_slug,
|
||||
"discord": {
|
||||
"server_id": server_id,
|
||||
"channel_id": channel_id,
|
||||
"server_name": names.get("server"),
|
||||
"channel_name": names.get("channel"),
|
||||
"server_url": base,
|
||||
"channel_url": f"{base}/{channel_id}" if channel_id else None,
|
||||
},
|
||||
}
|
||||
hit = exact or server_whole
|
||||
if hit is not None:
|
||||
source, artist = hit
|
||||
result.update(
|
||||
state="source_match",
|
||||
artist=self._artist_payload(artist),
|
||||
source=self._source_payload(source),
|
||||
covered_by_server=exact is None,
|
||||
)
|
||||
elif on_server is not None:
|
||||
result.update(state="artist_match", artist=self._artist_payload(on_server[1]))
|
||||
else:
|
||||
result["state"] = "new"
|
||||
return result
|
||||
|
||||
async def _discord_names(self, server_id: str | None, channel_id: str | None) -> dict:
|
||||
"""Server/channel display names via the stored Discord token. Never
|
||||
raises and never waits out a rate limit: it runs while the operator
|
||||
looks at a page, so a slow or missing answer just means no names."""
|
||||
if self._crypto is None:
|
||||
return {}
|
||||
import asyncio
|
||||
|
||||
from .credential_service import CredentialService
|
||||
from .discord_client import DiscordClient
|
||||
try:
|
||||
token = await CredentialService(self.session, self._crypto).get_token(DISCORD)
|
||||
if not token:
|
||||
return {}
|
||||
client = DiscordClient(token, max_retries=0)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(None, client.describe, server_id, channel_id),
|
||||
timeout=_NAME_LOOKUP_SECONDS,
|
||||
)
|
||||
except Exception as exc: # names are decoration — never fail the call
|
||||
log.info("Discord name lookup failed: %s", exc)
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _artist_payload(artist) -> dict:
|
||||
return {"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||
|
||||
@staticmethod
|
||||
def _source_payload(source) -> dict:
|
||||
return {
|
||||
"id": source.id,
|
||||
"artist_id": source.artist_id,
|
||||
"platform": source.platform,
|
||||
"url": source.url,
|
||||
"enabled": source.enabled,
|
||||
}
|
||||
|
||||
def _derive(self, url: str) -> tuple[str, str]:
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
|
||||
@@ -241,11 +241,19 @@ def _fetch_gdrive(url: str, dest_dir: Path, *, read_timeout: float,
|
||||
def _fetch_mega(url: str, dest_dir: Path, *, read_timeout: float,
|
||||
total_timeout: float,
|
||||
should_stop: Callable[[], bool]) -> FetchResult:
|
||||
before = set(dest_dir.iterdir()) if dest_dir.exists() else set()
|
||||
# A folder link keeps its subfolders, so files are found at any depth.
|
||||
# Dotfiles are megatools' `.megatmp.*` staging names.
|
||||
def _complete_files() -> set[Path]:
|
||||
return {
|
||||
p for p in dest_dir.rglob("*")
|
||||
if p.is_file() and not p.name.startswith(".")
|
||||
} if dest_dir.exists() else set()
|
||||
|
||||
before = _complete_files()
|
||||
# megatools is a subprocess: its timeout IS a total wall-clock cap (the read
|
||||
# timeout has no analogue here), so the total budget applies directly.
|
||||
_run_mega_get(url, str(dest_dir), timeout=total_timeout)
|
||||
new = [p for p in dest_dir.iterdir() if p not in before and p.is_file()]
|
||||
new = sorted(_complete_files() - before)
|
||||
if not new:
|
||||
return FetchResult(error="mega-get wrote no new file")
|
||||
return FetchResult(files=new, bytes=sum(p.stat().st_size for p in new))
|
||||
|
||||
@@ -21,9 +21,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def refetch_links_for_post(session: Session, post_id: int) -> dict:
|
||||
"""Reset every settled ExternalLink on a post to pending (fresh attempt
|
||||
budget) and dispatch its fetch. In-flight ('downloading') links are left
|
||||
alone. Commits. Returns {links_total, links_reset}."""
|
||||
"""Reset every settled ExternalLink on a post to pending (fresh attempt and
|
||||
recovery budgets) and dispatch its fetch. In-flight ('downloading') links
|
||||
are left alone. Commits. Returns {links_total, links_reset}."""
|
||||
links = session.execute(
|
||||
select(ExternalLink).where(ExternalLink.post_id == post_id)
|
||||
).scalars().all()
|
||||
@@ -33,6 +33,7 @@ def refetch_links_for_post(session: Session, post_id: int) -> dict:
|
||||
continue
|
||||
link.status = "pending"
|
||||
link.attempts = 0
|
||||
link.recoveries = 0
|
||||
link.last_error = None
|
||||
link.completed_at = None
|
||||
reset_ids.append(link.id)
|
||||
|
||||
@@ -6,6 +6,9 @@ backend/app/services/gallery_dl.py). FC adaptations:
|
||||
- Validation uses backend/app/services/file_validator.py
|
||||
- Config dir under `<images_root>/.gallery-dl/`, not a separate config_path
|
||||
- PLATFORM_DEFAULTS scoped to the GS-6 platforms registered in FC-3b
|
||||
|
||||
The result it returns (`DownloadResult`, `ErrorType`) is shared with the native
|
||||
ingesters and lives in download_result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,39 +22,20 @@ import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from .download_result import (
|
||||
DownloadResult,
|
||||
ErrorType,
|
||||
classify_tier_gated,
|
||||
make_run_stats,
|
||||
tier_gated_message,
|
||||
)
|
||||
from .file_validator import is_validatable, quarantine_file, validate_file
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorType(StrEnum):
|
||||
AUTH_ERROR = "auth_error"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
NOT_FOUND = "not_found"
|
||||
ACCESS_DENIED = "access_denied"
|
||||
NETWORK_ERROR = "network_error"
|
||||
NO_NEW_CONTENT = "no_new_content"
|
||||
TIER_LIMITED = "tier_limited"
|
||||
TIMEOUT = "timeout"
|
||||
HTTP_ERROR = "http_error"
|
||||
UNSUPPORTED_URL = "unsupported_url"
|
||||
VALIDATION_FAILED = "validation_failed"
|
||||
# Native Patreon ingester only (plan #697): a response parsed as JSON but
|
||||
# didn't match the JSON:API shape the ingester depends on (missing `data`,
|
||||
# media lacking `file_name`/`url`, etc.). Distinct from AUTH_ERROR — the fix
|
||||
# is updating the ingester's field-set/parser, not rotating credentials. The
|
||||
# contract test guards against silently shipping this.
|
||||
API_DRIFT = "api_drift"
|
||||
# Run made real progress (downloaded ≥1 file) but did not finish in the
|
||||
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
|
||||
# mapping classifies this as "ok" because the next tick continues.
|
||||
PARTIAL = "partial"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
|
||||
# Tick mode (routine cron polls): skip ≤20 contiguous already-archived
|
||||
# items, then exit gallery-dl. Established subscription with zero new
|
||||
# content exits in ~30s of HEAD requests instead of walking to the bottom
|
||||
@@ -94,48 +78,6 @@ BACKFILL_CHUNK_SECONDS = 600
|
||||
_DEFAULT_GDL_TIMEOUT_SECONDS = 870
|
||||
|
||||
|
||||
# --- Discord naming ---------------------------------------------------------
|
||||
#
|
||||
# Derived from a REAL sidecar (operator's instance, 2026-09-13), not from memory
|
||||
# of gallery-dl's extractor. What gallery-dl's discord extractor actually emits
|
||||
# for an attachment: `channel` is a plain STRING (the channel's name), the
|
||||
# message is `message_id`, the attachment's position in it is `num`, and there
|
||||
# is NO `id` key at all.
|
||||
#
|
||||
# The previous patterns asked for `{channel[name]}` and `{id}`. Both render as
|
||||
# "None", so every Discord download since the platform was added landed in a
|
||||
# directory called `None` as `<date>_None_<original name>`. Worse, the sidecar was
|
||||
# named `{filename}.json` — the attachment's ORIGINAL name — which (a) `find_
|
||||
# sidecar` can never pair with `<date>_None_<name>.png`, so no Discord file ever
|
||||
# got a Post or a post date, and (b) collides: every `image.png` in a channel
|
||||
# overwrote the same `image.json`, so the one sidecar that survived described
|
||||
# whichever message happened to be written last.
|
||||
#
|
||||
# The fix names the sidecar EXACTLY like the media minus its extension, so
|
||||
# `find_sidecar`'s first candidate (`media.with_suffix(".json")`) is the match
|
||||
# and the name is unique per attachment. tests/test_gallery_dl_naming.py renders
|
||||
# these patterns against a sanitized copy of the real sidecar, so a key that
|
||||
# does not exist fails CI instead of silently becoming "None".
|
||||
DISCORD_FILENAME = "{date:%Y%m%d}_{message_id}_{num:>02}_{filename}.{extension}"
|
||||
DISCORD_DIRECTORY = ["{channel}"]
|
||||
|
||||
|
||||
def sidecar_name_for(media_pattern: str) -> str | None:
|
||||
"""The metadata filename pattern that names a sidecar exactly like its media.
|
||||
|
||||
Returns None for a pattern that does not end in `.{extension}`, since then
|
||||
there is no media stem to mirror and the caller must fall back.
|
||||
"""
|
||||
suffix = ".{extension}"
|
||||
if not media_pattern.endswith(suffix):
|
||||
return None
|
||||
return media_pattern[: -len(suffix)] + ".json"
|
||||
|
||||
|
||||
def metadata_postprocessor(filename: str) -> dict:
|
||||
return {"name": "metadata", "mode": "json", "directory": ".", "filename": filename}
|
||||
|
||||
|
||||
def archive_path(images_root: Path) -> Path:
|
||||
"""gallery-dl's download archive: the record of what it has already fetched.
|
||||
|
||||
@@ -189,82 +131,6 @@ class SourceConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadResult:
|
||||
success: bool
|
||||
url: str
|
||||
artist_slug: str
|
||||
platform: str
|
||||
files_downloaded: int = 0
|
||||
files_quarantined: int = 0
|
||||
quarantined_paths: list[str] = field(default_factory=list)
|
||||
written_paths: list[str] = field(default_factory=list)
|
||||
# Native ingester only: post-only sidecar paths for posts that have NO
|
||||
# downloadable media (pure-text posts), so the importer can still upsert the
|
||||
# Post + its body. Empty on the gallery-dl path. Phase 3 imports these via
|
||||
# Importer.upsert_post_record (keyed on external_post_id → updates, never
|
||||
# doubles, the same Post a media import would create).
|
||||
post_record_paths: list[str] = field(default_factory=list)
|
||||
# Native ingester, recapture mode only (#830): (on-disk path, CDN source_url)
|
||||
# pairs for already-present media whose ImageRecord.source_filehash should be
|
||||
# backfilled (inline-image localization) WITHOUT re-download or unlink. Empty
|
||||
# on the gallery-dl path and outside recapture.
|
||||
relink_source_paths: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
return_code: int = 0
|
||||
error_type: ErrorType | None = None
|
||||
error_message: str | None = None
|
||||
duration_seconds: float = 0.0
|
||||
started_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
# Plan #704 — structured fields the NATIVE ingester populates directly (None
|
||||
# on the gallery-dl path, which keeps the regex-over-stdout route). When set,
|
||||
# phase 3 reads these instead of scraping the text it would otherwise have to
|
||||
# reconstruct: `run_stats` mirrors _compute_run_stats' shape; `cursor` is the
|
||||
# backfill checkpoint the ingester knows exactly (no parse_last_cursor).
|
||||
run_stats: dict | None = None
|
||||
cursor: str | None = None
|
||||
posts_processed: int = 0
|
||||
# Plan #708 B1 — the server's Retry-After seconds on a RATE_LIMITED result, so
|
||||
# the platform cooldown matches the hint instead of a flat default. None when
|
||||
# unknown (no header, or not a rate-limit failure).
|
||||
retry_after_seconds: float | None = None
|
||||
|
||||
|
||||
def extract_errors_warnings(stderr: str) -> str:
|
||||
"""Keep only the `[error]`/`[warning]` lines from a captured stderr blob.
|
||||
|
||||
A generic download-log helper (module-level so the native-ingester result
|
||||
path can shape its logs without reaching through a GalleryDLService instance).
|
||||
"""
|
||||
if not stderr:
|
||||
return ""
|
||||
kept = [
|
||||
line for line in stderr.splitlines()
|
||||
if "][error]" in line.lower() or "][warning]" in line.lower()
|
||||
]
|
||||
return "\n".join(kept)
|
||||
|
||||
|
||||
def truncate_log(text: str, max_bytes: int = 500_000) -> str:
|
||||
"""Cap a captured log to `max_bytes`, eliding the middle (head + tail kept)."""
|
||||
if not text:
|
||||
return text
|
||||
encoded = text.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text
|
||||
half = max_bytes // 2
|
||||
head = encoded[:half].decode("utf-8", errors="ignore")
|
||||
tail = encoded[-half:].decode("utf-8", errors="ignore")
|
||||
head_lines = head.count("\n")
|
||||
tail_lines = tail.count("\n")
|
||||
total_lines = text.count("\n")
|
||||
elided = max(0, total_lines - head_lines - tail_lines)
|
||||
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
|
||||
return head + marker + tail
|
||||
|
||||
|
||||
def _summarize_validation_failures(failures: list[dict]) -> str:
|
||||
if not failures:
|
||||
return "Validation failed"
|
||||
@@ -279,106 +145,6 @@ def _summarize_validation_failures(failures: list[dict]) -> str:
|
||||
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
|
||||
|
||||
|
||||
# (parse_last_cursor was removed in plan #704: the native ingester now carries
|
||||
# its checkpoint cursor as a structured DownloadResult.cursor field, so there is
|
||||
# no log text to scrape — and gallery-dl platforms never had a cursor.)
|
||||
|
||||
|
||||
def make_run_stats(
|
||||
*,
|
||||
exit_code: int = 0,
|
||||
downloaded_count: int = 0,
|
||||
skipped_count: int = 0,
|
||||
per_item_failures: int = 0,
|
||||
warning_count: int = 0,
|
||||
tier_gated_count: int = 0,
|
||||
quarantined_count: int = 0,
|
||||
dead_lettered_count: int = 0,
|
||||
) -> dict:
|
||||
"""The canonical `run_stats` dict shape, in ONE place.
|
||||
|
||||
Both result producers — gallery-dl's `_compute_run_stats` (log scrape) and the
|
||||
native ingester's per-outcome tally (ingest_core) — build through this so the
|
||||
key set can't drift between backends. Phase 3 + the Logs UI read these keys.
|
||||
"""
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"downloaded_count": downloaded_count,
|
||||
"skipped_count": skipped_count,
|
||||
"per_item_failures": per_item_failures,
|
||||
"warning_count": warning_count,
|
||||
"tier_gated_count": tier_gated_count,
|
||||
"quarantined_count": quarantined_count,
|
||||
"dead_lettered_count": dead_lettered_count,
|
||||
}
|
||||
|
||||
|
||||
# --- tier-gated classification, shared by BOTH backends ---------------------
|
||||
#
|
||||
# These three live together because the native ingester and the gallery-dl
|
||||
# subprocess must reach the same verdict from the same number. They did not:
|
||||
# gallery-dl classified TIER_LIMITED while ingest_core counted gated posts and
|
||||
# threw the count away, so the platforms FC owns reported a paywalled creator as
|
||||
# a silent one (#874 follow-up). One predicate, spread into both, rather than
|
||||
# the condition re-derived per backend.
|
||||
|
||||
|
||||
def classify_tier_gated(tier_gated_count: int) -> ErrorType | None:
|
||||
"""TIER_LIMITED when a walk saw tier-gated posts and nothing else failed.
|
||||
|
||||
Deliberately NOT conditioned on `downloaded == 0`. A creator whose top-tier
|
||||
posts we cannot see is tier-limited even in a week we did get their cheaper
|
||||
ones — the fact the operator needs ("there is content here you are not
|
||||
paying for") is true either way. gallery-dl has classified it this way since
|
||||
the paywall-as-"needs attention" complaint (see `_categorize_error`), and the
|
||||
native path now matches rather than inventing a stricter rule.
|
||||
|
||||
Callers must apply this only AFTER the real error categories (auth, rate
|
||||
limit, drift, …) have had their turn; tier-gating is the weakest signal and
|
||||
must never mask a genuine failure.
|
||||
"""
|
||||
return ErrorType.TIER_LIMITED if tier_gated_count else None
|
||||
|
||||
|
||||
def tier_gated_message(count: int) -> str:
|
||||
"""The one wording for the tier-gated verdict, so the two backends can't
|
||||
describe the same state differently in the Logs UI."""
|
||||
return (
|
||||
f"Subscription tier does not grant access to "
|
||||
f"{count} post{'s' if count != 1 else ''}"
|
||||
)
|
||||
|
||||
|
||||
# `Source.error_type` doubles as the failure-class chip, and a status of "ok"
|
||||
# CLEARS it (alembic 0032). TIER_LIMITED breaks that assumption: it rides an
|
||||
# otherwise-successful run, so without an exemption the chip is wiped the moment
|
||||
# it is set and `FailingSourcesCard`'s `tier_limited` palette entry can never
|
||||
# render. Informational classes are the exemption — they describe the source,
|
||||
# not a failure of the run.
|
||||
INFORMATIONAL_ERROR_TYPES = frozenset({ErrorType.TIER_LIMITED.value})
|
||||
|
||||
|
||||
def is_informational(error_type) -> bool:
|
||||
"""True for a class that reports a state rather than a failure. Accepts an
|
||||
ErrorType or the plain string persisted on Source.error_type."""
|
||||
return error_type is not None and str(error_type) in INFORMATIONAL_ERROR_TYPES
|
||||
|
||||
|
||||
def walk_completed(result: DownloadResult) -> bool:
|
||||
"""Did this walk reach the bottom cleanly?
|
||||
|
||||
The backfill lifecycle's completion test. An informational error_type still
|
||||
counts as complete: a fully-paywalled creator's backfill DID finish, and
|
||||
treating it as unfinished re-walks the same wall until the stall counter
|
||||
trips — the creator we can see least becoming the one we fetch most.
|
||||
"""
|
||||
return (
|
||||
result.success
|
||||
and result.return_code == 0
|
||||
and (result.error_type is None or is_informational(result.error_type))
|
||||
)
|
||||
|
||||
|
||||
class GalleryDLService:
|
||||
"""Service for executing gallery-dl downloads."""
|
||||
|
||||
@@ -417,27 +183,16 @@ class GalleryDLService:
|
||||
# (services/patreon_ingester.py), not gallery-dl.
|
||||
PLATFORM_DEFAULTS = {
|
||||
# subscribestar removed — native-ingester platform now (#71); pixiv
|
||||
# removed likewise (#129); deviantart removed at #3069 as a dropped
|
||||
# platform, not a migrated one. The remaining entries are the
|
||||
# gallery-dl platforms not yet migrated.
|
||||
# removed likewise (#129); discord likewise (milestone 428, whose
|
||||
# downloader keeps this config's on-disk naming); deviantart removed at
|
||||
# #3069 as a dropped platform, not a migrated one. HentaiFoundry is the
|
||||
# one platform left here, by the operator's choice not to migrate it.
|
||||
"hentaifoundry": {
|
||||
"content_types": ["all"],
|
||||
"directory": [],
|
||||
"filename": "{category}_{index:>03}_{title[:50]}.{extension}",
|
||||
"include": "all",
|
||||
},
|
||||
"discord": {
|
||||
"content_types": ["all"],
|
||||
"directory": DISCORD_DIRECTORY,
|
||||
"filename": DISCORD_FILENAME,
|
||||
# Overrides the global `{filename}.json` sidecar for this extractor
|
||||
# only — see the Discord naming note above.
|
||||
"postprocessors": [metadata_postprocessor(sidecar_name_for(DISCORD_FILENAME))],
|
||||
"embeds": "all",
|
||||
"stickers": True,
|
||||
"reactions": False,
|
||||
"threads": True,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -552,17 +307,6 @@ class GalleryDLService:
|
||||
if source_config.filename_pattern:
|
||||
platform_section["filename"] = source_config.filename_pattern
|
||||
|
||||
# A platform that names its sidecar after its media must keep doing so
|
||||
# under a per-source filename override, or the pairing breaks exactly the
|
||||
# way Discord's did. No metadata wanted means no platform postprocessor
|
||||
# either — the global list was already dropped above.
|
||||
if "postprocessors" in platform_section:
|
||||
mirrored = sidecar_name_for(platform_section.get("filename") or "")
|
||||
if not source_config.save_metadata or mirrored is None:
|
||||
platform_section.pop("postprocessors")
|
||||
else:
|
||||
platform_section["postprocessors"] = [metadata_postprocessor(mirrored)]
|
||||
|
||||
platform_section["metadata"] = source_config.save_metadata
|
||||
|
||||
return config
|
||||
@@ -778,16 +522,6 @@ class GalleryDLService:
|
||||
tier_gated_count=tier_gated_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_errors_warnings(stderr: str) -> str:
|
||||
# Thin delegator to the module-level helper (kept for existing callers).
|
||||
return extract_errors_warnings(stderr)
|
||||
|
||||
@staticmethod
|
||||
def _truncate_log(text: str, max_bytes: int = 500_000) -> str:
|
||||
# Thin delegator to the module-level helper (kept for existing callers).
|
||||
return truncate_log(text, max_bytes)
|
||||
|
||||
async def download(
|
||||
self,
|
||||
url: str,
|
||||
@@ -810,9 +544,6 @@ class GalleryDLService:
|
||||
|
||||
if cookies_path:
|
||||
config["extractor"]["cookies"] = cookies_path
|
||||
if auth_token and platform == "discord":
|
||||
config["extractor"].setdefault("discord", {})
|
||||
config["extractor"]["discord"]["token"] = auth_token
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
|
||||
@@ -996,8 +727,6 @@ class GalleryDLService:
|
||||
config = self._build_config_for_source(platform, source_config, artist_slug)
|
||||
if cookies_path:
|
||||
config["extractor"]["cookies"] = cookies_path
|
||||
if auth_token and platform == "discord":
|
||||
config["extractor"].setdefault("discord", {})["token"] = auth_token
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
|
||||
|
||||
@@ -45,7 +45,7 @@ from .tag_query import (
|
||||
# provenance (filesystem imports). Returned by facets() as a null-valued
|
||||
# bucket; the frontend maps that null back to this sentinel in the URL so the
|
||||
# bucket is selectable. Underscore-wrapped so it can't collide with a real
|
||||
# gallery-dl platform name (patreon/pixiv/...).
|
||||
# gallery-dl platform name (patreon/hentaifoundry/...).
|
||||
UNSOURCED_PLATFORM = "__unsourced__"
|
||||
|
||||
|
||||
|
||||
+325
-175
@@ -16,6 +16,7 @@ from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from PIL import Image
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -43,16 +44,15 @@ from ..utils.paths import (
|
||||
from ..utils.phash import compute_phash, find_similar, fingerprint_path, fingerprints_match
|
||||
from ..utils.sidecar import find_sidecar, parse_sidecar
|
||||
from ..utils.slug import slugify
|
||||
from .archive_extractor import extract_archive, is_archive
|
||||
from ..utils.staging import is_temp_staging_file
|
||||
from .archive_extractor import extract_archive, is_archive, software_marker
|
||||
from .attachment_store import AttachmentStore
|
||||
from .audits import single_color
|
||||
from .link_extract import extract_external_links
|
||||
from .thumbnailer import Thumbnailer
|
||||
from .wip_title import (
|
||||
WIP_TITLE_SOFT_SOURCE,
|
||||
WIP_TITLE_SOURCE,
|
||||
apply_wip_image_tags,
|
||||
matches_soft_wip_title,
|
||||
matches_wip_title,
|
||||
resolve_wip_tag_id,
|
||||
)
|
||||
@@ -117,6 +117,21 @@ def is_video(path: Path) -> bool:
|
||||
return path.suffix.lower() in VIDEO_EXTS
|
||||
|
||||
|
||||
def _not_content(path: Path) -> ImportResult | None:
|
||||
"""The skip result for a file that is never library content: a sidecar
|
||||
`.json` (metadata) or a downloader's staging file (an unfinished download,
|
||||
which becomes content only under its final name). None for anything else."""
|
||||
if path.suffix.lower() == ".json":
|
||||
error = "sidecar json is metadata, not content"
|
||||
elif is_temp_staging_file(path):
|
||||
error = "unfinished download (staging file), not content"
|
||||
else:
|
||||
return None
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image, error=error,
|
||||
)
|
||||
|
||||
|
||||
def _safe_ext(path: Path) -> str:
|
||||
"""Conservatively extract a file extension for PostAttachment.ext
|
||||
(varchar(32)). Thin wrapper over the shared `utils.paths.safe_ext` (kept for
|
||||
@@ -293,6 +308,44 @@ class Importer:
|
||||
sp.rollback()
|
||||
return self.session.execute(stmt).scalar_one()
|
||||
|
||||
def _store_new_record(self, record: ImageRecord) -> ImageRecord | None:
|
||||
"""Insert `record`, or return the row that already holds its bytes.
|
||||
|
||||
The sha256 lookup before an insert can lose a race: two copies of one
|
||||
picture in different folders, imported side by side by two workers,
|
||||
both find no row, and the unique index refuses the second insert. Only
|
||||
that insert is undone (a savepoint), so the session stays usable and
|
||||
the caller files its file as a duplicate of the winner. None means
|
||||
`record` was stored."""
|
||||
sp = self.session.begin_nested()
|
||||
try:
|
||||
self.session.add(record)
|
||||
self.session.flush()
|
||||
sp.commit()
|
||||
return None
|
||||
except IntegrityError:
|
||||
sp.rollback()
|
||||
winner = self.session.execute(
|
||||
select(ImageRecord).where(ImageRecord.sha256 == record.sha256)
|
||||
).scalar_one_or_none()
|
||||
if winner is None:
|
||||
raise
|
||||
return winner
|
||||
|
||||
def _file_as_duplicate(
|
||||
self, existing: ImageRecord, path: Path, artist: Artist | None,
|
||||
explicit_source: Source | None,
|
||||
) -> ImportResult:
|
||||
"""A byte-identical copy of `existing`: link the copy's post to the
|
||||
image (enrich-on-duplicate), so a picture posted twice shows on both
|
||||
posts, and report the skip."""
|
||||
self._apply_sidecar(existing, path, artist, explicit_source=explicit_source)
|
||||
self.session.commit()
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
||||
image_id=existing.id, error="sha256 already present",
|
||||
)
|
||||
|
||||
def _find_or_create_source(
|
||||
self, *, artist_id: int, platform: str, url: str,
|
||||
) -> Source:
|
||||
@@ -412,11 +465,8 @@ class Importer:
|
||||
media members (one Post via the archive-adjacent sidecar) and
|
||||
preserve the archive itself. Other non-media → PostAttachment.
|
||||
Nothing is skipped for being non-media (FC-2d-iii)."""
|
||||
if source.suffix.lower() == ".json":
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||
error="sidecar json is metadata, not content",
|
||||
)
|
||||
if (refused := _not_content(source)) is not None:
|
||||
return refused
|
||||
if is_archive(source):
|
||||
return self._import_archive(source)
|
||||
if not is_supported(source):
|
||||
@@ -428,11 +478,19 @@ class Importer:
|
||||
return self._upsert_artist(name) if name else None
|
||||
|
||||
def _post_for_sidecar(
|
||||
self, source: Path, artist: Artist | None
|
||||
self, source: Path, artist: Artist | None,
|
||||
*, source_row: Source | None = None,
|
||||
) -> Post | None:
|
||||
"""If a sidecar sits next to `source`, ensure its Source+Post
|
||||
exist (idempotent) and return the Post — so attachments can link
|
||||
to the same Post the per-member _apply_sidecar will reuse."""
|
||||
to the same Post the per-member _apply_sidecar will reuse.
|
||||
|
||||
`source_row` is the subscription being downloaded, when there is one,
|
||||
and wins over the (artist, platform) lookup — as it does in
|
||||
`upsert_post_record`. The lookup takes the artist's FIRST source on the
|
||||
platform, which is right only while an artist has one: a Discord artist
|
||||
has one per channel, and every non-image file from a later channel was
|
||||
filed under the first as an undated second post (#4435)."""
|
||||
sc = find_sidecar(source)
|
||||
if sc is None or artist is None:
|
||||
return None
|
||||
@@ -444,10 +502,13 @@ class Importer:
|
||||
log.warning("sidecar parse failed for %s: %s", sc, exc)
|
||||
return None
|
||||
sd = parse_sidecar(data)
|
||||
platform = sd.platform or "unknown"
|
||||
src = self._lookup_source_for_sidecar(
|
||||
artist_id=artist.id, platform=platform,
|
||||
)
|
||||
if source_row is not None:
|
||||
src = source_row
|
||||
else:
|
||||
platform = sd.platform or "unknown"
|
||||
src = self._lookup_source_for_sidecar(
|
||||
artist_id=artist.id, platform=platform,
|
||||
)
|
||||
epid = sd.external_post_id or sc.stem
|
||||
return self._find_or_create_post(
|
||||
source_id=src.id if src else None,
|
||||
@@ -533,28 +594,42 @@ class Importer:
|
||||
# lays files out under /images/<artist_slug>/...). Filesystem
|
||||
# import still calls bare _import_archive(source) and falls
|
||||
# back to the path-walk derivation as before.
|
||||
probe = safe_probe.probe_archive(source)
|
||||
#
|
||||
# A game or app build is recognised first, from the archive's directory
|
||||
# alone. The probe reads and checksums every member, which for a
|
||||
# multi-gigabyte build is minutes of work on a file that is kept whole
|
||||
# and never extracted anyway.
|
||||
marker = software_marker(source)
|
||||
probe = None if marker is not None else safe_probe.probe_archive(source)
|
||||
if probe is not None and probe.crashed:
|
||||
return ImportResult(
|
||||
status="failed",
|
||||
error=f"archive probe crashed/timed out: {probe.reason}",
|
||||
)
|
||||
|
||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist_use, source_row=source_row)
|
||||
if marker is not None:
|
||||
# A game or app build: kept whole, its bundled images left alone.
|
||||
self._capture_attachment(
|
||||
source, post=post, artist=artist_use, resolved=True,
|
||||
)
|
||||
log.info("%s: a software build (holds %s), kept unextracted",
|
||||
source.name, marker)
|
||||
return ImportResult(status="attached")
|
||||
if not probe.ok:
|
||||
if probe.crashed:
|
||||
return ImportResult(
|
||||
status="failed",
|
||||
error=f"archive probe crashed/timed out: {probe.reason}",
|
||||
)
|
||||
# Clean rejection (bomb cap exceeded, integrity mismatch):
|
||||
# still preserve the archive file itself as an attachment so
|
||||
# nothing silently vanishes, matching extract_archive's
|
||||
# fail-soft contract.
|
||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist_use)
|
||||
# Clean rejection (bomb cap exceeded, integrity mismatch): the
|
||||
# archive file itself is still kept as an attachment so nothing
|
||||
# silently vanishes, matching extract_archive's fail-soft contract.
|
||||
self._capture_attachment(
|
||||
source, post=post, artist=artist_use, resolved=True,
|
||||
)
|
||||
reason = f"archive probe rejected, captured unextracted: {probe.reason}"
|
||||
log.warning("%s: %s", source.name, reason)
|
||||
return ImportResult(status="attached", error=reason)
|
||||
|
||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist_use)
|
||||
# Committed before extraction, so a member that fails (and is rolled
|
||||
# back on its own) can never take the archive's post with it.
|
||||
self.session.commit()
|
||||
member_ids: list[int] = []
|
||||
# Every member image touched (new + superseded + deduped), so the
|
||||
# from_attachment_id stamp below covers files that already existed in the
|
||||
@@ -567,7 +642,7 @@ class Importer:
|
||||
# or failed/corrupt members — instead of one catch-all string.
|
||||
counts = {
|
||||
"media": 0, "deduped": 0, "unsupported": 0,
|
||||
"failed": 0, "nested": 0, "nested_rejected": 0,
|
||||
"failed": 0, "nested": 0, "nested_rejected": 0, "nested_software": 0,
|
||||
}
|
||||
self._collect_archive_members(
|
||||
source, attribution=source, source_row=source_row,
|
||||
@@ -599,20 +674,21 @@ class Importer:
|
||||
if counts["media"] == 0 and counts["nested"] == 0:
|
||||
reason = ("archive yielded no members (unsupported/corrupt, or the "
|
||||
"extractor backend failed)")
|
||||
elif counts["deduped"] and not (
|
||||
elif (counts["deduped"] or counts["nested_software"]) and not (
|
||||
counts["unsupported"] or counts["failed"] or counts["nested_rejected"]
|
||||
):
|
||||
log.info(
|
||||
"%s: all %d image member(s) already in library (deduped + linked "
|
||||
"to this post) — archive preserved, no new import",
|
||||
source.name, counts["deduped"],
|
||||
"%s: no new images — %d already in the library (linked to this "
|
||||
"post), %d nested software build(s) skipped; archive preserved",
|
||||
source.name, counts["deduped"], counts["nested_software"],
|
||||
)
|
||||
return ImportResult(status="attached")
|
||||
else:
|
||||
reason = (
|
||||
f"no new images — media={counts['media']} deduped={counts['deduped']} "
|
||||
f"unsupported/non-media={counts['unsupported']} failed={counts['failed']} "
|
||||
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']}"
|
||||
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']} "
|
||||
f"nested-software={counts['nested_software']}"
|
||||
)
|
||||
log.warning("%s: %s", source.name, reason)
|
||||
return ImportResult(status="attached", error=reason)
|
||||
@@ -645,6 +721,15 @@ class Importer:
|
||||
_ARCHIVE_MAX_DEPTH, member_path.name,
|
||||
)
|
||||
continue
|
||||
# Recognised from its directory before the probe reads
|
||||
# every member, as for the outer archive.
|
||||
if (marker := software_marker(member_path)) is not None:
|
||||
counts["nested_software"] += 1
|
||||
log.info(
|
||||
"nested archive is a software build (holds %s), "
|
||||
"skipped: %s", marker, member_path.name,
|
||||
)
|
||||
continue
|
||||
probe = safe_probe.probe_archive(member_path)
|
||||
if not probe.ok:
|
||||
counts["nested_rejected"] += 1
|
||||
@@ -681,7 +766,15 @@ class Importer:
|
||||
member_record_ids.add(res.image_id)
|
||||
else:
|
||||
counts["failed"] += 1
|
||||
except SoftTimeLimitExceeded:
|
||||
# The task's time budget is spent: stop here so the task marks
|
||||
# its row failed, rather than carrying on until the hard kill.
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — defensive per level; keep going
|
||||
# Each member commits on its own, so this undoes only the one in
|
||||
# progress; without it the next query fails on the dead transaction
|
||||
# and the archive is never stored.
|
||||
self.session.rollback()
|
||||
log.warning(
|
||||
"archive extraction failed for %s (depth %d): %s",
|
||||
archive_path.name, depth, exc,
|
||||
@@ -781,7 +874,6 @@ class Importer:
|
||||
# Compute file dimensions (images only) and apply filters.
|
||||
width = height = None
|
||||
duration = None # video container duration (Tier-1 near-dup key, #871)
|
||||
has_alpha = False
|
||||
if is_video(source):
|
||||
# Layer-3 isolation: validate the container via ffprobe (a
|
||||
# separate process) before the rest of the pipeline touches
|
||||
@@ -800,52 +892,9 @@ class Importer:
|
||||
)
|
||||
width, height, duration = probe.width, probe.height, probe.duration
|
||||
else:
|
||||
try:
|
||||
with Image.open(source) as im:
|
||||
im.verify()
|
||||
with Image.open(source) as im:
|
||||
width, height = im.size
|
||||
has_alpha = im.mode in ("RGBA", "LA") or (
|
||||
im.mode == "P" and "transparency" in im.info
|
||||
)
|
||||
except Exception as exc:
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image, error=str(exc),
|
||||
)
|
||||
|
||||
if (self.settings.min_width and width < self.settings.min_width) or (
|
||||
self.settings.min_height and height < self.settings.min_height
|
||||
):
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.too_small,
|
||||
error=f"{width}x{height} below min {self.settings.min_width}x{self.settings.min_height}",
|
||||
)
|
||||
|
||||
if self.settings.skip_transparent and has_alpha:
|
||||
try:
|
||||
pct = self._transparency_pct(source)
|
||||
except OSError as exc:
|
||||
# PIL.verify() at line 263 only validates header structure;
|
||||
# truncated/corrupt pixel data only surfaces when load()
|
||||
# actually decodes (here via getchannel('A')). Convert to
|
||||
# invalid_image skip so the Celery autoretry loop doesn't
|
||||
# bounce the same broken file forever.
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||
error=f"PIL load failed during transparency check: {exc}",
|
||||
)
|
||||
if pct >= self.settings.transparency_threshold:
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.too_transparent,
|
||||
error=f"{pct:.2%} transparent",
|
||||
)
|
||||
|
||||
if self.settings.skip_single_color and self._single_color_hit(source):
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.single_color,
|
||||
error=(f"one color dominates >"
|
||||
f"{self.settings.single_color_threshold:.0%}"),
|
||||
)
|
||||
refusal, width, height = self._screen_still(source)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
|
||||
# Artist anchored to the attribution path (folder→artist), resolved
|
||||
# UP-FRONT so the enrich-on-duplicate branches link provenance with the
|
||||
@@ -862,34 +911,17 @@ class Importer:
|
||||
existing = self.session.execute(existing_stmt).scalar_one_or_none()
|
||||
if existing:
|
||||
if not self.deep:
|
||||
# Enrich-on-duplicate (parity with attach_in_place): a re-scanned
|
||||
# file that is a byte-dup of an existing image still links its
|
||||
# post via _apply_sidecar, so a cross-posted image shows on every
|
||||
# post.
|
||||
self._apply_sidecar(
|
||||
existing, attribution_path, path_artist,
|
||||
explicit_source=explicit_source,
|
||||
)
|
||||
self.session.commit()
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
||||
image_id=existing.id, error="sha256 already present",
|
||||
return self._file_as_duplicate(
|
||||
existing, attribution_path, path_artist, explicit_source,
|
||||
)
|
||||
return self._deep_rederive(existing, source, attribution_path)
|
||||
|
||||
# Perceptual near-dup (images only; videos keep phash NULL).
|
||||
phash = None
|
||||
if not is_video(source):
|
||||
try:
|
||||
with Image.open(source) as im:
|
||||
phash = compute_phash(im)
|
||||
except OSError as exc:
|
||||
# Same rationale as the transparency-check guard above:
|
||||
# broken-pixel-data files pass verify() but blow up here.
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||
error=f"PIL load failed during phash compute: {exc}",
|
||||
)
|
||||
refusal, phash = self._phash_of(source)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if phash is not None:
|
||||
candidates = self._phash_candidates_cache()
|
||||
rel, match_id = find_similar(
|
||||
@@ -959,8 +991,14 @@ class Importer:
|
||||
origin="imported_filesystem",
|
||||
integrity_status="unknown",
|
||||
)
|
||||
self.session.add(record)
|
||||
self.session.flush()
|
||||
if (winner := self._store_new_record(record)) is not None:
|
||||
# The copy just written is a stray unless it is the winner's own
|
||||
# file (the same source imported twice lands on the same path).
|
||||
if Path(winner.path) != dest:
|
||||
dest.unlink(missing_ok=True)
|
||||
return self._file_as_duplicate(
|
||||
winner, attribution_path, path_artist, explicit_source,
|
||||
)
|
||||
self._phash_cache_append(phash, width, height, record.id)
|
||||
|
||||
# Folder→artist (path_artist resolved up-front above); bind it to the
|
||||
@@ -1040,9 +1078,7 @@ class Importer:
|
||||
removal sticks. The existing catalogue is covered separately by the
|
||||
operator-triggered backfill sweep. Gated by the settings toggle, and
|
||||
best-effort: any failure is logged, never allowed to fail the import."""
|
||||
hard_on = self.settings.wip_title_tagging_enabled
|
||||
soft_on = self.settings.wip_soft_title_tagging_enabled
|
||||
if not (hard_on or soft_on):
|
||||
if not self.settings.wip_title_tagging_enabled:
|
||||
return
|
||||
if record.primary_post_id is None:
|
||||
return
|
||||
@@ -1050,21 +1086,14 @@ class Importer:
|
||||
title = self.session.execute(
|
||||
select(Post.post_title).where(Post.id == record.primary_post_id)
|
||||
).scalar_one_or_none()
|
||||
# HARD tier ("WIP"/"work in progress") wins — higher precision, and it
|
||||
# trains the head; SOFT (sketch/doodle, #1474) is the provisional fallback
|
||||
# that never trains (source wip_title_soft).
|
||||
if hard_on and matches_wip_title(title):
|
||||
source = WIP_TITLE_SOURCE
|
||||
elif soft_on and matches_soft_wip_title(title):
|
||||
source = WIP_TITLE_SOFT_SOURCE
|
||||
else:
|
||||
if not matches_wip_title(title):
|
||||
return
|
||||
if self._wip_tag_id is _UNSET:
|
||||
self._wip_tag_id = resolve_wip_tag_id(self.session)
|
||||
if self._wip_tag_id is None:
|
||||
return
|
||||
apply_wip_image_tags(
|
||||
self.session, [record.id], self._wip_tag_id, source=source
|
||||
self.session, [record.id], self._wip_tag_id, source=WIP_TITLE_SOURCE
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — a tag must never fail an import
|
||||
log.warning(
|
||||
@@ -1141,9 +1170,51 @@ class Importer:
|
||||
if post.artist_id is None:
|
||||
post.artist_id = artist.id
|
||||
self._apply_post_fields(post, sd)
|
||||
self._redate_post_images(post)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def _redate_post_images(self, post: Post) -> None:
|
||||
"""Carry a post's date onto the images already linked to it (#4431).
|
||||
|
||||
The native ingesters import a post's media BEFORE its record: the
|
||||
per-media sidecar holds only the image identity (post-first, #856), and
|
||||
the date arrives with `_post.json`. So `_attach_provenance` links each
|
||||
image to a post that has no date yet, and the image keeps its download
|
||||
time. This runs when the record lands, and applies the same two rules
|
||||
`_attach_provenance` applies: `effective_date` is the PRIMARY post's
|
||||
date, and `earliest_post_date` is the earliest date across every post
|
||||
the image is linked to. Only rows that differ are written."""
|
||||
if post.post_date is None:
|
||||
return
|
||||
self.session.flush()
|
||||
self.session.execute(
|
||||
update(ImageRecord)
|
||||
.where(ImageRecord.primary_post_id == post.id)
|
||||
.where(ImageRecord.effective_date.is_distinct_from(post.post_date))
|
||||
.values(effective_date=post.post_date)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
linked = select(ImageProvenance.image_record_id).where(
|
||||
ImageProvenance.post_id == post.id
|
||||
)
|
||||
earliest = (
|
||||
select(func.min(Post.post_date))
|
||||
.select_from(ImageProvenance)
|
||||
.join(Post, Post.id == ImageProvenance.post_id)
|
||||
.where(ImageProvenance.image_record_id == ImageRecord.id)
|
||||
.where(Post.post_date.is_not(None))
|
||||
.correlate(ImageRecord)
|
||||
.scalar_subquery()
|
||||
)
|
||||
self.session.execute(
|
||||
update(ImageRecord)
|
||||
.where(ImageRecord.id.in_(linked))
|
||||
.where(ImageRecord.earliest_post_date.is_distinct_from(earliest))
|
||||
.values(earliest_post_date=earliest)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
|
||||
def attach_in_place(
|
||||
self,
|
||||
path: Path,
|
||||
@@ -1177,62 +1248,28 @@ class Importer:
|
||||
caller may delete the on-disk original (mirrors duplicate_hash)
|
||||
- failed → file untouched; caller decides
|
||||
"""
|
||||
if path.suffix.lower() == ".json":
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||
error="sidecar json is metadata, not content",
|
||||
)
|
||||
if (refused := _not_content(path)) is not None:
|
||||
return refused
|
||||
if is_archive(path):
|
||||
return self._import_archive(
|
||||
path, artist=artist, source_row=source,
|
||||
)
|
||||
if not is_supported(path):
|
||||
post = self._post_for_sidecar(path, artist) if artist else None
|
||||
post = (
|
||||
self._post_for_sidecar(path, artist, source_row=source)
|
||||
if artist else None
|
||||
)
|
||||
return self._capture_attachment(
|
||||
path, post=post, artist=artist, resolved=True,
|
||||
)
|
||||
|
||||
# Format / dimension / transparency filters (mirror _import_media).
|
||||
# The same content filters as _import_media.
|
||||
width = height = None
|
||||
duration = None # video container duration (Tier-1 near-dup key, #871)
|
||||
has_alpha = False
|
||||
if not is_video(path):
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
im.verify()
|
||||
with Image.open(path) as im:
|
||||
width, height = im.size
|
||||
has_alpha = im.mode in ("RGBA", "LA") or (
|
||||
im.mode == "P" and "transparency" in im.info
|
||||
)
|
||||
except Exception as exc:
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
if (self.settings.min_width and width < self.settings.min_width) or (
|
||||
self.settings.min_height and height < self.settings.min_height
|
||||
):
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.too_small,
|
||||
error=f"{width}x{height} below min {self.settings.min_width}x{self.settings.min_height}",
|
||||
)
|
||||
|
||||
if self.settings.skip_transparent and has_alpha:
|
||||
pct = self._transparency_pct(path)
|
||||
if pct >= self.settings.transparency_threshold:
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.too_transparent,
|
||||
error=f"{pct:.2%} transparent",
|
||||
)
|
||||
|
||||
if self.settings.skip_single_color and self._single_color_hit(path):
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.single_color,
|
||||
error=(f"one color dominates >"
|
||||
f"{self.settings.single_color_threshold:.0%}"),
|
||||
)
|
||||
refusal, width, height = self._screen_still(path)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
else:
|
||||
# Best-effort probe for dims + duration so downloaded videos can dedup
|
||||
# (#871). LENIENT: unlike _import_media this path does not reject on a
|
||||
@@ -1255,21 +1292,14 @@ class Importer:
|
||||
# only sets it when NULL. Without this the new post is left with no
|
||||
# image AND (if its other content also deduped) no attachment, i.e. a
|
||||
# bare shell — operator-flagged 2026-06-08 (1589 empty Anduo posts).
|
||||
self._apply_sidecar(existing, path, artist, explicit_source=source)
|
||||
self.session.commit()
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
||||
image_id=existing.id, error="sha256 already present",
|
||||
)
|
||||
return self._file_as_duplicate(existing, path, artist, source)
|
||||
|
||||
# phash dedup + supersede
|
||||
phash = None
|
||||
if not is_video(path):
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
phash = compute_phash(im)
|
||||
except Exception:
|
||||
phash = None
|
||||
refusal, phash = self._phash_of(path)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if phash is not None:
|
||||
candidates = self._phash_candidates_cache()
|
||||
rel, match_id = find_similar(
|
||||
@@ -1340,8 +1370,8 @@ class Importer:
|
||||
)
|
||||
if artist is not None:
|
||||
record.artist_id = artist.id
|
||||
self.session.add(record)
|
||||
self.session.flush()
|
||||
if (winner := self._store_new_record(record)) is not None:
|
||||
return self._file_as_duplicate(winner, path, artist, source)
|
||||
self._phash_cache_append(phash, width, height, record.id)
|
||||
|
||||
# Sidecar provenance (best-effort). When `source` is passed, link
|
||||
@@ -1356,6 +1386,51 @@ class Importer:
|
||||
self.session.commit()
|
||||
return ImportResult(status="imported", image_id=record.id)
|
||||
|
||||
def discard_after_attach(self, path: Path, result: ImportResult) -> bool:
|
||||
"""Delete a downloaded file that `attach_in_place` has finished with,
|
||||
when keeping it would only leave a second copy or a rejected file on
|
||||
disk. Returns True when the file was removed. Both download paths (a
|
||||
subscription walk's import step and the external-link worker) apply
|
||||
this one policy:
|
||||
|
||||
- attached: the attachments store holds the canonical copy.
|
||||
- imported or superseded ARCHIVE: its new images are in the library and
|
||||
the archive itself is in the attachments store.
|
||||
- duplicate skip: the library already holds the content.
|
||||
- soft skip (too small, too transparent, single colour, not a readable
|
||||
image): the import filters rejected it. A file left on disk with no
|
||||
record is picked up by the next walk and imported again; the seen
|
||||
ledger is what stops it being downloaded again.
|
||||
- failed: the archive probe crashed, and a retry would crash again.
|
||||
|
||||
An imported or superseded media file is kept: it IS the record's file.
|
||||
So is any path an ImageRecord points at, whatever the status, because a
|
||||
re-fetch can write identical bytes over a record's own file. A staging
|
||||
file is kept too: it may be a download still being written, and the
|
||||
temp-file sweep removes it once it is old enough to be abandoned."""
|
||||
if is_temp_staging_file(path):
|
||||
return False
|
||||
if result.status in ("imported", "superseded"):
|
||||
redundant = is_archive(path)
|
||||
else:
|
||||
redundant = result.status in ("attached", "skipped", "failed")
|
||||
if not redundant:
|
||||
return False
|
||||
owned = self.session.execute(
|
||||
select(ImageRecord.id).where(
|
||||
ImageRecord.path.in_({str(path), str(path.resolve())})
|
||||
).limit(1)
|
||||
).first()
|
||||
if owned is not None:
|
||||
log.info("keeping %s: it is image %s's file", path.name, owned.id)
|
||||
return False
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
log.warning("could not remove %s: %s", path, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _upsert_artist(self, name: str) -> Artist:
|
||||
slug = slugify(name)
|
||||
artist = self.session.execute(
|
||||
@@ -1733,6 +1808,81 @@ class Importer:
|
||||
# is operator-tunable per surface.
|
||||
_SINGLE_COLOR_TOLERANCE = 30
|
||||
|
||||
def _screen_still(
|
||||
self, source: Path,
|
||||
) -> tuple[ImportResult | None, int | None, int | None]:
|
||||
"""Read a still image's size and apply the library's content filters.
|
||||
|
||||
Returns `(refusal, width, height)`. `refusal` is a skip result when
|
||||
the file is not a readable image, is below the minimum size, is mostly
|
||||
transparent or is one colour, and None when the file passes.
|
||||
|
||||
A file whose header verifies but whose pixel data is truncated only
|
||||
fails once PIL decodes it, as the transparency check does. That decode
|
||||
error is a skip too: raised, it would retry the import task forever,
|
||||
or abort a download run in the middle of its files.
|
||||
"""
|
||||
try:
|
||||
with Image.open(source) as im:
|
||||
im.verify()
|
||||
with Image.open(source) as im:
|
||||
width, height = im.size
|
||||
has_alpha = im.mode in ("RGBA", "LA") or (
|
||||
im.mode == "P" and "transparency" in im.info
|
||||
)
|
||||
except Exception as exc:
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||
error=str(exc),
|
||||
), None, None
|
||||
|
||||
if (self.settings.min_width and width < self.settings.min_width) or (
|
||||
self.settings.min_height and height < self.settings.min_height
|
||||
):
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.too_small,
|
||||
error=f"{width}x{height} below min {self.settings.min_width}x{self.settings.min_height}",
|
||||
), width, height
|
||||
|
||||
if self.settings.skip_transparent and has_alpha:
|
||||
try:
|
||||
pct = self._transparency_pct(source)
|
||||
except OSError as exc:
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||
error=f"PIL load failed during transparency check: {exc}",
|
||||
), width, height
|
||||
if pct >= self.settings.transparency_threshold:
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.too_transparent,
|
||||
error=f"{pct:.2%} transparent",
|
||||
), width, height
|
||||
|
||||
if self.settings.skip_single_color and self._single_color_hit(source):
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.single_color,
|
||||
error=(f"one color dominates >"
|
||||
f"{self.settings.single_color_threshold:.0%}"),
|
||||
), width, height
|
||||
|
||||
return None, width, height
|
||||
|
||||
def _phash_of(self, source: Path) -> tuple[ImportResult | None, object]:
|
||||
"""The perceptual hash of a still image, as `(refusal, phash)`.
|
||||
|
||||
Hashing decodes every pixel, so this is where a file with truncated
|
||||
pixel data can first fail; that is a skip rather than an exception,
|
||||
for the same reason as in `_screen_still`.
|
||||
"""
|
||||
try:
|
||||
with Image.open(source) as im:
|
||||
return None, compute_phash(im)
|
||||
except OSError as exc:
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||
error=f"PIL load failed during phash compute: {exc}",
|
||||
), None
|
||||
|
||||
def _single_color_hit(self, source: Path) -> bool:
|
||||
"""True when one color dominates beyond the configured threshold — the
|
||||
same canonical predicate the Cleanup audit runs (audits.single_color,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user