Compare commits
233
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93a5fc5b4 | ||
|
|
725bf15f88 | ||
|
|
b14818303c | ||
|
|
499720d87e | ||
|
|
bd24f4e876 | ||
|
|
0e15c44c51 | ||
|
|
6d98dfc0ec | ||
|
|
6c76f08b69 | ||
|
|
7b1019ba82 | ||
|
|
0d204e6837 | ||
|
|
8300029741 | ||
|
|
b5b437ca80 | ||
|
|
ce0dac3524 | ||
|
|
9b5ec86222 | ||
|
|
89c83ee5de | ||
|
|
d3192f1843 | ||
|
|
5a5694f200 | ||
|
|
bb1a938cc0 | ||
|
|
fc0293029d | ||
|
|
7d1c701b67 | ||
|
|
b638382cd5 | ||
|
|
17903068b4 | ||
|
|
31d400ab0a | ||
|
|
d45ce5e426 | ||
|
|
77bfc32b02 | ||
|
|
d13efe1b69 | ||
|
|
ca6d26d236 | ||
|
|
5366047d55 | ||
|
|
2923257529 | ||
|
|
934731e9ef | ||
|
|
4c67c116b2 | ||
|
|
f8b667604f | ||
|
|
11572f469a | ||
|
|
ba3fd2a118 | ||
|
|
9bf095179a | ||
|
|
cefc064e0f | ||
|
|
06757daf80 | ||
|
|
a723ef436b | ||
|
|
d68f39ca50 | ||
|
|
a66a695977 | ||
|
|
5289fa3879 | ||
|
|
ebac34e17b | ||
|
|
4c3ba20198 | ||
|
|
d2acec61ae | ||
|
|
15ae5ef4fa | ||
|
|
e3ceefc820 | ||
|
|
e52090a4cf | ||
|
|
bfba8045e4 | ||
|
|
a708357436 | ||
|
|
d9354ac1e1 | ||
|
|
1d48770793 | ||
|
|
489e6aaaee | ||
|
|
ed20df905b | ||
|
|
5ba9871ef0 | ||
|
|
2a820d0848 | ||
|
|
2f9aa3d86c | ||
|
|
560a5000a2 | ||
|
|
7ddad231f8 | ||
|
|
a78f7eaace | ||
|
|
71337b0ba4 | ||
|
|
3996205f3b | ||
|
|
9f9db01456 | ||
|
|
216b7fc743 | ||
|
|
fbb76e6f36 | ||
|
|
5ef1478ade | ||
|
|
88ff4147e1 | ||
|
|
1ea02ad44c | ||
|
|
937cfb65b4 | ||
|
|
baac851220 | ||
|
|
a28c33281a | ||
|
|
40be0a9323 | ||
|
|
2c6bf26bfc | ||
|
|
3c1e76bd44 | ||
|
|
831c5b1c10 | ||
|
|
76b6af4903 | ||
|
|
92494ec4ed | ||
|
|
bdfc17477c | ||
|
|
6cd3153bf4 | ||
|
|
5f2853168a | ||
|
|
9a979ee808 | ||
|
|
3138f912fd | ||
|
|
9df874e396 | ||
|
|
6915a7590a | ||
|
|
5e8c28236a | ||
|
|
7e3c0f0b74 | ||
|
|
5d0c7ba706 | ||
|
|
18300e1f8a | ||
|
|
d52ac0a0e2 | ||
|
|
401fe8213e | ||
|
|
e8774d7953 | ||
|
|
bc0f00c51b | ||
|
|
1bef68aa29 | ||
|
|
1a4bc2f981 | ||
|
|
862ace69d6 | ||
|
|
abf88b1a15 | ||
|
|
c05dcafbea | ||
|
|
55e8632dab | ||
|
|
825e6b90bf | ||
|
|
fb012c557c | ||
|
|
66593ab895 | ||
|
|
b266a54ad3 | ||
|
|
ad803b646f | ||
|
|
1f5da3d283 | ||
|
|
93034f580d | ||
|
|
9b9b12f410 | ||
|
|
376d310693 | ||
|
|
bc69495a16 | ||
|
|
478f898e72 | ||
|
|
38a5e7f332 | ||
|
|
57fe15c267 | ||
|
|
eb3231ef10 | ||
|
|
e9af459c0d | ||
|
|
6f02806aec | ||
|
|
a1d19bd96a | ||
|
|
26827ff38f | ||
|
|
26dcfaf6c2 | ||
|
|
9b1b0369cc | ||
|
|
18123fb9cb | ||
|
|
2e806f202f | ||
|
|
18d5c05639 | ||
|
|
11ddfc3876 | ||
|
|
2b8ce86622 | ||
|
|
49bee77cdc | ||
|
|
c209e3b37e | ||
|
|
cffdd93418 | ||
|
|
fd84be40dd | ||
|
|
79f510d7f8 | ||
|
|
59181069da | ||
|
|
428ecd8642 | ||
|
|
ed1e04b831 | ||
|
|
f5156bd847 | ||
|
|
dfc3922d24 | ||
|
|
3eb08e926b | ||
|
|
9e81ced359 | ||
|
|
11e9f5af60 | ||
|
|
909fa37b15 | ||
|
|
dfab8f65ff | ||
|
|
618f7cdc36 | ||
|
|
028ea33a7c | ||
|
|
444c1fb075 | ||
|
|
26c68b0a75 | ||
|
|
e75427b19a | ||
|
|
5447fab987 | ||
|
|
bad37e07b2 | ||
|
|
2bfc9936a1 | ||
|
|
4c6406ee18 | ||
|
|
bb47e80b3e | ||
|
|
dc1083b5e0 | ||
|
|
e46893fefd | ||
|
|
0666e15211 | ||
|
|
747390631d | ||
|
|
e0d2a20588 | ||
|
|
1d84f67418 | ||
|
|
91265df3d6 | ||
|
|
11acdb0322 | ||
|
|
2eb9fd5dd0 | ||
|
|
01e5ce1410 | ||
|
|
3bb94674cf | ||
|
|
a75c602175 | ||
|
|
ef8f4f7193 | ||
|
|
ec3d27b219 | ||
|
|
03bd3b2eda | ||
|
|
7395e77d75 | ||
|
|
575d817919 | ||
|
|
2a8f7cd8b6 | ||
|
|
83f8af8090 | ||
|
|
9a2617c1a2 | ||
|
|
81688815a0 | ||
|
|
773128c3bf | ||
|
|
ce7b154ae9 | ||
|
|
9430a9d9c3 | ||
|
|
23aee56ce3 | ||
|
|
711abea567 | ||
|
|
844bb86802 | ||
|
|
a8f6a464aa | ||
|
|
ab9922ad2e | ||
|
|
0533807669 | ||
|
|
279dff3fb6 | ||
|
|
37e66cddc4 | ||
|
|
9cf6b2d363 | ||
|
|
6ef0fed41f | ||
|
|
89b48f8f35 | ||
|
|
d60e0b9494 | ||
|
|
9c27a2d3c7 | ||
|
|
93e37681b7 | ||
|
|
64ca858574 | ||
|
|
9d0c0b7da8 | ||
|
|
8e4d252ae4 | ||
|
|
fdd3e01f56 | ||
|
|
c82fb308b6 | ||
|
|
8cf8d2ca4d | ||
|
|
b1d58bc3b8 | ||
|
|
65386f02a0 | ||
|
|
667b05f14e | ||
|
|
856e9104b4 | ||
|
|
0397642b21 | ||
|
|
237575447d | ||
|
|
ed358757dc | ||
|
|
d181f4afb8 | ||
|
|
2886fa4997 | ||
|
|
f256f587ee | ||
|
|
384d8d5e50 | ||
|
|
319e8c1d18 | ||
|
|
9075d8eadd | ||
|
|
88e53e5b86 | ||
|
|
37e8b796a1 | ||
|
|
4e82208926 | ||
|
|
52fff00353 | ||
|
|
c14338cbce | ||
|
|
8c36dd28b0 | ||
|
|
88cfb3dd02 | ||
|
|
5d4f223b71 | ||
|
|
05090c6e85 | ||
|
|
3a577d5ade | ||
|
|
f4fe02e346 | ||
|
|
e766197d99 | ||
|
|
3872e1dda9 | ||
|
|
9814f3dbaf | ||
|
|
b214460fdb | ||
|
|
ac55d0e8d8 | ||
|
|
89a89e0ded | ||
|
|
4e9aac2c05 | ||
|
|
2879ac6f2b | ||
|
|
b8dce6c483 | ||
|
|
d1c0b82a22 | ||
|
|
5526b8dc78 | ||
|
|
16eb7075c4 | ||
|
|
885dcf64f3 | ||
|
|
f2f6b6d25e | ||
|
|
0822240fde | ||
|
|
27f7f3fd01 | ||
|
|
c5bf564f53 | ||
|
|
602c7d275d |
+19
-98
@@ -1,103 +1,24 @@
|
||||
# FabledCurator configuration.
|
||||
#
|
||||
# Copy to `.env` and edit before your first production start:
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Only the two values under CHANGE THESE actually need your attention. The
|
||||
# rest have working defaults baked into docker-compose.yml and are listed
|
||||
# here so you know they exist, not because you have to set them.
|
||||
#
|
||||
# Almost nothing else lives here on purpose. FabledCurator is configured from
|
||||
# its own Settings UI, backed by the database — no restart, no YAML. If you
|
||||
# are looking for where to set an import path, a download schedule or an ML
|
||||
# threshold, it is in the app, not in this file.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CHANGE THESE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The Postgres password. docker-compose.yml falls back to a published default
|
||||
# (`fabledcurator_dev`) so that `docker compose up` works with no config at
|
||||
# all — which is exactly why you must not leave it at that on a real install.
|
||||
# It is the credential protecting your stored platform session cookies.
|
||||
DB_PASSWORD=
|
||||
|
||||
# Sets Quart's app.secret_key. Today it signs nothing: FabledCurator has no
|
||||
# login and uses no session cookies, so no value here is protecting anything
|
||||
# right now. Set it anyway. It is required at boot rather than defaulted so
|
||||
# that the day something session-backed does land, no instance is already
|
||||
# running on a value published in this file.
|
||||
#
|
||||
# openssl rand -hex 32
|
||||
SECRET_KEY=
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIRST BOOT ONLY — then delete this line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# FabledCurator encrypts your stored platform credentials with a Fernet key it
|
||||
# keeps at /images/secrets/credential_key.b64 — inside the ./images bind mount,
|
||||
# so it outlives the container. On a brand-new install that file does not exist
|
||||
# yet, and the app REFUSES TO START rather than quietly create one:
|
||||
#
|
||||
# MissingCredentialKey: Fernet key file not found at
|
||||
# /images/secrets/credential_key.b64
|
||||
#
|
||||
# That refusal is deliberate. Auto-creating a key is indistinguishable from the
|
||||
# disaster case — a restore that brought the database back but lost
|
||||
# ./images/secrets — and there it would mint a key that cannot decrypt anything,
|
||||
# leaving an instance that looks healthy while every paywalled download fails.
|
||||
# So the choice is yours to make explicitly, once.
|
||||
#
|
||||
# Set this for your first `up`, watch the container come up, then DELETE THE
|
||||
# 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.
|
||||
CURATOR_BOOTSTRAP_NEW_KEY=1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional — defaults are fine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Host port the UI is published on. The container always listens on 8080;
|
||||
# this is only the left-hand side of the port mapping.
|
||||
PORT=8080
|
||||
|
||||
# DEBUG | INFO | WARNING | ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Postgres identity. Change these only if you are pointing at a database you
|
||||
# manage yourself — the bundled postgres service is created with whatever is
|
||||
# set here, so changing them after the first start will not rename anything.
|
||||
# Database
|
||||
DB_USER=fabledcurator
|
||||
DB_PASSWORD=changeme_use_a_real_password
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=fabledcurator
|
||||
|
||||
# Set by docker-compose.yml to reach the bundled services. Override only when
|
||||
# running Postgres or Redis outside this stack.
|
||||
# DB_HOST=postgres
|
||||
# DB_PORT=5432
|
||||
# CELERY_BROKER_URL=redis://redis:6379/0
|
||||
# CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
# Redis / Celery
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
|
||||
# App
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY=changeme_32_byte_hex_secret
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# There is no authentication variable here, and that is not an omission
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# FabledCurator has no login, no accounts and no permission model. Anything
|
||||
# that can reach PORT is an administrator and can read the platform session
|
||||
# cookies the app stores for Patreon and SubscribeStar.
|
||||
#
|
||||
# Bind it to a trusted network. See "Before you expose it" in README.md and
|
||||
# the deployment posture section of SECURITY.md.
|
||||
#
|
||||
# The Firefox extension's API key is NOT configured here — it is generated
|
||||
# automatically on first use and shown under Settings → Maintenance, where you
|
||||
# can also rotate it.
|
||||
# Extension API key — used in FC-3, lands later but reserved now
|
||||
# Generate with: openssl rand -hex 32
|
||||
EXTENSION_API_KEY=
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Deployment posture: plain HTTP (no TLS in the app; reverse proxy if needed)
|
||||
# See docs/superpowers/specs/2026-05-13-fabledcurator-merge-design.md §2.1
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
- name: Resolve the Postgres service and install deps
|
||||
run: |
|
||||
set -eux
|
||||
# Same service-IP dance as build.yml's integration job; see the long
|
||||
# Same service-IP dance as ci.yml's integration job; see the long
|
||||
# comment there for why the job name must stay separator-free.
|
||||
PG=$(docker ps --filter "name=compare" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
test -n "$PG"
|
||||
@@ -87,21 +87,10 @@ jobs:
|
||||
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 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
|
||||
pg_ready=1
|
||||
break
|
||||
fi
|
||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||
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
|
||||
else
|
||||
@@ -172,11 +161,10 @@ jobs:
|
||||
mkdir -p /tmp/versions_held
|
||||
mv alembic/versions/*.py /tmp/versions_held/ 2>/dev/null || true
|
||||
DB_NAME=fc_gen alembic revision --autogenerate -m "baseline" || true
|
||||
# Printed rather than uploaded: the repo dropped actions/upload-artifact
|
||||
# in 2026-05, when the runner could not run v4+, and the job log is the
|
||||
# retrieval channel this job has proven. (gitea/runner 3.x runs stock
|
||||
# upload-artifact now — Scribe snippet #2271 — so an artifact is an
|
||||
# option if the log ever stops being enough.)
|
||||
# Printed rather than uploaded: ci-requirements.md records that this
|
||||
# runner cannot do actions/upload-artifact@v4+, and the repo dropped
|
||||
# the action entirely in 2026-05, so the job log is the retrieval
|
||||
# channel actually proven here.
|
||||
#
|
||||
# base64, not the raw file. A plain `cat` of the ~33KB candidate was
|
||||
# TRUNCATED MID-LINE by the runner on run 4964 — it stopped inside
|
||||
|
||||
+459
-1446
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
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"
|
||||
for i in $(seq 1 60); do
|
||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
# Relax durability on the throwaway CI Postgres so the per-test
|
||||
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
|
||||
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
|
||||
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
|
||||
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
|
||||
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
|
||||
# surprise can't red the job; fabledcurator is the postgres image's
|
||||
# bootstrap superuser.
|
||||
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
|
||||
alembic upgrade head
|
||||
pytest tests/ -v -m integration --durations=15
|
||||
@@ -0,0 +1,87 @@
|
||||
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."
|
||||
-19
@@ -70,22 +70,3 @@ alembic/versions/__pycache__/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
.superpowers/
|
||||
|
||||
# Raw platform captures (milestone 387 C0 and successors). These are real
|
||||
# authenticated API responses taken from the operator's own account, so they
|
||||
# carry account data — creator lists, pledge amounts, and (in Patreon's case)
|
||||
# the account email inside the `card` resources. They are kept locally because
|
||||
# re-capturing means re-authenticating by hand, and they are the ground truth a
|
||||
# characterization gets re-checked against.
|
||||
#
|
||||
# The whole directory is ignored, not one filename, so a future capture is
|
||||
# covered by this rule instead of needing a new line somebody has to remember.
|
||||
#
|
||||
# SANITIZED fixtures derived from these DO belong in git — put them somewhere
|
||||
# else (tests/fixtures/, not here), with the account data stripped.
|
||||
# Ignore the CONTENTS, not the directory: git does not descend into an
|
||||
# excluded directory, so a negation for a file inside one never takes effect.
|
||||
# Writing it this way lets README.md be committed while everything else here
|
||||
# stays out.
|
||||
tests/fixtures/captures/*
|
||||
!tests/fixtures/captures/README.md
|
||||
|
||||
+3
-103
@@ -28,10 +28,6 @@ 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 \
|
||||
@@ -40,59 +36,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt requirements-ml.txt ./
|
||||
COPY requirements.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 ./
|
||||
@@ -126,51 +72,5 @@ ENV FC_VERSION=${FC_VERSION}
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# 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"]
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
CMD ["web"]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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"]
|
||||
@@ -1,233 +1,14 @@
|
||||
<img src="frontend/public/logo.svg" alt="" width="132" align="right" />
|
||||
|
||||
# FabledCurator
|
||||
|
||||
<!-- overview:start -->
|
||||
Self-hosted media curation — a gallery, ML auto-tagging, and subscription-driven
|
||||
downloading in one application. Part of the FabledSword family.
|
||||
Self-hosted media curation — gallery, ML tagging, and subscription-driven downloading in one app. Part of the FabledSword family.
|
||||
|
||||
## What it does
|
||||
Combines what was [ImageRepo](https://git.fabledsword.com/bvandeusen/ImageRepo) (gallery, ML, importer) and [GallerySubscriber](https://git.fabledsword.com/bvandeusen/GallerySubscriber) (gallery-dl wrapper, subscriptions, credential capture) into a single product.
|
||||
|
||||
You point it at creators you follow. It downloads what they post, files it,
|
||||
tags it, and gives you something better than a folder full of images to look
|
||||
through afterwards.
|
||||
## Status
|
||||
|
||||
- **Gallery and browsing.** Images, videos and multi-page works, organised by
|
||||
artist, tag, post and series. A newest-first feed of what just arrived as the
|
||||
front page, a random Showcase, a filterable gallery, a similarity-driven
|
||||
Explore view, and a page-turning reader for series.
|
||||
- **Subscriptions.** Follows creators on Patreon, SubscribeStar, Discord and
|
||||
HentaiFoundry, on a schedule. Handles paywalled posts using your own
|
||||
logged-in session.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Maintenance.** Backups, library audits, thumbnail and embedding backfills,
|
||||
orphan cleanup — all from the UI, all as background jobs you can watch.
|
||||
|
||||
Everything is configured from the Settings UI and stored in the database. There
|
||||
is no config file to edit beyond a handful of bootstrap environment variables.
|
||||
<!-- overview:end -->
|
||||
|
||||
## Before you expose it
|
||||
|
||||
**FabledCurator has no login.** There are no user accounts, no passwords and no
|
||||
permission model. Anything that can reach the port is an administrator.
|
||||
|
||||
That matters more here than it would in most self-hosted apps, because of what
|
||||
this one stores: **live platform session cookies for Patreon and
|
||||
SubscribeStar** — accounts that usually have a payment method attached. Whoever reaches
|
||||
the port can read them, alongside your entire library.
|
||||
|
||||
So:
|
||||
|
||||
- Bind it to a LAN, a VPN, or a tunnel you control.
|
||||
- Do not port-forward it. Do not put it on a public hostname.
|
||||
- A reverse proxy that adds TLS but no authentication **does not help**. If you
|
||||
want it reachable from outside, put an authenticating proxy in front of it —
|
||||
a forward-auth provider, HTTP basic auth, an identity-aware tunnel — and treat
|
||||
that layer as the only thing standing between the internet and your accounts.
|
||||
|
||||
This is a deliberate design decision for a single-operator tool on a trusted
|
||||
network, not a bug and not an oversight. It is stated here because it decides
|
||||
how you are allowed to deploy it. [SECURITY.md](SECURITY.md) covers the rest of
|
||||
the threat model.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker** with Compose v2.
|
||||
- **~4 GB RAM** for the app, plus whatever Postgres needs for your library size.
|
||||
- **Disk** for your media, plus several GB for ML model weights.
|
||||
- **No GPU required.** The ML worker runs on CPU — tagging and embedding are
|
||||
slower, and that is the whole difference. A GPU is only involved if you
|
||||
separately run the optional agent (below), which is a different machine's job.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://git.fabledsword.com/bvandeusen/FabledCurator.git
|
||||
cd FabledCurator
|
||||
|
||||
cp .env.example .env
|
||||
$EDITOR .env # set DB_PASSWORD and SECRET_KEY
|
||||
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Then open <http://localhost:8080>.
|
||||
|
||||
**The `-f docker-compose.yml` is required, not decoration.** Compose
|
||||
auto-merges `docker-compose.override.yml` when you leave it off, and that
|
||||
override builds the images locally from source — the contributor path, not
|
||||
yours. Naming the file explicitly skips the override and pulls the published
|
||||
`:latest` images, which is the stable channel built from `main`.
|
||||
|
||||
If you forget it, the symptom is a long build instead of a quick pull.
|
||||
|
||||
## First run
|
||||
|
||||
The database schema is created automatically on first start — the web container
|
||||
runs its migrations before serving. Nothing to initialise by hand.
|
||||
|
||||
**One thing does need a deliberate act, and the app will not start without it.**
|
||||
FabledCurator encrypts your stored platform credentials with a key it keeps at
|
||||
`./images/secrets/credential_key.b64`. On a brand-new install that file does not
|
||||
exist, and rather than quietly creating one the app stops:
|
||||
|
||||
```
|
||||
MissingCredentialKey: Fernet key file not found at /images/secrets/credential_key.b64
|
||||
```
|
||||
|
||||
Set `CURATOR_BOOTSTRAP_NEW_KEY=1` in your `.env` for the first `up`, then delete
|
||||
the line once the container is running. `.env.example` ships it with that
|
||||
instruction attached.
|
||||
|
||||
The refusal is deliberate, and worth understanding rather than working around:
|
||||
auto-creating a key is indistinguishable from the disaster case — a restore that
|
||||
brought the database back but lost `./images/secrets` — where it would mint a key
|
||||
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.
|
||||
|
||||
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.
|
||||
- **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
|
||||
it, and there is not going to be one. Folder ingestion had a UI until July
|
||||
2026; it was retired once posts began arriving entirely through
|
||||
subscriptions and the browser extension, and the decision to leave it
|
||||
retired is deliberate — the folder path carries complexity the product does
|
||||
not need in order to do its job. The supported way to fill a new install is
|
||||
to add the creators you follow under **Subscriptions** and let it pull.
|
||||
|
||||
The `/api/import/trigger` endpoint is still wired up for anyone who wants to
|
||||
script a one-off against a folder mounted at `./import`, and its progress
|
||||
shows under **Settings → Activity**. Treat it as an unsupported escape
|
||||
hatch rather than a feature: nothing in the UI drives it and nothing else
|
||||
in this README depends on it.
|
||||
- **To download from a paywalled account**, FabledCurator needs that account's
|
||||
session — see the browser extension below. Without one it can still fetch
|
||||
public posts.
|
||||
- **Check Settings → Overview** to confirm the workers are alive. Every long
|
||||
operation in FabledCurator is a background job, so if the queues are not
|
||||
running, the UI will look like it is ignoring you rather than like it is
|
||||
broken.
|
||||
|
||||
## The browser extension
|
||||
|
||||
A Firefox extension does two jobs: it hands your logged-in platform sessions to
|
||||
FabledCurator so it can download on your behalf, and it adds a creator as a
|
||||
subscription in one click from their page.
|
||||
|
||||
It ships **inside the web image** — there is no add-on store listing to find.
|
||||
Go to **Subscriptions → Settings**, find the *Browser extension* card, and click
|
||||
**Install Firefox extension**. The XPI is Mozilla-signed, so Firefox installs it
|
||||
like any other add-on; the button serves it directly rather than making you
|
||||
download and side-load a file.
|
||||
|
||||
It pairs with your instance using an API key generated automatically on first
|
||||
use. The bar directly under that card shows the key and can rotate it.
|
||||
|
||||
See [extension/README.md](extension/README.md) for what it does in detail.
|
||||
|
||||
## The GPU agent
|
||||
|
||||
Optional, and separate. If you have a desktop with a graphics card, you can run
|
||||
an agent on it that leases ML jobs from FabledCurator over HTTP, does them on
|
||||
the GPU, and hands the results back. It never touches the database or Redis, so
|
||||
it is safe to run somewhere the rest of the stack is not.
|
||||
|
||||
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).
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml pull
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Migrations run automatically on start. Take a database backup first — Settings →
|
||||
Maintenance has one — because the schema moves forward and does not move back.
|
||||
|
||||
## Deployment posture
|
||||
|
||||
FabledCurator is built to run inside a homelab over plain HTTP. It does not
|
||||
generate certificates, redirect to HTTPS, or set HSTS. If you want TLS,
|
||||
terminate it at your reverse proxy. See [Before you expose it](#before-you-expose-it)
|
||||
for why TLS alone is not enough.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**The UI loads but nothing ever finishes.** The web container is up and the
|
||||
workers are not. `docker compose -f docker-compose.yml ps` — check `worker`,
|
||||
`scheduler` and `ml-worker` are healthy, not restarting.
|
||||
|
||||
**`docker compose up` started building instead of pulling.** You left off
|
||||
`-f docker-compose.yml`, so the dev override took over. See [Install](#install).
|
||||
|
||||
**Downloads fail with an auth error.** The stored session for that platform has
|
||||
expired. Re-capture it with the extension; sessions do not last forever.
|
||||
|
||||
**Which build am I running?** The foot of Settings shows a version and a
|
||||
channel, and `/api/health` returns the same two fields. There are no version
|
||||
tags on the images, so this is the authoritative answer.
|
||||
|
||||
---
|
||||
|
||||
# Developing FabledCurator
|
||||
|
||||
Everything below is about working on FabledCurator rather than running it. If
|
||||
you are installing it, you are done — see [CONTRIBUTING.md](CONTRIBUTING.md) if
|
||||
you want to send a patch.
|
||||
|
||||
## Status and channels
|
||||
|
||||
In production. `main` is continuously deployed — every merge builds and
|
||||
publishes `:latest`, so whatever is on `main` is what is running. Day-to-day
|
||||
work happens on `dev`, which publishes `:dev`.
|
||||
|
||||
For local development, the dev override handles everything:
|
||||
|
||||
```bash
|
||||
docker compose up -d # note: no -f, so the override applies
|
||||
```
|
||||
|
||||
That builds the images from source, turns on DEBUG logging, and exposes
|
||||
Postgres and Redis on the host. No `.env` required.
|
||||
In production. `main` is continuously deployed — every merge to `main` builds
|
||||
and publishes `:latest` images, so whatever is on `main` is what is running.
|
||||
Day-to-day work happens on `dev`, which publishes `:dev` images.
|
||||
|
||||
## Versions and tags
|
||||
|
||||
@@ -245,9 +26,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). 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.
|
||||
`YYYY.MM.DD.HHMM` UTC (rule 148). Four artifacts, four independent versions —
|
||||
a push touching only `agent/` re-versions the agent and leaves web and ml
|
||||
alone, and CI skips the builds whose content did not move.
|
||||
|
||||
Because no registry name carries it, the running instance's own report is the
|
||||
only answer to "which build is this?". The foot of Settings shows
|
||||
@@ -260,32 +41,60 @@ commits since the previous tag; it builds no image.
|
||||
|
||||
## What's in here
|
||||
|
||||
Four deployable pieces, built by `.forgejo/workflows/build.yml`:
|
||||
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
|
||||
|
||||
| Piece | Built from | Image | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`, `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`. |
|
||||
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`. The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
|
||||
| **ML worker** | `Dockerfile.ml` | `fabledcurator-ml` | Same app, plus `requirements-ml.txt` — tagging and embedding models that run in-container. |
|
||||
| **GPU agent** | `agent/Dockerfile` | `fabledcurator-agent` | Optional desktop-GPU worker (`agent/`). Leases jobs over **HTTP only** — never touches the database or Redis. Run it for a burst, stop it to reclaim the card. See `agent/README.md`. |
|
||||
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both `dev` and `main` (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See `extension/README.md`. |
|
||||
| **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
|
||||
|
||||
## Quick start
|
||||
|
||||
For local development and testing, just:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# UI: http://localhost:8080
|
||||
```
|
||||
|
||||
That uses sane dev defaults baked into `docker-compose.yml` and the dev
|
||||
override (`docker-compose.override.yml`, auto-merged) — local builds, DEBUG
|
||||
logging, exposed Postgres + Redis ports on the host. No `.env` required.
|
||||
|
||||
For a production-like deployment, override the dev defaults via shell env
|
||||
or a `.env` file (see `.env.example` for the variable names) and use:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml up -d
|
||||
# (skips the dev override, so containers pull published :latest images)
|
||||
```
|
||||
|
||||
`-f` is doing real work there: it tells Compose to use *only* that file, which
|
||||
skips `docker-compose.override.yml` and its local builds. What you get is the
|
||||
`:latest` images — the stable channel, built from `main`. This is the install
|
||||
path, and it is the one to use if you are running FabledCurator rather than
|
||||
working on it.
|
||||
|
||||
`:dev` is the other channel: rebuilt from the `dev` branch several times a day,
|
||||
bleeding edge, no stability promise. Nothing in this repo points an installer at
|
||||
it, and nothing should.
|
||||
|
||||
The GPU agent is deployed separately, on the machine with the card —
|
||||
`agent/docker-compose.yml`, not this stack.
|
||||
|
||||
## Deployment posture
|
||||
|
||||
FabledCurator is designed to run inside a self-hosted homelab environment over plain HTTP. If you want TLS, terminate it at your reverse proxy. The app does not generate certificates, redirect to HTTPS, or set HSTS.
|
||||
|
||||
## CI / Forgejo setup
|
||||
|
||||
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.
|
||||
Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
|
||||
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
|
||||
content verification), `build.yml` (sign + publish), and `release.yml`, which
|
||||
runs only on a `v*` tag and publishes a changelog without building anything.
|
||||
|
||||
**The toolchain each job runs in is its `container.image`, not its `runs-on`
|
||||
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
|
||||
@@ -309,15 +118,6 @@ source, so `main` finds `dev`'s signature already cached and makes no second AMO
|
||||
call. That cache is why signing must be one-shot — AMO rejects a re-signed
|
||||
version.
|
||||
|
||||
## History
|
||||
|
||||
FabledCurator combines what was
|
||||
[ImageRepo](https://git.fabledsword.com/bvandeusen/ImageRepo) (gallery, ML,
|
||||
importer) and
|
||||
[GallerySubscriber](https://git.fabledsword.com/bvandeusen/GallerySubscriber)
|
||||
(gallery-dl wrapper, subscriptions, credential capture) into a single product.
|
||||
Both are superseded; neither is maintained.
|
||||
|
||||
## License
|
||||
|
||||
**GNU Affero General Public License v3.0** — see [LICENSE](LICENSE).
|
||||
|
||||
+15
-29
@@ -26,49 +26,35 @@ FabledCurator is self-hosted and holds things worth stating plainly, because
|
||||
they shape what counts as a serious bug here:
|
||||
|
||||
- **Platform credentials.** The app captures and stores session cookies for
|
||||
third-party subscription sites (Patreon, SubscribeStar) so it can
|
||||
third-party subscription sites (Patreon, SubscribeStar, Pixiv) so it can
|
||||
download on the operator's behalf. These are live credentials for accounts
|
||||
that usually carry a payment method. Anything that discloses them, decrypts
|
||||
them, or lets one user of a shared instance read another's is high severity.
|
||||
- **An extension API key.** The Firefox extension authenticates to the backend
|
||||
with a shared key. Anything that leaks it or lets it be bypassed is a way in.
|
||||
- **No authentication of its own.** This is the most important thing on this
|
||||
page. FabledCurator has no login, no user accounts and no permission model —
|
||||
there is no `User` table and no session auth anywhere in the backend. Every
|
||||
HTTP client that can reach the port is the administrator, with full read and
|
||||
write access to everything above, including the stored platform credentials.
|
||||
Access control is entirely the operator's job, done at the network layer.
|
||||
Reports that an unauthenticated caller can reach an endpoint are therefore
|
||||
describing the design; reports that something *crosses the network boundary
|
||||
the operator drew* — an SSRF, a request forgery that rides a browser the
|
||||
operator already has open, a path that leaks state to an origin the operator
|
||||
did not authorise — are in scope and are serious.
|
||||
- **A multi-user sharing ACL.** Instances can be shared. A bug that lets one
|
||||
account see content another has not shared is an access-control failure, not
|
||||
a cosmetic one.
|
||||
- **Arbitrary media from the internet.** Downloaded files are decoded, hashed,
|
||||
thumbnailed and fed to ML models. Anything that turns a hostile file into
|
||||
code execution is in scope.
|
||||
|
||||
## Deployment posture — read this before reporting
|
||||
|
||||
FabledCurator is designed to run **inside a private network, over plain HTTP,
|
||||
reachable only by its operator**. It does not terminate TLS, redirect to
|
||||
HTTPS, or set HSTS; if you want transport security, terminate it at your
|
||||
reverse proxy. It also does not authenticate anyone — see above. These are
|
||||
documented design decisions, not oversights.
|
||||
FabledCurator is designed to run **inside a private network, over plain HTTP**.
|
||||
It does not terminate TLS, redirect to HTTPS, or set HSTS; if you want
|
||||
transport security, terminate it at your reverse proxy. This is a documented
|
||||
design decision, not an oversight.
|
||||
|
||||
Putting this on the public internet, with or without TLS, hands whoever finds
|
||||
it your Patreon and SubscribeStar sessions. A reverse proxy that adds
|
||||
TLS but not an authentication layer does not change that.
|
||||
|
||||
Reports that reduce to "the application is served over HTTP", "there is no
|
||||
HSTS header", or "the API needs no credentials" describe those decisions
|
||||
rather than vulnerabilities. Reports that the operator can cause the software
|
||||
to do something destructive are usually also by design — the operator is the
|
||||
administrator of their own instance.
|
||||
Reports that reduce to "the application is served over HTTP" or "there is no
|
||||
HSTS header" describe that decision rather than a vulnerability. Reports that
|
||||
an authenticated operator can cause the software to do something destructive
|
||||
are usually also by design — the operator is the administrator of their own
|
||||
instance.
|
||||
|
||||
What remains in scope is everything that crosses a boundary the software is
|
||||
actually supposed to hold: between untrusted downloaded content and the host,
|
||||
between a third-party origin and an operator's open browser session, and
|
||||
between the credentials at rest and anything that is not the operator.
|
||||
supposed to hold: between one user and another, between an unauthenticated
|
||||
visitor and any of it, and between untrusted downloaded content and the host.
|
||||
|
||||
## Supported versions
|
||||
|
||||
|
||||
+10
-40
@@ -1,21 +1,10 @@
|
||||
# FabledCurator GPU agent — runs on the desktop with the GPU.
|
||||
#
|
||||
# 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
|
||||
# CUDA 12.9 + cuDNN 9 runtime so onnxruntime-gpu can use the card (it needs
|
||||
# cuDNN 9 — the plain -runtime image lacks it: "libcudnn.so.9: cannot open
|
||||
# shared object file"); ffmpeg for video frames. Ubuntu 24.04 → Python 3.12.
|
||||
# Stays on the CUDA-12 / cuDNN-9 line the default onnxruntime-gpu + torch are
|
||||
# built against (CUDA 13 has only nascent ONNX Runtime support).
|
||||
FROM nvidia/cuda:12.9.2-cudnn-runtime-ubuntu24.04
|
||||
|
||||
# PIP_BREAK_SYSTEM_PACKAGES: Ubuntu 24.04 marks its system Python as externally
|
||||
# managed (PEP 668), so a global `pip install` errors without this. It's a
|
||||
@@ -27,12 +16,10 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
# 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
|
||||
# torch from the CUDA-12.4 wheel index; its wheels bundle their own CUDA + cuDNN
|
||||
# so they run on the 12.9 base and coexist with onnxruntime-gpu. Installed first
|
||||
# + separately so the GPU build of torch is deterministic and layer-cached.
|
||||
RUN pip3 install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cu124
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
COPY fc_agent ./fc_agent
|
||||
@@ -40,23 +27,6 @@ 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).
|
||||
|
||||
+2
-17
@@ -15,28 +15,13 @@ 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: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
|
||||
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
|
||||
```
|
||||
|
||||
## 1. Get a token
|
||||
In FC: **Settings → Tagging → GPU agent → Generate token** (or Rotate). Copy it.
|
||||
|
||||
## 2. Pull (CI publishes it alongside the web image)
|
||||
## 2. Pull (CI publishes it alongside the web/ml images)
|
||||
```sh
|
||||
docker pull git.fabledsword.com/bvandeusen/fabledcurator-agent:latest
|
||||
```
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
"""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)
|
||||
+11
-55
@@ -11,22 +11,17 @@ import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from . import accel, logbuf
|
||||
from .build_info import FC_CHANNEL, FC_REVISION, FC_VERSION, build_id, display_version
|
||||
from . import logbuf
|
||||
from .config import Config
|
||||
from .gpu import read_gpu
|
||||
from .worker import Worker
|
||||
|
||||
log = logging.getLogger("fc_agent.app")
|
||||
|
||||
# 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.
|
||||
# Bump on every agent change. The page embeds this and /status reports it; the UI
|
||||
# warns to reload when they differ — so a stale browser-cached page can't be
|
||||
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
|
||||
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle the GPU models release their VRAM and reload on the next job (env IDLE_UNLOAD_SECONDS, 0=off) · sleep mode sheds to one downloader"
|
||||
|
||||
logbuf.install()
|
||||
cfg = Config.from_env()
|
||||
@@ -47,9 +42,6 @@ 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
|
||||
@@ -60,14 +52,7 @@ def _maybe_autostart() -> None:
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
# 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())
|
||||
)
|
||||
return _PAGE.replace("__BUILD__", VERSION)
|
||||
|
||||
|
||||
@app.post("/start")
|
||||
@@ -132,15 +117,7 @@ def status():
|
||||
s["fc_url"] = cfg.fc_url
|
||||
s["configured"] = bool(cfg.token)
|
||||
s["queue"] = worker.latest_queue()
|
||||
# `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
|
||||
s["build"] = VERSION
|
||||
return JSONResponse(s)
|
||||
|
||||
|
||||
@@ -192,11 +169,7 @@ _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;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}
|
||||
color:var(--fg);border:1px solid var(--bd);border-radius:8px}
|
||||
.unit{color:var(--mut);font-size:12px;font-weight:600}
|
||||
.hint{color:var(--mut);font-size:12px;margin-top:12px}
|
||||
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:16px}
|
||||
@@ -230,12 +203,11 @@ _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>__VERSION__</code></p>
|
||||
<p class=meta>Server <code id=fc>—</code> · token <code id=cfg>—</code> · build <code id=build>__BUILD__</code></p>
|
||||
|
||||
<div id=verbanner class=banner style="display:none;background:#3a1212;border-color:#5a1717;color:#ffb3b3">
|
||||
a newer agent version is running — reload this page (Ctrl+Shift+R) to update the controls
|
||||
</div>
|
||||
<div id=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>
|
||||
@@ -253,9 +225,7 @@ _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>
|
||||
@@ -292,7 +262,7 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
const PAGE_BUILD="__BUILD_ID__"
|
||||
const PAGE_BUILD="__BUILD__"
|
||||
let CAP=8
|
||||
// Optimistic transitional state on click, then apply the POST's own status
|
||||
// response (it returns worker.status()) for instant feedback — don't wait on the
|
||||
@@ -323,14 +293,6 @@ _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'},
|
||||
@@ -401,17 +363,11 @@ _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'
|
||||
if(s.queue && cpuOnly(s).length){ dc='dot amber'; lbl='running · CPU only (degraded)' } }
|
||||
else if(st==='running'){ dc='dot '+(s.queue?'green':'amber'); lbl=s.queue?'running':'running · curator unreachable' }
|
||||
else if(st==='starting'){ dc='dot amber'; lbl='starting…' }
|
||||
else if(st==='stopping'){ dc='dot amber'; lbl='stopping…' }
|
||||
dot.className=dc; connlbl.textContent=lbl
|
||||
banner.style.display=(st==='running' && !s.queue)?'block':'none'
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""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,8 +7,6 @@ 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):
|
||||
@@ -74,10 +72,7 @@ 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,
|
||||
"accel": accel.summary(),
|
||||
},
|
||||
json={"agent_id": self.agent_id, "batch_size": batch_size},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
@@ -95,9 +90,7 @@ class FcClient:
|
||||
})
|
||||
|
||||
def heartbeat(self, job_ids: list[int]) -> None:
|
||||
self._post_quiet(
|
||||
"/api/gpu/jobs/heartbeat", {"job_ids": job_ids, "accel": accel.summary()},
|
||||
)
|
||||
self._post_quiet("/api/gpu/jobs/heartbeat", {"job_ids": job_ids})
|
||||
|
||||
def fail(self, job_id: int, error: str) -> None:
|
||||
self._post_quiet("/api/gpu/jobs/fail", {"job_id": job_id, "error": error})
|
||||
|
||||
@@ -342,44 +342,15 @@ class Worker:
|
||||
|
||||
# --- background loops ---------------------------------------------------
|
||||
def _heartbeat_loop(self) -> None:
|
||||
"""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.
|
||||
"""
|
||||
"""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."""
|
||||
while True:
|
||||
if self._running:
|
||||
with self._held_lock:
|
||||
ids = list(self._held)
|
||||
self.client.heartbeat(ids)
|
||||
if ids:
|
||||
self.client.heartbeat(ids)
|
||||
time.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
def _queue_poll_loop(self):
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
# 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. 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;
|
||||
# server-side fallback run.
|
||||
onnxruntime-gpu
|
||||
# The crop EMBEDDER (concept bag). torch is installed separately in the
|
||||
# Dockerfile from the CUDA-12.4 wheel index so the GPU build is deterministic;
|
||||
# transformers loads whatever SigLIP-family model the server announces.
|
||||
transformers>=4.45
|
||||
# Crop PROPOSERS — small YOLO detectors (booru_yolo anatomy, COCO person, comic
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""service_seen — the learned roster that makes a stopped part observable.
|
||||
|
||||
Milestone 365. Nothing in FabledCurator knew what was SUPPOSED to be running:
|
||||
`celery inspect` reports the workers that answer, so a dead worker was a
|
||||
shorter list rather than a red light, and the only surface that could tell an
|
||||
operator otherwise was Portainer. This table is the memory that turns an
|
||||
absence into something the app can see.
|
||||
|
||||
Keyed on the queue set for a celery role and on agent_id for the GPU agent —
|
||||
NOT on the celery worker name, which here is `celery@<container id>` and is
|
||||
minted fresh on every deploy. See the model docstring for why that choice is
|
||||
the whole design.
|
||||
|
||||
## First migration on the collapsed baseline
|
||||
|
||||
0089 is the single generated baseline that replaced revisions 0001..0089
|
||||
(milestone 328). This is the first revision written on top of it, so it is
|
||||
also the first evidence that the chain steps forward from the collapse rather
|
||||
than merely reproducing the schema — which nothing had demonstrated yet.
|
||||
|
||||
An existing install is at 0089 because it ran the real 0089; a fresh one is at
|
||||
0089 because it ran the baseline. Both arrive here identically, which was the
|
||||
property the collapse was designed around.
|
||||
|
||||
Revision ID: 0090
|
||||
Revises: 0089
|
||||
Create Date: 2026-09-02
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0090"
|
||||
down_revision: Union[str, None] = "0089"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"service_seen",
|
||||
sa.Column("key", sa.String(length=128), nullable=False),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"first_seen_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"last_seen_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column("details", sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("key", name=op.f("pk_service_seen")),
|
||||
)
|
||||
# No secondary indexes, deliberately: one row per moving part means every
|
||||
# read is a handful of rows and an index would be write cost buying
|
||||
# nothing (#3301 removed seven of exactly that shape).
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("service_seen")
|
||||
@@ -1,81 +0,0 @@
|
||||
"""platform_membership — the learned roster of what the account actually pays for.
|
||||
|
||||
Milestone 387, phase C. FC knows which creators it was told to follow and
|
||||
nothing about which ones the operator is subscribed to; this table is the
|
||||
memory that makes the drift in both directions observable. See the model
|
||||
docstring for why the roster is learned rather than looked up live, and why
|
||||
`status` holds the platform's own word rather than a normalised FC value.
|
||||
|
||||
## Nothing populates this yet, on purpose
|
||||
|
||||
The sweep that fills it (C3) depends on a client seam (C2) that depends on
|
||||
characterising Patreon's real membership response from a captured sample (C0),
|
||||
which needs the operator's authenticated browser session. The table's SHAPE
|
||||
does not wait on that: it is deliberately free-form where C0's findings would
|
||||
otherwise dictate a column — `status` is an unconstrained String and `details`
|
||||
keeps the raw payload — so no capture can invalidate what is created here.
|
||||
|
||||
An empty table is the correct intermediate state. It is not dead code: C5 reads
|
||||
it to explain a tier-limited source, and C4 reads it to reconcile.
|
||||
|
||||
Revision ID: 0091
|
||||
Revises: 0090
|
||||
Create Date: 2026-09-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0091"
|
||||
down_revision: Union[str, None] = "0090"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"platform_membership",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("platform", sa.String(length=64), nullable=False),
|
||||
# Text, not a bounded String: an opaque upstream identifier we do not
|
||||
# mint, and guessing a ceiling for one is how a walk dies on a silent
|
||||
# truncation.
|
||||
sa.Column("external_campaign_id", sa.Text(), nullable=False),
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
sa.Column("url", sa.Text(), nullable=True),
|
||||
# No CHECK, deliberately (rule 36 considered and declined): the
|
||||
# vocabulary is each platform's own and is not ours to fix before C0
|
||||
# has characterised even one of them. The service owns the whitelist.
|
||||
sa.Column("status", sa.String(length=32), nullable=True),
|
||||
sa.Column("tier_names", sa.JSON(), nullable=True),
|
||||
sa.Column("amount_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=8), nullable=True),
|
||||
sa.Column(
|
||||
"first_seen_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"last_seen_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column("details", sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_platform_membership")),
|
||||
# The upsert's conflict target. Named explicitly because
|
||||
# touch_membership references it by name in ON CONFLICT — an
|
||||
# autogenerated name would make that call break on a rename nobody
|
||||
# connected to it.
|
||||
sa.UniqueConstraint(
|
||||
"platform", "external_campaign_id",
|
||||
name="uq_platform_membership_platform_campaign",
|
||||
),
|
||||
)
|
||||
# No secondary indexes. This table holds one row per subscription — tens,
|
||||
# not millions — so every query against it is a short scan and an index
|
||||
# would be write cost buying nothing (#3301 removed seven of that shape).
|
||||
# The unique constraint above already backs the only lookup that matters.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("platform_membership")
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Synthetic posts — FC authors a post for content that arrived as chat.
|
||||
|
||||
Milestone 388, step E2. Discord is a delivery channel, not a publisher: one
|
||||
message is not one post, and today every message becomes its own `post` row
|
||||
competing with authored work for the same surface. This adds the three columns
|
||||
that let FC group a creator's variant drop into a post it wrote itself, while
|
||||
keeping that fact visible and the grouping reversible.
|
||||
|
||||
## Why a flag and a back-pointer rather than a separate table
|
||||
|
||||
A synthetic post has to BE a post — same row, same columns — or every existing
|
||||
surface (feed, provenance, translation, attachments, series) would need a
|
||||
second code path for it. `synthesized_by` marks the ones FC authored;
|
||||
`absorbed_by_post_id` points a member message-post at the post that replaced
|
||||
it in the feed. The members are not deleted: they remain the images' true
|
||||
origin, and destroying them would make the grouping un-auditable at exactly
|
||||
the moment somebody wants to check it.
|
||||
|
||||
Reversal is one DELETE. `absorbed_by_post_id` is ON DELETE SET NULL, so
|
||||
removing a synthetic post releases its members and they return to the feed
|
||||
unaided.
|
||||
|
||||
Revision ID: 0092
|
||||
Revises: 0091
|
||||
Create Date: 2026-09-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0092"
|
||||
down_revision: Union[str, None] = "0091"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# No CHECK on synthesized_by (rule 36 considered and declined): there is one
|
||||
# grouper today and a second would be a new VALUE, not a new invariant —
|
||||
# matching source.error_type and service_seen.kind.
|
||||
op.add_column("post", sa.Column("synthesized_by", sa.String(length=32), nullable=True))
|
||||
op.add_column("post", sa.Column("synthesis_details", sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
"post", sa.Column("absorbed_by_post_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_post_absorbed_by_post_id"), "post", ["absorbed_by_post_id"],
|
||||
)
|
||||
# SET NULL, not CASCADE: deleting the synthetic post must RELEASE its
|
||||
# members, never take them with it. The members are the real capture.
|
||||
op.create_foreign_key(
|
||||
"fk_post_absorbed_by_post_id_post", "post", "post",
|
||||
["absorbed_by_post_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
# Grouping tunables. Every one of these is operator-facing (project rule
|
||||
# 25) because the quality bar here is a judgement call no test can settle:
|
||||
# too greedy merges distinct pieces, too shy leaves a drop scattered.
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"discord_grouping_enabled", sa.Boolean(),
|
||||
server_default="true", nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"discord_group_max_distance", sa.Float(),
|
||||
server_default=sa.text("0.10"), nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"discord_group_window_minutes", sa.Float(),
|
||||
server_default=sa.text("60"), nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ml_settings", "discord_group_window_minutes")
|
||||
op.drop_column("ml_settings", "discord_group_max_distance")
|
||||
op.drop_column("ml_settings", "discord_grouping_enabled")
|
||||
op.drop_constraint("fk_post_absorbed_by_post_id_post", "post", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_post_absorbed_by_post_id"), table_name="post")
|
||||
op.drop_column("post", "absorbed_by_post_id")
|
||||
op.drop_column("post", "synthesis_details")
|
||||
op.drop_column("post", "synthesized_by")
|
||||
@@ -1,86 +0,0 @@
|
||||
"""An open grouping — a synthetic post that a later drop can still join.
|
||||
|
||||
Milestone 388, step E3. E2's synthetic post was sealed at creation: a creator
|
||||
who added two more variants the next day started a second post. These two
|
||||
columns let the group stay open and absorb the follow-up, without the post
|
||||
either freezing or thrashing the feed.
|
||||
|
||||
## Why openness is derived rather than stored
|
||||
|
||||
There is no `closed_at` here on purpose. A group is open if it grew (or
|
||||
started) within `ml_settings.discord_group_close_after_hours`, so openness is a
|
||||
comparison rather than a state — which means lowering the setting closes old
|
||||
groups and raising it reopens them, with nothing to repair either way. A stored
|
||||
flag would need its own sweep to set it and its own repair path to ever change
|
||||
the policy, for no gain.
|
||||
|
||||
## Why `resurfaced_at` is separate from `last_grew_at`
|
||||
|
||||
They answer different questions. `last_grew_at` is when the group last
|
||||
absorbed something — it decides how long the group stays joinable and is what
|
||||
the card shows. `resurfaced_at` is the FEED POSITION, advanced only when the
|
||||
anti-thrash rule fires, so a group that gains one image a day updates in place
|
||||
while a genuine second wave moves once. Folding them together would make every
|
||||
addition a bump, which is the annoyance this step exists to avoid.
|
||||
|
||||
Both are NULL on every ordinary post, so the feed's sort key can COALESCE
|
||||
through `resurfaced_at` without moving anything that is not a grouping.
|
||||
|
||||
Revision ID: 0093
|
||||
Revises: 0092
|
||||
Create Date: 2026-09-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0093"
|
||||
down_revision: Union[str, None] = "0092"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"post", sa.Column("last_grew_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"post", sa.Column("resurfaced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# No index on either. The feed already sorts on an unindexed
|
||||
# COALESCE(post_date, downloaded_at) expression, so adding resurfaced_at to
|
||||
# that COALESCE changes nothing about how the query plans — and inventing a
|
||||
# functional index here would be guessing at the fix for a cost nobody has
|
||||
# measured. Measuring it is step B2's job.
|
||||
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"discord_group_close_after_hours", sa.Float(),
|
||||
server_default=sa.text("168"), nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"discord_group_resurface_min_images", sa.Integer(),
|
||||
server_default="2", nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"discord_group_resurface_cooldown_hours", sa.Float(),
|
||||
server_default=sa.text("24"), nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ml_settings", "discord_group_resurface_cooldown_hours")
|
||||
op.drop_column("ml_settings", "discord_group_resurface_min_images")
|
||||
op.drop_column("ml_settings", "discord_group_close_after_hours")
|
||||
op.drop_column("post", "resurfaced_at")
|
||||
op.drop_column("post", "last_grew_at")
|
||||
@@ -1,124 +0,0 @@
|
||||
"""post_association — "this Patreon post announced that Discord drop".
|
||||
|
||||
Milestone 388, step E5.
|
||||
|
||||
Two of the operator's artists post a deliberately cropped fragment on Patreon
|
||||
to signal that the real thing has landed in their Discord. This table holds the
|
||||
proposed and accepted links between the announcement and the drop.
|
||||
|
||||
Directional and confirm-only. The pair is asymmetric (the teaser announces the
|
||||
drop, not the reverse), the two posts are never merged (the creator published
|
||||
twice, deliberately — flattening that hides the behaviour being modelled), and
|
||||
nothing is linked until the operator accepts, following the FC-6.3 series
|
||||
matcher. A wrongly-asserted association tells them two different pieces are
|
||||
one, which is worse than no link at all.
|
||||
|
||||
Dismissed rows are KEPT. The row is what remembers the rejection, and
|
||||
re-proposing a rejected pair on every scan is what makes a review queue get
|
||||
ignored.
|
||||
|
||||
Revision ID: 0094
|
||||
Revises: 0093
|
||||
Create Date: 2026-09-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0094"
|
||||
down_revision: Union[str, None] = "0093"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"post_association",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("announcement_post_id", sa.Integer(), nullable=False),
|
||||
sa.Column("payload_post_id", sa.Integer(), nullable=False),
|
||||
sa.Column("score", sa.Float(), nullable=False),
|
||||
sa.Column("signals", sa.JSON(), nullable=True),
|
||||
# No CHECK on status (rule 36 considered and declined), matching
|
||||
# series_suggestion.status — the same review-queue vocabulary, and the
|
||||
# same check-existing-enums lesson.
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), server_default="pending", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_post_association")),
|
||||
# CASCADE on both sides: an association to a post that no longer exists
|
||||
# is not a fact worth keeping, and E3's reversal path (delete the
|
||||
# grouping) must not leave a dangling proposal behind.
|
||||
sa.ForeignKeyConstraint(
|
||||
["announcement_post_id"], ["post.id"], ondelete="CASCADE",
|
||||
name=op.f("fk_post_association_announcement_post_id_post"),
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["payload_post_id"], ["post.id"], ondelete="CASCADE",
|
||||
name=op.f("fk_post_association_payload_post_id_post"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"announcement_post_id", "payload_post_id",
|
||||
name="uq_post_association_pair",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_post_association_announcement_post_id"),
|
||||
"post_association", ["announcement_post_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_post_association_payload_post_id"),
|
||||
"post_association", ["payload_post_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_post_association_status"), "post_association", ["status"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_link_enabled", sa.Boolean(),
|
||||
server_default="true", nullable=False,
|
||||
),
|
||||
)
|
||||
# 0.60 sits ABOVE the largest single signal weight on purpose — see
|
||||
# post_association_service.WEIGHTS. That is what makes "time proximity
|
||||
# alone is never sufficient" arithmetic rather than aspirational.
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_link_threshold", sa.Float(),
|
||||
server_default="0.60", nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"import_settings",
|
||||
sa.Column(
|
||||
"discord_link_window_hours", sa.Float(),
|
||||
server_default="24", nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("import_settings", "discord_link_window_hours")
|
||||
op.drop_column("import_settings", "discord_link_threshold")
|
||||
op.drop_column("import_settings", "discord_link_enabled")
|
||||
op.drop_index(op.f("ix_post_association_status"), table_name="post_association")
|
||||
op.drop_index(
|
||||
op.f("ix_post_association_payload_post_id"), table_name="post_association",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_post_association_announcement_post_id"), table_name="post_association",
|
||||
)
|
||||
op.drop_table("post_association")
|
||||
@@ -1,63 +0,0 @@
|
||||
"""membership_sync — whether the roster actually synced, and when.
|
||||
|
||||
Milestone 387, step C3.
|
||||
|
||||
`platform_membership` (0091) records what was SEEN. This records whether
|
||||
looking happened at all — a different fact, and the one that makes an empty
|
||||
roster readable.
|
||||
|
||||
Without it, three situations collapse into one: the account subscribes to
|
||||
nothing, the sweep never ran, or the sweep failed. All three leave zero rows
|
||||
in `platform_membership`. "You are tracking 12 sources you no longer subscribe
|
||||
to" is correct in the first case and an invitation to cancel things the
|
||||
operator is actively paying for in the other two, which is why C4 gates its
|
||||
CONCLUSIONS on `last_success_at` rather than merely displaying it.
|
||||
|
||||
Two timestamps rather than one, deliberately: `last_attempt_at` moves every
|
||||
run, `last_success_at` only on a clean walk, and the gap between them is what
|
||||
lets the UI say "last synced 3 days ago, tried 20 minutes ago, failing".
|
||||
|
||||
Revision ID: 0095
|
||||
Revises: 0094
|
||||
Create Date: 2026-09-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0095"
|
||||
down_revision: Union[str, None] = "0094"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"membership_sync",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("platform", sa.String(length=64), nullable=False),
|
||||
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_count", sa.Integer(), nullable=True),
|
||||
# No CHECK: this carries an exception class name, and the vocabulary is
|
||||
# whatever the client raises — same call as source.error_type.
|
||||
sa.Column("last_error_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("last_error_message", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_membership_sync")),
|
||||
# The upsert's conflict target, named explicitly because the service
|
||||
# references it by name in ON CONFLICT.
|
||||
sa.UniqueConstraint("platform", name="uq_membership_sync_platform"),
|
||||
)
|
||||
# No secondary indexes: one row per platform, so every read is a short scan
|
||||
# and an index would be write cost buying nothing (#3301 removed seven of
|
||||
# that shape). Same reasoning as platform_membership in 0091.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("membership_sync")
|
||||
@@ -1,106 +0,0 @@
|
||||
"""artist_membership_suggestion — proposing that a creator and a membership match.
|
||||
|
||||
Milestone 388, step E4.
|
||||
|
||||
## What this migration deliberately does NOT add
|
||||
|
||||
No association table between Artist and Source, and no schema change to either.
|
||||
E4's first job was to verify what was actually missing, and the answer was
|
||||
neither the model nor the flows: `Source.artist_id` is a plain FK so many
|
||||
sources per artist already works, `POST /api/sources` already takes an
|
||||
`artist_id`, the add-source dialog already attaches to an EXISTING artist, and
|
||||
`SourceService.reassign` already moves a source between artists with post and
|
||||
image re-attribution. Building a parallel association table for a relationship
|
||||
the schema already expresses would have been the mistake rule 28 names.
|
||||
|
||||
What was missing is the SUGGESTION, and that is all this table holds.
|
||||
|
||||
Accepting a suggestion adds a SOURCE under the existing artist — it never
|
||||
merges two artists. Adding a source is trivially undone; a wrong merge silently
|
||||
mixes two creators' work and corrupts tagging, series and provenance with
|
||||
nothing left to separate them by.
|
||||
|
||||
Revision ID: 0096
|
||||
Revises: 0095
|
||||
Create Date: 2026-09-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0096"
|
||||
down_revision: Union[str, None] = "0095"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"artist_membership_suggestion",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("platform_membership_id", sa.Integer(), nullable=False),
|
||||
sa.Column("artist_id", sa.Integer(), nullable=False),
|
||||
sa.Column("score", sa.Float(), nullable=False),
|
||||
sa.Column("signals", sa.JSON(), nullable=True),
|
||||
# No CHECK on status (rule 36 considered and declined), matching
|
||||
# series_suggestion and post_association — the same review-queue
|
||||
# vocabulary and the same check-existing-enums lesson.
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), server_default="pending", nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_artist_membership_suggestion")),
|
||||
# CASCADE both ways: a suggestion about a membership or an artist that
|
||||
# no longer exists is not a fact worth keeping, and a dangling proposal
|
||||
# would render as a broken row in the review queue.
|
||||
sa.ForeignKeyConstraint(
|
||||
["platform_membership_id"], ["platform_membership.id"],
|
||||
ondelete="CASCADE",
|
||||
name=op.f("fk_artist_membership_suggestion_membership"),
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["artist_id"], ["artist.id"], ondelete="CASCADE",
|
||||
name=op.f("fk_artist_membership_suggestion_artist_id_artist"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"platform_membership_id", "artist_id",
|
||||
name="uq_artist_membership_suggestion_pair",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_artist_membership_suggestion_platform_membership_id"),
|
||||
"artist_membership_suggestion", ["platform_membership_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_artist_membership_suggestion_artist_id"),
|
||||
"artist_membership_suggestion", ["artist_id"],
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_artist_membership_suggestion_status"),
|
||||
"artist_membership_suggestion", ["status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_artist_membership_suggestion_status"),
|
||||
table_name="artist_membership_suggestion",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_artist_membership_suggestion_artist_id"),
|
||||
table_name="artist_membership_suggestion",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_artist_membership_suggestion_platform_membership_id"),
|
||||
table_name="artist_membership_suggestion",
|
||||
)
|
||||
op.drop_table("artist_membership_suggestion")
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Disable sources on retired platforms, so the scheduler stops selecting them.
|
||||
|
||||
Milestone #406, phase 1 (switch pixiv off). Rule #171 records the scope decision.
|
||||
|
||||
## Why this is a migration and not a button
|
||||
|
||||
The live instance had one pixiv source still ENABLED when pixiv was retired
|
||||
(read 2026-09-13, step 1) even though the operator believed it gone. Unregistering
|
||||
a platform removes it from code; it does not touch the `source` rows that name it.
|
||||
Left enabled, that row keeps being picked by the scheduler every interval, and
|
||||
`download_backends` now refuses it with `unsupported_url` — forever, as a
|
||||
climbing failure count on a source the operator has already given up.
|
||||
|
||||
A migration reaches the live instance on deploy without depending on anyone
|
||||
finding the row and clicking it. The `run_download` guard is what makes a stale
|
||||
enabled row SAFE; this is what makes it QUIET.
|
||||
|
||||
## Deliberately NOT done here
|
||||
|
||||
- **No rows are deleted.** Deleting a source sets its posts' `source_id` to NULL
|
||||
(FK `ON DELETE SET NULL`), and `uq_post_artist_external_id_null_source` can
|
||||
reject that if a source-less copy of one of those posts already exists. That
|
||||
needs checking against real data first, which is phase 2's job (step 6). A
|
||||
disable cannot collide with anything.
|
||||
- **No posts or images are touched.** The art stays.
|
||||
- **deviantart is included** because #3069 retired it and nothing disabled its
|
||||
rows either. The read found none, so for it this is a no-op — written anyway,
|
||||
so the statement names every retired platform rather than just the latest one.
|
||||
|
||||
## Hardcoded platform names
|
||||
|
||||
A migration is a record of one event, frozen in time, so it names the platforms
|
||||
it acted on rather than importing today's registry — the registry will keep
|
||||
changing and this revision must not.
|
||||
|
||||
Revision ID: 0097
|
||||
Revises: 0096
|
||||
Create Date: 2026-09-13
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0097"
|
||||
down_revision: Union[str, None] = "0096"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Clears the failure state the same way `SourceService.update` does when a
|
||||
# source is disabled through the app (issue #1285), so a retired source
|
||||
# does not keep showing as failing after it stops being polled. A disable
|
||||
# done here and one done by clicking must leave identical rows.
|
||||
op.execute(
|
||||
"UPDATE source SET enabled = false, last_error = NULL, "
|
||||
"error_type = NULL, consecutive_failures = 0 "
|
||||
"WHERE enabled AND platform IN ('pixiv', 'deviantart')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Irreversible by design: which of these rows were enabled before is not
|
||||
# recorded, and re-enabling every retired-platform source would resume
|
||||
# polling services the product no longer supports. Rule #22 owes no
|
||||
# migration story back to a dropped platform.
|
||||
pass
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Widen image_record.phash to 256-bit and re-hash the library (issue #4223).
|
||||
|
||||
The operator reported a 15-image variant pack landing as 3 records, and then
|
||||
that variants were STILL being dropped with `phash_threshold` at 0. Zero was
|
||||
already the floor of the dial, so no setting could have fixed it: at
|
||||
`hash_size=8` a pHash is 64 bits of coarse light/dark layout, and variant
|
||||
artwork sharing a composition produces the SAME 64 bits. Distance 0 meant
|
||||
"identical hash", never "identical image".
|
||||
|
||||
`utils/phash.py` moves to `hash_size=16` (256 bits, what ImageRepo always
|
||||
used) and adds an aspect-ratio gate plus a pixel-level confirm, so a merge is
|
||||
accepted on the files rather than on the hash.
|
||||
|
||||
## Why this NULLs every phash
|
||||
|
||||
Widening the column does not correct the values already in it. Every stored
|
||||
hash is a 64-bit hash of an image the app will now hash at 256 bits, and the
|
||||
two cannot be compared — `find_similar` skips a mismatched-length candidate
|
||||
rather than guessing, so leaving them would silently mean "no dedup, forever,
|
||||
for everything imported before today". NULL is the state `backfill_phash`
|
||||
already knows how to repair: it is NULL-only, keyset-paginated and
|
||||
restart-safe, and the beat schedule runs it daily.
|
||||
|
||||
Until that backfill finishes, image dedup degrades to sha256 only —
|
||||
duplicates may be kept. That is the safe direction, and the only one
|
||||
available: the alternative is comparing hashes of different widths, which
|
||||
would drop artwork. NOTHING here deletes or supersedes a file.
|
||||
|
||||
## Why the threshold is reset rather than carried over
|
||||
|
||||
`phash_threshold` counts bits, and the denominator went from 64 to 256. The
|
||||
stored number would keep its value while meaning something four times
|
||||
tighter. There is no honest carry-over, so every row goes to the new default
|
||||
of 24 — including the operator's 0, which was a workaround for the bug this
|
||||
revision fixes.
|
||||
|
||||
Revision ID: 0098
|
||||
Revises: 0097
|
||||
Create Date: 2026-09-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0098"
|
||||
down_revision: Union[str, None] = "0097"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# varchar(32) -> varchar(64): widening a length limit is a catalog-only
|
||||
# change in Postgres, so this does not rewrite the table or its index.
|
||||
op.alter_column(
|
||||
"image_record", "phash",
|
||||
existing_type=sa.String(32),
|
||||
type_=sa.String(64),
|
||||
existing_nullable=True,
|
||||
)
|
||||
op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL")
|
||||
op.alter_column(
|
||||
"import_settings", "phash_threshold",
|
||||
existing_type=sa.Integer(),
|
||||
server_default="24",
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.execute("UPDATE import_settings SET phash_threshold = 24")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The 64-bit hashes this replaced are gone, and a 64-char value does not
|
||||
# fit back into varchar(32) — so the column is cleared again on the way
|
||||
# down and left for backfill_phash to refill at whatever HASH_SIZE the
|
||||
# code is running. Rule #22: no legacy to preserve.
|
||||
op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL")
|
||||
op.alter_column(
|
||||
"image_record", "phash",
|
||||
existing_type=sa.String(64),
|
||||
type_=sa.String(32),
|
||||
existing_nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"import_settings", "phash_threshold",
|
||||
existing_type=sa.Integer(),
|
||||
server_default="10",
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.execute("UPDATE import_settings SET phash_threshold = 10")
|
||||
@@ -1,99 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,109 +0,0 @@
|
||||
"""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"],
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
"""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
|
||||
@@ -1,138 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,121 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,51 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,133 +0,0 @@
|
||||
"""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",
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
"""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
|
||||
@@ -1,63 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,54 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,44 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,75 +0,0 @@
|
||||
"""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")
|
||||
@@ -38,10 +38,8 @@ def all_blueprints() -> list[Blueprint]:
|
||||
from .suggestions import suggestions_bp
|
||||
from .system_activity import system_activity_bp
|
||||
from .system_backup import system_backup_bp
|
||||
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,
|
||||
@@ -53,8 +51,6 @@ def all_blueprints() -> list[Blueprint]:
|
||||
showcase_bp,
|
||||
settings_bp,
|
||||
system_activity_bp,
|
||||
workers_bp,
|
||||
system_health_bp,
|
||||
system_backup_bp,
|
||||
admin_bp,
|
||||
cleanup_bp,
|
||||
|
||||
@@ -475,20 +475,6 @@ async def trigger_reclaim_attachments():
|
||||
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
|
||||
(#3999). Body {"dry_run": bool}; dry_run is the DEFAULT, because the apply
|
||||
deletes files and makes gallery-dl forget every Discord download. Returns the
|
||||
Celery task id — poll /maintenance/task-result/<id> for the summary."""
|
||||
from ..tasks.admin import repair_discord_downloads_task
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", True))
|
||||
async_result = repair_discord_downloads_task.delay(dry_run=dry_run)
|
||||
return _queued(async_result)
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
|
||||
async def trigger_dedup_videos():
|
||||
"""Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
|
||||
|
||||
@@ -65,16 +65,6 @@ async def autocomplete():
|
||||
])
|
||||
|
||||
|
||||
@artists_bp.route("/names", methods=["GET"])
|
||||
async def names():
|
||||
"""Every artist, id + name + slug, alphabetical. For filter pickers that
|
||||
list artists before anything is typed; `autocomplete` deliberately returns
|
||||
nothing for an empty query."""
|
||||
async with get_session() as session:
|
||||
rows = await ArtistService(session).all_names()
|
||||
return jsonify([{"id": i, "name": n, "slug": s} for i, n, s in rows])
|
||||
|
||||
|
||||
@artists_bp.route("/directory", methods=["GET"])
|
||||
async def directory():
|
||||
"""FC-3f: cursor-paginated artists directory.
|
||||
|
||||
@@ -19,7 +19,6 @@ from ..models import AppSetting
|
||||
from ..services.extension_service import (
|
||||
ExtensionService,
|
||||
InvalidUrlError,
|
||||
UnknownArtistError,
|
||||
UnknownPlatformError,
|
||||
)
|
||||
from ..services.source_service import KNOWN_PLATFORMS
|
||||
@@ -88,14 +87,10 @@ 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)
|
||||
# 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)
|
||||
result = await ExtensionService(session).probe(url)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -107,15 +102,6 @@ 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")
|
||||
|
||||
from .credentials import _get_crypto
|
||||
|
||||
@@ -123,13 +109,9 @@ async def quick_add_source():
|
||||
if not await _ext_key_required(session):
|
||||
return _bad("unauthorized", status=401)
|
||||
try:
|
||||
# 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,
|
||||
)
|
||||
except UnknownArtistError as exc:
|
||||
return _bad("not_found", detail=str(exc), status=404)
|
||||
# crypto lets a pixiv add resolve the artist's display name via the
|
||||
# stored OAuth token (else it falls back to the numeric id). #130.
|
||||
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
|
||||
except UnknownPlatformError as exc:
|
||||
return _bad(
|
||||
"unknown_platform",
|
||||
|
||||
@@ -21,7 +21,6 @@ from ..services.gallery_service import image_url
|
||||
from ..services.ml.gpu_jobs import GpuJobService, error_dedupe_statements
|
||||
from ..services.ml.gpu_triage import classify_reason, recover_defective_image
|
||||
from ..services.ml.regions import RegionService
|
||||
from ..services.service_roster import touch_service
|
||||
|
||||
gpu_bp = Blueprint("gpu", __name__, url_prefix="/api/gpu")
|
||||
|
||||
@@ -245,29 +244,6 @@ 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 {}
|
||||
@@ -280,21 +256,6 @@ async def lease():
|
||||
if not await _agent_authed(session):
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
|
||||
# The agent cannot be polled — it is HTTP-only and pulls from here, so
|
||||
# web never dials it. A lease IS the check-in, and until milestone 365
|
||||
# it was thrown away: an agent sitting idle with nothing to lease left
|
||||
# no trace at all and was indistinguishable from one switched off a
|
||||
# week ago. Recorded on the call that was already happening.
|
||||
await touch_service(
|
||||
session,
|
||||
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),
|
||||
**_accel_detail(body),
|
||||
},
|
||||
)
|
||||
ml = await MLSettings.load(session)
|
||||
# image rows for url/mime in one shot
|
||||
ids = [j.image_record_id for j in jobs]
|
||||
@@ -368,16 +329,6 @@ async def heartbeat():
|
||||
if not await _agent_authed(session):
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
n = await GpuJobService(session).heartbeat(agent_id, job_ids)
|
||||
await touch_service(
|
||||
session,
|
||||
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,
|
||||
**_accel_detail(body),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"extended": n})
|
||||
|
||||
|
||||
@@ -48,17 +48,6 @@ _EDITABLE = (
|
||||
"process_conflict_threshold",
|
||||
"embedder_model_name",
|
||||
"embedder_model_version",
|
||||
# Discord drop grouping (#388 E2). Operator-facing because the quality bar
|
||||
# is a judgement no test can settle: too greedy merges distinct pieces, too
|
||||
# shy leaves a drop scattered.
|
||||
"discord_grouping_enabled",
|
||||
"discord_group_max_distance",
|
||||
"discord_group_window_minutes",
|
||||
# E3: how long a grouping stays open, and the anti-thrash rule that keeps
|
||||
# a growing one from monopolising the feed.
|
||||
"discord_group_close_after_hours",
|
||||
"discord_group_resurface_min_images",
|
||||
"discord_group_resurface_cooldown_hours",
|
||||
*_DETECTOR_FIELDS,
|
||||
)
|
||||
|
||||
@@ -159,24 +148,6 @@ def _validate(p: dict) -> str | None:
|
||||
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
|
||||
return "process_conflict_threshold must be between 0 and 1"
|
||||
# Discord drop grouping (#388 E2). max_distance is a cosine DISTANCE, so
|
||||
# unlike the *_threshold family above it is not on the auto-apply scale:
|
||||
# 0 is identical and 1 is unrelated, and both ends are legal. The upper
|
||||
# bound is 1.0 rather than AUTO_APPLY_THRESHOLD_MAX for that reason.
|
||||
if not (0.0 <= float(p["discord_group_max_distance"]) <= 1.0):
|
||||
return "discord_group_max_distance must be between 0 and 1"
|
||||
if float(p["discord_group_window_minutes"]) <= 0:
|
||||
return "discord_group_window_minutes must be > 0"
|
||||
# A group must stay open at least as long as the drop window it was cut
|
||||
# with, or the joiner could never reach a message the grouper deferred —
|
||||
# the two would fight, and the symptom (drops that never grow) would look
|
||||
# like the predicate failing rather than a settings contradiction.
|
||||
if float(p["discord_group_close_after_hours"]) * 60 < float(p["discord_group_window_minutes"]):
|
||||
return "discord_group_close_after_hours must be at least the drop window"
|
||||
if int(p["discord_group_resurface_min_images"]) < 1:
|
||||
return "discord_group_resurface_min_images must be >= 1"
|
||||
if float(p["discord_group_resurface_cooldown_hours"]) < 0:
|
||||
return "discord_group_resurface_cooldown_hours must be >= 0"
|
||||
# Embedder model swap (#1190): both must be non-empty. Changing them means a
|
||||
# different embedding space — the operator must re-embed + retrain after.
|
||||
for key in ("embedder_model_name", "embedder_model_version"):
|
||||
|
||||
@@ -5,8 +5,6 @@ from quart import Blueprint, jsonify, request
|
||||
from ..extensions import get_session
|
||||
from ..models import ImportSettings, Post
|
||||
from ..services import interpreter_client as ic
|
||||
from ..services.post_association_service import PostAssociationService
|
||||
from ..services.post_association_service import rescan as association_rescan
|
||||
from ..services.post_feed_service import PostFeedService
|
||||
from ..services.source_service import KNOWN_PLATFORMS
|
||||
from ..utils.text import html_to_plain
|
||||
@@ -167,48 +165,3 @@ async def set_translation_override(post_id: int):
|
||||
"translated_source_lang": post.translated_source_lang,
|
||||
"applied": applied,
|
||||
})
|
||||
|
||||
|
||||
# --- #388 E5: the announcement review queue -------------------------------
|
||||
#
|
||||
# Confirm-only, following the series-suggestion routes (api/tags.py). Nothing
|
||||
# here links anything on its own: the matcher proposes, the operator decides.
|
||||
|
||||
|
||||
@posts_bp.route("/associations", methods=["GET"])
|
||||
async def list_associations():
|
||||
async with get_session() as session:
|
||||
return jsonify({"items": await PostAssociationService(session).list_pending()})
|
||||
|
||||
|
||||
@posts_bp.route("/associations/<int:association_id>/accept", methods=["POST"])
|
||||
async def accept_association(association_id: int):
|
||||
async with get_session() as session:
|
||||
result = await PostAssociationService(session).accept(association_id)
|
||||
if result is None:
|
||||
return _bad("association not found", 404)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@posts_bp.route("/associations/<int:association_id>/dismiss", methods=["POST"])
|
||||
async def dismiss_association(association_id: int):
|
||||
async with get_session() as session:
|
||||
result = await PostAssociationService(session).dismiss(association_id)
|
||||
if result is None:
|
||||
return _bad("association not found", 404)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@posts_bp.route("/associations/rescan", methods=["POST"])
|
||||
async def rescan_associations():
|
||||
"""Manual re-scan. The beat sweep only looks at recent posts (a pair has to
|
||||
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:
|
||||
# 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,17 +36,9 @@ _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",
|
||||
# #388 E5 — the announcement matcher (Patreon teaser ↔ Discord drop).
|
||||
"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",
|
||||
@@ -117,12 +109,6 @@ 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:
|
||||
@@ -164,32 +150,6 @@ async def update_import_settings():
|
||||
return jsonify(
|
||||
{"error": "series_suggest_threshold must be a number in [0, 1]"}
|
||||
), 400
|
||||
if "discord_link_enabled" in body and not isinstance(
|
||||
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:
|
||||
return jsonify(
|
||||
{"error": "discord_link_threshold must be a number in [0, 1]"}
|
||||
), 400
|
||||
if "discord_link_window_hours" in body:
|
||||
v = body["discord_link_window_hours"]
|
||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v <= 0:
|
||||
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
|
||||
):
|
||||
|
||||
+2
-177
@@ -1,15 +1,10 @@
|
||||
"""FC-3a: CRUD over Source rows. FC-3c adds POST /<id>/check."""
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import DownloadEvent, MembershipSync, PlatformMembership, Source
|
||||
from ..services.artist_membership_service import ArtistMembershipService
|
||||
from ..services.artist_membership_service import rescan as membership_rescan
|
||||
from ..services.artist_service import ArtistService
|
||||
from ..services.membership_reconcile import reconcile_all
|
||||
from ..services.membership_roster import roster_is_fresh, source_for_membership
|
||||
from ..models import DownloadEvent, Source
|
||||
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
|
||||
from ..services.source_service import (
|
||||
KNOWN_PLATFORMS,
|
||||
@@ -201,16 +196,6 @@ 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())
|
||||
@@ -303,163 +288,3 @@ async def check_source(source_id: int):
|
||||
download_source.delay(source_id)
|
||||
|
||||
return jsonify({"download_event_id": event_id, "status": "pending"}), 202
|
||||
|
||||
|
||||
# --- #387 C3: the membership roster's sync state --------------------------
|
||||
#
|
||||
# Rule 164's visibility requirement lives here. A roster that failed to sync,
|
||||
# or never has, must be DISTINGUISHABLE from an account that subscribes to
|
||||
# nothing — otherwise the reconciliation this unlocks would tell the operator
|
||||
# to cancel sources they are actively paying for.
|
||||
|
||||
|
||||
@sources_bp.route("/membership-sync", methods=["GET"])
|
||||
async def membership_sync_status():
|
||||
async with get_session() as session:
|
||||
rows = (await session.execute(select(MembershipSync))).scalars().all()
|
||||
counts = dict(
|
||||
(await session.execute(
|
||||
select(PlatformMembership.platform, func.count())
|
||||
.group_by(PlatformMembership.platform)
|
||||
)).all()
|
||||
)
|
||||
return jsonify({"platforms": [
|
||||
{
|
||||
"platform": r.platform,
|
||||
"last_attempt_at": r.last_attempt_at.isoformat() if r.last_attempt_at else None,
|
||||
# NULL here means NEVER, and the UI must say so in words. Rendering
|
||||
# it as 0 or as "-" is the exact conflation this endpoint exists to
|
||||
# prevent.
|
||||
"last_success_at": r.last_success_at.isoformat() if r.last_success_at else None,
|
||||
"last_count": r.last_count,
|
||||
"last_error_type": r.last_error_type,
|
||||
"last_error_message": r.last_error_message,
|
||||
# Whether a CONCLUSION may be drawn from this roster — not merely
|
||||
# whether it looks recent. C4 gates on this, and it is computed
|
||||
# server-side so no caller can forget to.
|
||||
"fresh": roster_is_fresh(r),
|
||||
"known_memberships": counts.get(r.platform, 0),
|
||||
}
|
||||
for r in sorted(rows, key=lambda r: r.platform)
|
||||
]})
|
||||
|
||||
|
||||
@sources_bp.route("/membership-sync", methods=["POST"])
|
||||
async def trigger_membership_sync():
|
||||
"""Run the roster sweep now.
|
||||
|
||||
The beat schedule runs daily, which is right for a billing-cycle fact but
|
||||
far too slow when the operator has just connected a credential and wants to
|
||||
see whether it works. Queued rather than run inline: it crosses the network
|
||||
to an external service and the request path is not where that belongs.
|
||||
"""
|
||||
from ..tasks.maintenance import sync_memberships
|
||||
|
||||
sync_memberships.delay()
|
||||
return jsonify({"queued": True})
|
||||
|
||||
|
||||
# --- #388 E4: creator/membership suggestions ------------------------------
|
||||
#
|
||||
# Confirm-only. Accepting ADDS A SOURCE under the existing artist — it never
|
||||
# merges two artists, because adding a source is trivially undone and a wrong
|
||||
# merge silently mixes two creators' work with nothing left to separate them by.
|
||||
|
||||
|
||||
@sources_bp.route("/membership-suggestions", methods=["GET"])
|
||||
async def list_membership_suggestions():
|
||||
async with get_session() as session:
|
||||
return jsonify({"items": await ArtistMembershipService(session).list_pending()})
|
||||
|
||||
|
||||
@sources_bp.route("/membership-suggestions/<int:sid>/accept", methods=["POST"])
|
||||
async def accept_membership_suggestion(sid: int):
|
||||
async with get_session() as session:
|
||||
result = await ArtistMembershipService(session).accept(sid)
|
||||
if result is None:
|
||||
return _bad("suggestion_not_found", status=404)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@sources_bp.route("/membership-suggestions/<int:sid>/dismiss", methods=["POST"])
|
||||
async def dismiss_membership_suggestion(sid: int):
|
||||
async with get_session() as session:
|
||||
result = await ArtistMembershipService(session).dismiss(sid)
|
||||
if result is None:
|
||||
return _bad("suggestion_not_found", status=404)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@sources_bp.route("/membership-suggestions/rescan", methods=["POST"])
|
||||
async def rescan_membership_suggestions():
|
||||
async with get_session() as session:
|
||||
result = await membership_rescan(session)
|
||||
await session.commit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# --- #387 C4: reconciling the roster against what FC actually tracks -------
|
||||
#
|
||||
# Asymmetric on purpose. The "you subscribe but FC doesn't follow it" direction
|
||||
# carries a per-row action, because adding a source is the reversible half. The
|
||||
# "FC follows it but your roster doesn't show it" direction is REPORT ONLY by
|
||||
# the operator's decision (2026-09-11): it says what it sees and links to the
|
||||
# Subscriptions row, and offers no one-click disable.
|
||||
|
||||
|
||||
@sources_bp.route("/reconciliation", methods=["GET"])
|
||||
async def reconciliation():
|
||||
async with get_session() as session:
|
||||
return jsonify(await reconcile_all(session))
|
||||
|
||||
|
||||
@sources_bp.route("/reconciliation/adopt", methods=["POST"])
|
||||
async def adopt_membership():
|
||||
"""Start tracking a creator the roster says the account already pays for.
|
||||
|
||||
One row, one click, never a sweep side effect: adding a source commits disk,
|
||||
worker time and rate budget, and unwinding it means deleting files.
|
||||
"""
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return _bad("invalid_body", status=400)
|
||||
membership_id = body.get("membership_id")
|
||||
if not isinstance(membership_id, int):
|
||||
return _bad("membership_id_required", status=400)
|
||||
|
||||
async with get_session() as session:
|
||||
membership = await session.get(PlatformMembership, membership_id)
|
||||
if membership is None:
|
||||
return _bad("membership_not_found", status=404)
|
||||
if not membership.url:
|
||||
return _bad("membership_has_no_url", status=400)
|
||||
|
||||
existing = await source_for_membership(session, membership)
|
||||
if existing is not None:
|
||||
# The operator got there by another route between the page load and
|
||||
# the click. That is them being ahead of us, not an error.
|
||||
return jsonify({"already_tracked": existing.id})
|
||||
|
||||
# The sweep already captured the creator's real display name, so the
|
||||
# artist gets its true name with NO lookup on the request path. Task
|
||||
# #1293 asked for `resolve_display_name` here; the roster satisfies that
|
||||
# concern earlier in the pipeline than #1293 expected, which also keeps
|
||||
# this route off the network entirely (rule 164). The vanity is the
|
||||
# fallback, never the preferred value.
|
||||
name = membership.display_name or membership.vanity_or_none()
|
||||
if not name:
|
||||
return _bad("membership_has_no_name", status=400)
|
||||
|
||||
artist, _created = await ArtistService(session).find_or_create(name)
|
||||
try:
|
||||
record = await SourceService(session).create(
|
||||
artist_id=artist.id,
|
||||
platform=membership.platform,
|
||||
url=membership.url,
|
||||
)
|
||||
except DuplicateSourceError as exc:
|
||||
return jsonify({"already_tracked": exc.existing_id})
|
||||
artist_id = artist.id
|
||||
return jsonify({"source_id": record.id, "artist_id": artist_id}), 201
|
||||
|
||||
@@ -21,22 +21,18 @@ 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",
|
||||
)
|
||||
|
||||
# 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)
|
||||
# Canonical queue order — must match celery_app.task_routes. UI renders
|
||||
# in this order; queues with no LLEN response show as null rather than
|
||||
# absent.
|
||||
_QUEUE_NAMES = (
|
||||
"default", "import", "thumbnail", "ml",
|
||||
"download", "scan", "maintenance", "maintenance_long",
|
||||
)
|
||||
|
||||
# Cache module-level so all requests share the cache between polls.
|
||||
# Tests can reset via direct dict mutation if needed.
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"""Is every part of FabledCurator running? One verdict, one endpoint.
|
||||
|
||||
Milestone 365. The nav indicator and the System page both read this and
|
||||
nothing else — composing a verdict is this module's job, not the UI's.
|
||||
|
||||
## Two kinds of part, answered two different ways
|
||||
|
||||
**Learned** — celery roles and the GPU agent, from `service_seen`. The
|
||||
question is "how long since it checked in", and these are the parts that can
|
||||
be ABSENT, which is the whole point: `celery inspect` alone reports presence,
|
||||
so a dead worker is a shorter list rather than a red light.
|
||||
|
||||
**Probed live** — Postgres and Redis. Always expected, never learned, and a
|
||||
last-seen for them would be actively misleading: that Redis answered thirty
|
||||
seconds ago says nothing about now.
|
||||
|
||||
## This endpoint must never fail because something it checks has failed
|
||||
|
||||
The inversion is easy to write by accident and it destroys the feature exactly
|
||||
when it is needed — a 500 when Redis is down, instead of `redis: down`. Every
|
||||
probe is wrapped, every wait has a deadline (rule 156), and the roster refresh
|
||||
swallows its own errors. The worst case is a part reported `unknown`, which is
|
||||
a true statement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from ..config import get_config
|
||||
from ..extensions import get_session
|
||||
from ..models import ServiceSeen
|
||||
from ..services.worker_lanes import SWEEP_PERIOD_SECONDS
|
||||
|
||||
system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system")
|
||||
|
||||
# How long a learned part may go quiet before it is doubted, then disbelieved.
|
||||
#
|
||||
# These are deliberately generous, and the reason is a deploy rather than a
|
||||
# worker: `docker compose up -d` rolls start-first, so a role is briefly served
|
||||
# by two containers and then by neither while the old one drains. Thresholds
|
||||
# tight enough to catch a crash in seconds would paint the page red every time
|
||||
# the stack is updated, and an alarm that cries wolf on every deploy is one
|
||||
# nobody reads. Tune down only after watching a real deploy pass through.
|
||||
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, _DEGRADED: 2, _STALE: 3, _DOWN: 4}
|
||||
|
||||
|
||||
def _age_state(age_seconds: float) -> str:
|
||||
if age_seconds >= DOWN_AFTER_SECONDS:
|
||||
return _DOWN
|
||||
if age_seconds >= STALE_AFTER_SECONDS:
|
||||
return _STALE
|
||||
return _OK
|
||||
|
||||
|
||||
def _describe_learned(name: str, state: str, age: float, details: dict) -> str:
|
||||
"""Say what the state MEANS. A red chip tells an operator less than a
|
||||
sentence does at the moment they are deciding whether to go and look."""
|
||||
if state == _OK:
|
||||
replicas = details.get("replicas")
|
||||
if replicas and replicas > 1:
|
||||
return f"{name} is running ({replicas} replicas)"
|
||||
return f"{name} is running"
|
||||
mins = int(age // 60)
|
||||
ago = f"{mins} min" if mins else f"{int(age)}s"
|
||||
if state == _STALE:
|
||||
return f"{name} has not checked in for {ago}"
|
||||
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:
|
||||
await asyncio.wait_for(
|
||||
session.execute(text("SELECT 1")), timeout=PROBE_TIMEOUT_SECONDS
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — a probe reports, it never raises
|
||||
return {
|
||||
"key": "postgres", "kind": "datastore", "name": "PostgreSQL",
|
||||
"state": _DOWN, "detail": f"not answering: {type(exc).__name__}",
|
||||
}
|
||||
return {
|
||||
"key": "postgres", "kind": "datastore", "name": "PostgreSQL", "state": _OK,
|
||||
"detail": "answering", "latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
}
|
||||
|
||||
|
||||
def _ping_redis_sync() -> None:
|
||||
import redis # local import; mirrors system_activity's pattern
|
||||
|
||||
client = redis.Redis.from_url(
|
||||
get_config().celery_broker_url,
|
||||
socket_connect_timeout=PROBE_TIMEOUT_SECONDS,
|
||||
socket_timeout=PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
client.ping()
|
||||
|
||||
|
||||
async def _probe_redis() -> dict:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(_ping_redis_sync), timeout=PROBE_TIMEOUT_SECONDS * 2
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"key": "redis", "kind": "datastore", "name": "Redis",
|
||||
"state": _DOWN,
|
||||
"detail": f"not answering: {type(exc).__name__} — queues and workers "
|
||||
f"cannot be reached either",
|
||||
}
|
||||
return {
|
||||
"key": "redis", "kind": "datastore", "name": "Redis", "state": _OK,
|
||||
"detail": "answering", "latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
}
|
||||
|
||||
|
||||
@system_health_bp.route("/health", methods=["GET"])
|
||||
async def system_health():
|
||||
"""Every part, its state, and one overall verdict.
|
||||
|
||||
Response: {overall, parts: [{key, kind, name, state, detail, last_seen_at,
|
||||
…}], checked_at}
|
||||
"""
|
||||
parts: list[dict] = []
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async with get_session() as session:
|
||||
# Postgres first, and if it is unreachable nothing else can be read —
|
||||
# say so rather than failing, because "the database is down" is the
|
||||
# single most useful thing this endpoint can ever report.
|
||||
pg = await _probe_postgres(session)
|
||||
parts.append(pg)
|
||||
|
||||
if pg["state"] == _OK:
|
||||
# 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, 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": 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"},
|
||||
})
|
||||
|
||||
parts.append(await _probe_redis())
|
||||
|
||||
overall = max((p["state"] for p in parts), key=lambda s: _SEVERITY[s], default=_UNKNOWN)
|
||||
return jsonify({
|
||||
"overall": overall,
|
||||
"parts": sorted(parts, key=lambda p: (-_SEVERITY[p["state"]], p["name"])),
|
||||
"checked_at": now.isoformat(),
|
||||
# So the UI can explain a `stale` without hard-coding the same numbers
|
||||
# in a second place.
|
||||
"thresholds": {
|
||||
"stale_after_seconds": STALE_AFTER_SECONDS,
|
||||
"down_after_seconds": DOWN_AFTER_SECONDS,
|
||||
},
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
"""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)
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -14,7 +14,6 @@ Queues:
|
||||
from celery import Celery
|
||||
|
||||
from .config import get_config
|
||||
from .services.worker_lanes import SWEEP_PERIOD_SECONDS
|
||||
|
||||
|
||||
def make_celery() -> Celery:
|
||||
@@ -62,13 +61,6 @@ 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"},
|
||||
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
|
||||
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
|
||||
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
|
||||
@@ -119,34 +111,6 @@ 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
|
||||
@@ -156,14 +120,6 @@ def make_celery() -> Celery:
|
||||
"schedule": 86400.0, # daily — sweep .part/.partial left by a
|
||||
# download/import killed mid-write (graceful-shutdown fallout)
|
||||
},
|
||||
"backfill-phash-daily": {
|
||||
"task": "backend.app.tasks.maintenance.backfill_phash",
|
||||
"schedule": 86400.0, # daily — NULL-only, so a no-op once the
|
||||
# library is hashed. This is what makes migration 0098's
|
||||
# re-hash happen on its own: 0098 NULLs every phash, and
|
||||
# without a scheduled refill the library would sit
|
||||
# dedup-disabled until someone ran a deep scan (#4223).
|
||||
},
|
||||
"train-heads-nightly": {
|
||||
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
||||
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
||||
@@ -244,26 +200,6 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
||||
"schedule": 86400.0,
|
||||
},
|
||||
"group-discord-drops-hourly": {
|
||||
"task": "backend.app.tasks.maintenance.group_discord_drops",
|
||||
"schedule": 3600.0, # hourly. Not daily: the grouping signal is
|
||||
# the SigLIP embedding, which lands asynchronously AFTER import
|
||||
# (#388 E2), so this sweep is what picks up a drop once its
|
||||
# vectors have caught up. No-op unless discord_grouping_enabled.
|
||||
},
|
||||
"match-post-associations-hourly": {
|
||||
"task": "backend.app.tasks.maintenance.match_post_associations",
|
||||
"schedule": 3600.0, # hourly, and AFTER the grouper's own cadence
|
||||
# by construction: a pair cannot be proposed until the drop it
|
||||
# points at exists as a grouping (#388 E5). No-op unless
|
||||
# discord_link_enabled.
|
||||
},
|
||||
"sync-memberships-daily": {
|
||||
"task": "backend.app.tasks.maintenance.sync_memberships",
|
||||
"schedule": 86400.0, # daily — memberships change on a BILLING
|
||||
# cycle, not a download cadence (#387 C3). No-op per platform
|
||||
# when the client lacks the seam or no credential exists.
|
||||
},
|
||||
"integrity-verify-weekly": {
|
||||
"task": "backend.app.tasks.maintenance.verify_integrity",
|
||||
"schedule": 604800.0, # weekly
|
||||
@@ -360,9 +296,6 @@ 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
|
||||
|
||||
@@ -16,11 +16,8 @@ class Config:
|
||||
celery_broker_url: str
|
||||
celery_result_backend: str
|
||||
|
||||
# Sets Quart's app.secret_key. Nothing signs a cookie today (FC has no
|
||||
# login and no session use), so this currently protects nothing — it is
|
||||
# required rather than defaulted so that the day something session-backed
|
||||
# does land, no instance is already running on a value we published.
|
||||
secret_key: str
|
||||
extension_api_key: str # used by the Firefox extension; lands in FC-3 but read here
|
||||
log_level: str
|
||||
|
||||
@property
|
||||
@@ -50,5 +47,6 @@ def get_config() -> Config:
|
||||
celery_broker_url=os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"),
|
||||
celery_result_backend=os.environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0"),
|
||||
secret_key=os.environ["SECRET_KEY"],
|
||||
extension_api_key=os.environ.get("EXTENSION_API_KEY", ""),
|
||||
log_level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||
)
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
from .app_setting import AppSetting
|
||||
from .artist import Artist
|
||||
from .artist_membership_suggestion import ArtistMembershipSuggestion
|
||||
from .artist_visit import ArtistVisit
|
||||
from .backup_run import BackupRun
|
||||
from .base import Base
|
||||
from .character_prototype import CcipPrototypeState, CharacterPrototype
|
||||
from .credential import Credential
|
||||
from .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
|
||||
@@ -24,19 +21,17 @@ from .import_batch import ImportBatch
|
||||
from .import_settings import ImportSettings
|
||||
from .import_task import ImportTask
|
||||
from .library_audit_run import LibraryAuditRun
|
||||
from .membership_sync import MembershipSync
|
||||
from .ml_settings import MLSettings
|
||||
from .patreon_failed_media import PatreonFailedMedia
|
||||
from .patreon_seen_media import PatreonSeenMedia
|
||||
from .platform_membership import PlatformMembership
|
||||
from .pixiv_failed_media import PixivFailedMedia
|
||||
from .pixiv_seen_media import PixivSeenMedia
|
||||
from .post import Post
|
||||
from .post_association import PostAssociation
|
||||
from .post_attachment import PostAttachment, attachment_download_url
|
||||
from .presentation_review import PresentationReview
|
||||
from .series_chapter import SeriesChapter
|
||||
from .series_page import SeriesPage
|
||||
from .series_suggestion import SeriesSuggestion
|
||||
from .service_seen import ServiceSeen
|
||||
from .source import Source
|
||||
from .subscribestar_failed_media import SubscribeStarFailedMedia
|
||||
from .subscribestar_seen_media import SubscribeStarSeenMedia
|
||||
@@ -46,34 +41,28 @@ 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",
|
||||
"AppSetting",
|
||||
"Artist",
|
||||
"ArtistMembershipSuggestion",
|
||||
"ArtistVisit",
|
||||
"BackupRun",
|
||||
"Source",
|
||||
"Credential",
|
||||
"DiscordFailedMedia",
|
||||
"DiscordSeenMedia",
|
||||
"PatreonFailedMedia",
|
||||
"PatreonSeenMedia",
|
||||
"PixivFailedMedia",
|
||||
"PixivSeenMedia",
|
||||
"SubscribeStarFailedMedia",
|
||||
"SubscribeStarSeenMedia",
|
||||
"Post",
|
||||
"PostAssociation",
|
||||
"PostAttachment",
|
||||
"attachment_download_url",
|
||||
"PresentationReview",
|
||||
"SeriesChapter",
|
||||
"SeriesPage",
|
||||
"SeriesSuggestion",
|
||||
"PlatformMembership",
|
||||
"ServiceSeen",
|
||||
"ImageRecord",
|
||||
"ImageProvenance",
|
||||
"ImageRegion",
|
||||
@@ -87,7 +76,6 @@ __all__ = [
|
||||
"ImportTask",
|
||||
"ImportSettings",
|
||||
"LibraryAuditRun",
|
||||
"MembershipSync",
|
||||
"MLSettings",
|
||||
"HeadAutoApplyRun",
|
||||
"HeadMetric",
|
||||
@@ -100,6 +88,4 @@ __all__ = [
|
||||
"TagPositiveConfirmation",
|
||||
"TagSuggestionRejection",
|
||||
"TaskRun",
|
||||
"WorkerLane",
|
||||
"WorkerLaneSample",
|
||||
]
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
"""artist_membership_suggestion — "this creator and that membership are the same".
|
||||
|
||||
Milestone 388, step E4.
|
||||
|
||||
## What was NOT needed here
|
||||
|
||||
E4's first job was to check what is actually missing, and the answer was: not
|
||||
the schema, and not the flows. `Source.artist_id` is a plain FK, so many
|
||||
sources per artist is already the data model; `POST /api/sources` already takes
|
||||
an `artist_id`; the add-source dialog already has an artist autocomplete that
|
||||
attaches to an EXISTING artist; and `SourceService.reassign` already moves a
|
||||
source between artists WITH post and image re-attribution. A sweep for
|
||||
one-source-per-artist assumptions found only `func.count()` calls, which are
|
||||
the opposite of assuming one.
|
||||
|
||||
So no parallel association table was built for a relationship the schema
|
||||
already expresses (rule 28). What was missing is the SUGGESTION — FC proposing
|
||||
the link from the roster instead of waiting to be told.
|
||||
|
||||
## Confirm-only, and what "accept" actually does
|
||||
|
||||
Accepting adds a SOURCE for the membership's platform under the artist that
|
||||
already has the other channel. It does NOT merge two artists. That distinction
|
||||
is the whole safety margin: adding a source is trivially undone, whereas a
|
||||
wrong artist merge silently mixes two creators' work and corrupts tagging,
|
||||
series and provenance downstream — with nothing left to tell them apart by.
|
||||
|
||||
Dismissed rows are kept, not deleted, for the same reason as every other review
|
||||
queue here: the row is what remembers the rejection, and re-proposing a
|
||||
rejected pair on every scan is what makes a queue get ignored.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ArtistMembershipSuggestion(Base):
|
||||
__tablename__ = "artist_membership_suggestion"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"platform_membership_id", "artist_id",
|
||||
name="uq_artist_membership_suggestion_pair",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
platform_membership_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("platform_membership.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
artist_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("artist.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
# Per-signal strengths as scored. Without it, "why was this suggested" is
|
||||
# unanswerable the moment a weight or the threshold moves.
|
||||
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# pending | linked | dismissed. Plain String, no CHECK — same call as
|
||||
# series_suggestion.status and post_association.status.
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default="pending", index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
server_default=func.now(), onupdate=func.now(),
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""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()
|
||||
)
|
||||
@@ -64,11 +64,7 @@ class ImageRecord(Base):
|
||||
# that 0001 also built was an exact duplicate of it — dropped in 0089
|
||||
# (#3301). Lookups by sha256 use the constraint's index.
|
||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 64 hex chars = the 256-bit hash utils.phash emits at hash_size=16. Was
|
||||
# String(32) (64-bit) until migration 0098; the narrow column was the
|
||||
# reason for the undersized hash, and the undersized hash was collapsing
|
||||
# variant artwork into one record (#4223).
|
||||
phash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
phash: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
@@ -42,12 +42,7 @@ class ImportSettings(Base):
|
||||
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95, server_default="0.95")
|
||||
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
|
||||
# unit CHANGED in migration 0098 — it used to be bits out of 64 — so the
|
||||
# old default of 10 is not this scale's 10, and 0098 resets every row.
|
||||
# This is now the cheap PRE-FILTER: the aspect + pixel gates decide, which
|
||||
# is what lets it be generous enough to catch a re-encoded rescale.
|
||||
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=24, server_default="24")
|
||||
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10, server_default="10")
|
||||
|
||||
# FC-3c downloader knobs
|
||||
download_rate_limit_seconds: Mapped[float] = mapped_column(
|
||||
@@ -68,16 +63,6 @@ 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",
|
||||
@@ -112,77 +97,6 @@ class ImportSettings(Base):
|
||||
server_default="0.5",
|
||||
)
|
||||
|
||||
# Milestone 388 E5 — the announcement matcher: "this Patreon post announced
|
||||
# that Discord drop". Lives here rather than in MLSettings, with the series
|
||||
# matcher it is modelled on, because it runs no inference: the signals are
|
||||
# time proximity and whether the post says so.
|
||||
discord_link_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
# The weighted-score cut-off. 0.60 is not arbitrary: it is deliberately set
|
||||
# ABOVE the largest single signal weight, which is what makes "time
|
||||
# proximity alone must never be sufficient" an ARITHMETIC property rather
|
||||
# than a hope. On a busy day an artist posts several times; if proximity
|
||||
# could carry a pair by itself, every one of those days would produce false
|
||||
# pairs and the review queue would be abandoned. See
|
||||
# post_association_service.WEIGHTS — a guard test pins the relationship.
|
||||
discord_link_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.60,
|
||||
server_default="0.60",
|
||||
)
|
||||
# How far apart the announcement and the drop may be. The Patreon post
|
||||
# exists IN ORDER TO announce the drop, so they are minutes-to-hours apart;
|
||||
# a day is generous and still excludes "same week".
|
||||
discord_link_window_hours: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=24.0,
|
||||
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).
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"""membership_sync — did the roster actually sync, and when.
|
||||
|
||||
Milestone 387, step C3.
|
||||
|
||||
`platform_membership` records what was SEEN. This records whether looking
|
||||
happened at all, and that is a different fact — the one that makes an empty
|
||||
roster readable.
|
||||
|
||||
## Why this table has to exist
|
||||
|
||||
Without it, three very different situations are one indistinguishable state:
|
||||
|
||||
* the account genuinely subscribes to nothing,
|
||||
* the sweep has never run,
|
||||
* the sweep ran and failed.
|
||||
|
||||
All three produce zero rows in `platform_membership`. Telling the operator
|
||||
"you are tracking 12 sources you do not subscribe to" is correct in the first
|
||||
case and catastrophic in the other two — it is an invitation to cancel things
|
||||
they are actively paying for. C4 must therefore gate its CONCLUSIONS on
|
||||
`last_success_at`, not merely display it.
|
||||
|
||||
`MAX(platform_membership.last_seen_at)` was the tempting shortcut and does not
|
||||
work: it cannot distinguish "synced fine, found nothing" from "never synced".
|
||||
`task_run` was the other candidate and is worse — its retention prunes ok rows
|
||||
after 24h, so a sweep that last succeeded three days ago would leave no trace
|
||||
at all.
|
||||
|
||||
## Separate attempt and success timestamps, deliberately
|
||||
|
||||
`last_attempt_at` moves every run; `last_success_at` moves only on a clean
|
||||
walk. The GAP between them is the staleness signal, and keeping them apart is
|
||||
what lets the UI say "last synced 3 days ago, last tried 20 minutes ago,
|
||||
failing" — which is a different message from either half alone.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class MembershipSync(Base):
|
||||
__tablename__ = "membership_sync"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("platform", name="uq_membership_sync_platform"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
platform: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
# Moves on EVERY run, success or not — so "we are trying" is visible even
|
||||
# while "we are succeeding" is not.
|
||||
last_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Moves only on a COMPLETE walk. This is the freshness signal C4 gates its
|
||||
# conclusions on; NULL means never — which must never be rendered as zero.
|
||||
last_success_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# How many memberships the last SUCCESSFUL walk saw. Paired with
|
||||
# last_success_at so "0" is only ever readable as a real zero.
|
||||
last_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Cleared on success. Plain String, no CHECK — this carries an exception
|
||||
# class name (PatreonAuthError, PatreonDriftError, ...) and the vocabulary
|
||||
# is whatever the client raises, exactly as source.error_type works.
|
||||
last_error_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
last_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
server_default=func.now(), onupdate=func.now(),
|
||||
)
|
||||
@@ -252,69 +252,6 @@ class MLSettings(Base):
|
||||
Integer, nullable=False, default=64,
|
||||
server_default="64",
|
||||
)
|
||||
# -- Discord drop grouping (milestone 388) -----------------------------
|
||||
# FC authors a post out of a creator's variant drop. The predicate is three
|
||||
# axes ANDed together, and the time one does the real work: SIMILARITY
|
||||
# ALONE OVER-GROUPS. Any two pieces of the same character by the same
|
||||
# artist sit close in SigLIP space, so a cosine-only rule collapses a month
|
||||
# of one character into a single "post". What makes a variant set a set is
|
||||
# that it was dropped TOGETHER.
|
||||
discord_grouping_enabled: Mapped[bool] = mapped_column(
|
||||
# ON by default, matching the operator's standing opt-OUT preference for
|
||||
# automatic behaviour (2026-06-29, recorded on the head/ccip auto-apply
|
||||
# switches). Safe to default on because the act is reversible by one
|
||||
# DELETE: removing a synthetic post un-absorbs its members.
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
# Cosine DISTANCE, not similarity — this is the units gallery_service's
|
||||
# `cosine_distance` already speaks, and converting at the query site is a
|
||||
# step to get backwards. Lower = stricter. 0.10 is deliberately TIGHT: the
|
||||
# two failure modes are not symmetric. Grouping too shy leaves a drop
|
||||
# scattered, which is visible and fixable by raising this; grouping too
|
||||
# greedy merges distinct pieces into a post that claims they belong
|
||||
# together, which is the failure that would discredit the feature.
|
||||
discord_group_max_distance: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.10,
|
||||
server_default=text("0.10"),
|
||||
)
|
||||
# The gap that ENDS a drop, measured between CONSECUTIVE messages rather
|
||||
# than from the first — an artist trickling variants out over an evening is
|
||||
# one drop, and a window anchored on the first message would cut it in half
|
||||
# at an arbitrary point.
|
||||
discord_group_window_minutes: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=60.0,
|
||||
server_default=text("60"),
|
||||
)
|
||||
# How long a synthetic post keeps accepting new members (#388 E3). This is
|
||||
# NOT the drop window above: the window cuts one sweep's messages into
|
||||
# drops, this decides how long a finished drop can still be REJOINED when a
|
||||
# creator adds variants days later. A week by default — long enough for the
|
||||
# "and here is the alt outfit" follow-up that motivated the feature, short
|
||||
# enough that a group does not still be open when the same character comes
|
||||
# round again months later and gets absorbed by mistake.
|
||||
#
|
||||
# Openness is DERIVED from this, not stored: a group is open if it grew (or
|
||||
# started) within this period. So lowering it closes old groups and raising
|
||||
# it reopens them, which is comprehensible and reversible — the alternative,
|
||||
# a stored closed_at, would need its own repair path to ever change.
|
||||
discord_group_close_after_hours: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=168.0,
|
||||
server_default=text("168"),
|
||||
)
|
||||
# Anti-thrash (#388 E3). An updated post SHOULD be visible — that is the
|
||||
# point of keeping it open — but a group gaining one image a day must not
|
||||
# monopolise the feed. Growth smaller than this never moves the post, and
|
||||
# no group moves more than once per cooldown, so a drip-feed updates in
|
||||
# place while a real second wave resurfaces exactly once.
|
||||
discord_group_resurface_min_images: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=2,
|
||||
server_default="2",
|
||||
)
|
||||
discord_group_resurface_cooldown_hours: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=24.0,
|
||||
server_default=text("24"),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
+17
-8
@@ -1,9 +1,16 @@
|
||||
"""DiscordFailedMedia — per-source dead-letter ledger of Discord files that
|
||||
keep failing to download or validate.
|
||||
"""PixivFailedMedia — per-source dead-letter ledger of Pixiv media that keeps
|
||||
failing to download/validate.
|
||||
|
||||
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.
|
||||
Mirror of PatreonFailedMedia/SubscribeStarFailedMedia. Media that fails every
|
||||
walk (404'd pximg URL, deleted work, persistently-corrupt bytes) would
|
||||
otherwise re-error forever and re-burn backfill chunks. After ``attempts``
|
||||
reaches the dead-letter threshold the ingester skips it on routine
|
||||
tick/backfill walks (recovery still re-attempts). A later clean download
|
||||
clears the row.
|
||||
|
||||
`filehash` is the same synthesized ``<illust_id>:p<num>`` /
|
||||
``<illust_id>:ugoira`` key the seen-ledger uses. UNIQUE (source_id, filehash)
|
||||
is the upsert key.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -15,10 +22,12 @@ from sqlalchemy.types import DateTime
|
||||
from .base import Base
|
||||
|
||||
|
||||
class DiscordFailedMedia(Base):
|
||||
__tablename__ = "discord_failed_media"
|
||||
class PixivFailedMedia(Base):
|
||||
__tablename__ = "pixiv_failed_media"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_id", "filehash", name="uq_discord_failed_media_source_id"),
|
||||
UniqueConstraint(
|
||||
"source_id", "filehash", name="uq_pixiv_failed_media_source_id"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""PixivSeenMedia — per-source ledger of Pixiv media already
|
||||
downloaded+processed.
|
||||
|
||||
Mirror of PatreonSeenMedia/SubscribeStarSeenMedia for the Pixiv native
|
||||
ingester (replacing gallery-dl). One queryable row per (source, media) so
|
||||
routine walks skip media we've already ingested; recovery mode bypasses the
|
||||
ledger to re-walk.
|
||||
|
||||
Pixiv original URLs carry no content hash, so `filehash` is always the
|
||||
synthesized ``<illust_id>:p<num>`` (page) / ``<illust_id>:ugoira`` (frame
|
||||
zip) key — stable across any URL-shape drift. String(128) matches the sibling
|
||||
ledgers.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import DateTime
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PixivSeenMedia(Base):
|
||||
__tablename__ = "pixiv_seen_media"
|
||||
__table_args__ = (
|
||||
# Dedup key the downloader upserts against: one ledger row per
|
||||
# (source, media). A second sighting of the same media is a no-op.
|
||||
UniqueConstraint(
|
||||
"source_id", "filehash", name="uq_pixiv_seen_media_source_id"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,152 +0,0 @@
|
||||
"""platform_membership — the learned roster of what the account actually pays for.
|
||||
|
||||
Milestone 387, phase C. FabledCurator knows which creators it has been TOLD to
|
||||
follow (`source`), and nothing about which ones the operator is actually
|
||||
subscribed to. Those two sets drift in both directions and the app cannot
|
||||
currently see either drift:
|
||||
|
||||
* A subscription the operator pays for that FC does not track is content they
|
||||
believe they are archiving and are not.
|
||||
* A source FC keeps walking after the subscription lapsed is requests spent on
|
||||
a wall, reported as a creator who has gone quiet.
|
||||
|
||||
This table is the memory that makes both visible — every membership the account
|
||||
has been observed to hold, and when it was last seen.
|
||||
|
||||
## Why a learned roster rather than a live lookup
|
||||
|
||||
Same reasoning as `service_seen` (milestone 365), and the same shape: an
|
||||
absence is only observable against a record of presence. A membership that
|
||||
stops appearing in a sweep is the signal — "you were subscribed to this, now
|
||||
you aren't" — and there is nowhere to read that from a live call, because a
|
||||
live call returns what IS, never what stopped being.
|
||||
|
||||
It also means the reconciliation surface keeps working when Patreon is
|
||||
unreachable, degraded to a stale roster with a visible age rather than an empty
|
||||
page (rule 164).
|
||||
|
||||
## Roster truth, NOT per-post truth
|
||||
|
||||
The single most important thing about this table: `tier_names` says which tiers
|
||||
the account holds. It does **not** say which posts those tiers unlock. A
|
||||
creator can gate a post behind an access rule that maps onto no tier name at
|
||||
all.
|
||||
|
||||
`current_user_can_view` — read per post by `patreon_client.post_is_gated` — is
|
||||
the authoritative signal, and phase A already turned it into a durable
|
||||
per-source state. This roster EXPLAINS that state ("you are no longer a patron"
|
||||
vs "your tier doesn't cover these posts"). It must never be used to decide
|
||||
whether to fetch something. Getting that backwards would make FC silently stop
|
||||
fetching content the operator is paying for, which is the worst failure
|
||||
available in this milestone.
|
||||
|
||||
## status is a plain String, and deliberately the platform's own word
|
||||
|
||||
Not a Postgres ENUM, not CHECK-gated — matching `service_seen.kind`,
|
||||
`gpu_job.status` and `source.error_type`. Two reasons, and the first is the
|
||||
real one:
|
||||
|
||||
1. **The vocabulary is not ours to invent.** Patreon says `active_patron` /
|
||||
`former_patron` / `declined_patron`; SubscribeStar and FANBOX will say
|
||||
something else. Storing each platform's own word verbatim and mapping to
|
||||
FC's meaning at the READ site keeps this table a record of what was
|
||||
observed rather than a lossy translation of it. A lowest-common-denominator
|
||||
enum picked before any platform has been characterised (step C0) would be a
|
||||
guess baked into the schema.
|
||||
2. A constraint swap per new value (rule 36) would be cost with no invariant
|
||||
behind it, exactly as `service_seen.kind` records.
|
||||
|
||||
The service layer owns the whitelist and the mapping; the column owns the
|
||||
evidence.
|
||||
|
||||
## Retention: aged out, never deleted on disappearance
|
||||
|
||||
A membership that stops appearing in a sweep is NOT removed. Its disappearance
|
||||
is the fact the reconciliation surface reads, and deleting the row would
|
||||
destroy the signal at the moment it became interesting. `last_seen_at` is what
|
||||
makes "gone" decidable, and a retention policy ages rows out on time rather
|
||||
than on absence.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PlatformMembership(Base):
|
||||
__tablename__ = "platform_membership"
|
||||
__table_args__ = (
|
||||
# The natural key the sweep's upsert conflicts on. Named explicitly
|
||||
# because `touch_membership` references it by name in ON CONFLICT.
|
||||
UniqueConstraint(
|
||||
"platform", "external_campaign_id",
|
||||
name="uq_platform_membership_platform_campaign",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
platform: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# The platform's own id for the thing subscribed to — a Patreon campaign
|
||||
# id, whatever SubscribeStar and FANBOX call theirs. Text rather than a
|
||||
# bounded String: these are opaque upstream identifiers and guessing a
|
||||
# ceiling for a value we do not mint is how a walk dies on a truncation.
|
||||
external_campaign_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# For the reconciliation UI, and for matching against Source.url — the
|
||||
# vanity/URL is what the two sides actually have in common.
|
||||
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# The platform's own word. See the module docstring — this is evidence,
|
||||
# not a normalised FC status.
|
||||
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
# Nullable throughout: a free follow has no tier and no money attached, and
|
||||
# a platform may not expose an amount at all. Absent must stay
|
||||
# distinguishable from zero — "free" and "we don't know" are different
|
||||
# answers to "what is this costing".
|
||||
tier_names: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
|
||||
# NEVER updated after insert. The one field that answers "has this ever
|
||||
# been true", which is what makes a disappearance readable rather than
|
||||
# indistinguishable from never having existed. `touch_membership`
|
||||
# deliberately excludes it from the ON CONFLICT update set.
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
# The raw membership as the platform returned it, so a later question can
|
||||
# be answered without re-fetching — and so a field we did not think to
|
||||
# model is not lost. Displayed and never queried, like service_seen.details.
|
||||
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
def vanity_or_none(self) -> str | None:
|
||||
"""The platform's URL slug for this creator, if it can be known.
|
||||
|
||||
NOT a column, and that is C1's design working as intended rather than
|
||||
an omission: the roster was modelled before any platform had been
|
||||
characterised, so `details` exists precisely to carry the fields we did
|
||||
not know to model. The vanity turned out to be one of them (#3886), and
|
||||
it is reachable without a migration.
|
||||
|
||||
Falls back to the URL's last segment, which is what a vanity IS on
|
||||
every platform seen so far — but only as a fallback, because the
|
||||
platform's own word for it is the better answer when present.
|
||||
"""
|
||||
campaign = (self.details or {}).get("campaign") or {}
|
||||
vanity = campaign.get("vanity")
|
||||
if isinstance(vanity, str) and vanity:
|
||||
return vanity
|
||||
if self.url:
|
||||
tail = self.url.rstrip("/").rsplit("/", 1)[-1]
|
||||
return tail or None
|
||||
return None
|
||||
@@ -102,61 +102,3 @@ class Post(Base):
|
||||
downloaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
# -- Synthetic posts (milestone 388). ----------------------------------
|
||||
# Discord is a delivery CHANNEL, not a publisher: one message is not one
|
||||
# post. So FC authors the post itself, grouping a creator's variant drop
|
||||
# into a single row (services/discord_grouping.py).
|
||||
#
|
||||
# NULL for every post a creator actually wrote — which is all of them until
|
||||
# a grouper runs. Non-NULL names the grouper that authored this row, and is
|
||||
# the ONE flag the UI keys off to say so. The honesty rule is the whole
|
||||
# point: a synthetic post must never present itself as authored, and a
|
||||
# column that is absent-or-a-name makes "was this us?" answerable from the
|
||||
# row rather than inferred from its shape.
|
||||
#
|
||||
# Plain String, no CHECK (rule 36 considered and declined) — same reasoning
|
||||
# as source.error_type and service_seen.kind. There is exactly one grouper
|
||||
# today; a second would be a value, not an invariant.
|
||||
synthesized_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# What it was built from, so the operator can audit a grouping FC invented:
|
||||
# member post ids, message count, and the thresholds in force when the
|
||||
# decision was made. That last part matters — the thresholds are operator-
|
||||
# tunable, so "why did it group these" is unanswerable a month later
|
||||
# without recording the values that produced it.
|
||||
synthesis_details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# Set on a MEMBER post, pointing at the synthetic post that absorbed it.
|
||||
# The feed hides absorbed posts (they are the chat lines the synthetic post
|
||||
# replaced); every other surface still reaches them by id, because they
|
||||
# remain the image's true origin and the grouping has to be inspectable.
|
||||
#
|
||||
# Self-FK, ON DELETE SET NULL: deleting a synthetic post un-absorbs its
|
||||
# members and they return to the feed on their own. That is the reversal
|
||||
# path, and it is one DELETE — nothing to undo by hand.
|
||||
absorbed_by_post_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("post.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
# -- An OPEN grouping (milestone 388 E3) -------------------------------
|
||||
# A synthetic post is not sealed at creation: a creator who adds two more
|
||||
# variants the next day extends the existing post rather than starting a
|
||||
# new one. These two columns are what make that possible without the post
|
||||
# either freezing or thrashing the feed.
|
||||
#
|
||||
# `last_grew_at` is when the group last absorbed something. It answers two
|
||||
# questions: how long the group stays JOINABLE (a group closes after a
|
||||
# quiet period — artists reuse characters for years, and a group left open
|
||||
# forever will eventually absorb something it shouldn't), and what the card
|
||||
# shows as "updated N ago". NULL means it has never grown since creation.
|
||||
last_grew_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# The feed position, and ONLY set when the anti-thrash rule fires — see
|
||||
# discord_grouping.should_resurface. A group that gains one image a day
|
||||
# must not sit permanently at the top of the feed, so growth updates the
|
||||
# post without necessarily moving it; a genuine second wave moves it once.
|
||||
#
|
||||
# NULL on every ordinary post, which is why the feed's sort key can
|
||||
# COALESCE through it without changing where anything else lands.
|
||||
resurfaced_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""PostAssociation — "this Patreon post announced that Discord drop".
|
||||
|
||||
Milestone 388, step E5, and the point of the milestone rather than its tail.
|
||||
|
||||
Two of the operator's artists post a deliberately CROPPED fragment on Patreon
|
||||
to signal that the real thing has landed in their Discord. The Patreon post is
|
||||
the announcement; the Discord grouping (milestone 388 E2) is the payload. This
|
||||
row is the link between them.
|
||||
|
||||
## Directional, and NOT a merge
|
||||
|
||||
`announcement` → `payload` is asymmetric on purpose. The teaser announces the
|
||||
drop; the drop does not announce the teaser, and a symmetric "related posts"
|
||||
edge would lose the only thing that makes the pair interesting.
|
||||
|
||||
Nor are the two collapsed into one post. The creator published twice,
|
||||
deliberately, on two platforms with different audiences — flattening that
|
||||
hides the very behaviour being modelled, and would destroy the operator's
|
||||
ability to see that the Patreon post is a teaser at all.
|
||||
|
||||
## Confirm-only, following FC-6.3 (task 737)
|
||||
|
||||
`status` starts at `pending` and nothing is linked until the operator accepts.
|
||||
A wrongly-asserted association tells them two different pieces are one, which
|
||||
is worse than no link: no link leaves them where they already are, a wrong one
|
||||
actively misinforms. Same reason the series matcher writes to a review queue
|
||||
instead of filing posts on its own.
|
||||
|
||||
`status` is a plain String, no CHECK — matching `series_suggestion.status`,
|
||||
which records the same check-existing-enums lesson.
|
||||
|
||||
## Why pHash could not do this, and the correction matters
|
||||
|
||||
The original plan claimed this link was already sitting in `image_provenance`
|
||||
via pHash dedup. It is not. `compute_phash` is `imagehash.phash` at
|
||||
`hash_size=8` — a DCT hash over the WHOLE image, robust to rescaling and
|
||||
recompression but NOT to cropping, because a crop changes the global
|
||||
signature. Cross-platform provenance still links straight re-posts; it does
|
||||
nothing for a cropped teaser and its full version, which is precisely the pair
|
||||
the operator described. Hence a scored proposal rather than a lookup.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PostAssociation(Base):
|
||||
__tablename__ = "post_association"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"announcement_post_id", "payload_post_id",
|
||||
name="uq_post_association_pair",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# The teaser — a real post the creator wrote (Patreon, today).
|
||||
announcement_post_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# What it announced — a synthetic Discord grouping, today. CASCADE on both
|
||||
# sides: an association to a post that no longer exists is not a fact worth
|
||||
# keeping, and E3's reversal path (delete the grouping) should not leave a
|
||||
# dangling proposal behind.
|
||||
payload_post_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
# Per-signal strengths as scored, so a proposal stays explicable after the
|
||||
# weights or the threshold are tuned. Without it, "why was this suggested"
|
||||
# is unanswerable the moment anything moves.
|
||||
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# pending | linked | dismissed. A DISMISSED row is kept, not deleted — it
|
||||
# is what stops the matcher proposing the same rejected pair on every
|
||||
# subsequent scan, which is the behaviour that makes a review queue
|
||||
# unusable.
|
||||
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()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
server_default=func.now(), onupdate=func.now(),
|
||||
)
|
||||
@@ -1,88 +0,0 @@
|
||||
"""service_seen — the learned roster of FabledCurator's own moving parts.
|
||||
|
||||
Nothing else in this application knows what is SUPPOSED to be running.
|
||||
`celery inspect` reports the workers that answer, so a stopped worker is a
|
||||
shorter list rather than a red light, and Postgres and Redis have no
|
||||
representation at all. That is why the only place an operator could see a
|
||||
dead service was Portainer, which knows the intended set (milestone 365).
|
||||
|
||||
This table is the memory that makes an absence observable: every part that
|
||||
has ever checked in, and when it last did. A row that stops advancing is a
|
||||
part that stopped.
|
||||
|
||||
## Why the key is not the hostname
|
||||
|
||||
`_read_workers_sync()` returns celery's worker names, which here are
|
||||
`celery@<container id>`. Those are minted fresh on every deploy. Keyed on
|
||||
them, this table would record a death and a birth every time the stack is
|
||||
updated — and a status page that goes red on every deploy is a status page
|
||||
nobody reads, which is worse than not having one.
|
||||
|
||||
So a celery role is keyed on its **queue set**, which is assigned per role in
|
||||
docker-compose.yml (`CELERY_QUEUES`) and survives container replacement:
|
||||
|
||||
default,import,thumbnail,download -> worker
|
||||
maintenance,scan -> scheduler (celery worker --beat)
|
||||
ml -> ml-worker
|
||||
|
||||
Two replicas of one role share a queue set and are therefore ONE row — which
|
||||
is right, because the question being answered is "is that role being served",
|
||||
not "how many containers exist". The replica count and their hostnames go in
|
||||
`details`, where they can change without the identity changing.
|
||||
|
||||
The GPU agent is keyed on its `agent_id`, the identity its lease protocol
|
||||
already uses (`api/gpu.py`).
|
||||
|
||||
## What is NOT in here
|
||||
|
||||
Postgres and Redis. They are always expected and never learned, and a
|
||||
last-seen for them would be actively misleading — that one answered thirty
|
||||
seconds ago says nothing about now. They are probed live at request time.
|
||||
|
||||
## kind
|
||||
|
||||
Plain `String`, not a Postgres ENUM and not CHECK-gated, matching
|
||||
`gpu_job.status` and `backup_run.status`. The value set here is expected to
|
||||
grow as parts are added, and a constraint swap per new kind (rule 36) would
|
||||
be cost with no invariant behind it.
|
||||
|
||||
celery — a worker role, keyed on its queue set
|
||||
agent — a GPU agent, keyed on its agent_id
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ServiceSeen(Base):
|
||||
__tablename__ = "service_seen"
|
||||
|
||||
# No indexes beyond the primary key, deliberately. This table holds one row
|
||||
# per moving part — a handful, forever — so every query against it is a
|
||||
# full read of a few rows and an index would be write cost buying nothing
|
||||
# (the lesson of #3301, which removed seven redundant ones).
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
# What to call it in the UI. Derived from the queue set where it is
|
||||
# recognised, and falling back to the raw queue list where it is not — a
|
||||
# deployment that slices its queues differently should still show something
|
||||
# true rather than a name this code invented for it.
|
||||
display_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
# The parts that change without changing identity: replica hostnames,
|
||||
# active task counts, the queues actually being served. Kept as a blob
|
||||
# because it is displayed and never queried — giving it columns would
|
||||
# invite filtering on it, which is what the activity endpoints are for.
|
||||
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
@@ -1,85 +0,0 @@
|
||||
"""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(),
|
||||
)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""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(),
|
||||
)
|
||||
@@ -1,227 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,181 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,146 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,337 +0,0 @@
|
||||
"""Proposing that a creator FC tracks and a membership it found are the same.
|
||||
|
||||
Milestone 388, step E4. An instance of the confirm-only matcher shape
|
||||
(snippet #3842), and a sibling of `post_association_service`.
|
||||
|
||||
## What E4 turned out NOT to need
|
||||
|
||||
The step's own first instruction was to verify before building, and the
|
||||
verification said: not the schema, not the flows. `Source.artist_id` is a plain
|
||||
FK so many sources per artist already works; `POST /api/sources` already takes
|
||||
an `artist_id`; the add-source dialog already has an artist autocomplete that
|
||||
attaches to an EXISTING artist; `SourceService.reassign` already moves a source
|
||||
between artists WITH post and image re-attribution; and a sweep for
|
||||
one-source-per-artist assumptions found only `func.count()` calls, which are
|
||||
the opposite of assuming one.
|
||||
|
||||
So the association a Discord source and a Patreon source share is already
|
||||
expressible today. What was missing is FC OFFERING it.
|
||||
|
||||
## Accept adds a SOURCE — it never merges artists
|
||||
|
||||
The asymmetry that sets the whole posture: adding a source is trivially undone.
|
||||
A wrong artist merge silently mixes two creators' work and corrupts tagging,
|
||||
series and provenance downstream, with nothing left to tell the two apart by.
|
||||
So the accepted action is "add the missing channel to this artist", and merging
|
||||
is not offered at all.
|
||||
|
||||
## The signals
|
||||
|
||||
1. **Name.** The roster's `display_name` and `vanity`, slugified, against the
|
||||
artist's `slug`. Graded rather than boolean — an exact match is strong
|
||||
evidence, a containment match is a hint.
|
||||
2. **Declared.** A post already under this artist whose body links to
|
||||
`patreon.com/<vanity>` for this exact membership. A creator pointing at
|
||||
their own Patreon from their own Discord is close to a statement.
|
||||
|
||||
Signal 2 is NOT read from `ExternalLink`, and that correction is worth keeping:
|
||||
`link_extract.SUPPORTED_HOSTS` is file hosts only (mega/gdrive/mediafire/
|
||||
dropbox/pixeldrain) and `host_for()` returns None for patreon.com, so no
|
||||
`ExternalLink` row is ever written for one. The same trap already caught E5 for
|
||||
Discord invites.
|
||||
|
||||
## Weights, and what they make impossible
|
||||
|
||||
name 0.65 · declared 0.35, cut at 0.60
|
||||
|
||||
Chosen so the arithmetic encodes the judgement rather than a code path doing it:
|
||||
|
||||
* an EXACT name match alone (0.65) proposes — same slug on both sides is
|
||||
strong, and requiring corroboration would mean proposing almost nothing;
|
||||
* a CONTAINMENT name match alone (0.6 * 0.65 = 0.39) does not — "art" inside
|
||||
"artgirl" is a coincidence generator, and it needs the declaration;
|
||||
* the declaration ALONE (0.35) never proposes, at any setting at or above
|
||||
0.60 — a creator may link another creator's Patreon, and a link is not a
|
||||
claim of identity.
|
||||
|
||||
A guard test pins all three against WEIGHTS directly, so they survive a
|
||||
refactor of the scorer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import (
|
||||
Artist,
|
||||
ArtistMembershipSuggestion,
|
||||
PlatformMembership,
|
||||
Post,
|
||||
Source,
|
||||
)
|
||||
from ..utils.slug import slugify
|
||||
from ..utils.text import html_to_plain
|
||||
from .membership_roster import source_for_membership
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WEIGHTS = {"name": 0.65, "declared": 0.35}
|
||||
DEFAULT_THRESHOLD = 0.60
|
||||
|
||||
NAME_EXACT = 1.0
|
||||
# Containment is a hint, not a match: "art" sits inside "artgirl", and slugs
|
||||
# are short enough that coincidental containment is common.
|
||||
NAME_CONTAINS = 0.6
|
||||
# Below this many characters, containment is noise rather than signal — a
|
||||
# 3-character slug is inside a great many longer ones.
|
||||
_MIN_CONTAINMENT_LEN = 5
|
||||
|
||||
MAX_CANDIDATES = 25
|
||||
|
||||
|
||||
def name_signal(membership: PlatformMembership, artist: Artist) -> float:
|
||||
"""Graded slug agreement between a membership and an artist.
|
||||
|
||||
Both the display name and the vanity are tried, because creators routinely
|
||||
differ between the two ("Team Melon Collie" vs "MelonCollieStudios") and
|
||||
either may be the one the operator typed when they created the artist.
|
||||
"""
|
||||
artist_slug = slugify(artist.name or "") if artist.name else ""
|
||||
if not artist_slug or artist_slug == "untitled":
|
||||
return 0.0
|
||||
candidates = {
|
||||
slugify(v) for v in (membership.display_name, membership.vanity_or_none())
|
||||
if v
|
||||
}
|
||||
candidates.discard("untitled")
|
||||
if not candidates:
|
||||
return 0.0
|
||||
if artist_slug in candidates:
|
||||
return NAME_EXACT
|
||||
for c in candidates:
|
||||
if len(c) < _MIN_CONTAINMENT_LEN or len(artist_slug) < _MIN_CONTAINMENT_LEN:
|
||||
continue
|
||||
if c in artist_slug or artist_slug in c:
|
||||
return NAME_CONTAINS
|
||||
return 0.0
|
||||
|
||||
|
||||
def declared_signal(body: str | None, vanity: str | None) -> float:
|
||||
"""Does this post body point at THIS membership's Patreon page?
|
||||
|
||||
Matched against the RAW body, not the stripped text: these links live in an
|
||||
anchor's `href`, and `html_to_plain` discards attributes — the same trap
|
||||
that caught E5's invite detection. The stripped text is checked too, for
|
||||
bodies that paste the URL as plain text.
|
||||
"""
|
||||
if not body or not vanity:
|
||||
return 0.0
|
||||
pattern = re.compile(
|
||||
r"patreon\.com/(?:c/|cw/|checkout/)?" + re.escape(vanity) + r"\b", re.I
|
||||
)
|
||||
if pattern.search(body):
|
||||
return 1.0
|
||||
return 1.0 if pattern.search(html_to_plain(body) or "") else 0.0
|
||||
|
||||
|
||||
def weighted_score(signals: dict) -> float:
|
||||
return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4)
|
||||
|
||||
|
||||
class ArtistMembershipService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _decided(self, membership_id: int) -> set[int]:
|
||||
"""Artists already proposed for this membership, in ANY status.
|
||||
|
||||
Dismissed included: the row is what remembers the rejection, and
|
||||
re-proposing a rejected pair every scan is what makes a queue ignored.
|
||||
"""
|
||||
rows = (await self.session.execute(
|
||||
select(ArtistMembershipSuggestion.artist_id).where(
|
||||
ArtistMembershipSuggestion.platform_membership_id == membership_id
|
||||
)
|
||||
)).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
async def _candidate_artists(self, membership: PlatformMembership) -> list[Artist]:
|
||||
"""Artists that have SOME source but none for this membership's platform.
|
||||
|
||||
A hard filter, not a scored signal. An artist FC already tracks on this
|
||||
platform needs no suggestion — the link exists — and an artist with no
|
||||
sources at all is not a creator FC is following through another channel,
|
||||
which is the whole case this step is about.
|
||||
"""
|
||||
# `select(...).exists()` rather than a bare `exists().where(...)`: the
|
||||
# latter has no FROM to correlate against and does not reliably render.
|
||||
has_any = select(Source.id).where(Source.artist_id == Artist.id).exists()
|
||||
has_this = (
|
||||
select(Source.id)
|
||||
.where(
|
||||
Source.artist_id == Artist.id,
|
||||
Source.platform == membership.platform,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
return (await self.session.execute(
|
||||
select(Artist).where(has_any, ~has_this).limit(MAX_CANDIDATES)
|
||||
)).scalars().all()
|
||||
|
||||
async def _declared_for(self, artist_id: int, vanity: str | None) -> float:
|
||||
if not vanity:
|
||||
return 0.0
|
||||
# Bounded scan: the newest posts are where a creator's current links
|
||||
# live, and an unbounded body scan per (artist, membership) pair would
|
||||
# be the expensive part of this sweep.
|
||||
bodies = (await self.session.execute(
|
||||
select(Post.description)
|
||||
.where(Post.artist_id == artist_id, Post.description.is_not(None))
|
||||
.order_by(func.coalesce(Post.post_date, Post.downloaded_at).desc())
|
||||
.limit(50)
|
||||
)).scalars().all()
|
||||
for body in bodies:
|
||||
if declared_signal(body, vanity) > 0:
|
||||
return 1.0
|
||||
return 0.0
|
||||
|
||||
async def match_membership(
|
||||
self, membership_id: int, *, threshold: float = DEFAULT_THRESHOLD,
|
||||
) -> int:
|
||||
membership = await self.session.get(PlatformMembership, membership_id)
|
||||
if membership is None:
|
||||
return 0
|
||||
# The shared identity join (C4), used here as the NEGATIVE check. A
|
||||
# membership FC already has a source for is tracked — whoever it happens
|
||||
# to be filed under — and proposing it to some OTHER artist would be
|
||||
# exactly the wrong link this service exists to avoid making.
|
||||
# `_candidate_artists` only knows whether a GIVEN artist has a source on
|
||||
# the platform, which cannot see a source sitting under someone else.
|
||||
if await source_for_membership(self.session, membership) is not None:
|
||||
return 0
|
||||
already = await self._decided(membership_id)
|
||||
|
||||
made = 0
|
||||
for artist in await self._candidate_artists(membership):
|
||||
if artist.id in already:
|
||||
continue
|
||||
signals = {
|
||||
"name": name_signal(membership, artist),
|
||||
"declared": await self._declared_for(
|
||||
artist.id, membership.vanity_or_none()
|
||||
),
|
||||
}
|
||||
score = weighted_score(signals)
|
||||
if score < threshold:
|
||||
continue
|
||||
self.session.add(ArtistMembershipSuggestion(
|
||||
platform_membership_id=membership.id,
|
||||
artist_id=artist.id,
|
||||
score=score,
|
||||
signals=signals,
|
||||
status="pending",
|
||||
))
|
||||
made += 1
|
||||
return made
|
||||
|
||||
async def list_pending(self) -> list[dict]:
|
||||
rows = (await self.session.execute(
|
||||
select(ArtistMembershipSuggestion, PlatformMembership, Artist)
|
||||
.join(
|
||||
PlatformMembership,
|
||||
PlatformMembership.id
|
||||
== ArtistMembershipSuggestion.platform_membership_id,
|
||||
)
|
||||
.join(Artist, Artist.id == ArtistMembershipSuggestion.artist_id)
|
||||
.where(ArtistMembershipSuggestion.status == "pending")
|
||||
.order_by(
|
||||
ArtistMembershipSuggestion.score.desc(),
|
||||
ArtistMembershipSuggestion.id.desc(),
|
||||
)
|
||||
)).all()
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"score": s.score,
|
||||
"signals": s.signals,
|
||||
"artist": {"id": a.id, "name": a.name, "slug": a.slug},
|
||||
"membership": {
|
||||
"id": m.id,
|
||||
"platform": m.platform,
|
||||
"display_name": m.display_name,
|
||||
"url": m.url,
|
||||
},
|
||||
}
|
||||
for s, m, a in rows
|
||||
]
|
||||
|
||||
async def accept(self, suggestion_id: int) -> dict | None:
|
||||
"""Add the missing channel to the artist. NEVER merges two artists.
|
||||
|
||||
Returns the created source's id, or `already_linked` when a source for
|
||||
that platform appeared between the proposal and the click — which is
|
||||
not an error, it is the operator having done it by hand.
|
||||
"""
|
||||
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
|
||||
if s is None:
|
||||
return None
|
||||
membership = await self.session.get(PlatformMembership, s.platform_membership_id)
|
||||
if membership is None or not membership.url:
|
||||
return None
|
||||
|
||||
existing = (await self.session.execute(
|
||||
select(Source.id).where(
|
||||
Source.artist_id == s.artist_id,
|
||||
Source.platform == membership.platform,
|
||||
)
|
||||
)).scalars().first()
|
||||
if existing is not None:
|
||||
s.status = "linked"
|
||||
return {"id": s.id, "status": s.status, "already_linked": existing}
|
||||
|
||||
# Through SourceService, NOT a bare Source() insert. It carries the
|
||||
# platform/URL validation, the duplicate check and the #693
|
||||
# backfill-arming that a hand-added source gets — building a second,
|
||||
# quieter way to create a source is how the two drift until one of them
|
||||
# is subtly broken (rule 28: repurpose the existing surface).
|
||||
from .source_service import DuplicateSourceError, SourceService
|
||||
|
||||
try:
|
||||
record = await SourceService(self.session).create(
|
||||
artist_id=s.artist_id,
|
||||
platform=membership.platform,
|
||||
url=membership.url,
|
||||
)
|
||||
except DuplicateSourceError as exc:
|
||||
# The same URL already exists for this artist — the operator got
|
||||
# there first by a different route. Not an error.
|
||||
s.status = "linked"
|
||||
return {"id": s.id, "status": s.status, "already_linked": exc.existing_id}
|
||||
s.status = "linked"
|
||||
return {"id": s.id, "status": s.status, "source_id": record.id}
|
||||
|
||||
async def dismiss(self, suggestion_id: int) -> dict | None:
|
||||
s = await self.session.get(ArtistMembershipSuggestion, suggestion_id)
|
||||
if s is None:
|
||||
return None
|
||||
# Kept, not deleted — the row is what remembers the rejection.
|
||||
s.status = "dismissed"
|
||||
return {"id": s.id, "status": s.status}
|
||||
|
||||
|
||||
async def rescan(session: AsyncSession, *, threshold: float = DEFAULT_THRESHOLD) -> dict:
|
||||
"""Offer every known membership to the artists FC already tracks."""
|
||||
ids = (await session.execute(select(PlatformMembership.id))).scalars().all()
|
||||
svc = ArtistMembershipService(session)
|
||||
proposed = 0
|
||||
for mid in ids:
|
||||
proposed += await svc.match_membership(mid, threshold=threshold)
|
||||
log.info(
|
||||
"artist/membership matcher: scanned %d membership(s), proposed %d pair(s)",
|
||||
len(ids), proposed,
|
||||
)
|
||||
return {"scanned": len(ids), "proposed": proposed}
|
||||
@@ -300,18 +300,6 @@ class ArtistService:
|
||||
await self.session.commit()
|
||||
return artist
|
||||
|
||||
async def all_names(self) -> list[tuple[int, str, str]]:
|
||||
"""Every artist as (id, name, slug), alphabetical.
|
||||
|
||||
For pickers that should show a full list before anything is typed (the
|
||||
Latest feed's artist filter). Three columns and no joins, so it stays
|
||||
cheap on a library of thousands of artists.
|
||||
"""
|
||||
rows = (await self.session.execute(
|
||||
select(Artist.id, Artist.name, Artist.slug).order_by(func.lower(Artist.name))
|
||||
)).all()
|
||||
return [(r.id, r.name, r.slug) for r in rows]
|
||||
|
||||
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
|
||||
cleaned = (prefix or "").strip()
|
||||
if not cleaned:
|
||||
|
||||
@@ -24,25 +24,6 @@ from pathlib import Path
|
||||
|
||||
_BACKUPS_DIRNAME = "_backups"
|
||||
|
||||
# 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")
|
||||
|
||||
# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The Celery
|
||||
# soft limit signals the Python process; subprocess.Popen in a blocking syscall
|
||||
# ignores that signal, so these bound the worst case directly. Each sits just
|
||||
@@ -192,10 +173,8 @@ def backup_images(
|
||||
[
|
||||
"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
|
||||
),
|
||||
f"--exclude={images_root.name}/_backups",
|
||||
f"--exclude={images_root.name}/_quarantine",
|
||||
],
|
||||
_IMAGES_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
@@ -81,59 +80,10 @@ 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()
|
||||
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()
|
||||
self._key_path.write_bytes(key)
|
||||
os.chmod(self._key_path, 0o600)
|
||||
return key
|
||||
|
||||
def encrypt(self, plaintext: str) -> bytes:
|
||||
return self._fernet.encrypt(plaintext.encode("utf-8"))
|
||||
|
||||
@@ -16,13 +16,10 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from sqlalchemy import Select, and_
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Source
|
||||
from .gallery_dl import ErrorType
|
||||
|
||||
|
||||
async def get_or_create[T](
|
||||
session: AsyncSession,
|
||||
@@ -53,45 +50,3 @@ async def get_or_create[T](
|
||||
except IntegrityError:
|
||||
await sp.rollback()
|
||||
return (await session.execute(select_stmt)).scalar_one(), False
|
||||
|
||||
|
||||
# --- shared Source health predicates ----------------------------------------
|
||||
#
|
||||
# The subscriptions rollup, the front-door status ribbon and the list endpoint
|
||||
# all have to agree on what "failing" and "no access" MEAN, or the ribbon says
|
||||
# 3 and the card it links to shows 4. Same reasoning as get_or_create above:
|
||||
# divergent copies of one predicate are how the drift creeps in. Defined here
|
||||
# rather than in source_service because scheduler_service needs them too, and
|
||||
# source_service already imports scheduler_service (the other direction would
|
||||
# be a cycle).
|
||||
|
||||
|
||||
def failing_sources_clause():
|
||||
"""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 and_(Source.enabled.is_(True), Source.consecutive_failures > 0)
|
||||
|
||||
|
||||
def no_access_sources_clause():
|
||||
"""A source we can't see the content of: the walk works, the tier doesn't
|
||||
grant it (#874 / milestone #387 phase A). Not a failure — kept separate
|
||||
from failing_sources_clause on purpose, and the two are disjoint because
|
||||
an informational class only ever rides an otherwise-OK run."""
|
||||
return Source.error_type == ErrorType.TIER_LIMITED
|
||||
|
||||
@@ -1,562 +0,0 @@
|
||||
"""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.
|
||||
|
||||
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 dataclasses import dataclass
|
||||
from datetime import date
|
||||
from urllib.parse import unquote
|
||||
|
||||
import requests
|
||||
|
||||
from .native_ingest_common import (
|
||||
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
|
||||
|
||||
# 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"})
|
||||
|
||||
_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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaItem:
|
||||
"""One file of a Discord message. `filename`/`extension` are gallery-dl's
|
||||
split of the URL; `num` is its 1-based position across the message's files,
|
||||
which is what names it on disk.
|
||||
|
||||
`media_id` is what the seen-ledger keys on, and it is deliberately NOT
|
||||
`num`: an edit that removes a file renumbers the ones after it, and a
|
||||
positional key would then call a different file seen. It is the
|
||||
attachment's id, or for an embed (which has none) a hash of its URL path —
|
||||
the query string is a signature that changes on every fetch. `filehash` is
|
||||
always None; nothing in a signed CDN URL is a content hash."""
|
||||
|
||||
url: str
|
||||
filename: str
|
||||
extension: str
|
||||
kind: str
|
||||
post_id: str
|
||||
num: int
|
||||
media_id: str
|
||||
filehash: str | None = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
# -- 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 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
|
||||
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."""
|
||||
mid = str(post.get("id") or "")
|
||||
snapshots = [post] + [
|
||||
(s or {}).get("message") or {}
|
||||
for s in post.get("message_snapshots") or []
|
||||
if ((s or {}).get("message") or {}).get("type", 0) in MESSAGE_TYPES
|
||||
]
|
||||
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 with no files. gallery-dl wrote a sidecar only
|
||||
beside a file, so a text-only chat line never became a post, and 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 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
|
||||
@@ -1,212 +0,0 @@
|
||||
"""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 logging
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
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,
|
||||
MediaOutcome,
|
||||
PostRecordOutcome,
|
||||
make_session,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
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/",
|
||||
}),
|
||||
)
|
||||
|
||||
def download_post(
|
||||
self,
|
||||
post: dict,
|
||||
media_items: list,
|
||||
artist_slug: str,
|
||||
*,
|
||||
is_seen: Callable[[object], bool] = lambda m: False,
|
||||
should_stop: Callable[[], bool] = lambda: False,
|
||||
recapture: bool = False,
|
||||
) -> list[MediaOutcome]:
|
||||
"""Every file of one message; per-file outcomes, one failure isolated."""
|
||||
folder = channel_dir(self.images_root, artist_slug, post)
|
||||
outcomes: list[MediaOutcome] = []
|
||||
for media in media_items:
|
||||
if should_stop():
|
||||
break
|
||||
try:
|
||||
outcomes.append(self._download_one(
|
||||
post, media, folder, artist_slug, is_seen, recapture=recapture,
|
||||
))
|
||||
except Exception as exc: # resilient: isolate one item's failure
|
||||
log.warning(
|
||||
"Discord media failed (message %s, file %d): %s",
|
||||
post.get("id"), media.num, exc,
|
||||
)
|
||||
outcomes.append(
|
||||
MediaOutcome(media=media, status="error", path=None, error=str(exc))
|
||||
)
|
||||
return outcomes
|
||||
|
||||
def _download_one(
|
||||
self,
|
||||
post: dict,
|
||||
media,
|
||||
folder: Path,
|
||||
artist_slug: str,
|
||||
is_seen: Callable[[object], bool],
|
||||
*,
|
||||
recapture: bool = False,
|
||||
) -> MediaOutcome:
|
||||
seen = is_seen(media)
|
||||
if seen and not recapture:
|
||||
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
|
||||
stem = media_stem(post, media)
|
||||
path = folder / f"{stem}.{media.extension or _NO_EXTENSION}"
|
||||
if path.exists(): # tier-2: gallery-dl (or an earlier walk) wrote it
|
||||
return MediaOutcome(media=media, status="skipped_disk", path=path, error=None)
|
||||
if seen: # recapture never re-fetches a seen file that is gone
|
||||
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
|
||||
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
if self._rate_limit > 0:
|
||||
time.sleep(self._rate_limit)
|
||||
out = self._fetch_get(media.url, path)
|
||||
reason, quarantined = self._validate_path(out, artist_slug, media.url)
|
||||
if reason is not None:
|
||||
return MediaOutcome(media=media, status="quarantined", path=quarantined, error=reason)
|
||||
sidecar = {"category": PLATFORM, "message_id": str(post.get("id") or "")}
|
||||
sidecar["source_url"] = media.url
|
||||
(folder / f"{stem}.json").write_text(json.dumps(sidecar, indent=2))
|
||||
return MediaOutcome(media=media, status="downloaded", path=out, error=None)
|
||||
|
||||
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_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),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
||||
"""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.
|
||||
|
||||
`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
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from ..models import DiscordFailedMedia, DiscordSeenMedia
|
||||
from .discord_client import DiscordAPIError, DiscordClient, MediaItem
|
||||
from .discord_downloader import DiscordDownloader
|
||||
from .ingest_core import Ingester
|
||||
|
||||
_LEDGER_KEY_MAX = 128
|
||||
|
||||
|
||||
def _ledger_key(media: MediaItem) -> str:
|
||||
"""`<message_id>:<media_id>` — stable across edits (see MediaItem)."""
|
||||
return f"{media.post_id}:{media.media_id}"[:_LEDGER_KEY_MAX]
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,183 +0,0 @@
|
||||
"""Repair the Discord downloads made before the naming fix (issue #3999).
|
||||
|
||||
Until dc840fe, gallery-dl's Discord patterns asked for keys the extractor never
|
||||
emits, so every Discord download landed as
|
||||
`<artist>/discord/None/<date>_None_<original name>`, next to a sidecar named
|
||||
after the attachment's ORIGINAL name. That broke two things:
|
||||
|
||||
* **No Post, no date.** `find_sidecar` can never pair those names, so these
|
||||
files were imported as loose images with no Post, and a card shows the
|
||||
download time.
|
||||
* **No trustworthy metadata to relink from.** Every `image.png` in a channel
|
||||
wrote the same `image.json`, so the surviving sidecar describes whichever
|
||||
message was written last. The message id is gone from the filename too.
|
||||
|
||||
The operator chose a clean re-download (2026-09-13) over relinking in place:
|
||||
delete the broken files and their records, make gallery-dl forget it fetched
|
||||
them, and backfill every Discord source again under the fixed naming.
|
||||
|
||||
## Why the archive is cleared for ALL of Discord
|
||||
|
||||
gallery-dl records a download as `discord{message_id}_{num}` (upstream
|
||||
`DiscordExtractor.archive_fmt`, prefixed with the category). The broken files
|
||||
lost their message ids, so there is no way to forget one source's entries and
|
||||
not another's. Every Discord download made before the fix is broken, so
|
||||
forgetting all of them is exactly right. Anything downloaded AFTER the fix still
|
||||
exists on disk under its correct name, and gallery-dl's `skip` sees the file and
|
||||
does not fetch it again.
|
||||
|
||||
## What it does not touch
|
||||
|
||||
Discord posts FC grouped itself (#388 E2) are built from Posts, and these files
|
||||
never had one, so there is nothing grouped to unwind. Images outside a
|
||||
`discord/None/` directory are never selected: the path filter requires both the
|
||||
`None` directory and the `_None_` filename, the pair only the bug produced.
|
||||
|
||||
Operator-triggered only (Settings, preview first). Never on a beat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import ImageRecord, Source
|
||||
from .cleanup_service import delete_images
|
||||
from .gallery_dl import archive_path
|
||||
from .source_service import arm_backfill
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# `%` and `_` are LIKE wildcards, so the literal underscores around None are
|
||||
# escaped. `________` is the eight-digit date prefix the old pattern wrote.
|
||||
_BROKEN_PATH_LIKE = r"%/discord/None/________\_None\_%"
|
||||
|
||||
# Upstream keys asset downloads as `asset_{server_id}_{id}`. FC never fetches
|
||||
# server assets, but excluding them keeps this to exactly the message
|
||||
# attachments the bug mangled.
|
||||
_ARCHIVE_SQL_MATCH = r"entry LIKE 'discord%' AND entry NOT LIKE 'discordasset\_%' ESCAPE '\'"
|
||||
_COUNT_SQL = "SELECT COUNT(*) FROM archive WHERE " + _ARCHIVE_SQL_MATCH
|
||||
_DELETE_SQL = "DELETE FROM archive WHERE " + _ARCHIVE_SQL_MATCH
|
||||
|
||||
|
||||
def broken_directories(images_root: Path) -> list[Path]:
|
||||
"""Every `<artist>/discord/None` directory. Artist folders that differ only by
|
||||
case (`Conto` and `conto`) are separate directories and both are found."""
|
||||
return sorted(d for d in Path(images_root).glob("*/discord/None") if d.is_dir())
|
||||
|
||||
|
||||
def count_archive_entries(archive: Path) -> int:
|
||||
return _archive(archive, delete=False)
|
||||
|
||||
|
||||
def forget_archive_entries(archive: Path) -> int:
|
||||
return _archive(archive, delete=True)
|
||||
|
||||
|
||||
def _archive(archive: Path, *, delete: bool) -> int:
|
||||
if not archive.is_file():
|
||||
return 0
|
||||
# A download running at the same moment holds this file briefly. Waiting
|
||||
# 30s for its lock beats failing the repair over a transient contention.
|
||||
conn = sqlite3.connect(str(archive), timeout=30)
|
||||
try:
|
||||
has_table = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='archive'"
|
||||
).fetchone()
|
||||
if not has_table:
|
||||
return 0
|
||||
if not delete:
|
||||
return conn.execute(_COUNT_SQL).fetchone()[0]
|
||||
cur = conn.execute(_DELETE_SQL)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _sweep_directory(directory: Path) -> tuple[int, bool]:
|
||||
"""Remove what the record deletes left behind: the collided sidecars, plus any
|
||||
file that never became a record (a quarantined or rejected download). Then
|
||||
the directory itself, if it is empty. Returns (files removed, dir removed)."""
|
||||
removed = 0
|
||||
for f in directory.iterdir():
|
||||
if f.is_file():
|
||||
try:
|
||||
f.unlink()
|
||||
removed += 1
|
||||
except OSError as exc:
|
||||
log.warning("discord repair: could not remove %s: %s", f, exc)
|
||||
try:
|
||||
directory.rmdir()
|
||||
return removed, True
|
||||
except OSError:
|
||||
return removed, False
|
||||
|
||||
|
||||
def repair_discord_downloads(
|
||||
session: Session, *, images_root: Path, dry_run: bool,
|
||||
) -> dict:
|
||||
images_root = Path(images_root)
|
||||
archive = archive_path(images_root)
|
||||
|
||||
broken = select(ImageRecord.id, ImageRecord.size_bytes).where(
|
||||
ImageRecord.path.like(_BROKEN_PATH_LIKE, escape="\\")
|
||||
)
|
||||
rows = session.execute(broken).all()
|
||||
image_ids = [r.id for r in rows]
|
||||
directories = broken_directories(images_root)
|
||||
sources = session.execute(
|
||||
select(Source).where(Source.platform == "discord")
|
||||
).scalars().all()
|
||||
|
||||
summary = {
|
||||
"images": len(image_ids),
|
||||
"bytes": sum(r.size_bytes or 0 for r in rows),
|
||||
"directories": len(directories),
|
||||
"sources": len(sources),
|
||||
"enabled_sources": sum(1 for s in sources if s.enabled),
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
summary["archive_entries"] = count_archive_entries(archive)
|
||||
return summary
|
||||
|
||||
deleted = delete_images(session, image_ids=image_ids, images_root=images_root)
|
||||
|
||||
swept = 0
|
||||
directories_removed = 0
|
||||
for d in directories:
|
||||
n, gone = _sweep_directory(d)
|
||||
swept += n
|
||||
directories_removed += int(gone)
|
||||
|
||||
# Only after the files are gone. Forgetting first and failing half way would
|
||||
# leave gallery-dl free to re-fetch into a directory still full of the old
|
||||
# copies.
|
||||
forgotten = forget_archive_entries(archive)
|
||||
|
||||
for source in sources:
|
||||
arm_backfill(source)
|
||||
session.commit()
|
||||
|
||||
remaining = session.execute(
|
||||
select(func.count(ImageRecord.id)).where(
|
||||
ImageRecord.path.like(_BROKEN_PATH_LIKE, escape="\\")
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
summary.update(
|
||||
images_deleted=deleted["images_deleted"],
|
||||
files_failed=deleted["files_failed"],
|
||||
leftover_files_removed=swept,
|
||||
directories_removed=directories_removed,
|
||||
archive_entries=forgotten,
|
||||
backfills_started=len(sources),
|
||||
remaining=remaining,
|
||||
)
|
||||
log.info("discord repair applied: %s", summary)
|
||||
return summary
|
||||
@@ -23,41 +23,17 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from .discord_ingester import DiscordIngester
|
||||
from .gallery_dl 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 .platforms import known_platform_keys
|
||||
from .pixiv_client import user_id_from_url
|
||||
from .pixiv_ingester import PixivIngester
|
||||
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) 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:
|
||||
"""Why `platform` may not be downloaded or verified, or None if it may.
|
||||
|
||||
A source can outlive its platform. Retiring one (DeviantArt #3069, pixiv
|
||||
#406) unregisters it, but its `Source` rows — and the `enabled` flag on
|
||||
them — are data, and data survives a deploy. So this refuses at the two
|
||||
functions every download and every credential probe pass through, instead
|
||||
of trusting the scheduler's `enabled` filter and every future caller to
|
||||
agree.
|
||||
|
||||
Without it a retired platform does not fail: it falls through to the
|
||||
gallery-dl branch, which is precisely where a platform lands once it is no
|
||||
longer native — and gallery-dl still has an extractor for it.
|
||||
"""
|
||||
if platform in known_platform_keys():
|
||||
return None
|
||||
return f"{platform!r} is not a supported platform (retired or unknown)"
|
||||
# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord) until
|
||||
# they migrate too.
|
||||
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "pixiv"})
|
||||
|
||||
# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
|
||||
# messages so the operator sees the exact lookup endpoint that was hit.
|
||||
@@ -71,8 +47,8 @@ def _native_ingester_cls(platform: str):
|
||||
dispatch pick up the replacement."""
|
||||
return {
|
||||
"patreon": PatreonIngester,
|
||||
"pixiv": PixivIngester,
|
||||
"subscribestar": SubscribeStarIngester,
|
||||
"discord": DiscordIngester,
|
||||
}[platform]
|
||||
|
||||
|
||||
@@ -90,7 +66,6 @@ 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
|
||||
@@ -105,16 +80,9 @@ async def run_download(
|
||||
backfill state machine and owns phase 3.
|
||||
"""
|
||||
platform = ctx["platform"]
|
||||
refusal = _unsupported_platform_message(platform)
|
||||
if refusal is not None:
|
||||
return DownloadResult(
|
||||
success=False, url=ctx["url"], artist_slug=ctx["artist_slug"],
|
||||
platform=platform,
|
||||
error_type=ErrorType.UNSUPPORTED_URL, error_message=refusal,
|
||||
), None
|
||||
if uses_native_ingester(platform):
|
||||
return await _run_native_ingester(
|
||||
ctx, source_config, mode, gdl, sync_session_factory, revisit_days
|
||||
ctx, source_config, mode, gdl, sync_session_factory
|
||||
)
|
||||
result = await gdl.download(
|
||||
url=ctx["url"],
|
||||
@@ -132,17 +100,25 @@ 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
|
||||
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:
|
||||
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":
|
||||
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}; "
|
||||
@@ -154,7 +130,6 @@ 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;
|
||||
@@ -170,8 +145,8 @@ async def _run_native_ingester(
|
||||
platform, ctx["url"], ctx["cookies_path"], overrides
|
||||
)
|
||||
if not campaign_id:
|
||||
# Patreon: vanity lookup failed. (SubscribeStar's campaign id is the
|
||||
# URL itself — never lands here.)
|
||||
# Patreon: vanity lookup failed. Pixiv: no numeric user id in the URL.
|
||||
# (SubscribeStar's campaign id is the URL itself — never lands here.)
|
||||
url = ctx["url"]
|
||||
return (
|
||||
DownloadResult(
|
||||
@@ -203,7 +178,7 @@ async def _run_native_ingester(
|
||||
validate=gdl._validate_files,
|
||||
rate_limit=rate_limit,
|
||||
request_sleep=request_sleep,
|
||||
# Uniform across adapters: a token platform would authenticate with
|
||||
# Uniform across adapters: token platforms (pixiv) authenticate with
|
||||
# it, cookie platforms accept-and-ignore — so this construction stays
|
||||
# platform-agnostic.
|
||||
auth_token=ctx["auth_token"],
|
||||
@@ -219,9 +194,6 @@ 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"),
|
||||
@@ -245,23 +217,19 @@ async def verify_source_credential(
|
||||
network / nothing to test). Callers don't branch on platform — they call
|
||||
this and render the result.
|
||||
"""
|
||||
refusal = _unsupported_platform_message(platform)
|
||||
if refusal is not None:
|
||||
# Inconclusive rather than False: nothing was probed, so nothing was
|
||||
# rejected. False would tell the operator their credential is bad.
|
||||
return None, refusal
|
||||
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.
|
||||
# resolves the campaign id first; Pixiv's is one OAuth refresh (the
|
||||
# exact call that fails when the token is bad — no feed walk).
|
||||
if platform == "subscribestar":
|
||||
from .subscribestar_ingester import verify_subscribestar_credential
|
||||
|
||||
return await verify_subscribestar_credential(url, cookies_path, config_overrides)
|
||||
if platform == "discord":
|
||||
from .discord_ingester import verify_discord_credential
|
||||
if platform == "pixiv":
|
||||
from .pixiv_ingester import verify_pixiv_credential
|
||||
|
||||
return await verify_discord_credential(url, auth_token)
|
||||
return await verify_pixiv_credential(auth_token)
|
||||
from .patreon_ingester import verify_patreon_credential
|
||||
|
||||
return await verify_patreon_credential(url, cookies_path, config_overrides)
|
||||
|
||||
@@ -34,12 +34,9 @@ from .gallery_dl import (
|
||||
GalleryDLService,
|
||||
SourceConfig,
|
||||
extract_errors_warnings,
|
||||
is_informational,
|
||||
truncate_log,
|
||||
walk_completed,
|
||||
)
|
||||
from .importer import Importer
|
||||
from .ingest_core import DEFAULT_REVISIT_DAYS
|
||||
from .platforms import auth_type_for
|
||||
from .scheduler_service import set_platform_cooldown
|
||||
|
||||
@@ -61,7 +58,6 @@ 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
|
||||
@@ -73,12 +69,6 @@ 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."""
|
||||
@@ -186,7 +176,6 @@ 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]:
|
||||
@@ -423,13 +412,6 @@ 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)
|
||||
if mark_seen is not None:
|
||||
await loop.run_in_executor(None, mark_seen)
|
||||
|
||||
# #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
|
||||
@@ -570,13 +552,11 @@ class DownloadService:
|
||||
# page is no longer double-counted. new_overrides (read fresh above)
|
||||
# carries the ingester's committed value forward untouched.
|
||||
|
||||
# Shared with the result path so the two halves can't disagree about
|
||||
# what "finished" means. Note it admits an INFORMATIONAL error_type: a
|
||||
# fully-paywalled creator's backfill really did reach the bottom, and
|
||||
# treating it as unfinished would re-walk that wall every chunk until
|
||||
# the stall counter tripped — the creator we can see least becoming the
|
||||
# one we fetch most.
|
||||
completed = walk_completed(dl_result)
|
||||
completed = (
|
||||
dl_result.success
|
||||
and dl_result.error_type is None
|
||||
and dl_result.return_code == 0
|
||||
)
|
||||
if completed:
|
||||
new_overrides["_backfill_state"] = "complete"
|
||||
new_overrides.pop("_backfill_cursor", None)
|
||||
@@ -645,17 +625,8 @@ class DownloadService:
|
||||
if status == "ok":
|
||||
source.consecutive_failures = 0
|
||||
source.last_error = None
|
||||
# alembic 0032 — clear the failure-class chip on success, EXCEPT an
|
||||
# informational class. tier_limited rides an otherwise-successful
|
||||
# run: failures stay 0 and last_error stays clear (the run did not
|
||||
# fail and must not earn a backoff), but "there is content here we
|
||||
# aren't allowed to see" is a durable fact about the SOURCE, not
|
||||
# about this run. Clearing it here is what left FailingSourcesCard's
|
||||
# `tier_limited` palette entry unreachable — the chip was wiped by
|
||||
# the very success that produced it.
|
||||
source.error_type = (
|
||||
error_type if is_informational(error_type) else None
|
||||
)
|
||||
# alembic 0032 — clear the failure-class chip on success.
|
||||
source.error_type = None
|
||||
elif status == "error":
|
||||
source.consecutive_failures = (source.consecutive_failures or 0) + 1
|
||||
source.last_error = error_message
|
||||
|
||||
@@ -21,9 +21,6 @@ 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
|
||||
|
||||
|
||||
class UnknownPlatformError(Exception):
|
||||
"""URL didn't match any platform pattern."""
|
||||
@@ -33,10 +30,6 @@ class InvalidUrlError(Exception):
|
||||
"""URL was empty or missing a scheme."""
|
||||
|
||||
|
||||
class UnknownArtistError(Exception):
|
||||
"""quick-add named an `artist_id` that does not exist."""
|
||||
|
||||
|
||||
# Mirrored byte-for-byte from extension/lib/platforms.js
|
||||
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
|
||||
# reviewers catch drift.
|
||||
@@ -62,104 +55,44 @@ _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+)?/?(?:[?#].*)?$",
|
||||
("pixiv", re.compile(
|
||||
r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P<slug>\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 platform's display name at
|
||||
# add-time. None → skip resolution, fall back to the handle.
|
||||
# Optional decryptor for resolving a token-auth platform's display name
|
||||
# (pixiv) at add-time. None → skip resolution, fall back to the handle.
|
||||
self._crypto = crypto
|
||||
|
||||
async def quick_add_source(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
artist_id: int | None = None,
|
||||
artist_name: str | 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."""
|
||||
async def quick_add_source(self, url: str) -> dict:
|
||||
platform, raw_slug = self._derive(url)
|
||||
url = canonical_source_url(platform, url, raw_slug)
|
||||
# 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), 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)
|
||||
# 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()
|
||||
if existing is not None:
|
||||
artist = (await self.session.execute(
|
||||
select(Artist).where(Artist.id == existing.artist_id)
|
||||
)).scalar_one()
|
||||
return self._shape(existing, artist, created_source=False, created_artist=False)
|
||||
|
||||
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
|
||||
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)
|
||||
# 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)
|
||||
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)
|
||||
|
||||
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:
|
||||
return {
|
||||
@@ -183,17 +116,12 @@ 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 — 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 — 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.
|
||||
The resolvers are sync, so they run in an executor."""
|
||||
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}"
|
||||
if self._crypto is None or platform not in ("patreon", "subscribestar"):
|
||||
if self._crypto is None or platform not in ("pixiv", "patreon", "subscribestar"):
|
||||
return raw_slug
|
||||
import asyncio
|
||||
|
||||
@@ -201,7 +129,15 @@ class ExtensionService:
|
||||
cred = CredentialService(self.session, self._crypto)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
if platform == "patreon":
|
||||
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":
|
||||
cookies = await cred.get_cookies_path("patreon")
|
||||
from .patreon_resolver import resolve_display_name
|
||||
name = await loop.run_in_executor(
|
||||
@@ -242,8 +178,6 @@ class ExtensionService:
|
||||
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)
|
||||
artist = (await self.session.execute(
|
||||
@@ -283,106 +217,6 @@ class ExtensionService:
|
||||
},
|
||||
}
|
||||
|
||||
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():
|
||||
raise InvalidUrlError("url is empty")
|
||||
|
||||
@@ -17,7 +17,6 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
@@ -95,16 +94,6 @@ BACKFILL_CHUNK_SECONDS = 600
|
||||
_DEFAULT_GDL_TIMEOUT_SECONDS = 870
|
||||
|
||||
|
||||
def archive_path(images_root: Path) -> Path:
|
||||
"""gallery-dl's download archive: the record of what it has already fetched.
|
||||
|
||||
One definition, because the Discord repair (services/discord_repair.py) has
|
||||
to find the same file the downloader writes, without constructing a service
|
||||
whose __init__ creates directories.
|
||||
"""
|
||||
return Path(images_root) / ".gallery-dl" / "archive.sqlite3"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceConfig:
|
||||
"""Per-source overrides loaded from Source.config_overrides JSON.
|
||||
@@ -189,13 +178,6 @@ class DownloadResult:
|
||||
# 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. None on gallery-dl.
|
||||
mark_seen_after_import: Callable[[], None] | None = None
|
||||
|
||||
|
||||
def extract_errors_warnings(stderr: str) -> str:
|
||||
@@ -279,72 +261,6 @@ def make_run_stats(
|
||||
}
|
||||
|
||||
|
||||
# --- 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."""
|
||||
|
||||
@@ -383,16 +299,24 @@ class GalleryDLService:
|
||||
# (services/patreon_ingester.py), not gallery-dl.
|
||||
PLATFORM_DEFAULTS = {
|
||||
# subscribestar removed — native-ingester platform now (#71); pixiv
|
||||
# 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.
|
||||
# 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.
|
||||
"hentaifoundry": {
|
||||
"content_types": ["all"],
|
||||
"directory": [],
|
||||
"filename": "{category}_{index:>03}_{title[:50]}.{extension}",
|
||||
"include": "all",
|
||||
},
|
||||
"discord": {
|
||||
"content_types": ["all"],
|
||||
"directory": ["{channel[name]}"],
|
||||
"filename": "{date:%Y%m%d}_{id}_{filename}.{extension}",
|
||||
"embeds": "all",
|
||||
"stickers": True,
|
||||
"reactions": False,
|
||||
"threads": True,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -412,7 +336,7 @@ class GalleryDLService:
|
||||
config = {
|
||||
"extractor": {
|
||||
"base-directory": str(self.images_root),
|
||||
"archive": str(archive_path(self.images_root)),
|
||||
"archive": str(self._config_dir / "archive.sqlite3"),
|
||||
"skip": True,
|
||||
"sleep": self._rate_limit,
|
||||
"sleep-request": max(0.5, self._rate_limit / 4),
|
||||
@@ -607,12 +531,12 @@ class GalleryDLService:
|
||||
line for line in combined.split("\n")
|
||||
if "][warning]" in line and "not allowed to view post" in line
|
||||
]
|
||||
# Same predicate + wording the native path uses, so the two backends
|
||||
# can't drift on what counts as tier-gated or how it reads.
|
||||
count = len(tier_gated_lines)
|
||||
gated = classify_tier_gated(count)
|
||||
if gated is not None:
|
||||
return (gated, tier_gated_message(count))
|
||||
if tier_gated_lines:
|
||||
count = len(tier_gated_lines)
|
||||
return (
|
||||
ErrorType.TIER_LIMITED,
|
||||
f"Subscription tier does not grant access to {count} post{'s' if count != 1 else ''}",
|
||||
)
|
||||
|
||||
# Partial-success: the subprocess exited non-zero (typically because
|
||||
# the wall-clock timeout fired mid-walk), but it had downloaded ≥1
|
||||
@@ -754,6 +678,9 @@ 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),
|
||||
@@ -937,6 +864,8 @@ 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/hentaifoundry/...).
|
||||
# gallery-dl platform name (patreon/pixiv/...).
|
||||
UNSOURCED_PLATFORM = "__unsourced__"
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
|
||||
]
|
||||
|
||||
|
||||
def _diversify_similar(src, rows, limit, *, dup_threshold=32, lam=0.40):
|
||||
def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
|
||||
"""Trim a nearest-cosine candidate pool down to `limit` diverse picks.
|
||||
|
||||
1. pHash collapse: drop any candidate whose perceptual hash is within
|
||||
@@ -338,11 +338,6 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=32, lam=0.40):
|
||||
2026-07-01 — dropped 0.55→0.40, dup 6→8, paired with a wider pool in
|
||||
`similar()`).
|
||||
|
||||
`dup_threshold` counts Hamming bits, so it moved 8→32 when the pHash went
|
||||
from 64 to 256 bits (#4223, migration 0098) — the same fraction of the
|
||||
hash, i.e. the tuning the operator chose, unchanged. This collapse is
|
||||
DISPLAY-only: it hides a near-dup from one rail, it never drops a record.
|
||||
|
||||
Falls back to nearest-order (`rows[:limit]`) on any failure or a small pool.
|
||||
"""
|
||||
if len(rows) <= 1:
|
||||
|
||||
@@ -33,14 +33,13 @@ from ..models import (
|
||||
)
|
||||
from ..utils import safe_probe
|
||||
from ..utils.paths import (
|
||||
canonical_subdir,
|
||||
derive_subdir,
|
||||
derive_top_level_artist,
|
||||
filehash_from_url,
|
||||
hash_suffixed_name,
|
||||
safe_ext,
|
||||
)
|
||||
from ..utils.phash import compute_phash, find_similar, fingerprint_path, fingerprints_match
|
||||
from ..utils.phash import compute_phash, find_similar
|
||||
from ..utils.sidecar import find_sidecar, parse_sidecar
|
||||
from ..utils.slug import slugify
|
||||
from .archive_extractor import extract_archive, is_archive
|
||||
@@ -235,38 +234,6 @@ class Importer:
|
||||
(phash, width or 0, height or 0, image_id)
|
||||
)
|
||||
|
||||
def _pixel_confirmer(self, source: Path):
|
||||
"""Build `find_similar`'s gate-3 callback for an incoming file.
|
||||
|
||||
pHash proposes; this accepts. A candidate is a duplicate only if its
|
||||
file really is the same picture as `source` at a different size —
|
||||
which is the only merge the operator asked for (#4223). Everything
|
||||
else (a missing file, an unreadable one, a deleted row) returns
|
||||
False: the destructive outcomes here are dropping a download and
|
||||
overwriting a kept file, so an unanswerable question must not read
|
||||
as "yes".
|
||||
|
||||
Both sides' fingerprints are computed lazily and cached, so an
|
||||
import that matches nothing costs no I/O at all and an archive
|
||||
member that keeps hitting the same candidate pays for it once.
|
||||
"""
|
||||
new_fp: list = []
|
||||
cand_fps: dict[int, object] = {}
|
||||
|
||||
def confirm(candidate_id: int) -> bool:
|
||||
if not new_fp:
|
||||
new_fp.append(fingerprint_path(source))
|
||||
if new_fp[0] is None:
|
||||
return False
|
||||
if candidate_id not in cand_fps:
|
||||
rec = self.session.get(ImageRecord, candidate_id)
|
||||
cand_fps[candidate_id] = (
|
||||
fingerprint_path(Path(rec.path)) if rec and rec.path else None
|
||||
)
|
||||
return fingerprints_match(new_fp[0], cand_fps[candidate_id])
|
||||
|
||||
return confirm
|
||||
|
||||
def _get_or_create(self, stmt, factory):
|
||||
"""Race-safe find-or-create. Run `stmt` (scalar_one_or_none); if a
|
||||
row exists, return it. Otherwise open a savepoint and INSERT
|
||||
@@ -895,7 +862,6 @@ class Importer:
|
||||
rel, match_id = find_similar(
|
||||
phash, width or 0, height or 0,
|
||||
candidates, self.settings.phash_threshold,
|
||||
confirm=self._pixel_confirmer(source),
|
||||
)
|
||||
if rel == "larger_exists":
|
||||
# Enrich-on-duplicate (parity with attach_in_place).
|
||||
@@ -945,7 +911,7 @@ class Importer:
|
||||
)
|
||||
return ImportResult(status="superseded", image_id=match_id)
|
||||
|
||||
dest = self._copy_to_library(source, sha, attribution_path, path_artist)
|
||||
dest = self._copy_to_library(source, sha, attribution_path)
|
||||
|
||||
record = ImageRecord(
|
||||
path=str(dest),
|
||||
@@ -1275,7 +1241,6 @@ class Importer:
|
||||
rel, match_id = find_similar(
|
||||
phash, width or 0, height or 0,
|
||||
candidates, self.settings.phash_threshold,
|
||||
confirm=self._pixel_confirmer(path),
|
||||
)
|
||||
if rel == "larger_exists":
|
||||
# Enrich-on-duplicate: link the near-dup's post to the
|
||||
@@ -1601,8 +1566,7 @@ class Importer:
|
||||
)
|
||||
|
||||
def _copy_to_library(
|
||||
self, source: Path, sha: str, attribution_path: Path,
|
||||
artist: Artist | None = None,
|
||||
self, source: Path, sha: str, attribution_path: Path
|
||||
) -> Path:
|
||||
"""Copy `source` to its final library path. Returns the destination.
|
||||
|
||||
@@ -1610,18 +1574,8 @@ class Importer:
|
||||
_import_media (filesystem scan) and _supersede (when new_path is
|
||||
not passed). FC-3c's attach_in_place skips this helper entirely
|
||||
— the file is already at its final home.
|
||||
|
||||
`artist`, when resolved, decides the top-level directory: the
|
||||
library is keyed on the Artist row's slug, NOT on however the
|
||||
import folder happened to be capitalised. Without that, an import
|
||||
from `/import/Conto/` and a download for the same artist write to
|
||||
`Conto/` and `conto/` respectively and the library grows a second
|
||||
home for one artist (milestone #421).
|
||||
"""
|
||||
subdir = canonical_subdir(
|
||||
derive_subdir(attribution_path, self.import_root),
|
||||
artist.slug if artist else None,
|
||||
)
|
||||
subdir = derive_subdir(attribution_path, self.import_root)
|
||||
dest_dir = self.images_root / subdir if subdir else self.images_root
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_name = hash_suffixed_name(source.stem, sha, source.suffix)
|
||||
@@ -1656,16 +1610,7 @@ class Importer:
|
||||
that path (FC-3c attach_in_place case) — skip the copy step.
|
||||
Otherwise the file is copied via _copy_to_library."""
|
||||
if new_path is None:
|
||||
# The KEPT row's artist decides the destination, not the incoming
|
||||
# file's folder — a supersede rewrites `existing.path`, so writing
|
||||
# it anywhere but that artist's canonical directory would move a
|
||||
# row OUT of the tree milestone #421 is consolidating. ImageRecord
|
||||
# carries `artist_id` with no relationship attribute, so this is a
|
||||
# lookup rather than `existing.artist`.
|
||||
kept_artist = artist
|
||||
if kept_artist is None and existing.artist_id is not None:
|
||||
kept_artist = self.session.get(Artist, existing.artist_id)
|
||||
dest = self._copy_to_library(source, sha, source, kept_artist)
|
||||
dest = self._copy_to_library(source, sha, source)
|
||||
else:
|
||||
dest = new_path
|
||||
|
||||
|
||||
@@ -31,19 +31,11 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import ImageRecord
|
||||
from .gallery_dl import (
|
||||
DownloadResult,
|
||||
ErrorType,
|
||||
classify_tier_gated,
|
||||
make_run_stats,
|
||||
tier_gated_message,
|
||||
)
|
||||
from .gallery_dl import DownloadResult, ErrorType, make_run_stats
|
||||
from .native_ingest_common import NativeAuthError, NativeDriftError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -53,33 +45,6 @@ log = logging.getLogger(__name__)
|
||||
# per-file HEADs. Headroom against paywalled/undownloadable items interleaving.
|
||||
_TICK_SEEN_THRESHOLD = 20
|
||||
|
||||
# How far back a tick keeps looking even once everything is already-have-it —
|
||||
# the REVISIT WINDOW. Operator, 2026-09-23, holding up 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 creator who edits a three-day-old post to append a hotfix build was
|
||||
# structurally unreachable: that post sits twenty-odd already-seen items down
|
||||
# the feed, so the count early-out above fired before the walk ever got to it.
|
||||
# Not a bug in the early-out — a COUNT cannot express "recent".
|
||||
#
|
||||
# So the early-out now needs BOTH conditions: the run of already-seen items AND
|
||||
# a post published before the horizon. Strictly a widening. Two properties this
|
||||
# shape has and a plain "walk the last N days" would not:
|
||||
#
|
||||
# * window 0 is exactly the old behaviour, so the feature has an off switch
|
||||
# that costs nothing to reason about;
|
||||
# * no window can make a tick stop EARLIER than it used to. A source paused
|
||||
# for months has an unseen backlog stretching well past any horizon, and
|
||||
# the walk still runs to the end of it — the horizon is a FLOOR on how far
|
||||
# to look, never a ceiling.
|
||||
#
|
||||
# The live value is `ImportSettings.download_revisit_days` (rule 25 — an
|
||||
# operator tuning how far back their creators edit should not need a redeploy).
|
||||
# This is the fallback for a caller that passes none.
|
||||
DEFAULT_REVISIT_DAYS = 30
|
||||
|
||||
# plan #705 #7: after this many failed download/validate attempts a media is
|
||||
# "dead-lettered" and skipped on routine tick/backfill walks (recovery still
|
||||
# re-attempts it). Stops a permanently-broken media re-erroring forever.
|
||||
@@ -106,44 +71,6 @@ _LIVE_PROGRESS_INTERVAL = 5.0
|
||||
# recapture (the operator's schema-test flow) reaches the sample.
|
||||
_CANARY_MIN_SAMPLE = 30
|
||||
|
||||
# The walk's time budget covers only the walk, but phase 3 runs in the SAME
|
||||
# Celery task, under the same soft limit (tasks/download.py: 1350s). A walk that
|
||||
# finds a lot of work for phase 3 must stop early and leave it to the next chunk,
|
||||
# or phase 3 is killed mid-import: TamadaHeijun's recapture, 2026-09-24, walked
|
||||
# for ~2 min and then spent 20 min importing 431 orphans and relinking ~3000
|
||||
# on-disk files, and died at the soft limit.
|
||||
#
|
||||
# So the walk also stops when its elapsed time PLUS phase 3's estimated cost
|
||||
# would pass CHUNK_TOTAL_SECONDS. Costs measured on the live instance: 431
|
||||
# imports took 976s (~2.3s each: hash, pHash, sidecar, provenance); a relink is
|
||||
# a sha256 over NFS, 0.15s for the 8.8 MB average file, plus a lookup.
|
||||
# test_download_source_task pins CHUNK_TOTAL_SECONDS under the soft limit.
|
||||
CHUNK_TOTAL_SECONDS = 1200.0
|
||||
PHASE3_IMPORT_SECONDS = 2.5
|
||||
PHASE3_RELINK_SECONDS = 0.25
|
||||
|
||||
|
||||
def _parse_published(raw: object) -> datetime | None:
|
||||
"""An ISO-8601 post date from either native client, as aware UTC.
|
||||
|
||||
Patreon's `published_at` is tz-aware with a `Z` or `+00:00` offset;
|
||||
SubscribeStar's is NAIVE (`_parse_ss_datetime` renders a parsed local
|
||||
timestamp with no zone). A naive value is read as UTC — the alternative is
|
||||
discarding it, and a post whose date we refuse to read is a post the revisit
|
||||
window can never reach.
|
||||
|
||||
Anything unparseable returns None, which reads downstream as "not provably
|
||||
recent" and leaves the walk on its pre-revisit behaviour. Never raises: a
|
||||
date we cannot read must not fail a walk that is otherwise working.
|
||||
"""
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
class Ingester:
|
||||
"""Generic native-ingest orchestration. Subclass with a platform adapter
|
||||
@@ -180,9 +107,9 @@ class Ingester:
|
||||
# (e.g. "Patreon API", "SubscribeStar markup").
|
||||
self._drift_label = drift_label or platform
|
||||
# #862 canary opt-out: platforms whose posts legitimately have empty
|
||||
# bodies across large samples would false-positive the
|
||||
# zero-bodies-means-drift alarm; their clients catch drift structurally
|
||||
# (response-shape checks) instead. The
|
||||
# bodies across large samples (pixiv — caption-less artists are common)
|
||||
# would false-positive the zero-bodies-means-drift alarm; their clients
|
||||
# catch drift structurally (response-shape checks) instead. The
|
||||
# "bodies X/N" summary line still surfaces the ratio either way.
|
||||
self._body_canary = body_canary
|
||||
|
||||
@@ -199,17 +126,11 @@ class Ingester:
|
||||
resume_cursor: str | None = None,
|
||||
time_budget_seconds: float = 870.0,
|
||||
seen_threshold: int = _TICK_SEEN_THRESHOLD,
|
||||
revisit_days: int = DEFAULT_REVISIT_DAYS,
|
||||
posts_base: int = 0,
|
||||
event_id: int | None = None,
|
||||
) -> DownloadResult:
|
||||
"""Walk + download for one source, returning a gallery-dl-shaped result.
|
||||
|
||||
`revisit_days` is the tick's revisit window (see DEFAULT_REVISIT_DAYS):
|
||||
inside it a tick neither early-outs nor trusts the post-record gate, so
|
||||
a post edited after we first captured it is re-read and its new
|
||||
attachments downloaded. 0 turns the window off.
|
||||
|
||||
`mode` is "tick" | "backfill" | "recovery" | "recapture". Recovery
|
||||
bypasses the tier-1 seen-ledger AND the dead-letter ledger (tier-2 disk
|
||||
still skips kept files). Recapture (#830) is the cheap "re-grab post
|
||||
@@ -252,29 +173,6 @@ class Ingester:
|
||||
# no media download, no post-record stub. Absent on stub/not-yet-migrated
|
||||
# clients → nothing is ever treated as gated.
|
||||
post_is_gated = getattr(self.client, "post_is_gated", None)
|
||||
# The revisit window (see DEFAULT_REVISIT_DAYS). `post_meta` is an
|
||||
# existing client seam — both native clients already implement it, for
|
||||
# a preview sample whose caller has since gone, so this needed no new
|
||||
# contract, only a live consumer for one. Absent seam, an unreadable
|
||||
# date or a window of 0 → `horizon` never matches and the walk behaves
|
||||
# exactly as it did before 2026-09-23.
|
||||
#
|
||||
# The window applies to TICKS only. A backfill is gated on purpose
|
||||
# (capture each post once) and `recapture` mode already exists for the
|
||||
# operator-driven "re-read every body" pass; a horizon there would be a
|
||||
# third overlapping answer to a question that has two.
|
||||
post_meta = getattr(self.client, "post_meta", None)
|
||||
# #4413: optional client seam for a source that is several feeds walked
|
||||
# one after another (a Discord server: every channel and thread). The
|
||||
# tick early-out means "this feed has nothing new", and without the
|
||||
# seam it ends the WHOLE walk — so the first quiet channel would hide
|
||||
# every channel after it. With it, the early-out asks the client to
|
||||
# move on and the walk continues. Absent → the early-out ends the walk,
|
||||
# exactly as before (Patreon and SubscribeStar are one feed each).
|
||||
skip_feed = getattr(self.client, "skip_feed", None)
|
||||
horizon: datetime | None = None
|
||||
if mode == "tick" and revisit_days > 0 and post_meta is not None:
|
||||
horizon = datetime.now(UTC) - timedelta(days=revisit_days)
|
||||
start = time.monotonic()
|
||||
last_live = start # plan #709: last live-progress write timestamp
|
||||
log_lines: list[str] = []
|
||||
@@ -286,9 +184,6 @@ class Ingester:
|
||||
# source_filehash and (b) link the on-disk image to its Post (#1288) —
|
||||
# WITHOUT re-downloading or unlinking the file. Empty outside recapture.
|
||||
relink: list[tuple[str, str, str]] = []
|
||||
# Media handed to phase 3 for import. Marked seen by phase 3 once the
|
||||
# import has run (`mark_seen_after_import`), not here — see there.
|
||||
fetched: list[tuple[str, str]] = []
|
||||
downloaded = 0
|
||||
errors = 0
|
||||
quarantined = 0
|
||||
@@ -309,18 +204,11 @@ class Ingester:
|
||||
# absolute across chunks instead of an inflating sum. posts_processed
|
||||
# stays the gross per-chunk count used for the run summary.
|
||||
chunk_new_posts = 0
|
||||
# Posts inside the revisit window that we had already captured, and the
|
||||
# media those revisits turned up. Reported in the run summary — the
|
||||
# operator's ask was to SEE the updated posts, not only to end up with
|
||||
# their files ("so we can update ours to match").
|
||||
revisited = 0
|
||||
revisit_downloads = 0
|
||||
consecutive_seen = 0
|
||||
emitted_cursor: str | None = None
|
||||
reached_bottom = False
|
||||
budget_hit = False
|
||||
early_out = False
|
||||
feeds_caught_up = 0 # #4413: feeds a tick left early via skip_feed
|
||||
stopped = False # plan #708 B4: operator hit Stop mid-walk
|
||||
cancel_armed = False # latched once we observe a live "running" state
|
||||
|
||||
@@ -342,7 +230,6 @@ class Ingester:
|
||||
written_paths=written,
|
||||
post_record_paths=list(post_records),
|
||||
relink_source_paths=list(relink),
|
||||
mark_seen_after_import=lambda: self._mark_seen(source_id, fetched),
|
||||
stdout="\n".join(log_lines),
|
||||
stderr="",
|
||||
return_code=return_code,
|
||||
@@ -358,12 +245,6 @@ class Ingester:
|
||||
per_item_failures=errors,
|
||||
quarantined_count=quarantined,
|
||||
dead_lettered_count=dead_lettered,
|
||||
# #874 follow-up: the native path counted gated posts but
|
||||
# never reported them, so DownloadDetailModal's "Tier-gated"
|
||||
# field read 0 on every native walk while gallery-dl's read
|
||||
# true. A paywalled creator was indistinguishable from a
|
||||
# silent one.
|
||||
tier_gated_count=gated_skipped,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -418,17 +299,7 @@ class Ingester:
|
||||
|
||||
# Time-box check at the post boundary (coarse, like a gallery-dl
|
||||
# chunk). Backfill/recovery resume from emitted_cursor next chunk.
|
||||
# The second half is phase 3's share of the task — see
|
||||
# CHUNK_TOTAL_SECONDS. A mid-page stop resumes the same page.
|
||||
elapsed = time.monotonic() - start
|
||||
phase3 = (
|
||||
len(written) * PHASE3_IMPORT_SECONDS
|
||||
+ len(relink) * PHASE3_RELINK_SECONDS
|
||||
)
|
||||
if (
|
||||
elapsed >= time_budget_seconds
|
||||
or elapsed + phase3 >= CHUNK_TOTAL_SECONDS
|
||||
):
|
||||
if time.monotonic() - start >= time_budget_seconds:
|
||||
budget_hit = True
|
||||
break
|
||||
|
||||
@@ -439,20 +310,6 @@ class Ingester:
|
||||
# resume_cursor None, so everything counts.
|
||||
if not (resume_cursor and page_cursor == resume_cursor):
|
||||
chunk_new_posts += 1
|
||||
# Inside the revisit window? Computed per post rather than
|
||||
# "stop once one post is old" because the feed is only MOSTLY
|
||||
# date-ordered — a pinned or re-pinned post can sit above older
|
||||
# ones, and one such post must not end the walk.
|
||||
in_window = False
|
||||
if horizon is not None:
|
||||
published = _parse_published((post_meta(post) or {}).get("date"))
|
||||
in_window = published is not None and published >= horizon
|
||||
# Set by the post-record block below when this post was already
|
||||
# captured on an earlier walk. Stays False when the platform has
|
||||
# no post-record seam, so the revisit accounting simply reports
|
||||
# nothing rather than guessing.
|
||||
post_already_recorded = False
|
||||
downloaded_before = downloaded
|
||||
# Tier-gated post (#874): the account can't fully view it, so
|
||||
# Patreon serves only blurred locked-preview media. Skip it
|
||||
# ENTIRELY — no media download AND no post-record stub (operator
|
||||
@@ -484,31 +341,11 @@ class Ingester:
|
||||
set() if recapture_records
|
||||
else self._seen_keys(source_id, [pkey])
|
||||
)
|
||||
post_already_recorded = pkey in already
|
||||
# A post inside the revisit window is re-read even
|
||||
# though the gate has it: that gate's whole job is to
|
||||
# stop us paying for a post twice, and an EDITED post is
|
||||
# not the same post. `revisit=True` keeps the cost at
|
||||
# zero requests — the downloader re-reads the body from
|
||||
# the feed response already in hand and declines to
|
||||
# write at all if that body came back empty, so a
|
||||
# detail-fetched body is never overwritten by a blank.
|
||||
if not post_already_recorded or in_window:
|
||||
rec = write_post_record(
|
||||
post, artist_slug, revisit=post_already_recorded,
|
||||
)
|
||||
if not post_already_recorded:
|
||||
# FIRST captures only feed the #862 body canary.
|
||||
# A revisit legitimately comes back empty — a
|
||||
# post whose body only ever arrived from the
|
||||
# detail endpoint has none in the feed, and the
|
||||
# downloader declines to write it. Counting
|
||||
# those into the sample would walk the canary
|
||||
# toward firing on healthy ticks, which is the
|
||||
# one thing a drift alarm must never do.
|
||||
posts_recorded += 1
|
||||
if rec.body_chars:
|
||||
posts_with_body += 1
|
||||
if pkey not in already:
|
||||
rec = write_post_record(post, artist_slug)
|
||||
posts_recorded += 1
|
||||
if rec.body_chars:
|
||||
posts_with_body += 1
|
||||
if rec.path is not None:
|
||||
post_records.append(str(rec.path))
|
||||
self._mark_seen(source_id, [(pkey, ppid)])
|
||||
@@ -518,8 +355,7 @@ class Ingester:
|
||||
# a 0-char body is the "why is this one empty" answer.
|
||||
log_lines.append(
|
||||
f" post {ppid} [{rec.post_type or '?'}] "
|
||||
+ ("re-read, " if post_already_recorded else "")
|
||||
+ f"body: {rec.body_chars} chars"
|
||||
f"body: {rec.body_chars} chars"
|
||||
+ ("" if rec.body_chars else " — EMPTY")
|
||||
+ (f" — {rec.title}" if rec.title else "")
|
||||
)
|
||||
@@ -552,13 +388,6 @@ class Ingester:
|
||||
recapture=recapture,
|
||||
)
|
||||
|
||||
# An on-disk file is only "done" if something imported it. One
|
||||
# with no ImageRecord at its path was written by a run that died
|
||||
# before phase 3 — it goes to import, not to the ledger.
|
||||
imported_paths = self._recorded_paths([
|
||||
str(o.path) for o in outcomes
|
||||
if o.status == "skipped_disk" and o.path is not None
|
||||
])
|
||||
to_mark: list[tuple[str, str]] = []
|
||||
to_clear: list[str] = [] # recovered → drop any dead-letter row
|
||||
to_fail: list[tuple[str, str, str]] = [] # (key, post_id, error)
|
||||
@@ -570,29 +399,11 @@ class Ingester:
|
||||
downloaded += 1
|
||||
if outcome.path is not None:
|
||||
written.append(str(outcome.path))
|
||||
fetched.append((key, media_item.post_id))
|
||||
to_mark.append((key, media_item.post_id))
|
||||
to_clear.append(key)
|
||||
consecutive_seen = 0
|
||||
elif (
|
||||
outcome.status == "skipped_disk"
|
||||
and outcome.path is not None
|
||||
and str(outcome.path) not in imported_paths
|
||||
):
|
||||
# On disk, never imported: a prior run wrote it and died
|
||||
# before phase 3. Import it now. Safe to feed to
|
||||
# attach_in_place because no record owns this path —
|
||||
# the unlink below is about a file that IS the record.
|
||||
written.append(str(outcome.path))
|
||||
fetched.append((key, media_item.post_id))
|
||||
to_clear.append(key)
|
||||
skipped_count += 1
|
||||
consecutive_seen += 1
|
||||
log_lines.append(
|
||||
f" post {media_item.post_id} — on disk but never "
|
||||
f"imported: {outcome.path.name}"
|
||||
)
|
||||
elif outcome.status == "skipped_disk":
|
||||
# Already on disk and imported. Reconcile the ledger so a
|
||||
# Already on disk (a prior run). Reconcile the ledger so a
|
||||
# later tick skips it at tier-1 without a disk stat, but
|
||||
# do NOT re-feed it to phase 3 — attach_in_place would see
|
||||
# the duplicate sha256 and unlink the on-disk copy.
|
||||
@@ -628,15 +439,7 @@ class Ingester:
|
||||
to_fail.append((key, media_item.post_id, outcome.error or "error"))
|
||||
# An error neither advances nor resets the run-of-seen.
|
||||
|
||||
# `not in_window` is the revisit window's half of the
|
||||
# early-out: a run of already-seen items is only permission
|
||||
# to stop once the walk is BELOW the horizon. Both halves,
|
||||
# never either alone — see DEFAULT_REVISIT_DAYS.
|
||||
if (
|
||||
mode == "tick"
|
||||
and not in_window
|
||||
and consecutive_seen >= seen_threshold
|
||||
):
|
||||
if mode == "tick" and consecutive_seen >= seen_threshold:
|
||||
early_out = True
|
||||
break
|
||||
|
||||
@@ -650,21 +453,6 @@ class Ingester:
|
||||
if to_fail:
|
||||
self._record_failures(source_id, to_fail)
|
||||
|
||||
# An already-captured post that yielded NEW media is an edited
|
||||
# post — the operator's Floppystack case, and the one thing in
|
||||
# this walk worth naming individually in the run log. The media
|
||||
# half needed no new detection: `extract_media` reads the media
|
||||
# list off the live feed response, so a hotfix build appended
|
||||
# last night is simply a ledger key we have never seen.
|
||||
new_here = downloaded - downloaded_before
|
||||
if post_already_recorded and new_here:
|
||||
revisited += 1
|
||||
revisit_downloads += new_here
|
||||
log_lines.append(
|
||||
f" post {post.get('id')} — updated: "
|
||||
f"{new_here} new file(s)"
|
||||
)
|
||||
|
||||
# plan #709: time-throttled live progress to the running event so
|
||||
# the Downloads view ticks ~every 5s, independent of page size.
|
||||
now = time.monotonic()
|
||||
@@ -676,22 +464,12 @@ class Ingester:
|
||||
"errors": errors,
|
||||
"quarantined": quarantined,
|
||||
"posts": posts_processed,
|
||||
# Ticks during the walk, not only at finalization: a
|
||||
# deep backfill on a creator we've lost access to is
|
||||
# otherwise a long run of zeros with no explanation.
|
||||
"gated": gated_skipped,
|
||||
})
|
||||
|
||||
if early_out:
|
||||
if skip_feed is None:
|
||||
break
|
||||
skip_feed()
|
||||
feeds_caught_up += 1
|
||||
early_out = False
|
||||
consecutive_seen = 0
|
||||
break
|
||||
else:
|
||||
# A walk that left feeds early did not read to their ends.
|
||||
reached_bottom = not feeds_caught_up
|
||||
reached_bottom = True
|
||||
except self._error_base as exc:
|
||||
# The platform's client-error base — _failure_result (adapter)
|
||||
# maps it to a typed error.
|
||||
@@ -733,13 +511,6 @@ class Ingester:
|
||||
# visible in the Raw stdout (e.g. "bodies 3/180" reads as off).
|
||||
+ (f", bodies {posts_with_body}/{posts_recorded}" if posts_recorded else "")
|
||||
+ (f", {gated_skipped} gated-skipped" if gated_skipped else "")
|
||||
# Only when it happened: on a quiet tick this is 0 and saying so
|
||||
# every run would bury the times it is not.
|
||||
+ (
|
||||
f", {revisited} post(s) updated ({revisit_downloads} new file(s))"
|
||||
if revisited else ""
|
||||
)
|
||||
+ (f", {feeds_caught_up} feed(s) caught up" if feeds_caught_up else "")
|
||||
+ (", reached end" if reached_bottom else "")
|
||||
+ (", time-boxed" if budget_hit else "")
|
||||
)
|
||||
@@ -753,13 +524,7 @@ class Ingester:
|
||||
# next chunk resumes from the emitted cursor. No progress → TIMEOUT,
|
||||
# which feeds download_service's backfill stall-guard. rc<0 mirrors
|
||||
# subprocess TimeoutExpired so completion detection stays false.
|
||||
# Work handed to phase 3 is progress too: a recapture chunk that
|
||||
# stopped for its imports downloaded nothing, and may not have left
|
||||
# its first page.
|
||||
made_progress = (
|
||||
downloaded > 0 or bool(written) or bool(relink)
|
||||
or emitted_cursor != resume_cursor
|
||||
)
|
||||
made_progress = downloaded > 0 or emitted_cursor != resume_cursor
|
||||
if made_progress:
|
||||
return _result(
|
||||
success=False, return_code=-1,
|
||||
@@ -799,33 +564,17 @@ class Ingester:
|
||||
error_type=ErrorType.API_DRIFT, error_message=msg,
|
||||
)
|
||||
|
||||
# Normal success: reached the bottom, or a tick that early-outed. A
|
||||
# zero-download walk still returns success here — a re-confirming walk
|
||||
# that found nothing new genuinely completed. A tick that early-outed
|
||||
# also lands here; ticks never set backfill state so the lifecycle is a
|
||||
# no-op for them.
|
||||
#
|
||||
# success=True and return_code=0 are load-bearing, not cosmetic. They
|
||||
# are what make this a COMPLETE walk for
|
||||
# download_service._apply_backfill_lifecycle (via walk_completed) and
|
||||
# what map it to status "ok", so a walk that fetched nothing doesn't
|
||||
# accrue consecutive_failures or a backoff it hasn't earned.
|
||||
#
|
||||
# #874 follow-up: "nothing new" and "everything sat behind a tier you
|
||||
# don't hold" are different facts, and returning None for both made a
|
||||
# paywalled creator indistinguishable from a silent one. TIER_LIMITED is
|
||||
# classified LAST — every real failure has already returned above —
|
||||
# because tier-gating is the weakest signal and must never mask a
|
||||
# genuine error. It is informational, so walk_completed still counts
|
||||
# this walk as finished (see that predicate for why re-walking a
|
||||
# paywalled creator forever is the bug being avoided).
|
||||
gated_error = classify_tier_gated(gated_skipped)
|
||||
# Normal success: reached the bottom, or a tick that early-outed. rc 0 +
|
||||
# error_type None is REQUIRED for a backfill/recovery walk that reached
|
||||
# the bottom to be marked COMPLETE by
|
||||
# download_service._apply_backfill_lifecycle — so we return None even
|
||||
# when downloaded == 0 (a re-confirming walk that found nothing new still
|
||||
# completed). success=True maps to status "ok" regardless. A tick that
|
||||
# early-outed also returns here; ticks never set backfill state so the
|
||||
# lifecycle is a no-op for them.
|
||||
return _result(
|
||||
success=True, return_code=0,
|
||||
error_type=gated_error,
|
||||
error_message=(
|
||||
tier_gated_message(gated_skipped) if gated_error else None
|
||||
),
|
||||
error_type=None, error_message=None,
|
||||
)
|
||||
|
||||
# -- failure mapping (adapter overrides) -------------------------------
|
||||
@@ -965,16 +714,6 @@ class Ingester:
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def _recorded_paths(self, paths: list[str]) -> set[str]:
|
||||
"""Which of `paths` an ImageRecord already points at."""
|
||||
if not paths:
|
||||
return set()
|
||||
with self.session_factory() as session:
|
||||
rows = session.execute(
|
||||
select(ImageRecord.path).where(ImageRecord.path.in_(paths))
|
||||
).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
def _mark_seen(self, source_id: int, items: list[tuple[str, str]]) -> None:
|
||||
"""Idempotent upsert of (filehash, post_id) seen-ledger rows for a page.
|
||||
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
"""Reconciling the learned roster against the sources FC actually tracks.
|
||||
|
||||
Milestone 387, step C4. The step the operator asked for; C0-C3 are what make it
|
||||
trustworthy enough to act on.
|
||||
|
||||
## The buckets
|
||||
|
||||
1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The
|
||||
adoption win, and the only bucket carrying an action.
|
||||
2. `tracked_not_subscribed` — FC follows this and the roster does not show you
|
||||
paying for it. No longer shown on the card: the operator reversed the
|
||||
2026-09-11 "report only" call on 2026-09-13. The lapsed half of it now ACTS,
|
||||
in `apply_membership_lapses` below (#3995). The absent half still only
|
||||
reports, because absence proves nothing.
|
||||
3. `matched` — the healthy set. Counted, not listed loudly.
|
||||
4. `unidentified` — sources this join cannot speak to at all. Reported as
|
||||
exactly that, because the alternative is filing them under a verdict.
|
||||
|
||||
## Why absence is the dangerous direction
|
||||
|
||||
Bucket 1 is safe to be wrong about: the cost of offering a source the operator
|
||||
does not want is one ignored row. Bucket 2 is not. It is computed from an
|
||||
ABSENCE — no membership matched — and three different things produce that
|
||||
absence: the subscription genuinely lapsed, the sweep failed, or the creator
|
||||
renamed and this source has never been walked so no exact id was ever cached.
|
||||
|
||||
Two guards follow from that, and they are the substance of this module:
|
||||
|
||||
* the whole bucket is gated on `roster_is_fresh`, so a failed or never-run sweep
|
||||
yields an empty list rather than a confident accusation (C3 built the state
|
||||
this reads);
|
||||
* every row carries the BASIS for its claim, so "your membership says former
|
||||
patron" and "we know this creator's id and it is not in your roster" and "we
|
||||
only have a URL handle to go on" are three different sentences rather than one
|
||||
overconfident one.
|
||||
|
||||
`has_paid_access` returning None is honoured throughout: unknown is never
|
||||
rendered as lapsed. That is the whole reason it returns a tri-state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Artist, MembershipSync, PlatformMembership, Source
|
||||
from .membership_roster import (
|
||||
get_sync_state,
|
||||
identity_keys_for_source,
|
||||
pair_sources_with_memberships,
|
||||
roster_is_fresh,
|
||||
url_tail,
|
||||
)
|
||||
from .native_ingest_common import has_paid_access
|
||||
|
||||
# Why a source appears in `tracked_not_subscribed`. Ordered strongest first —
|
||||
# the UI renders a different sentence per basis, because collapsing them into
|
||||
# one would make the weakest claim sound like the strongest.
|
||||
BASIS_LAPSED = "lapsed" # a matched membership says access ended
|
||||
BASIS_ABSENT_EXACT = "absent_exact" # exact id known, not in a fresh roster
|
||||
BASIS_ABSENT_HANDLE = "absent_handle" # only a URL handle to go on
|
||||
|
||||
|
||||
def _membership_row(m: PlatformMembership) -> dict:
|
||||
return {
|
||||
"id": m.id,
|
||||
"platform": m.platform,
|
||||
"external_campaign_id": m.external_campaign_id,
|
||||
"display_name": m.display_name or m.vanity_or_none(),
|
||||
"url": m.url,
|
||||
"vanity": m.vanity_or_none(),
|
||||
"status": m.status,
|
||||
"tier_names": m.tier_names,
|
||||
"amount_cents": m.amount_cents,
|
||||
"currency": m.currency,
|
||||
"paid_access": has_paid_access(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _source_row(source: Source, artist: Artist) -> dict:
|
||||
return {
|
||||
"id": source.id,
|
||||
"platform": source.platform,
|
||||
"url": source.url,
|
||||
"enabled": source.enabled,
|
||||
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
||||
}
|
||||
|
||||
|
||||
async def reconcile(
|
||||
session: AsyncSession, *, platform: str, now: datetime | None = None,
|
||||
) -> dict:
|
||||
"""Sort one platform's memberships and sources into the four buckets.
|
||||
|
||||
Always returns the COMPLETE shape, including when the roster is not fresh —
|
||||
a caller reading `len(result["tracked_not_subscribed"])` must not have to
|
||||
check which keys exist first. `fresh` is what says whether the emptiness
|
||||
means anything.
|
||||
"""
|
||||
state = await get_sync_state(session, platform)
|
||||
fresh = roster_is_fresh(state, now=now)
|
||||
|
||||
memberships = (await session.execute(
|
||||
select(PlatformMembership).where(PlatformMembership.platform == platform)
|
||||
)).scalars().all()
|
||||
rows = (await session.execute(
|
||||
select(Source, Artist)
|
||||
.join(Artist, Artist.id == Source.artist_id)
|
||||
.where(Source.platform == platform)
|
||||
)).all()
|
||||
|
||||
# The join itself lives in `membership_roster` beside `match_kind`, so C5's
|
||||
# gated-reason annotation pairs sources with memberships by exactly the same
|
||||
# rule this card sorts them by. Two copies would let the Subscriptions row
|
||||
# and this card disagree about which creator a source IS.
|
||||
pairs = pair_sources_with_memberships([s for s, _a in rows], memberships)
|
||||
matched_membership_ids = {m.id for m, _kind in pairs.values()}
|
||||
|
||||
subscribed_not_tracked = []
|
||||
for m in memberships:
|
||||
if m.id in matched_membership_ids:
|
||||
continue
|
||||
paid = has_paid_access(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
)
|
||||
# A membership FC knows has ENDED is not an adoption opportunity —
|
||||
# adding it would start a walk that can only fetch what is already
|
||||
# public. Unknown (None) is still offered: the operator can judge it,
|
||||
# and refusing to show it would hide a real subscription behind a word
|
||||
# this code has not been taught.
|
||||
if paid is False:
|
||||
continue
|
||||
subscribed_not_tracked.append(_membership_row(m))
|
||||
|
||||
tracked_not_subscribed = []
|
||||
matched = []
|
||||
unidentified = []
|
||||
for source, artist in rows:
|
||||
pair = pairs.get(source.id)
|
||||
if pair is not None:
|
||||
m, kind = pair
|
||||
paid = has_paid_access(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
)
|
||||
if paid is False:
|
||||
if not source.enabled:
|
||||
# Already off. Reporting a source the operator has already
|
||||
# stopped following is noise, not a finding.
|
||||
continue
|
||||
row = _source_row(source, artist)
|
||||
row["basis"] = BASIS_LAPSED
|
||||
row["matched_by"] = kind
|
||||
row["membership"] = _membership_row(m)
|
||||
tracked_not_subscribed.append(row)
|
||||
else:
|
||||
row = _source_row(source, artist)
|
||||
row["matched_by"] = kind
|
||||
row["membership"] = _membership_row(m)
|
||||
matched.append(row)
|
||||
continue
|
||||
|
||||
# No membership matched. Whether that MEANS anything depends entirely on
|
||||
# how well this source can be identified at all.
|
||||
has_exact = bool(identity_keys_for_source(source))
|
||||
if not has_exact and url_tail(source.url) is None:
|
||||
# Nothing to match on — a sidecar anchor or a URL with no handle.
|
||||
# Reported as unidentified rather than silently dropped, so the
|
||||
# counts add up to the source list the operator can see.
|
||||
unidentified.append(_source_row(source, artist))
|
||||
continue
|
||||
if not source.enabled:
|
||||
# Already off. Telling the operator to stop following something they
|
||||
# have stopped following is noise, not a finding.
|
||||
continue
|
||||
row = _source_row(source, artist)
|
||||
row["basis"] = BASIS_ABSENT_EXACT if has_exact else BASIS_ABSENT_HANDLE
|
||||
row["matched_by"] = None
|
||||
row["membership"] = None
|
||||
tracked_not_subscribed.append(row)
|
||||
|
||||
# THE GATE. Everything above computed the bucket; this decides whether it may
|
||||
# be shown. A stale or never-run roster makes every absence meaningless, and
|
||||
# an absence rendered as a verdict is how this feature would tell the
|
||||
# operator to cancel something they are still paying for.
|
||||
if not fresh:
|
||||
tracked_not_subscribed = []
|
||||
|
||||
return {
|
||||
"platform": platform,
|
||||
"fresh": fresh,
|
||||
# How many sources exist on this platform at all. The UI needs it to
|
||||
# decide whether an untrustworthy roster is worth mentioning: with no
|
||||
# sources here there is nothing to reconcile, and a stale-roster warning
|
||||
# would be noise on an install that simply has not started yet (that
|
||||
# empty-install case is C6's, not this card's).
|
||||
"tracked_total": len(rows),
|
||||
"last_success_at": (
|
||||
state.last_success_at.isoformat()
|
||||
if state is not None and state.last_success_at else None
|
||||
),
|
||||
"subscribed_not_tracked": subscribed_not_tracked,
|
||||
"tracked_not_subscribed": tracked_not_subscribed,
|
||||
"matched": matched,
|
||||
"unidentified": unidentified,
|
||||
}
|
||||
|
||||
|
||||
async def reconcile_all(session: AsyncSession, now: datetime | None = None) -> dict:
|
||||
"""Every platform the roster knows about, in one payload for the UI.
|
||||
|
||||
The platform list is the UNION of platforms with memberships and platforms
|
||||
with sync state, not just the former. A sweep that has never succeeded has
|
||||
recorded zero memberships, and deriving the list from memberships alone
|
||||
would drop exactly that platform from the payload — making a broken
|
||||
credential indistinguishable from a platform FC was never asked about. That
|
||||
distinction is the whole reason C3 records sync state.
|
||||
"""
|
||||
with_memberships = (await session.execute(
|
||||
select(PlatformMembership.platform).distinct()
|
||||
)).scalars().all()
|
||||
with_state = (await session.execute(
|
||||
select(MembershipSync.platform)
|
||||
)).scalars().all()
|
||||
platforms = set(with_memberships) | set(with_state)
|
||||
return {
|
||||
"platforms": [
|
||||
await reconcile(session, platform=p, now=now) for p in sorted(platforms)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stop pulling what the account no longer pays for (#3995)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Operator decision, 2026-09-13, reversing the 2026-09-11 "report only" call
|
||||
# for this direction: "if I kill a subscription on patreon I would like the
|
||||
# pulling to stop on curator as well", with automatic resume on resubscribing.
|
||||
#
|
||||
# This is a SOURCE-level action taken by the daily sweep, visible on the source
|
||||
# row and reversible there. It is not a fetch-path decision. The line C5 draws,
|
||||
# that the roster never decides a POST is inaccessible, still holds: nothing
|
||||
# here reads per-post access, and no download path reads the roster
|
||||
# (`test_no_fetch_path_can_read_the_roster`). The scheduler keeps selecting on
|
||||
# `enabled` alone.
|
||||
#
|
||||
# Acts ONLY on positive evidence. A source whose matched membership says access
|
||||
# has ended is stopped. A source with NO matched membership is left alone,
|
||||
# because absence has innocent causes: a creator rename, a source never walked
|
||||
# so no id is cached, a membership the platform stopped listing. Stopping on
|
||||
# absence would switch off things the operator still pays for.
|
||||
#
|
||||
# Two app-managed config_overrides keys carry the state. The `_` prefix is
|
||||
# already the "FC writes this, an operator edit preserves it" family.
|
||||
# _membership_stopped set when the sweep stops a source; the sweep resumes
|
||||
# ONLY sources carrying it, so a source the operator
|
||||
# switched off by hand is never switched back on
|
||||
# _membership_kept set by SourceService.update when the operator turns a
|
||||
# stopped source back ON: a deliberate choice to keep
|
||||
# pulling a lapsed creator, which the next sweep must
|
||||
# not undo. Cleared when the membership is paid again.
|
||||
STOPPED_KEY = "_membership_stopped"
|
||||
KEPT_KEY = "_membership_kept"
|
||||
|
||||
|
||||
def _access_expires_at(m: PlatformMembership) -> datetime | None:
|
||||
"""When paid access actually ends, if the platform says.
|
||||
|
||||
Patreon keeps a cancelled membership's access until the end of the billing
|
||||
period and reports that date (`member.access_expires_at`, note #3992).
|
||||
SubscribeStar's page gives no such date, so a cancelled SubscribeStar
|
||||
membership stops at once. Returns None when there is no usable date.
|
||||
"""
|
||||
details = m.details or {}
|
||||
raw = details.get("access_expires_at") or (details.get("member") or {}).get("access_expires_at")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
async def apply_membership_lapses(
|
||||
session: AsyncSession, *, platform: str, now: datetime | None = None,
|
||||
) -> dict:
|
||||
"""Stop sources whose paid access has ended; resume the ones this stopped.
|
||||
|
||||
Refuses to act on a roster that isn't fresh, for the same reason C4 refuses
|
||||
to draw conclusions from one.
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
state = await get_sync_state(session, platform)
|
||||
if not roster_is_fresh(state, now=now):
|
||||
return {"platform": platform, "skipped": "roster not fresh", "stopped": 0, "resumed": 0}
|
||||
|
||||
memberships = (await session.execute(
|
||||
select(PlatformMembership).where(PlatformMembership.platform == platform)
|
||||
)).scalars().all()
|
||||
sources = (await session.execute(
|
||||
select(Source).where(Source.platform == platform)
|
||||
)).scalars().all()
|
||||
pairs = pair_sources_with_memberships(list(sources), list(memberships))
|
||||
|
||||
stopped: list[int] = []
|
||||
resumed: list[int] = []
|
||||
for source in sources:
|
||||
pair = pairs.get(source.id)
|
||||
if pair is None:
|
||||
continue # absence is never acted on, see above
|
||||
m, _kind = pair
|
||||
paid = has_paid_access(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
)
|
||||
co = dict(source.config_overrides or {})
|
||||
|
||||
if paid is True:
|
||||
changed = co.pop(KEPT_KEY, None) is not None
|
||||
if STOPPED_KEY in co:
|
||||
co.pop(STOPPED_KEY)
|
||||
source.enabled = True
|
||||
resumed.append(source.id)
|
||||
changed = True
|
||||
if changed:
|
||||
source.config_overrides = co
|
||||
continue
|
||||
|
||||
# Unknown status: never a reason to stop something (has_paid_access's
|
||||
# tri-state exists for exactly this).
|
||||
if paid is None:
|
||||
continue
|
||||
if not source.enabled or co.get(KEPT_KEY):
|
||||
continue
|
||||
expires = _access_expires_at(m)
|
||||
if expires is not None and expires > now:
|
||||
continue # still inside the paid-through period
|
||||
|
||||
co[STOPPED_KEY] = {"at": now.isoformat(), "status": m.status}
|
||||
source.config_overrides = co
|
||||
source.enabled = False
|
||||
# The same clean slate a manual disable gives (SourceService.update,
|
||||
# #1285), so a stopped source doesn't linger as failing or gated.
|
||||
source.last_error = None
|
||||
source.error_type = None
|
||||
source.consecutive_failures = 0
|
||||
stopped.append(source.id)
|
||||
|
||||
await session.commit()
|
||||
return {"platform": platform, "stopped": len(stopped), "resumed": len(resumed)}
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
"""The learned membership roster: what the account actually subscribes to.
|
||||
|
||||
Milestone 387, phase C. Sibling of `service_roster` (milestone 365) and built
|
||||
on the same insight — an absence is only observable against a record of
|
||||
presence. There, a stopped worker; here, a subscription that lapsed.
|
||||
|
||||
## Nothing calls this yet
|
||||
|
||||
`touch_membership` is written before its caller because the caller (the sweep,
|
||||
C3) needs a client seam (C2) that needs Patreon's real response characterised
|
||||
from a captured sample (C0), and that capture needs the operator's browser
|
||||
session. The write side does not depend on any of it: an upsert keyed on
|
||||
(platform, external_campaign_id) is the same regardless of what the payload
|
||||
turns out to look like, and `details` carries whatever C0 finds.
|
||||
|
||||
## Why the whitelist lives here and not in the column
|
||||
|
||||
`platform_membership.status` is an unconstrained String holding the PLATFORM's
|
||||
own word — `active_patron`, not some normalised FC value. The mapping from
|
||||
those words to FC's meaning is a read-site concern and belongs in code that can
|
||||
be corrected without a migration, because the vocabulary comes from whatever
|
||||
each platform says and will be discovered per platform rather than designed up
|
||||
front. `native_ingest_common.MEMBERSHIP_STATUS` is where that knowledge
|
||||
accumulates as platforms are characterised, and it holds no guesses. It lives
|
||||
there rather than here because platform clients need it, and a client may not
|
||||
import this module (test_gated_reason.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import MembershipSync, PlatformMembership, Source
|
||||
from .native_ingest_common import has_paid_access
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def touch_membership(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
platform: str,
|
||||
external_campaign_id: str,
|
||||
display_name: str | None = None,
|
||||
url: str | None = None,
|
||||
status: str | None = None,
|
||||
tier_names: list | None = None,
|
||||
amount_cents: int | None = None,
|
||||
currency: str | None = None,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
"""Record that this membership was observed just now.
|
||||
|
||||
Upsert rather than read-modify-write, for the same reason as
|
||||
`service_roster.touch_service`: a sweep may overlap its own previous run,
|
||||
and the last writer is simply the most recent sighting.
|
||||
|
||||
`first_seen_at` is deliberately NOT in the update set. It is the one field
|
||||
that answers "has this ever been true", which is what makes a membership's
|
||||
later DISAPPEARANCE readable as a lapse rather than indistinguishable from
|
||||
a creator FC never knew about. Every other column is last-writer-wins,
|
||||
including status — a membership that goes from active to former must move.
|
||||
"""
|
||||
stmt = pg_insert(PlatformMembership).values(
|
||||
platform=platform,
|
||||
external_campaign_id=external_campaign_id,
|
||||
display_name=display_name,
|
||||
url=url,
|
||||
status=status,
|
||||
tier_names=tier_names,
|
||||
amount_cents=amount_cents,
|
||||
currency=currency,
|
||||
details=details or {},
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
constraint="uq_platform_membership_platform_campaign",
|
||||
set_={
|
||||
"display_name": stmt.excluded.display_name,
|
||||
"url": stmt.excluded.url,
|
||||
"status": stmt.excluded.status,
|
||||
"tier_names": stmt.excluded.tier_names,
|
||||
"amount_cents": stmt.excluded.amount_cents,
|
||||
"currency": stmt.excluded.currency,
|
||||
"details": stmt.excluded.details,
|
||||
"last_seen_at": func.now(),
|
||||
},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The sweep, and the state that makes its failures readable (#387 C3)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# How long a successful sync stays trustworthy. Beyond this the roster is
|
||||
# STALE, and C4 must refuse to draw conclusions from it — "you are tracking 12
|
||||
# sources you no longer subscribe to", computed from a roster that stopped
|
||||
# syncing a week ago, is an invitation to cancel things the operator is still
|
||||
# paying for.
|
||||
#
|
||||
# Generous relative to the daily cadence: a few missed runs are a blip, not a
|
||||
# reason to stop trusting a roster that changes on a billing cycle.
|
||||
ROSTER_STALE_AFTER = timedelta(days=3)
|
||||
|
||||
|
||||
async def get_sync_state(session: AsyncSession, platform: str) -> MembershipSync | None:
|
||||
return (await session.execute(
|
||||
select(MembershipSync).where(MembershipSync.platform == platform)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
|
||||
def roster_is_fresh(state: MembershipSync | None, *, now: datetime | None = None) -> bool:
|
||||
"""May a caller draw CONCLUSIONS from this roster?
|
||||
|
||||
False for never-synced and for stale, and those are deliberately the same
|
||||
answer here even though the UI must tell them apart: both mean the roster
|
||||
is not evidence. The asymmetry that matters is that `False` never means
|
||||
"you subscribe to nothing" — it means "we do not know", and a caller that
|
||||
cannot represent "we do not know" must not be asking this question.
|
||||
"""
|
||||
if state is None or state.last_success_at is None:
|
||||
return False
|
||||
now = now or datetime.now(UTC)
|
||||
return (now - state.last_success_at) <= ROSTER_STALE_AFTER
|
||||
|
||||
|
||||
async def _record_sync(session: AsyncSession, platform: str, **values) -> None:
|
||||
stmt = pg_insert(MembershipSync).values(platform=platform, **values)
|
||||
await session.execute(stmt.on_conflict_do_update(
|
||||
constraint="uq_membership_sync_platform",
|
||||
set_={**values, "updated_at": func.now()},
|
||||
))
|
||||
|
||||
|
||||
def roster_user_id(client) -> str | None:
|
||||
"""The account id a client's roster walk needs, if that client needs one.
|
||||
|
||||
Patreon's members endpoint filters on the account's own user id, so the
|
||||
sweep has to resolve it first. SubscribeStar's /subscriptions page is simply
|
||||
the logged-in account's, with nothing to resolve. Probed with `getattr`,
|
||||
the same way the sweep probes `iter_memberships` itself (rule #169), rather
|
||||
than called unconditionally.
|
||||
|
||||
Calling `current_user_id()` unconditionally was the one place the membership
|
||||
seam was still Patreon-shaped: note #3970 promised a second platform would be
|
||||
one `builders` line plus the client method, and D1 found the sweep would
|
||||
instead have crashed on the first client without that method.
|
||||
"""
|
||||
resolve = getattr(client, "current_user_id", None)
|
||||
return resolve() if resolve is not None else None
|
||||
|
||||
|
||||
async def sync_platform(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
platform: str,
|
||||
fetch: Callable[[], Awaitable[list]],
|
||||
now: datetime | None = None,
|
||||
) -> dict:
|
||||
"""Walk one platform's roster and record what happened.
|
||||
|
||||
`fetch` is injected rather than built here so the error-to-state mapping —
|
||||
the part with the consequences — is testable without a credential, and so
|
||||
this service needs to know nothing about how any particular client is
|
||||
constructed.
|
||||
|
||||
THE FETCH COMPLETES BEFORE ANYTHING IS WRITTEN. That ordering is the whole
|
||||
safety property: a walk that dies half way through pagination writes
|
||||
nothing, so a failure can never leave a roster that is partly this week's
|
||||
and partly last week's. (`touch_membership` never deletes, so a failure
|
||||
cannot empty the roster either — but "intact" should mean intact, not
|
||||
merely non-empty.)
|
||||
|
||||
Returns a summary dict; never raises for a platform failure, because one
|
||||
platform failing must not abort the others.
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
await _record_sync(session, platform, last_attempt_at=now)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
memberships = await fetch()
|
||||
except Exception as exc: # noqa: BLE001 - deliberately broad, see below
|
||||
# Broad on purpose: a sweep is a background job, and ANY escape here
|
||||
# kills the run for every other platform too. The exception's class
|
||||
# name is recorded so the distinction the client drew (auth vs drift
|
||||
# vs transport) survives into the UI, which is where it is actionable.
|
||||
#
|
||||
# EXCEPT the worker asking us to stop. Celery raises its soft time
|
||||
# limit as an ordinary Exception subclass, so a broad catch swallows
|
||||
# the shutdown request and lets the sweep run on into the HARD limit,
|
||||
# where it is SIGKILLed mid-transaction. A sweep that cannot be stopped
|
||||
# is worse than one that fails. (KeyboardInterrupt and SystemExit are
|
||||
# BaseException and pass through this clause already.)
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
|
||||
if isinstance(exc, SoftTimeLimitExceeded):
|
||||
raise
|
||||
await session.rollback()
|
||||
await _record_sync(
|
||||
session, platform,
|
||||
last_error_type=type(exc).__name__,
|
||||
last_error_message=str(exc)[:2000],
|
||||
)
|
||||
await session.commit()
|
||||
log.warning("membership sync failed for %s: %s", platform, exc)
|
||||
return {"platform": platform, "ok": False, "error": type(exc).__name__}
|
||||
|
||||
for m in memberships:
|
||||
await touch_membership(
|
||||
session,
|
||||
platform=platform,
|
||||
external_campaign_id=m.campaign_id,
|
||||
display_name=m.display_name,
|
||||
url=m.url,
|
||||
status=m.status,
|
||||
tier_names=m.tier_names or None,
|
||||
amount_cents=m.amount_cents,
|
||||
currency=m.currency,
|
||||
details={**(m.details or {}), "is_free_member": m.is_free_member},
|
||||
)
|
||||
await _record_sync(
|
||||
session, platform,
|
||||
last_success_at=now,
|
||||
last_count=len(memberships),
|
||||
# Cleared on success — a stale error beside a fresh success would read
|
||||
# as "still broken" forever.
|
||||
last_error_type=None,
|
||||
last_error_message=None,
|
||||
)
|
||||
await session.commit()
|
||||
log.info("membership sync ok for %s: %d membership(s)", platform, len(memberships))
|
||||
return {"platform": platform, "ok": True, "count": len(memberships)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Membership <-> Source identity (#387 C4)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# "Is this membership already tracked?" is asked by TWO features — C4's
|
||||
# reconciliation buckets and E4's creator suggestions — and it lives here, once,
|
||||
# on purpose. Built inline in C4 it would have looked finished while leaving E4
|
||||
# matching on name similarity alone, so the two would answer the same question
|
||||
# differently and only one of them would be right.
|
||||
#
|
||||
# E4 and C4 use it from opposite sides: E4 as the NEGATIVE check (propose only
|
||||
# where nothing matches) and C4 as the join itself.
|
||||
|
||||
# Any platform that caches its creator id does so under this suffix; see
|
||||
# `download_service._phase3_persist`, which writes `patreon_campaign_id`.
|
||||
_CAMPAIGN_KEY_SUFFIX = "_campaign_id"
|
||||
|
||||
|
||||
def identity_keys_for_source(source: Source) -> set[str]:
|
||||
"""Every platform-side creator id cached on this source.
|
||||
|
||||
Reads ANY `<platform>_campaign_id` override rather than naming Patreon's,
|
||||
so a second platform participates by caching its id under the same suffix —
|
||||
no registry, no `if platform ==` branch (rule 169). A source that has never
|
||||
been walked has cached nothing and simply contributes no exact key, which is
|
||||
what makes the handle fallback below necessary rather than merely tolerated.
|
||||
"""
|
||||
keys = set()
|
||||
for name, value in (source.config_overrides or {}).items():
|
||||
if name.endswith(_CAMPAIGN_KEY_SUFFIX) and isinstance(value, str) and value:
|
||||
keys.add(value)
|
||||
return keys
|
||||
|
||||
|
||||
def url_tail(url: str | None) -> str | None:
|
||||
"""The creator handle at the end of a source URL, lowercased.
|
||||
|
||||
Deliberately the same derivation as `PlatformMembership.vanity_or_none`'s
|
||||
own fallback, so both sides of the comparison reduce a URL to a handle the
|
||||
same way. Query strings and fragments are stripped first; Patreon's `/c/`
|
||||
and `/cw/` forms both end in the vanity, so they need no special case (the
|
||||
missing-`/c/` regex is what broke creator detection in #1485).
|
||||
|
||||
Returns None for the pre-0030 `sidecar:` synthetic anchors, which are not
|
||||
feeds and must never match anything.
|
||||
"""
|
||||
if not url or url.startswith("sidecar:"):
|
||||
return None
|
||||
cleaned = url.split("?", 1)[0].split("#", 1)[0]
|
||||
tail = cleaned.rstrip("/").rsplit("/", 1)[-1]
|
||||
return tail.lower() or None
|
||||
|
||||
|
||||
def match_kind(source: Source, membership: PlatformMembership) -> str | None:
|
||||
"""How this source and this membership are known to be the same creator.
|
||||
|
||||
Returns "campaign" for an exact platform-id match, "vanity" for agreeing URL
|
||||
handles, or None for no evidence.
|
||||
|
||||
THE ORDER MUST NOT BE INVERTED. The campaign id is exact and the handle is
|
||||
not, but the id is only written AFTER a source has been walked at least once
|
||||
— so checking the handle first would let a stale or renamed URL outvote the
|
||||
authoritative id on every source FC has actually polled.
|
||||
"""
|
||||
if source.platform != membership.platform:
|
||||
return None
|
||||
if membership.external_campaign_id in identity_keys_for_source(source):
|
||||
return "campaign"
|
||||
vanity = membership.vanity_or_none()
|
||||
tail = url_tail(source.url)
|
||||
if vanity and tail and vanity.strip().lower() == tail:
|
||||
return "vanity"
|
||||
return None
|
||||
|
||||
|
||||
def pair_sources_with_memberships(
|
||||
sources: list[Source], memberships: list[PlatformMembership],
|
||||
) -> dict[int, tuple[PlatformMembership, str]]:
|
||||
"""Source id -> the membership it is the same creator as, and how we know.
|
||||
|
||||
Extracted from C4's reconcile loop when C5 became its second caller. It is
|
||||
a nested loop rather than a SQL join because the match is a predicate over
|
||||
a JSON blob and a derived URL handle, neither of which is indexable, and
|
||||
both sides are tens of rows on any real library. Keeping it in Python means
|
||||
ONE definition of identity (`match_kind`) instead of a second one in SQL
|
||||
that could drift from it.
|
||||
|
||||
First match wins, which is `match_kind`'s ordering doing its job: a source
|
||||
with a cached campaign id can only pair with the membership holding that
|
||||
id, so an ambiguous handle never outvotes it.
|
||||
"""
|
||||
pairs: dict[int, tuple[PlatformMembership, str]] = {}
|
||||
for source in sources:
|
||||
for m in memberships:
|
||||
kind = match_kind(source, m)
|
||||
if kind:
|
||||
pairs[source.id] = (m, kind)
|
||||
break
|
||||
return pairs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Why the posts are invisible (#387 C5)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A3 made a tier-gated source say "47 posts you can't see". These are the words
|
||||
# the roster is allowed to add to that count — and ONLY to that count.
|
||||
#
|
||||
# THE LINE: the roster ANNOTATES the gated flag, it never produces it.
|
||||
# `current_user_can_view` (read per post by `patreon_client.post_is_gated`) is
|
||||
# the authoritative per-post signal, and entitled-tier data cannot stand in for
|
||||
# it — a creator can gate a post behind an access rule that maps onto no tier
|
||||
# name at all. So nothing here may suppress a download, skip a walk, or decide
|
||||
# a post is inaccessible. It explains a skip that ALREADY happened. Getting
|
||||
# that backwards would make FC silently stop fetching content the operator is
|
||||
# paying for, which is the worst failure available in this milestone.
|
||||
# `test_no_fetch_path_can_read_the_roster` pins that structurally.
|
||||
GATED_LAPSED = "lapsed" # the membership ended — resubscribe, or disable
|
||||
GATED_TIER = "tier" # paying, but this tier doesn't reach these posts
|
||||
GATED_FREE = "free" # a current FREE follow — nobody is paying for access
|
||||
|
||||
|
||||
def gated_reason(
|
||||
platform: str, status: str | None, *, is_free_member: bool = False,
|
||||
) -> str | None:
|
||||
"""Why a tier-gated source's posts are out of reach, if the roster knows.
|
||||
|
||||
None means "no words beyond the count" and is the answer for every case
|
||||
where the roster is not evidence: a status this code has not been taught,
|
||||
and (at the call site) a campaign absent from the roster or a roster too
|
||||
stale to trust. Absence is not evidence — the same discipline as
|
||||
`test_post_is_gated_only_on_explicit_false`.
|
||||
|
||||
`is_free_member` is read AFTER the status axis, not folded into it, which
|
||||
is why `has_paid_access` is called here with it forced off. The two axes
|
||||
are independent in Patreon's payload, and collapsing them loses a real
|
||||
distinction: a current free follower has not lost anything, so telling them
|
||||
"you're not a patron any more" would be a false sentence about a state they
|
||||
were never in.
|
||||
"""
|
||||
by_status = has_paid_access(platform, status, is_free_member=False)
|
||||
if by_status is None:
|
||||
return None
|
||||
if not by_status:
|
||||
return GATED_LAPSED
|
||||
return GATED_FREE if is_free_member else GATED_TIER
|
||||
|
||||
|
||||
async def gated_reasons_for_sources(
|
||||
session: AsyncSession, sources: list[Source], *, now: datetime | None = None,
|
||||
) -> dict[int, str]:
|
||||
"""The reason word for each of these sources, where the roster has one.
|
||||
|
||||
Callers pass ONLY the sources already known to be tier-gated: the question
|
||||
"why can't I see these posts" is meaningless for a source whose posts are
|
||||
all visible, and asking it anyway would put roster data on rows that have
|
||||
no gated state for it to annotate.
|
||||
|
||||
Sources with no entry in the result get A3's bare count, which is the
|
||||
correct degraded rendering for all three of: platform never swept, roster
|
||||
stale, campaign not in the roster.
|
||||
"""
|
||||
if not sources:
|
||||
return {}
|
||||
|
||||
platforms = {s.platform for s in sources}
|
||||
# Per platform, because freshness is per platform: a working Patreon sweep
|
||||
# must not lend its credibility to a SubscribeStar roster that has never
|
||||
# run. Same gate as C4's `tracked_not_subscribed`, for the same reason.
|
||||
fresh = {
|
||||
p for p in platforms
|
||||
if roster_is_fresh(await get_sync_state(session, p), now=now)
|
||||
}
|
||||
if not fresh:
|
||||
return {}
|
||||
|
||||
memberships = (await session.execute(
|
||||
select(PlatformMembership).where(
|
||||
PlatformMembership.platform.in_(sorted(fresh))
|
||||
)
|
||||
)).scalars().all()
|
||||
pairs = pair_sources_with_memberships(
|
||||
[s for s in sources if s.platform in fresh], memberships,
|
||||
)
|
||||
|
||||
reasons: dict[int, str] = {}
|
||||
for source_id, (m, _kind) in pairs.items():
|
||||
reason = gated_reason(
|
||||
m.platform, m.status,
|
||||
is_free_member=bool((m.details or {}).get("is_free_member")),
|
||||
)
|
||||
if reason is not None:
|
||||
reasons[source_id] = reason
|
||||
return reasons
|
||||
|
||||
|
||||
async def source_for_membership(
|
||||
session: AsyncSession, membership: PlatformMembership,
|
||||
) -> Source | None:
|
||||
"""The source FC already tracks for this membership, if there is one.
|
||||
|
||||
Scoped to the membership's own platform, so a creator tracked on Discord and
|
||||
subscribed to on Patreon does not read as already-tracked — that pairing is
|
||||
E4's suggestion to make, not an identity.
|
||||
"""
|
||||
rows = (await session.execute(
|
||||
select(Source).where(Source.platform == membership.platform)
|
||||
)).scalars().all()
|
||||
for source in rows:
|
||||
if match_kind(source, membership):
|
||||
return source
|
||||
return None
|
||||
@@ -35,58 +35,6 @@ DEFAULT_SIM_THRESHOLD = 0.85
|
||||
_FIGURE_KINDS = ("face", "figure")
|
||||
|
||||
|
||||
# How many cosine scores to hold in memory at once, per matmul block.
|
||||
# 4M float32 is 16 MB — small enough to stay in cache-friendly territory on the
|
||||
# shared ml lane, large enough that the per-call overhead stops mattering.
|
||||
_MAX_SCORE_ELEMS = 4_000_000
|
||||
|
||||
|
||||
def char_maxima(q_by_image, allref, seg, np, *, max_elems=_MAX_SCORE_ELEMS):
|
||||
"""(n_images, n_chars) — each image's best cosine to each character.
|
||||
|
||||
`q_by_image` is one L2-normalised `(n_figures, dim)` array per image, in
|
||||
the order the answer comes back in. `allref` is every character's
|
||||
prototypes stacked, and `seg` their per-character start offsets into it.
|
||||
|
||||
## Why this is batched, and why that is safe
|
||||
|
||||
`scheduled_ccip_auto_apply` did this one image at a time — a `(nq, dim) @
|
||||
(dim, total)` product per image, over every image in the library on every
|
||||
run. At ~119k images that is 119k separate matmuls, each too small to pay
|
||||
for its own BLAS setup, and on 2026-09-23 the daily sweep hit its 1800s
|
||||
soft limit on the operator's instance.
|
||||
|
||||
Batching changes no arithmetic. The score a character gets for an image is
|
||||
a max over that image's figures AND over that character's prototypes, and
|
||||
max does not care in what order or grouping it is taken — so reducing the
|
||||
prototype axis first (per row, inside a block) and the figure axis after
|
||||
(per image, across blocks) gives exactly what the per-image loop gave.
|
||||
That equivalence is what `test_char_maxima_matches_the_per_image_loop`
|
||||
pins, against the naive form written out longhand.
|
||||
|
||||
Blocked by ROWS rather than done in one product, because the full score
|
||||
matrix is (all figures in the chunk x every prototype) and that grows with
|
||||
the library on both axes. The block bound is on elements, so the memory
|
||||
this uses stays flat as either axis grows.
|
||||
"""
|
||||
counts = [len(q) for q in q_by_image]
|
||||
rows = np.vstack(q_by_image)
|
||||
total = max(int(allref.shape[0]), 1)
|
||||
block = max(1, max_elems // total)
|
||||
|
||||
per_row = np.empty((rows.shape[0], len(seg)), dtype=np.float32)
|
||||
for a in range(0, rows.shape[0], block):
|
||||
scores = rows[a:a + block] @ allref.T
|
||||
per_row[a:a + block] = np.maximum.reduceat(scores, seg, axis=1)
|
||||
|
||||
# Start offset of each image's rows. Every image has at least one figure —
|
||||
# it is in `q_by_image` because a region produced it — so these strictly
|
||||
# increase, which is what `reduceat` needs to reduce rather than pass a row
|
||||
# through untouched.
|
||||
starts = np.cumsum([0] + counts[:-1])
|
||||
return np.maximum.reduceat(per_row, starts, axis=0)
|
||||
|
||||
|
||||
async def _settings_threshold(session: AsyncSession) -> float:
|
||||
val = (
|
||||
await session.execute(
|
||||
|
||||
@@ -11,21 +11,12 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFile
|
||||
|
||||
from ..worker_lanes import LANES_BY_NAME
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
# Cap torch's intra-op threads so each ml-worker replica is a bounded core
|
||||
# consumer on a shared node (torch otherwise uses all cores).
|
||||
#
|
||||
# Read from the lane rather than restated here. This was a literal 4 beside a
|
||||
# comment reading "keep N_replicas x this within the cores allotted to ML" —
|
||||
# a constraint written where nothing could act on it, and nothing did: the ML
|
||||
# ceiling came from memory alone, offered the operator ~49 slots on a
|
||||
# large-memory host, and the lane spent 2026-09-23 with ~200 torch threads on
|
||||
# it. `derived_ceiling` now divides the cores by this number, which only means
|
||||
# anything while the two are the same number.
|
||||
_INTRA_OP_THREADS = LANES_BY_NAME["ml"].threads_per_slot
|
||||
# consumer on a shared node (torch otherwise uses all cores). Keep
|
||||
# N_replicas × this within the cores allotted to ML to avoid oversubscription.
|
||||
_INTRA_OP_THREADS = 4
|
||||
|
||||
DEFAULT_MODEL_NAME = os.environ.get(
|
||||
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
|
||||
|
||||
@@ -211,59 +211,6 @@ class PostRecordOutcome:
|
||||
body_chars: int
|
||||
|
||||
|
||||
# -- membership roster seam (shared dataclass, #387 C2/C7) -----------------
|
||||
|
||||
@dataclass
|
||||
class Membership:
|
||||
"""One membership the ACCOUNT holds, as the roster needs it (#387 C2).
|
||||
|
||||
Lives HERE rather than in the platform module that first produced it, for
|
||||
the same reason `PostRecordOutcome` does: it is the seam's contract, not
|
||||
Patreon's. C7 moved it — while it sat in `patreon_client` a second platform
|
||||
would have had to import its contract from the first platform's module,
|
||||
which inverts the dependency and is how a "portable" seam quietly becomes
|
||||
Patreon-shaped.
|
||||
|
||||
Deliberately not a raw upstream row: the sweep should not have to know that
|
||||
a tier lives behind a JSON:API `reward` relationship, and
|
||||
`platform_membership` should not gain columns because one platform shapes
|
||||
things a certain way.
|
||||
|
||||
`status` carries the PLATFORM's own word, verbatim and unmapped
|
||||
(`active_patron`, `former_patron`, ...). Deciding what it means is the read
|
||||
site's job — `has_paid_access`, below — precisely so an
|
||||
unrecognised word records as evidence rather than as a decision.
|
||||
|
||||
`is_free_member` is SEPARATE from status and must stay that way. Patreon
|
||||
expresses a free follow as this boolean rather than as a status value, so
|
||||
"does the account pay for this" is `status == "active_patron" and not
|
||||
is_free_member` — a question the status string alone cannot answer. NOTE:
|
||||
the C0 capture contains no ACTIVE free member, so the two fields are
|
||||
perfectly correlated in that sample; the separation is what the schema
|
||||
says, not something the sample proves.
|
||||
|
||||
A platform that lacks a field supplies the empty answer, never a guess:
|
||||
no tiers -> `[]`, no pledge -> `amount_cents=None` (absent stays
|
||||
distinguishable from zero — "free" and "we don't know" are different
|
||||
answers), no vanity -> None and identity falls back to the URL tail.
|
||||
"""
|
||||
|
||||
campaign_id: str
|
||||
display_name: str | None
|
||||
url: str | None
|
||||
vanity: str | None
|
||||
status: str | None
|
||||
is_free_member: bool
|
||||
tier_names: list[str]
|
||||
amount_cents: int | None
|
||||
currency: str | None
|
||||
# Everything the roster did not model, kept so a later question can be
|
||||
# answered without another authenticated round-trip. Scoped to the
|
||||
# membership's own attributes plus the creator's — never the raw page,
|
||||
# which is where the card/address resources live.
|
||||
details: dict
|
||||
|
||||
|
||||
# -- base downloader (shared fetch/validate plumbing) ----------------------
|
||||
|
||||
class BaseNativeDownloader:
|
||||
@@ -396,81 +343,3 @@ class BaseNativeDownloader:
|
||||
sidecar_path = media_path.with_suffix(".json")
|
||||
sidecar_path.write_text(json.dumps(data, indent=2))
|
||||
return sidecar_path
|
||||
|
||||
# --- membership status vocabulary (#387) ------------------------------------
|
||||
#
|
||||
# Lives here, beside `Membership`, rather than in `membership_roster`. It is
|
||||
# pure platform knowledge with no database behind it, and the platform clients
|
||||
# need it too. Patreon's must tell a lapsed membership to a deleted creator
|
||||
# (skippable) from a paid one it cannot attribute (drift), and a client may not
|
||||
# import `membership_roster`: test_gated_reason.py forbids any fetch path from
|
||||
# reaching the roster, so the roster can explain a skip but never cause one.
|
||||
#
|
||||
# Platform word -> whether the account currently has paid access.
|
||||
#
|
||||
# Every entry here must come from a CHARACTERISED response, never from API docs
|
||||
# or a plausible guess — project rule 130, and inventing a status before seeing
|
||||
# it in a real payload is exactly the failure it names.
|
||||
#
|
||||
# patreon: from a live capture of the operator's own session, 2026-09-10
|
||||
# (Scribe note #3886). Only two values were OBSERVED in `patron_status` and
|
||||
# only those two are here.
|
||||
#
|
||||
# `declined_patron` is deliberately ABSENT even though it looks obviously
|
||||
# right. It appears in the request's `filter[membership_type]`, and the capture
|
||||
# proved that filter is NOT the same vocabulary as the attribute — a row
|
||||
# selected by the filter as `free_member` came back with
|
||||
# `patron_status: former_patron`, a word the filter does not contain. Reading
|
||||
# the filter as an enum is the specific mistake the capture caught; adding
|
||||
# `declined_patron` on the strength of it would be repeating that mistake one
|
||||
# step later.
|
||||
#
|
||||
# Unknown words are NOT an error: an unrecognised status means the roster
|
||||
# records evidence it cannot yet interpret, which is a better state than
|
||||
# dropping the row or asserting a meaning for it.
|
||||
#
|
||||
# subscribestar: from a live capture of the account's /subscriptions page,
|
||||
# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word —
|
||||
# a membership's state is which of two tables it sits in — so the "word" stored
|
||||
# is the table card's own `data-identifier`, verbatim. Those two identifiers are
|
||||
# the whole vocabulary; there is nothing further to characterise later.
|
||||
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {
|
||||
"patreon": {
|
||||
"active_patron": True,
|
||||
"former_patron": False,
|
||||
},
|
||||
"subscribestar": {
|
||||
"active_subscriptions": True,
|
||||
"cancelled_subscriptions": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def has_paid_access(
|
||||
platform: str, status: str | None, *, is_free_member: bool = False,
|
||||
) -> bool | None:
|
||||
"""Does this membership mean the account currently PAYS for access?
|
||||
|
||||
Returns None for a status this code has not been taught, which callers must
|
||||
treat as "unknown" rather than as False. The difference matters: False says
|
||||
the operator has lost access, and asserting that from an unrecognised word
|
||||
would tell them to cancel a source they are still paying for.
|
||||
|
||||
`is_free_member` is a second axis, not a status, and that is Patreon's
|
||||
design rather than ours: the capture shows a free follow expressed as a
|
||||
boolean alongside `patron_status`, so a "current" membership can still be
|
||||
one nobody is paying for. Taking status alone would report a free follower
|
||||
as a paying patron, and C4 would then never offer to clean it up.
|
||||
|
||||
(Honest limit: the capture contains no ACTIVE free member, so it cannot
|
||||
demonstrate the two axes coming apart. The separation is what the payload's
|
||||
shape says; the sample only shows it is possible, not that it happens.)
|
||||
"""
|
||||
if status is None:
|
||||
return None
|
||||
known = MEMBERSHIP_STATUS.get(platform, {}).get(status)
|
||||
if known is None:
|
||||
return None
|
||||
if not known:
|
||||
return False
|
||||
return not is_free_member
|
||||
|
||||
@@ -14,18 +14,6 @@ the later step can drive it:
|
||||
- extract_media(post, included_index) → list[MediaItem]
|
||||
- parse_cursor_from_url(url) → cursor
|
||||
|
||||
Milestone 387 added a SECOND read path on the same session: the membership
|
||||
roster — what the ACCOUNT subscribes to, as opposed to what one creator has
|
||||
posted.
|
||||
- iter_memberships(user_id) → Iterator[Membership]
|
||||
- current_user_id() → str
|
||||
|
||||
It is an OPTIONAL seam by construction, probed with
|
||||
`getattr(client, "iter_memberships", None)` exactly as `post_is_gated` already
|
||||
is. A client that does not implement it (Discord, HentaiFoundry) makes the
|
||||
whole feature invisible for that platform — no flag, no config row, no
|
||||
"unsupported" branch to keep alive.
|
||||
|
||||
Drift detection is loud on purpose: Patreon ships JSON:API and the shapes we
|
||||
depend on (top-level `data`, media resources carrying `file_name`/`url`) are
|
||||
the contract. If a response comes back as an HTML login page or a media
|
||||
@@ -53,12 +41,10 @@ from ..utils.paths import filehash_from_url
|
||||
from ..utils.prosemirror import post_body_html
|
||||
from .native_ingest_common import (
|
||||
_MAX_429_RETRIES,
|
||||
Membership,
|
||||
NativeAuthError,
|
||||
NativeDriftError,
|
||||
NativeIngestError,
|
||||
basename_from_url,
|
||||
has_paid_access,
|
||||
make_session,
|
||||
retry_after_seconds,
|
||||
)
|
||||
@@ -66,34 +52,8 @@ from .native_ingest_common import (
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_POSTS_URL = "https://www.patreon.com/api/posts"
|
||||
_MEMBERS_URL = "https://www.patreon.com/api/members"
|
||||
_CURRENT_USER_URL = "https://www.patreon.com/api/current_user"
|
||||
_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# --- membership roster contract (#387 C2) ---------------------------------
|
||||
# Characterized from a real capture of the operator's own session — Scribe note
|
||||
# #3886. NOT from Patreon's public v2 API, which is the CREATOR api behind
|
||||
# OAuth scopes and a different surface entirely (project rule 130).
|
||||
#
|
||||
# DELIBERATELY MINIMAL, and that is a privacy decision rather than a
|
||||
# performance one. The web app's own include set pulls `latest_pledge.card`
|
||||
# and `address`; the card resources come back carrying the ACCOUNT HOLDER'S
|
||||
# EMAIL in `merchant_name`. Copying the browser's query string wholesale — the
|
||||
# obvious move — would have FC fetching payment PII it has no use for and can
|
||||
# only mishandle. We ask for the creator and the tier, and nothing else.
|
||||
_MEMBERS_INCLUDE = "campaign,reward"
|
||||
_FIELDS_MEMBER = (
|
||||
"patron_status,is_free_member,is_gifted,pledge_amount_cents,currency,"
|
||||
"pledge_cadence,next_charge_date,access_expires_at"
|
||||
)
|
||||
_FIELDS_MEMBERS_CAMPAIGN = "name,url,vanity,is_active"
|
||||
_FIELDS_REWARD = "title"
|
||||
# The browser sends 1000. Whether a server-side ceiling applies below that is
|
||||
# untested (note #3886, open question 4), so page conservatively: a wrong guess
|
||||
# costs one extra request, and the paging loop is driven by meta.pagination
|
||||
# rather than by this number.
|
||||
_MEMBERS_PAGE_COUNT = 200
|
||||
|
||||
# JSON:API request contract (observed from real traffic — see module plan).
|
||||
_INCLUDE = (
|
||||
"campaign,access_rules,attachments,attachments_media,audio,images,media,"
|
||||
@@ -222,27 +182,20 @@ class PatreonClient:
|
||||
params["page[cursor]"] = cursor
|
||||
return params
|
||||
|
||||
def _request(self, url: str, params: dict[str, str], *, what: str, scope: str) -> dict:
|
||||
"""One paced, retried, error-classified GET returning parsed JSON.
|
||||
|
||||
Extracted from `_fetch` so the membership endpoint (#387 C2) rides the
|
||||
SAME request path rather than growing a second copy of the 429 backoff,
|
||||
the auth-vs-drift classification and the Retry-After plumbing. Two
|
||||
copies of this would drift, and the half that drifted would be the one
|
||||
that only runs once a day.
|
||||
|
||||
`what` / `scope` only shape the messages ("posts"/"campaign_id=123"),
|
||||
so a failure still says which call failed and against what.
|
||||
"""
|
||||
def _fetch(self, campaign_id: str, cursor: str | None) -> dict:
|
||||
if self._request_sleep > 0:
|
||||
time.sleep(self._request_sleep) # pace the API endpoint
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
resp = self._session.get(url, params=params, timeout=_TIMEOUT_SECONDS)
|
||||
resp = self._session.get(
|
||||
_POSTS_URL,
|
||||
params=self._params(campaign_id, cursor),
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise PatreonAPIError(
|
||||
f"Patreon {what} request failed ({scope}): {exc}"
|
||||
f"Patreon posts request failed (campaign_id={campaign_id}): {exc}"
|
||||
) from exc
|
||||
|
||||
# Transient rate-limit: back off and retry rather than failing the
|
||||
@@ -252,8 +205,8 @@ class PatreonClient:
|
||||
attempt += 1
|
||||
delay = retry_after_seconds(resp, attempt)
|
||||
log.warning(
|
||||
"Patreon 429 (%s) — backing off %.1fs (retry %d/%d)",
|
||||
scope, delay, attempt, self._max_retries,
|
||||
"Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)",
|
||||
campaign_id, delay, attempt, self._max_retries,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
@@ -263,8 +216,9 @@ class PatreonClient:
|
||||
# Auth rejected — expired/missing cookies or an insufficient tier.
|
||||
# Actionable as "rotate credentials", so it's auth, not drift/http.
|
||||
raise PatreonAuthError(
|
||||
f"Patreon {what} API returned HTTP {resp.status_code} — auth "
|
||||
f"rejected (cookies expired or tier insufficient; {scope})",
|
||||
f"Patreon posts API returned HTTP {resp.status_code} — auth "
|
||||
f"rejected (cookies expired or tier insufficient; "
|
||||
f"campaign_id={campaign_id})",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
@@ -280,27 +234,24 @@ class PatreonClient:
|
||||
except (TypeError, ValueError):
|
||||
retry_after = None
|
||||
raise PatreonAPIError(
|
||||
f"Patreon {what} API returned HTTP {resp.status_code} ({scope})",
|
||||
f"Patreon posts API returned HTTP {resp.status_code} "
|
||||
f"(campaign_id={campaign_id})",
|
||||
status_code=resp.status_code,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
try:
|
||||
return resp.json()
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
# A non-JSON body here is almost always the HTML login/challenge
|
||||
# page served when cookies are missing/expired — that is an AUTH
|
||||
# failure (rotate cookies), not API drift (update the ingester) and
|
||||
# not a transient network error.
|
||||
raise PatreonAuthError(
|
||||
f"Patreon {what} API returned a non-JSON response (likely an "
|
||||
f"HTML login/challenge page — session expired; {scope}): {exc}"
|
||||
"Patreon posts API returned a non-JSON response (likely an "
|
||||
f"HTML login/challenge page — session expired; "
|
||||
f"campaign_id={campaign_id}): {exc}"
|
||||
) from exc
|
||||
|
||||
def _fetch(self, campaign_id: str, cursor: str | None) -> dict:
|
||||
return self._request(
|
||||
_POSTS_URL, self._params(campaign_id, cursor),
|
||||
what="posts", scope=f"campaign_id={campaign_id}",
|
||||
)
|
||||
return payload
|
||||
|
||||
# -- parsing -----------------------------------------------------------
|
||||
|
||||
@@ -483,14 +434,8 @@ class PatreonClient:
|
||||
|
||||
@staticmethod
|
||||
def post_meta(post: dict) -> dict:
|
||||
"""Title + published date for a post. Part of the client contract.
|
||||
|
||||
Written for a preview sample (plan #708 B4) whose caller has since gone;
|
||||
as of 2026-09-23 its consumer is the core's REVISIT WINDOW, which needs
|
||||
a post's date to know whether a tick is still inside it. Both native
|
||||
clients answer in the same shape — an ISO-8601 string under `date`, or
|
||||
None — so the core reads a date without knowing the platform.
|
||||
"""
|
||||
"""Title + published date for a post — for the preview sample (plan #708
|
||||
B4). Part of the client contract `ingest_core.Ingester.preview` calls."""
|
||||
attrs = post.get("attributes") or {}
|
||||
title = attrs.get("title")
|
||||
published = attrs.get("published_at")
|
||||
@@ -565,190 +510,6 @@ class PatreonClient:
|
||||
return
|
||||
current_cursor = next_cursor
|
||||
|
||||
# -- membership roster (#387 C2) ---------------------------------------
|
||||
|
||||
def current_user_id(self) -> str:
|
||||
"""The signed-in account's own numeric user id.
|
||||
|
||||
Needed because `/api/members` is filtered by `filter[user_id]` — the
|
||||
endpoint answers "who are the members of X", and the account asking
|
||||
about ITSELF still has to say so.
|
||||
|
||||
INFERRED, NOT CHARACTERIZED. C0 captured `/api/members`, not this; what
|
||||
is relied on here is only the JSON:API envelope (`data.id`), which this
|
||||
same API demonstrably uses everywhere else. If that inference is wrong
|
||||
it raises drift rather than returning something plausible — which is
|
||||
the right failure, because the alternative is a confidently empty
|
||||
roster and an empty roster means "cancel everything" to C4.
|
||||
"""
|
||||
payload = self._request(
|
||||
_CURRENT_USER_URL, {"json-api-version": "1.0"},
|
||||
what="current_user", scope="self",
|
||||
)
|
||||
data = (payload or {}).get("data")
|
||||
if not isinstance(data, dict) or not data.get("id"):
|
||||
raise PatreonDriftError(
|
||||
"Patreon current_user response had no data.id — cannot scope "
|
||||
"the membership roster to this account"
|
||||
)
|
||||
return str(data["id"])
|
||||
|
||||
def _members_params(self, user_id: str | None, offset: int) -> dict[str, str]:
|
||||
params = {
|
||||
"include": _MEMBERS_INCLUDE,
|
||||
"fields[member]": _FIELDS_MEMBER,
|
||||
"fields[campaign]": _FIELDS_MEMBERS_CAMPAIGN,
|
||||
"fields[reward]": _FIELDS_REWARD,
|
||||
"page[offset]": str(offset),
|
||||
"page[count]": str(_MEMBERS_PAGE_COUNT),
|
||||
"json-api-version": "1.0",
|
||||
"json-api-use-default-includes": "false",
|
||||
}
|
||||
if user_id:
|
||||
params["filter[user_id]"] = user_id
|
||||
# NOTE: `filter[membership_type]` is deliberately NOT sent. The browser
|
||||
# sends the six values its settings page wants to show, and the capture
|
||||
# proves that list is NOT the same vocabulary as the `patron_status`
|
||||
# attribute — a row selected as `free_member` came back with
|
||||
# `patron_status: former_patron`, a word absent from the filter. Sending
|
||||
# no filter asks for everything the endpoint will give, which is what a
|
||||
# roster wants: a membership that DISAPPEARS is the signal C4 reads, and
|
||||
# a filter tuned for a UI that hides lapses would manufacture exactly
|
||||
# that disappearance. (Note #3886, open question 1.)
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _validate_members_response(response: dict) -> None:
|
||||
"""Drift checks specific to the roster.
|
||||
|
||||
Stricter than the posts path about pagination on purpose: `iter_posts`
|
||||
can treat a missing `links.next` as "that was the last page", but here
|
||||
a missing total is indistinguishable from a truncated page — and a
|
||||
roster that silently stops half way reads downstream as "you cancelled
|
||||
those", which is the worst wrong answer this feature can give.
|
||||
"""
|
||||
PatreonClient._validate_response(response)
|
||||
meta = response.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
raise PatreonDriftError("Patreon members response missing 'meta'")
|
||||
pagination = meta.get("pagination")
|
||||
if not isinstance(pagination, dict) or "total" not in pagination:
|
||||
raise PatreonDriftError(
|
||||
"Patreon members response missing meta.pagination.total — "
|
||||
"cannot tell a complete roster from a truncated one"
|
||||
)
|
||||
|
||||
def _membership(self, member: dict, index: dict) -> Membership | None:
|
||||
"""One member row as a Membership, or None for a row the roster can skip.
|
||||
|
||||
The one skippable row is a LAPSED membership whose creator no longer
|
||||
exists. The live roster (note #3886, CORRECTION 3) returned 104 rows,
|
||||
because FC sends no membership-type filter and so gets lapses going back
|
||||
years. One of them, a membership that ended in 2017, carried no
|
||||
`campaign` relationship at all: the key is absent, not null, and its
|
||||
reward names no campaign either. The creator's page is gone.
|
||||
|
||||
Raising on that row made the whole roster unusable over one membership
|
||||
nobody can act on. Skipping it changes no conclusion. A lapsed
|
||||
membership already means "not paying", absence means the same, and no
|
||||
Source can be matched to a campaign that no longer has an id.
|
||||
|
||||
The refusal stays for every other row. An active or unrecognised
|
||||
membership without a creator is something FC cannot vouch for, and
|
||||
dropping it would read downstream as a cancellation.
|
||||
"""
|
||||
attrs = member.get("attributes") or {}
|
||||
if "patron_status" not in attrs:
|
||||
raise PatreonDriftError(
|
||||
"Patreon member resource has no patron_status attribute"
|
||||
)
|
||||
|
||||
campaign_ids = self._related_ids(member, "campaign")
|
||||
if not campaign_ids:
|
||||
paid = has_paid_access(
|
||||
"patreon", attrs.get("patron_status"),
|
||||
is_free_member=bool(attrs.get("is_free_member")),
|
||||
)
|
||||
if paid is False:
|
||||
log.info(
|
||||
"Patreon roster: skipping a lapsed membership with no campaign "
|
||||
"(creator deleted); status=%s access_expires_at=%s",
|
||||
attrs.get("patron_status"), attrs.get("access_expires_at"),
|
||||
)
|
||||
return None
|
||||
raise PatreonDriftError(
|
||||
"Patreon member resource has no campaign relationship — a "
|
||||
"membership we cannot attribute to a creator is not usable"
|
||||
)
|
||||
campaign_id = campaign_ids[0]
|
||||
campaign = index.get(("campaign", campaign_id)) or {}
|
||||
|
||||
# A member has at most one reward, and `reward.data` is legitimately
|
||||
# null — an active patron with no tier. Absence is a fact about the
|
||||
# membership, not a parse failure.
|
||||
tier_names: list[str] = []
|
||||
for reward_id in self._related_ids(member, "reward"):
|
||||
title = (index.get(("reward", reward_id)) or {}).get("title")
|
||||
if title:
|
||||
tier_names.append(str(title))
|
||||
|
||||
return Membership(
|
||||
campaign_id=campaign_id,
|
||||
display_name=campaign.get("name"),
|
||||
url=campaign.get("url"),
|
||||
vanity=campaign.get("vanity"),
|
||||
status=attrs.get("patron_status"),
|
||||
# Default False, not None: the attribute is always present in the
|
||||
# capture, and treating a missing one as "free" would understate
|
||||
# access rather than overstate it.
|
||||
is_free_member=bool(attrs.get("is_free_member")),
|
||||
tier_names=tier_names,
|
||||
# The MEMBER's amount, never the reward's. `reward.amount_cents` is
|
||||
# the creator's list price in the CREATOR's currency (the capture
|
||||
# has CAD, DKK and EUR rewards sitting on USD pledges), so reading
|
||||
# it would report a number the operator has never been charged.
|
||||
amount_cents=attrs.get("pledge_amount_cents"),
|
||||
currency=attrs.get("currency"),
|
||||
details={"member": attrs, "campaign": campaign},
|
||||
)
|
||||
|
||||
def iter_memberships(self, user_id: str | None = None) -> Iterator[Membership]:
|
||||
"""Yield every membership the account holds.
|
||||
|
||||
Pages on `page[offset]`/`page[count]` against `meta.pagination.total` —
|
||||
NOT on `links`. The response's own `links.first` is built without the
|
||||
`/api/` prefix the request uses, so following it verbatim would hit the
|
||||
web page instead of the API (note #3886).
|
||||
|
||||
`user_id` omitted means the `filter[user_id]` parameter is omitted.
|
||||
Whether the endpoint then defaults to self is UNTESTED — pass
|
||||
`current_user_id()` unless you are deliberately probing that.
|
||||
"""
|
||||
user_id = user_id or None
|
||||
offset = 0
|
||||
seen = 0
|
||||
while True:
|
||||
response = self._request(
|
||||
_MEMBERS_URL, self._members_params(user_id, offset),
|
||||
what="members", scope="membership roster",
|
||||
)
|
||||
self._validate_members_response(response)
|
||||
index = self._transform(response)
|
||||
rows = [m for m in (response.get("data") or []) if isinstance(m, dict)]
|
||||
for member in rows:
|
||||
membership = self._membership(member, index)
|
||||
if membership is not None:
|
||||
yield membership
|
||||
|
||||
seen += len(rows)
|
||||
total = int(response["meta"]["pagination"]["total"] or 0)
|
||||
# An empty page terminates regardless of what `total` claims. Trusting
|
||||
# the total alone would spin forever against a server that reports
|
||||
# more rows than it will hand over.
|
||||
if not rows or seen >= total:
|
||||
return
|
||||
offset += len(rows)
|
||||
|
||||
# -- detail (full body enrichment) -------------------------------------
|
||||
|
||||
def fetch_post_detail_content(self, post_id: str) -> str | None:
|
||||
|
||||
@@ -340,7 +340,7 @@ class PatreonDownloader(BaseNativeDownloader):
|
||||
|
||||
def _write_sidecar_data(
|
||||
self, post: dict, sidecar_path: Path, *, source_url: str | None = None,
|
||||
minimal: bool = False, detail_fetch: bool = True,
|
||||
minimal: bool = False,
|
||||
) -> Path:
|
||||
"""Serialize the post's metadata to `sidecar_path`. The post-only record
|
||||
(`write_post_record`) writes the FULL post (body/title/date/url); the
|
||||
@@ -364,13 +364,7 @@ class PatreonDownloader(BaseNativeDownloader):
|
||||
# dict — so a multi-image post fetches detail at most once, the post-record
|
||||
# body-length read reuses it, and a fully-seen post (no fresh download → no
|
||||
# sidecar write) never pays the extra GET.
|
||||
# `detail_fetch=False` on a REVISIT (a post inside the tick's revisit
|
||||
# window that we already captured): re-read the body from the feed
|
||||
# response we are holding and pay nothing. Without this a 30-day window
|
||||
# would buy one detail GET per body-less post per tick, forever — a
|
||||
# per-creator cost that grows with how prolific they are, to re-fetch a
|
||||
# body we already stored.
|
||||
if (not content or not content.strip()) and self._content_fetcher and detail_fetch:
|
||||
if (not content or not content.strip()) and self._content_fetcher:
|
||||
fetched = self._content_fetcher(str(post.get("id") or ""))
|
||||
if fetched:
|
||||
content = fetched
|
||||
@@ -392,9 +386,7 @@ class PatreonDownloader(BaseNativeDownloader):
|
||||
sidecar_path.write_text(json.dumps(data, indent=2))
|
||||
return sidecar_path
|
||||
|
||||
def write_post_record(
|
||||
self, post: dict, artist_slug: str, *, revisit: bool = False,
|
||||
) -> PostRecordOutcome:
|
||||
def write_post_record(self, post: dict, artist_slug: str) -> PostRecordOutcome:
|
||||
"""Write a post-ONLY sidecar (no media file) for a media-less post, so
|
||||
the importer can still upsert the Post + its body — text posts often hold
|
||||
the only copy of an external <a href> link. Named `_post.json`: the
|
||||
@@ -405,18 +397,6 @@ class PatreonDownloader(BaseNativeDownloader):
|
||||
Returns a PostRecordOutcome (path None when the post has no id) carrying
|
||||
the captured body's shape — post_type + final char count — so the engine
|
||||
can log per-post handling without re-reading the post itself.
|
||||
|
||||
`revisit=True` is the tick re-reading a post it already captured
|
||||
(ingest_core's revisit window, #...). Two differences, both about not
|
||||
making an update cost more than it is worth:
|
||||
|
||||
* no detail-fetch — the body comes from the feed response already in
|
||||
hand, so a revisit costs zero requests;
|
||||
* a body that comes back empty writes NOTHING and returns `path=None`.
|
||||
On a first capture an empty body is the truth about the post; on a
|
||||
revisit it usually just means this post's body only ever came from
|
||||
the detail endpoint we just declined to call, and writing it would
|
||||
blank a stored body to say something we never learned.
|
||||
"""
|
||||
attrs = post.get("attributes") or {}
|
||||
title = attrs.get("title") if isinstance(attrs.get("title"), str) else None
|
||||
@@ -426,17 +406,9 @@ class PatreonDownloader(BaseNativeDownloader):
|
||||
return PostRecordOutcome(
|
||||
path=None, post_type=post_type, title=title, body_chars=0,
|
||||
)
|
||||
if revisit:
|
||||
feed_body = post_body_html(attrs)
|
||||
if not (isinstance(feed_body, str) and feed_body.strip()):
|
||||
return PostRecordOutcome(
|
||||
path=None, post_type=post_type, title=title, body_chars=0,
|
||||
)
|
||||
post_dir = self.images_root / artist_slug / "patreon" / post_dir_name(post)
|
||||
post_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = self._write_sidecar_data(
|
||||
post, post_dir / "_post.json", detail_fetch=not revisit,
|
||||
)
|
||||
path = self._write_sidecar_data(post, post_dir / "_post.json")
|
||||
# _write_sidecar_data has by now memoized any detail-fetched body onto
|
||||
# post["attributes"]["content"], so re-read it for the FINAL char count.
|
||||
body = (post.get("attributes") or {}).get("content")
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
"""Native Pixiv client — the Pixiv adapter's read path.
|
||||
|
||||
Pixiv has a real (if unofficial) API: the mobile app API gallery-dl drives
|
||||
(`PixivAppAPI`). Per the downloader ground rule — gallery-dl is the
|
||||
known-working base — this client mirrors gallery-dl 1.32.5's request profile
|
||||
EXACTLY: the same iOS app headers on every request, the same OAuth
|
||||
refresh-token dance against oauth.secure.pixiv.net (X-Client-Time +
|
||||
X-Client-Hash), and the same `/v1/user/illusts` walk paginated by `next_url`.
|
||||
Deviating from that profile is how the SubscribeStar/Patreon spikes broke, so
|
||||
any change here should be diffed against gallery-dl's extractor first.
|
||||
|
||||
Feed shape (characterized from gallery-dl 1.32.5, extractor/pixiv.py):
|
||||
- `GET /v1/user/illusts?user_id=<id>` returns `{"illusts": [work...],
|
||||
"next_url": "https://app-api...?user_id=..&offset=30" | null}`.
|
||||
- Pagination: re-issue the SAME endpoint with `next_url`'s query params. The
|
||||
query string doubles as our resumable page cursor (re-fetching it re-serves
|
||||
the same page — the ingest-core resume contract).
|
||||
- A work carries id/title/type(illust|manga|ugoira)/caption(HTML)/
|
||||
create_date(ISO+09:00)/tags[{name,translated_name}]/user/page_count/
|
||||
x_restrict/series/total_view/total_bookmarks/meta_single_page/meta_pages.
|
||||
- Files: multi-page → meta_pages[].image_urls.original; single page →
|
||||
meta_single_page.original_image_url; ugoira → `/v1/ugoira/metadata` zip
|
||||
(600x600 → 1920x1080 URL swap, gallery-dl's default non-original mode).
|
||||
|
||||
`campaign_id` for Pixiv is the numeric user id (extracted from the source URL
|
||||
by `user_id_from_url` — no network resolver needed).
|
||||
|
||||
Gated works: pixiv serves a `https://s.pximg.net/common/images/limit_*.png`
|
||||
placeholder as the "original" when a work is blocked for this account
|
||||
(sanity-level filter, my-pixiv lock, deleted). gallery-dl's fallback for those
|
||||
is a web-AJAX scrape that needs PHPSESSID browser cookies — FC stores only the
|
||||
OAuth refresh token, so (exactly like our previous gallery-dl configuration,
|
||||
which warned "No PHPSESSID cookie set") those works are skipped, via the
|
||||
post_is_gated seam. Auth failures are loud (rotate the refresh token); a
|
||||
response missing the fields we depend on is DRIFT (update this client).
|
||||
|
||||
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
from ..utils.paths import safe_ext
|
||||
from .native_ingest_common import (
|
||||
_MAX_429_RETRIES,
|
||||
NativeAuthError,
|
||||
NativeDriftError,
|
||||
NativeIngestError,
|
||||
make_session,
|
||||
retry_after_seconds,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TIMEOUT_SECONDS = 30.0
|
||||
_API_ROOT = "https://app-api.pixiv.net"
|
||||
_OAUTH_URL = "https://oauth.secure.pixiv.net/auth/token"
|
||||
|
||||
# gallery-dl's public Pixiv-app credentials (PixivAppAPI, also pixivpy's) —
|
||||
# these identify the official iOS app to the API, NOT the operator; the
|
||||
# operator's identity is the OAuth refresh token.
|
||||
_CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT"
|
||||
_CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj"
|
||||
_HASH_SECRET = (
|
||||
"28c1fdd170a5204386cb1313c7077b34"
|
||||
"f83e4aaf4aa829ce78c231e05b0bae2c"
|
||||
)
|
||||
|
||||
# The exact header set gallery-dl 1.32.5 installs on its session — the proven
|
||||
# app-API request profile. The Referer also unlocks i.pximg.net media GETs
|
||||
# (403 without it), so the downloader reuses this constant.
|
||||
PIXIV_APP_HEADERS = {
|
||||
"App-OS": "ios",
|
||||
"App-OS-Version": "16.7.2",
|
||||
"App-Version": "7.19.1",
|
||||
"User-Agent": "PixivIOSApp/7.19.1 (iOS 16.7.2; iPhone12,8)",
|
||||
"Referer": "https://app-api.pixiv.net/",
|
||||
}
|
||||
|
||||
# Placeholder image prefix pixiv serves instead of a blocked work's original
|
||||
# (limit_sanity_level / limit_mypixiv / limit_unknown variants).
|
||||
_LIMIT_URL = "https://s.pximg.net/common/images/limit_"
|
||||
|
||||
# The app API reports rate-limiting as an error MESSAGE (often on HTTP 403),
|
||||
# not only as HTTP 429. gallery-dl sleeps 300s in-walk; sleeping that long
|
||||
# inside our time-boxed chunk would eat the whole budget, so we surface it as
|
||||
# a typed 429 and let download_service's cooldown machinery honor the wait.
|
||||
_RATE_LIMIT_RETRY_AFTER = 300.0
|
||||
|
||||
_TITLE_MAX = 50 # gallery-dl pixiv filename template: {title[:50]}
|
||||
|
||||
_RATINGS = {0: "General", 1: "R-18", 2: "R-18G"}
|
||||
|
||||
|
||||
class PixivAPIError(NativeIngestError):
|
||||
"""Base for native Pixiv client failures. status_code / retry_after are
|
||||
inherited from NativeIngestError."""
|
||||
|
||||
|
||||
class PixivAuthError(PixivAPIError, NativeAuthError):
|
||||
"""Auth failure — missing/expired/revoked OAuth refresh token. Fix =
|
||||
rotate the credential (Settings → Credentials → Pixiv), not update the
|
||||
client. Maps to error_type 'auth_error'."""
|
||||
|
||||
|
||||
class PixivDriftError(PixivAPIError, NativeDriftError):
|
||||
"""A response did not match the shape this client depends on (missing
|
||||
`illusts`, un-parseable JSON where JSON was promised). Fail loud so the
|
||||
run flags 'the Pixiv app API changed' instead of silently importing
|
||||
nothing. Maps to API_DRIFT."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaItem:
|
||||
"""One resolved downloadable file belonging to a Pixiv work.
|
||||
|
||||
Fields mirror the other native clients' MediaItem so the downloader and
|
||||
ledger are structurally the same. Pixiv original URLs carry no content
|
||||
hash, so `filehash` is always None and the ledger keys on
|
||||
`<post_id>:<media_id>` where media_id is `p<num>` (page) or `ugoira`
|
||||
(the frame zip) — stable across URL-shape drift.
|
||||
"""
|
||||
|
||||
url: str
|
||||
filename: str
|
||||
kind: str
|
||||
filehash: str | None
|
||||
post_id: str
|
||||
media_id: str
|
||||
|
||||
|
||||
def user_id_from_url(url: str) -> str | None:
|
||||
"""The numeric pixiv user id from a source URL, or None.
|
||||
|
||||
Handles the modern forms FC accepts as sources
|
||||
(https://www.pixiv.net/users/<id>, /en/users/<id>) plus the legacy
|
||||
member.php?id=<id>. This IS the campaign id — no network resolver.
|
||||
"""
|
||||
parts = urlsplit(url or "")
|
||||
if "pixiv.net" not in parts.netloc:
|
||||
return None
|
||||
segs = [s for s in parts.path.split("/") if s]
|
||||
if segs and segs[0] == "en":
|
||||
segs = segs[1:]
|
||||
if len(segs) >= 2 and segs[0] == "users" and segs[1].isdigit():
|
||||
return segs[1]
|
||||
if segs and segs[0] == "member.php":
|
||||
qid = dict(parse_qsl(parts.query)).get("id", "")
|
||||
if qid.isdigit():
|
||||
return qid
|
||||
return None
|
||||
|
||||
|
||||
def _work_filename(work: dict, num: int, url: str) -> str:
|
||||
"""gallery-dl layout parity: `{id}_{title[:50]}_{num:>02}.{extension}`
|
||||
(the downloader sanitizes the final segment)."""
|
||||
title = work.get("title")
|
||||
title50 = (title if isinstance(title, str) else "")[:_TITLE_MAX]
|
||||
ext = safe_ext(urlsplit(url).path.rsplit("/", 1)[-1])
|
||||
return f"{work.get('id')}_{title50}_{num:02d}{ext}"
|
||||
|
||||
|
||||
class PixivClient:
|
||||
"""Synchronous Pixiv app-API read client. Construct with the operator's
|
||||
OAuth refresh token (the same token-type Credential the gallery-dl path
|
||||
consumed as `extractor.pixiv.refresh-token`)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
refresh_token: str | None,
|
||||
*,
|
||||
request_sleep: float = 0.0,
|
||||
max_retries: int = _MAX_429_RETRIES,
|
||||
session: requests.Session | None = None,
|
||||
):
|
||||
self.refresh_token = refresh_token
|
||||
self._request_sleep = request_sleep or 0.0
|
||||
self._max_retries = max_retries
|
||||
# No cookies — the app API authenticates via the Bearer token _login
|
||||
# installs. make_session still supplies the retry/UA plumbing; the
|
||||
# extra_headers overwrite its browser UA with the app profile.
|
||||
self._session = (
|
||||
session if session is not None
|
||||
else make_session(None, extra_headers=PIXIV_APP_HEADERS)
|
||||
)
|
||||
self._authed_user: dict = {}
|
||||
# Monotonic deadline after which the access token must be refreshed;
|
||||
# 0 forces a refresh on first use.
|
||||
self._token_deadline = 0.0
|
||||
|
||||
# -- auth ----------------------------------------------------------------
|
||||
|
||||
def _login(self) -> None:
|
||||
"""Exchange the refresh token for a Bearer access token (gallery-dl's
|
||||
`_login_impl`, including the X-Client-Time/X-Client-Hash pair the
|
||||
endpoint validates). No-op while the current token is still fresh."""
|
||||
if time.monotonic() < self._token_deadline:
|
||||
return
|
||||
if not self.refresh_token:
|
||||
raise PixivAuthError(
|
||||
"No Pixiv refresh token configured — add the OAuth refresh "
|
||||
"token as the Pixiv credential (token type)."
|
||||
)
|
||||
# gallery-dl stamps naive-UTC with a literal +00:00 suffix.
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||
headers = {
|
||||
"X-Client-Time": now,
|
||||
"X-Client-Hash": hashlib.md5(
|
||||
(now + _HASH_SECRET).encode()
|
||||
).hexdigest(),
|
||||
}
|
||||
data = {
|
||||
"client_id": _CLIENT_ID,
|
||||
"client_secret": _CLIENT_SECRET,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self.refresh_token,
|
||||
"get_secure_url": "1",
|
||||
}
|
||||
try:
|
||||
resp = self._session.post(
|
||||
_OAUTH_URL, data=data, headers=headers,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise PixivAPIError(f"Pixiv OAuth request failed: {exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise PixivAuthError(
|
||||
"Pixiv rejected the refresh token (HTTP "
|
||||
f"{resp.status_code}) — rotate the Pixiv credential.",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
try:
|
||||
payload = resp.json()["response"]
|
||||
access = payload["access_token"]
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
raise PixivDriftError(
|
||||
f"Pixiv OAuth response shape changed: {exc}"
|
||||
) from exc
|
||||
self._authed_user = payload.get("user") or {}
|
||||
self._session.headers["Authorization"] = f"Bearer {access}"
|
||||
# expires_in is 3600 today; refresh 60s early so a long walk never
|
||||
# rides an expiring token into a spurious 400.
|
||||
expires_in = payload.get("expires_in")
|
||||
lifetime = float(expires_in) if isinstance(expires_in, (int, float)) else 3600.0
|
||||
self._token_deadline = time.monotonic() + max(60.0, lifetime - 60.0)
|
||||
|
||||
# -- request -------------------------------------------------------------
|
||||
|
||||
def _call(self, endpoint: str, params: dict) -> dict:
|
||||
"""Authenticated app-API GET → parsed JSON body, with the shared 429
|
||||
backoff and the loud auth/drift/rate-limit mapping."""
|
||||
self._login()
|
||||
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 PixivAPIError(
|
||||
f"Pixiv request failed ({endpoint}): {exc}"
|
||||
) from exc
|
||||
if resp.status_code == 429 and attempt < self._max_retries:
|
||||
attempt += 1
|
||||
delay = retry_after_seconds(resp, attempt)
|
||||
log.warning(
|
||||
"Pixiv 429 (%s) — backing off %.1fs (retry %d/%d)",
|
||||
endpoint, delay, attempt, self._max_retries,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
break
|
||||
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError as exc:
|
||||
raise PixivDriftError(
|
||||
f"Pixiv returned non-JSON for {endpoint} "
|
||||
f"(HTTP {resp.status_code})"
|
||||
) from exc
|
||||
|
||||
error = body.get("error") if isinstance(body, dict) else None
|
||||
message = ""
|
||||
if isinstance(error, dict):
|
||||
message = str(
|
||||
error.get("user_message") or error.get("message") or ""
|
||||
)
|
||||
# Rate limiting first: the app API reports it as an error MESSAGE
|
||||
# (often on HTTP 403), which must not be mistaken for an auth failure.
|
||||
if resp.status_code == 429 or "rate limit" in message.lower():
|
||||
raise PixivAPIError(
|
||||
f"Pixiv rate limit hit ({endpoint}): {message or 'HTTP 429'}",
|
||||
status_code=429,
|
||||
retry_after=_RATE_LIMIT_RETRY_AFTER,
|
||||
)
|
||||
if resp.status_code in (400, 401, 403):
|
||||
# Invalid/expired access token surfaces as 400 invalid_grant-style
|
||||
# errors on the app API; 401/403 are straight auth rejections.
|
||||
raise PixivAuthError(
|
||||
f"Pixiv rejected the request ({endpoint}, HTTP "
|
||||
f"{resp.status_code}): {message or 'auth rejected'} — "
|
||||
"rotate the Pixiv refresh token.",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise PixivAPIError(
|
||||
f"Pixiv API error ({endpoint}, HTTP {resp.status_code}): "
|
||||
f"{message or 'unknown error'}",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
if error:
|
||||
# HTTP 200 carrying an error object — unexpected, but never
|
||||
# silently treat it as data.
|
||||
raise PixivAPIError(
|
||||
f"Pixiv API error ({endpoint}): {message or error}",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
return body
|
||||
|
||||
# -- normalization -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _normalize(work: dict) -> dict:
|
||||
"""Wrap an app-API work in the `{"id", "attributes", ...}` post shape
|
||||
the platform-agnostic core and shared helpers read. The raw work rides
|
||||
along under `_work` for extract_media / the post record."""
|
||||
title = work.get("title")
|
||||
caption = work.get("caption")
|
||||
wtype = work.get("type")
|
||||
return {
|
||||
"id": work.get("id"),
|
||||
"attributes": {
|
||||
"title": title if isinstance(title, str) else "",
|
||||
"content": caption if isinstance(caption, str) else "",
|
||||
"published_at": work.get("create_date"),
|
||||
"post_type": wtype if isinstance(wtype, str) else "illust",
|
||||
},
|
||||
"_work": work,
|
||||
}
|
||||
|
||||
# -- post-first seams ----------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def post_record_key(post: dict) -> tuple[str, str] | None:
|
||||
"""`(ledger_key, post_id)` gating post-record capture through the seen
|
||||
ledger (`post:<id>` synthetic key), or None when the work has no id."""
|
||||
pid = post.get("id")
|
||||
pid = str(pid) if pid is not None else ""
|
||||
if not pid:
|
||||
return None
|
||||
return (f"post:{pid}", pid)
|
||||
|
||||
@staticmethod
|
||||
def post_meta(post: dict) -> dict:
|
||||
attrs = post.get("attributes") or {}
|
||||
return {"title": attrs.get("title") or None, "date": attrs.get("published_at")}
|
||||
|
||||
@staticmethod
|
||||
def post_is_gated(post: dict) -> bool:
|
||||
"""True when this account cannot fetch the work's real files: pixiv
|
||||
substitutes a `limit_*` placeholder for the original (sanity-level
|
||||
filter / my-pixiv lock / deleted), or zeroes the author (deleted
|
||||
account). Mirrors #874 semantics: gated content leaves NO trace — a
|
||||
placeholder thumbnail and an empty stub would only pollute the
|
||||
archive. (gallery-dl's PHPSESSID web-scrape fallback for these is out
|
||||
of scope: FC holds no pixiv browser cookies — module docstring.)"""
|
||||
work = post.get("_work") or {}
|
||||
user = work.get("user") or {}
|
||||
if not user.get("id"):
|
||||
return True
|
||||
if work.get("meta_pages"):
|
||||
return False
|
||||
single = work.get("meta_single_page") or {}
|
||||
original = single.get("original_image_url")
|
||||
return isinstance(original, str) and original.startswith(_LIMIT_URL)
|
||||
|
||||
# -- media ---------------------------------------------------------------
|
||||
|
||||
def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]:
|
||||
"""Resolve a work's downloadable files (gallery-dl's `_extract_files`):
|
||||
multi-page originals, the single-page original, or the ugoira frame
|
||||
zip. `included_index` is unused (pixiv works are self-contained)."""
|
||||
work = post.get("_work") or {}
|
||||
pid = str(post.get("id") or "")
|
||||
if not pid or self.post_is_gated(post):
|
||||
return []
|
||||
|
||||
if work.get("type") == "ugoira":
|
||||
return self._ugoira_media(work, pid)
|
||||
|
||||
meta_pages = work.get("meta_pages") or []
|
||||
if meta_pages:
|
||||
items = []
|
||||
for num, page in enumerate(meta_pages):
|
||||
urls = page.get("image_urls") or {}
|
||||
url = urls.get("original")
|
||||
if not isinstance(url, str) or not url:
|
||||
continue
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=url,
|
||||
filename=_work_filename(work, num, url),
|
||||
kind="image",
|
||||
filehash=None,
|
||||
post_id=pid,
|
||||
media_id=f"p{num}",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
single = work.get("meta_single_page") or {}
|
||||
url = single.get("original_image_url")
|
||||
if not isinstance(url, str) or not url or url.startswith(_LIMIT_URL):
|
||||
return []
|
||||
return [
|
||||
MediaItem(
|
||||
url=url,
|
||||
filename=_work_filename(work, 0, url),
|
||||
kind="image",
|
||||
filehash=None,
|
||||
post_id=pid,
|
||||
media_id="p0",
|
||||
)
|
||||
]
|
||||
|
||||
def _ugoira_meta(self, work: dict, pid: str) -> dict | None:
|
||||
"""Fetch + memoize the ugoira metadata (frames + zip urls) for a work.
|
||||
|
||||
Idempotent and cached on the work dict, so the post record and the
|
||||
media extraction share ONE `/v1/ugoira/metadata` call regardless of
|
||||
which runs first (the core writes the post record BEFORE it extracts
|
||||
media). Returns None — and caches the miss — on a non-auth failure
|
||||
(matching gallery-dl's downgrade); auth failures stay loud."""
|
||||
if "_ugoira_meta" in work:
|
||||
return work["_ugoira_meta"]
|
||||
try:
|
||||
body = self._call("/v1/ugoira/metadata", {"illust_id": pid})
|
||||
meta = body["ugoira_metadata"]
|
||||
except PixivAuthError:
|
||||
raise
|
||||
except (PixivAPIError, KeyError, TypeError) as exc:
|
||||
log.warning("Pixiv ugoira metadata failed for %s: %s", pid, exc)
|
||||
work["_ugoira_meta"] = None
|
||||
return None
|
||||
work["_ugoira_meta"] = meta
|
||||
# Frame delays: a future ugoira→video conversion needs the timings (the
|
||||
# zip alone has none), so the post record captures them.
|
||||
work["_ugoira_frames"] = meta.get("frames") or []
|
||||
return meta
|
||||
|
||||
def fetch_ugoira_frames(self, post: dict) -> None:
|
||||
"""Populate `post['_work']['_ugoira_frames']` for an ugoira post (no-op
|
||||
otherwise). The core writes the post record BEFORE extract_media, so
|
||||
without this the frame timings would never reach the record; this
|
||||
fetches (and memoizes, so extract_media reuses it) the metadata. Injected
|
||||
into the downloader by the ingester, mirroring Patreon's content_fetcher.
|
||||
Auth errors propagate; other failures leave frames unset."""
|
||||
work = post.get("_work") or {}
|
||||
if work.get("type") != "ugoira":
|
||||
return
|
||||
pid = str(post.get("id") or "")
|
||||
if pid:
|
||||
self._ugoira_meta(work, pid)
|
||||
|
||||
def _ugoira_media(self, work: dict, pid: str) -> list[MediaItem]:
|
||||
"""The ugoira frame zip (gallery-dl's default non-original mode):
|
||||
`/v1/ugoira/metadata` → zip_urls.medium with the 600x600→1920x1080
|
||||
swap. A metadata failure downgrades to 'no media' with a warning
|
||||
(matching gallery-dl) instead of failing the walk — except auth
|
||||
failures, which stay loud."""
|
||||
meta = self._ugoira_meta(work, pid)
|
||||
if meta is None:
|
||||
return []
|
||||
try:
|
||||
zip_url = meta["zip_urls"]["medium"]
|
||||
except (KeyError, TypeError) as exc:
|
||||
log.warning("Pixiv ugoira zip url missing for %s: %s", pid, exc)
|
||||
return []
|
||||
url = zip_url.replace("_ugoira600x600", "_ugoira1920x1080", 1)
|
||||
return [
|
||||
MediaItem(
|
||||
url=url,
|
||||
filename=_work_filename(work, 0, url),
|
||||
kind="ugoira",
|
||||
filehash=None,
|
||||
post_id=pid,
|
||||
media_id="ugoira",
|
||||
)
|
||||
]
|
||||
|
||||
# -- iteration -----------------------------------------------------------
|
||||
|
||||
def iter_posts(
|
||||
self, campaign_id: str, cursor: str | None = None
|
||||
) -> Iterator[tuple[dict, dict, str | None]]:
|
||||
"""Yield (post, {}, page_cursor) for every work in the user's feed.
|
||||
|
||||
`campaign_id` is the numeric pixiv user id. `cursor` is the query
|
||||
string of the app API's `next_url` (offset pagination); None fetches
|
||||
page 1. The yielded `page_cursor` is the cursor that FETCHED this
|
||||
work's page, so the core checkpoints a value that re-serves the same
|
||||
page on resume (the shared cursor contract)."""
|
||||
if not str(campaign_id or "").isdigit():
|
||||
raise PixivDriftError(
|
||||
f"Pixiv campaign id must be a numeric user id, got "
|
||||
f"{campaign_id!r}"
|
||||
)
|
||||
current = cursor
|
||||
while True:
|
||||
page_cursor = current
|
||||
if current is None:
|
||||
params: dict = {"user_id": campaign_id}
|
||||
else:
|
||||
params = dict(parse_qsl(current))
|
||||
data = self._call("/v1/user/illusts", params)
|
||||
works = data.get("illusts")
|
||||
if not isinstance(works, list):
|
||||
raise PixivDriftError(
|
||||
"Pixiv user-illusts response had no 'illusts' list "
|
||||
f"(keys: {sorted(data)[:8]})"
|
||||
)
|
||||
for work in works:
|
||||
if not isinstance(work, dict):
|
||||
continue
|
||||
yield self._normalize(work), {}, page_cursor
|
||||
next_url = data.get("next_url")
|
||||
if not next_url:
|
||||
return
|
||||
current = str(next_url).rpartition("?")[2]
|
||||
|
||||
# -- user detail ---------------------------------------------------------
|
||||
|
||||
def resolve_display_name(self, user_id: str) -> str | None:
|
||||
"""The pixiv user's display name via `/v1/user/detail` (gallery-dl's
|
||||
user_detail) — used to name the Artist when a source is added by numeric
|
||||
id. None on any failure (the caller falls back to the id)."""
|
||||
try:
|
||||
body = self._call("/v1/user/detail", {"user_id": str(user_id)})
|
||||
except PixivAPIError:
|
||||
return None
|
||||
name = (body.get("user") or {}).get("name") if isinstance(body, dict) else None
|
||||
return name if isinstance(name, str) and name.strip() else None
|
||||
|
||||
# -- verify --------------------------------------------------------------
|
||||
|
||||
def verify_auth(self) -> tuple[bool | None, str]:
|
||||
"""Cheap credential probe: run the OAuth refresh (the thing that fails
|
||||
when the token is bad) without walking any feed."""
|
||||
try:
|
||||
self._token_deadline = 0.0 # force a real refresh
|
||||
self._login()
|
||||
except PixivAuthError as exc:
|
||||
return False, f"Pixiv rejected the credential — {exc}"
|
||||
except PixivAPIError as exc:
|
||||
return None, f"Couldn't verify (network/HTTP issue): {exc}"
|
||||
account = self._authed_user.get("account") or self._authed_user.get("name")
|
||||
suffix = f" as {account}" if account else ""
|
||||
return True, f"Credentials valid — Pixiv OAuth refresh succeeded{suffix}."
|
||||
|
||||
|
||||
def rating_label(x_restrict) -> str | None:
|
||||
"""Human rating from pixiv's x_restrict (0/1/2) — written into the post
|
||||
record so the archive keeps the R-18 flag without the reader needing to
|
||||
know pixiv's numeric scheme."""
|
||||
if isinstance(x_restrict, bool) or not isinstance(x_restrict, int):
|
||||
return None
|
||||
return _RATINGS.get(x_restrict)
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Native Pixiv media downloader — the Pixiv counterpart to
|
||||
patreon_downloader / subscribestar_downloader.
|
||||
|
||||
Given a normalized Pixiv work and its resolved `MediaItem`s
|
||||
(pixiv_client.extract_media), download the originals to gallery-dl's on-disk
|
||||
layout (so pre-cutover gallery-dl downloads are recognized on disk and not
|
||||
re-fetched), write the post-first sidecars the importer consumes, and report
|
||||
per-media outcomes.
|
||||
|
||||
On-disk layout (matches FC's gallery-dl pixiv config, PLATFORM_DEFAULTS:
|
||||
base-directory `<images_root>/<artist_slug>/pixiv` + `directory:
|
||||
["{category}"]` + filename `{id}_{title[:50]}_{num:>02}.{extension}`):
|
||||
|
||||
<images_root>/<artist_slug>/pixiv/pixiv/<id>_<title50>_<NN>.<ext>
|
||||
|
||||
— note the intentional DOUBLE `pixiv` segment: gallery-dl appended
|
||||
`{category}` under a base-directory that already ended in the platform name,
|
||||
and tier-2 disk-skip parity requires reproducing that exactly. The layout is
|
||||
FLAT (no per-post directory), so the post-first record is `_post_<id>.json`
|
||||
in the same directory (the id suffix prevents the collisions a bare
|
||||
`_post.json` would have here; phase 3 receives explicit post_record_paths, so
|
||||
the name is a convention, not a discovery key).
|
||||
|
||||
Simpler than Patreon (no Mux/yt-dlp video branch) — the one special file is
|
||||
the ugoira frame zip, downloaded as-is; FC's archive-containment import
|
||||
extracts the frames, and the frame DELAYS ride the post record (the zip
|
||||
carries none — a future ugoira→video conversion needs them).
|
||||
|
||||
PURE: no DB; the seen-skip is an injected predicate. FC runs on a plain-HTTP
|
||||
homelab; nothing here uses a secure-context Web API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from .native_ingest_common import (
|
||||
BaseNativeDownloader,
|
||||
MediaOutcome,
|
||||
PostRecordOutcome,
|
||||
)
|
||||
from .pixiv_client import PIXIV_APP_HEADERS, rating_label
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Control chars (0x00–0x1f + 0x7f DEL) — gallery-dl's default `path-remove`.
|
||||
_GDL_PATH_REMOVE_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def gdl_clean_filename(name: str) -> str:
|
||||
"""Reproduce gallery-dl's on-disk filename EXACTLY as it wrote it on this
|
||||
Linux host, so the tier-2 disk-skip recognizes pre-cutover files instead of
|
||||
re-downloading them.
|
||||
|
||||
gallery-dl's PathFormat.build_filename is `clean_path(clean_segment(name))`.
|
||||
On Linux (verified against gallery-dl 1.32.5 path.py) the defaults resolve to:
|
||||
- path-restrict "auto" → "/" → clean_segment replaces ONLY "/" → "_"
|
||||
- path-remove "\\x00-\\x1f\\x7f" → clean_path DELETES control chars
|
||||
- path-strip "auto" → "" → NO trailing dot/space stripping
|
||||
Crucially it does NOT touch the Windows-forbidden set (<>:"|?*) — those stay
|
||||
raw in titles on disk. A stricter sanitizer here would rename any such title,
|
||||
miss the on-disk match, and re-pull the whole work. Order mirrors gallery-dl
|
||||
(segment inner, path outer); for these disjoint char sets it's commutative.
|
||||
"""
|
||||
return _GDL_PATH_REMOVE_RE.sub("", name.replace("/", "_"))
|
||||
|
||||
# Enrichment keys copied verbatim from the app-API work dict into the post
|
||||
# record (they're already JSON scalars/objects). Everything lands in
|
||||
# Post.raw_metadata via the importer, so the archive keeps pixiv's stats and
|
||||
# structure without a schema change.
|
||||
_WORK_PASSTHROUGH_KEYS = (
|
||||
"type",
|
||||
"page_count",
|
||||
"width",
|
||||
"height",
|
||||
"total_view",
|
||||
"total_bookmarks",
|
||||
"total_comments",
|
||||
"is_bookmarked",
|
||||
"illust_ai_type",
|
||||
"series",
|
||||
)
|
||||
|
||||
|
||||
class PixivDownloader(BaseNativeDownloader):
|
||||
"""Download resolved Pixiv media to gallery-dl's on-disk layout.
|
||||
Subclasses BaseNativeDownloader for the shared streaming GET
|
||||
(transient-retry + Range-resume) and validation/quarantine. PURE: no DB."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
images_root: Path,
|
||||
cookies_path: str | None = None,
|
||||
*,
|
||||
validate: bool = True,
|
||||
rate_limit: float = 0.0,
|
||||
session: requests.Session | None = None,
|
||||
ugoira_frames_fetcher: Callable[[dict], None] | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
images_root, cookies_path, platform="pixiv",
|
||||
validate=validate, rate_limit=rate_limit, session=session,
|
||||
)
|
||||
# Injected by the ingester (client.fetch_ugoira_frames) so write_post_record
|
||||
# can populate frame timings — which extract_media memoizes, but the core
|
||||
# writes the post record FIRST. Mirrors Patreon's content_fetcher.
|
||||
self._ugoira_frames_fetcher = ugoira_frames_fetcher
|
||||
if session is None:
|
||||
# i.pximg.net 403s any GET without the app Referer; mirror the
|
||||
# client's full app-header profile (gallery-dl serves media off
|
||||
# the same session it drives the API with). An injected session
|
||||
# (tests) owns its own headers.
|
||||
self.session.headers.update(PIXIV_APP_HEADERS)
|
||||
|
||||
# -- public ------------------------------------------------------------
|
||||
|
||||
def download_post(
|
||||
self,
|
||||
post: dict,
|
||||
media_items: list,
|
||||
artist_slug: str,
|
||||
*,
|
||||
is_seen: Callable[[object], bool] = lambda m: False,
|
||||
should_stop: Callable[[], bool] = lambda: False,
|
||||
recapture: bool = False,
|
||||
) -> list[MediaOutcome]:
|
||||
"""Download every media item of one work; return per-item outcomes.
|
||||
Mirrors SubscribeStarDownloader.download_post (two-tier skip, mid-post
|
||||
time-box, recapture surfacing)."""
|
||||
flat_dir = self._flat_dir(artist_slug)
|
||||
outcomes: list[MediaOutcome] = []
|
||||
for media in media_items:
|
||||
if should_stop():
|
||||
break
|
||||
try:
|
||||
outcomes.append(
|
||||
self._download_one(
|
||||
post, media, flat_dir, artist_slug, is_seen,
|
||||
recapture=recapture,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # resilient: isolate one item's failure
|
||||
log.warning(
|
||||
"Pixiv media failed (work %s, %s): %s",
|
||||
post.get("id"), getattr(media, "media_id", "?"), exc,
|
||||
)
|
||||
outcomes.append(
|
||||
MediaOutcome(media=media, status="error", path=None, error=str(exc))
|
||||
)
|
||||
return outcomes
|
||||
|
||||
def _flat_dir(self, artist_slug: str) -> Path:
|
||||
# Double platform segment — gallery-dl layout parity (module docstring).
|
||||
return self.images_root / artist_slug / "pixiv" / "pixiv"
|
||||
|
||||
# -- per-item ----------------------------------------------------------
|
||||
|
||||
def _download_one(
|
||||
self,
|
||||
post: dict,
|
||||
media,
|
||||
flat_dir: Path,
|
||||
artist_slug: str,
|
||||
is_seen: Callable[[object], bool],
|
||||
*,
|
||||
recapture: bool = False,
|
||||
) -> MediaOutcome:
|
||||
seen = is_seen(media)
|
||||
if seen and not recapture:
|
||||
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
|
||||
|
||||
# The client's filename already carries the {id}_{title50}_{NN} shape
|
||||
# (raw title, gallery-dl-template order); clean it to the byte-exact
|
||||
# name gallery-dl wrote on disk so tier-2 disk-skip matches (else a
|
||||
# re-download of the whole work). See gdl_clean_filename.
|
||||
media_path = flat_dir / gdl_clean_filename(media.filename)
|
||||
|
||||
if media_path.exists(): # tier-2: already on disk
|
||||
return MediaOutcome(
|
||||
media=media, status="skipped_disk", path=media_path, error=None
|
||||
)
|
||||
# recapture: a seen item not on disk is NOT re-downloaded (recovery's job).
|
||||
if seen:
|
||||
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
|
||||
|
||||
flat_dir.mkdir(parents=True, exist_ok=True)
|
||||
if self._rate_limit > 0:
|
||||
time.sleep(self._rate_limit)
|
||||
|
||||
out_path = self._fetch_get(media.url, media_path)
|
||||
reason, quarantine_dest = self._validate_path(out_path, artist_slug, media.url)
|
||||
if reason is not None:
|
||||
return MediaOutcome(
|
||||
media=media, status="quarantined", path=quarantine_dest, error=reason,
|
||||
)
|
||||
self._write_minimal_sidecar(post, out_path, source_url=media.url)
|
||||
return MediaOutcome(media=media, status="downloaded", path=out_path, error=None)
|
||||
|
||||
# -- post record ---------------------------------------------------------
|
||||
|
||||
def write_post_record(self, post: dict, artist_slug: str) -> PostRecordOutcome:
|
||||
"""Write the post-first `_post_<id>.json` — the sole writer of the post
|
||||
body/metadata on the native path. Beyond the standard body fields, the
|
||||
record carries pixiv's own structure (tags + EN translations, rating,
|
||||
series, view/bookmark counts, AI flag, dimensions, author, ugoira frame
|
||||
delays) so the archive keeps what the platform knows about the work."""
|
||||
attrs = post.get("attributes") or {}
|
||||
work = post.get("_work") or {}
|
||||
title = attrs.get("title") if isinstance(attrs.get("title"), str) else None
|
||||
post_type = attrs.get("post_type") if isinstance(attrs.get("post_type"), str) else None
|
||||
pid = str(post.get("id") or "")
|
||||
if not pid:
|
||||
return PostRecordOutcome(
|
||||
path=None, post_type=post_type, title=title, body_chars=0,
|
||||
)
|
||||
|
||||
content = attrs.get("content")
|
||||
content = content if isinstance(content, str) else ""
|
||||
data: dict = {
|
||||
"category": "pixiv",
|
||||
"id": pid,
|
||||
"title": title or "",
|
||||
"content": content,
|
||||
"published_at": attrs.get("published_at"),
|
||||
# The post permalink is synthesized by platforms/pixiv.py
|
||||
# derive_post_url from `id` at parse time — no url key here.
|
||||
"rating": rating_label(work.get("x_restrict")),
|
||||
}
|
||||
for key in _WORK_PASSTHROUGH_KEYS:
|
||||
if key in work:
|
||||
data[key] = work[key]
|
||||
tags = work.get("tags")
|
||||
if isinstance(tags, list):
|
||||
data["tags"] = [
|
||||
{
|
||||
"name": t.get("name"),
|
||||
"translated_name": t.get("translated_name"),
|
||||
}
|
||||
for t in tags
|
||||
if isinstance(t, dict)
|
||||
]
|
||||
user = work.get("user")
|
||||
if isinstance(user, dict):
|
||||
data["user"] = {
|
||||
"id": user.get("id"),
|
||||
"account": user.get("account"),
|
||||
"name": user.get("name"),
|
||||
}
|
||||
# Ugoira frame timings. extract_media memoizes these, but the core writes
|
||||
# the post record BEFORE extracting media, so fetch them here (shared +
|
||||
# idempotent via the client's memoization) so the record actually keeps
|
||||
# them — the zip carries no timings.
|
||||
if (
|
||||
work.get("type") == "ugoira"
|
||||
and not work.get("_ugoira_frames")
|
||||
and self._ugoira_frames_fetcher is not None
|
||||
):
|
||||
self._ugoira_frames_fetcher(post)
|
||||
frames = work.get("_ugoira_frames")
|
||||
if frames:
|
||||
data["ugoira_frames"] = frames
|
||||
|
||||
flat_dir = self._flat_dir(artist_slug)
|
||||
flat_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = flat_dir / f"_post_{pid}.json"
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
return PostRecordOutcome(
|
||||
path=path, post_type=post_type, title=title, body_chars=len(content),
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Native Pixiv ingester — the Pixiv ADAPTER over the platform-agnostic core
|
||||
(`ingest_core.Ingester`).
|
||||
|
||||
Thin counterpart to patreon_ingester / subscribestar_ingester: wires the Pixiv
|
||||
client/downloader/ledger models/constraints/key into the core and supplies the
|
||||
Pixiv failure mapping. The modes (tick / backfill / recovery / recapture), the
|
||||
seen + dead-letter ledgers, cursor checkpointing, and the post-first capture
|
||||
all live in the core. `download_service.download_source` drives
|
||||
`PixivIngester.run` exactly as it drives the other two.
|
||||
|
||||
`campaign_id` is the numeric pixiv user id (download_backends extracts it from
|
||||
the source URL — no network resolver). Auth is the operator's OAuth refresh
|
||||
token (the token-type Credential), passed as `auth_token` — pixiv is the first
|
||||
native platform authenticating by token rather than cookies, so the uniform
|
||||
constructor accepts both and ignores what it doesn't need.
|
||||
|
||||
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 ..models import PixivFailedMedia, PixivSeenMedia
|
||||
from .ingest_core import DEAD_LETTER_THRESHOLD, Ingester
|
||||
from .pixiv_client import MediaItem, PixivAPIError, PixivClient
|
||||
from .pixiv_downloader import PixivDownloader
|
||||
|
||||
__all__ = [
|
||||
"DEAD_LETTER_THRESHOLD",
|
||||
"PixivIngester",
|
||||
"_ledger_key",
|
||||
"verify_pixiv_credential",
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_LEDGER_KEY_MAX = 128
|
||||
|
||||
|
||||
def _ledger_key(media: MediaItem) -> str:
|
||||
"""Stable per-media identity for the cross-run seen-ledger. Pixiv original
|
||||
URLs carry no content hash, so the key is the page/zip identity scoped to
|
||||
its work: `<illust_id>:p<num>` / `<illust_id>:ugoira`. Bounded to the
|
||||
column width."""
|
||||
if media.filehash:
|
||||
return media.filehash
|
||||
return f"{media.post_id}:{media.media_id}"[:_LEDGER_KEY_MAX]
|
||||
|
||||
|
||||
class PixivIngester(Ingester):
|
||||
"""Walk a pixiv user's works, download unseen originals, return a
|
||||
`DownloadResult`. A thin adapter over `ingest_core.Ingester`; `client` /
|
||||
`downloader` are injectable seams so unit tests run without network."""
|
||||
|
||||
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: PixivClient | None = None,
|
||||
downloader: PixivDownloader | None = None,
|
||||
):
|
||||
self.images_root = Path(images_root)
|
||||
self.cookies_path = str(cookies_path) if cookies_path else None
|
||||
resolved_client = (
|
||||
client
|
||||
if client is not None
|
||||
else PixivClient(auth_token, request_sleep=request_sleep)
|
||||
)
|
||||
resolved_downloader = (
|
||||
downloader
|
||||
if downloader is not None
|
||||
else PixivDownloader(
|
||||
self.images_root, cookies_path, validate=validate, rate_limit=rate_limit,
|
||||
# write_post_record runs before extract_media in the core, so it
|
||||
# fetches ugoira frame timings via the SAME client (shared,
|
||||
# memoized) — else the record's ugoira_frames stays empty.
|
||||
ugoira_frames_fetcher=resolved_client.fetch_ugoira_frames,
|
||||
)
|
||||
)
|
||||
super().__init__(
|
||||
client=resolved_client,
|
||||
downloader=resolved_downloader,
|
||||
session_factory=session_factory,
|
||||
seen_model=PixivSeenMedia,
|
||||
failed_model=PixivFailedMedia,
|
||||
seen_constraint="uq_pixiv_seen_media_source_id",
|
||||
failed_constraint="uq_pixiv_failed_media_source_id",
|
||||
ledger_key=_ledger_key,
|
||||
platform="pixiv",
|
||||
error_base=PixivAPIError,
|
||||
# API_DRIFT message phrasing; the base Ingester._failure_result owns
|
||||
# the auth/drift/HTTP→error_type mapping (shared across platforms).
|
||||
drift_label="Pixiv app API",
|
||||
# Captions are legitimately empty for many pixiv artists, so the
|
||||
# zero-bodies #862 canary would false-positive here; the client's
|
||||
# response-shape checks (missing `illusts` → drift) cover the same
|
||||
# failure class structurally.
|
||||
body_canary=False,
|
||||
)
|
||||
|
||||
|
||||
async def verify_pixiv_credential(
|
||||
auth_token: str | None,
|
||||
) -> tuple[bool | None, str]:
|
||||
"""Native Pixiv credential probe — one OAuth refresh via
|
||||
PixivClient.verify_auth (the exact call that fails when the token is
|
||||
bad; no feed walk). Returns the uniform `(ok, message)` contract so
|
||||
download_backends.verify_source_credential treats it like the others."""
|
||||
client = PixivClient(auth_token)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, client.verify_auth)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user