Compare commits
376 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc0f00c51b | |||
| 1463794778 | |||
| a5a95320df | |||
| 9326a82b29 | |||
| 48c8811d69 | |||
| 01933c5b26 | |||
| 74fef908d2 | |||
| 77baee49fd | |||
| 353b5d8087 | |||
| 1bef68aa29 | |||
| ca1c17446c | |||
| 06d5e83da4 | |||
| 1ed0895e8d | |||
| 291b90803d | |||
| 22c3b54746 | |||
| 179c1a9dcc | |||
| 1d39afa3b6 | |||
| 1a4bc2f981 | |||
| b69c70ab2b | |||
| 862ace69d6 | |||
| 4fd8790c85 | |||
| abf88b1a15 | |||
| 5143f4c34f | |||
| c05dcafbea | |||
| fc64f130b8 | |||
| 55e8632dab | |||
| 13d297b881 | |||
| 4974b7cf77 | |||
| 825e6b90bf | |||
| 6cd7281af5 | |||
| 6e3c5f697f | |||
| 958378312c | |||
| fb012c557c | |||
| d63dfa511a | |||
| 66593ab895 | |||
| e34f79fc56 | |||
| c8a8e23050 | |||
| e3855a5ae0 | |||
| b266a54ad3 | |||
| 5be6b9cada | |||
| ad803b646f | |||
| 4a1f255164 | |||
| 1f5da3d283 | |||
| 1728b43167 | |||
| 2d1cddd9b7 | |||
| 1aadf3267b | |||
| 93034f580d | |||
| 10434509d3 | |||
| 9b9b12f410 | |||
| b85327a79d | |||
| 3fcc4aeb43 | |||
| 376d310693 | |||
| 7b712920a4 | |||
| 0ecd1ce4f1 | |||
| 0cd2f391ee | |||
| e49cea3eba | |||
| 7127714316 | |||
| e206778a5c | |||
| 73dd301dbb | |||
| 23fab983a0 | |||
| bc69495a16 | |||
| 7c94d99b9f | |||
| 26589c3d98 | |||
| 6599a07468 | |||
| 6281cb1e66 | |||
| 77d02f57ae | |||
| af7f0078bc | |||
| 5269cd0709 | |||
| 068def2f24 | |||
| 478f898e72 | |||
| 311fe0ee9c | |||
| 38a5e7f332 | |||
| 3b435dc0ba | |||
| 57fe15c267 | |||
| eff64275fc | |||
| eb3231ef10 | |||
| 7f6345dccf | |||
| e9af459c0d | |||
| 479b7b54da | |||
| 976f581aa2 | |||
| 6f02806aec | |||
| 8771364cee | |||
| a1d19bd96a | |||
| 559d29fe1c | |||
| 26827ff38f | |||
| 204d341a99 | |||
| 9201b7b539 | |||
| 26dcfaf6c2 | |||
| 78a3977f8a | |||
| 9b1b0369cc | |||
| d526447496 | |||
| d8d8ecd78f | |||
| 82551a89d1 | |||
| ebe6ab9741 | |||
| 7ac5c7e522 | |||
| 817a002c2b | |||
| f678819093 | |||
| 18123fb9cb | |||
| ee1b45f8cc | |||
| 4272a19d40 | |||
| 25e1e098fb | |||
| 258c77dfcd | |||
| 2e806f202f | |||
| 002279e63b | |||
| 18d5c05639 | |||
| 51201b459e | |||
| 11ddfc3876 | |||
| e287802ecb | |||
| 2b8ce86622 | |||
| 540151290b | |||
| 9422eadabe | |||
| b3afc2437e | |||
| 49bee77cdc | |||
| 60a9c9e6ef | |||
| db7e1f2b59 | |||
| 369e3de684 | |||
| c209e3b37e | |||
| 41652db20f | |||
| f154603811 | |||
| cffdd93418 | |||
| b48ba60830 | |||
| 8dee2f9628 | |||
| 41aa8fe39e | |||
| 8b99dc9b81 | |||
| dcbc3ae335 | |||
| 00607a309b | |||
| fd84be40dd | |||
| 949c9abcc6 | |||
| f897e2534b | |||
| 79f510d7f8 | |||
| 976107bbe8 | |||
| 59181069da | |||
| 0d51b93aa7 | |||
| eb811e11f6 | |||
| 428ecd8642 | |||
| bcc7266021 | |||
| ed1e04b831 | |||
| 3df191e255 | |||
| b999480db5 | |||
| f5156bd847 | |||
| 65ec29ba9b | |||
| dfc3922d24 | |||
| 96c29c370b | |||
| 5e1655384f | |||
| 8dbf29f803 | |||
| 05f226a8f6 | |||
| bd2807cdd1 | |||
| 82b26b8aaa | |||
| 96e984cded | |||
| 13253b18d1 | |||
| 896e4f248c | |||
| d96918d777 | |||
| c342c73a25 | |||
| ca25f688c3 | |||
| 796e92540a | |||
| 2c67c27044 | |||
| 7fcef53d5b | |||
| 3eb08e926b | |||
| 5c3f8ebd70 | |||
| 9e81ced359 | |||
| 7c4b24c80d | |||
| 11e9f5af60 | |||
| 3e1303ea3c | |||
| 909fa37b15 | |||
| 2c544ad5af | |||
| 90c68f8b2a | |||
| 3e22e78aa4 | |||
| 013b9d7f06 | |||
| dfab8f65ff | |||
| 7bb765b6ed | |||
| 59746d213d | |||
| 618f7cdc36 | |||
| 3610ba495f | |||
| 028ea33a7c | |||
| 65211a3f2f | |||
| 444c1fb075 | |||
| e6d5f67f11 | |||
| 26c68b0a75 | |||
| a712cef92d | |||
| e75427b19a | |||
| 75eab188c8 | |||
| 0319812b45 | |||
| 22cdf0f334 | |||
| 79089b50b0 | |||
| 5447fab987 | |||
| 7a40a50fe9 | |||
| d55e52ae9b | |||
| c8b815afe6 | |||
| 3f92669f12 | |||
| 9ba3db75fd | |||
| 70d4017cf6 | |||
| bad37e07b2 | |||
| 14c244bd3d | |||
| 074c5868fb | |||
| f1a664e5a7 | |||
| 7b2a2051e9 | |||
| 9deebfa133 | |||
| 4854d74c5a | |||
| 409bbd43db | |||
| 4e83b4225a | |||
| c774042a85 | |||
| d5d23a92f2 | |||
| 2bfc9936a1 | |||
| a50902071a | |||
| c999c64cbe | |||
| 978f49adcc | |||
| e4cebf70d1 | |||
| 4958e8f7d4 | |||
| 4c6406ee18 | |||
| a8f624a0f1 | |||
| bb47e80b3e | |||
| df76bc0f58 | |||
| de4ef6ae74 | |||
| dc1083b5e0 | |||
| 408fcd488a | |||
| f2fbe2ae6e | |||
| b1778ca9f2 | |||
| fe0ed52595 | |||
| fb05c5eef7 | |||
| e46893fefd | |||
| e90e6b2c34 | |||
| 8e98e79968 | |||
| 0666e15211 | |||
| a00a2786e3 | |||
| 9770dd3474 | |||
| 7daf90f41e | |||
| aaa375654b | |||
| 5bc8ef65ad | |||
| 978959bdc4 | |||
| 7309d1d6d4 | |||
| 747390631d | |||
| daaa7543a8 | |||
| 19a91a1641 | |||
| c0fd80e694 | |||
| 9e262cc5f0 | |||
| db490e92df | |||
| 8ad40da145 | |||
| 1804a2c622 | |||
| 43b02d79a4 | |||
| 677317244e | |||
| a92817677d | |||
| e0d2a20588 | |||
| 394c7dcd67 | |||
| a73d9327d8 | |||
| 5201fab088 | |||
| b79708524e | |||
| 1d84f67418 | |||
| 1226d3b23a | |||
| 6c5dbfe4a0 | |||
| 91265df3d6 | |||
| 68cda6114d | |||
| c217009425 | |||
| f4f49d407e | |||
| a183be7e6e | |||
| f2e9ae07dc | |||
| d9d502a60d | |||
| 1819caaf5b | |||
| 22dc516dc7 | |||
| 11acdb0322 | |||
| 4c42a15fa1 | |||
| 2eb9fd5dd0 | |||
| 14c4dd1ea0 | |||
| 619e7712c2 | |||
| 01e5ce1410 | |||
| 416d8d71cd | |||
| 3bb94674cf | |||
| a3c9499e93 | |||
| 87c7318125 | |||
| a75c602175 | |||
| e4e35163ab | |||
| b7d07324ee | |||
| c65da42593 | |||
| ef8f4f7193 | |||
| 19eb4e9388 | |||
| a559fabdd5 | |||
| 711fd2bb75 | |||
| 3556a54260 | |||
| df2310bc70 | |||
| 9374f63953 | |||
| ec3d27b219 | |||
| 6332ae13fd | |||
| 3c89223dcb | |||
| 23f452021f | |||
| 62cca64dce | |||
| 89dfa42e18 | |||
| 2b69540ecc | |||
| 4fe53cdf6b | |||
| cb9b286c53 | |||
| a497104661 | |||
| 5bb25245a5 | |||
| 911d535f56 | |||
| 03bd3b2eda | |||
| e82c2ee57b | |||
| cd43439401 | |||
| bde19944db | |||
| 402086c34c | |||
| fb7383eea7 | |||
| e47fa0cf4b | |||
| b211900390 | |||
| 697a86d31c | |||
| 9a2cd569c3 | |||
| d592e0ca02 | |||
| 7a872a3619 | |||
| 4bb11ce7dc | |||
| e42a86d995 | |||
| b2e59e7e17 | |||
| e53f8959af | |||
| 5b615b7ded | |||
| d6c15f4ea0 | |||
| 3b2f7a41c3 | |||
| 218bfebb92 | |||
| ec43e823e1 | |||
| 682beafbc5 | |||
| 96c30eba13 | |||
| 2ec7d86a3b | |||
| 6222928746 | |||
| 1bdaa04aa2 | |||
| 1c2dc7659a | |||
| 7395e77d75 | |||
| 618dafde85 | |||
| 96fffaff64 | |||
| 575d817919 | |||
| add1c1ad14 | |||
| 593f65c9cc | |||
| 2a8f7cd8b6 | |||
| 86efbf7f2c | |||
| 3a0cca5aca | |||
| 83f8af8090 | |||
| a5b3702863 | |||
| 9a2617c1a2 | |||
| 509a7958cf | |||
| 81688815a0 | |||
| 5a6a95682d | |||
| 91b0145bc8 | |||
| 26e47a86cb | |||
| 773128c3bf | |||
| 928e3037f0 | |||
| ce7b154ae9 | |||
| b08b12eb8f | |||
| 9430a9d9c3 | |||
| 4fd6d4cc29 | |||
| 304e8aa878 | |||
| 0497394710 | |||
| 21a73cd1dc | |||
| 79cd1234e2 | |||
| 23aee56ce3 | |||
| 3f6ea601f8 | |||
| 6a25db4b8b | |||
| c802b26406 | |||
| 3a4270e6be | |||
| ae569c0f9a | |||
| 1adc47f59c | |||
| 9fe534139a | |||
| 16e0268da7 | |||
| 1f4ce8513b | |||
| 5a116ca9d0 | |||
| ef3ee5aceb | |||
| 914033db29 | |||
| d495605c12 | |||
| 76d8ad42a8 | |||
| 711abea567 | |||
| 6d630d13d6 | |||
| 3f30327fa5 | |||
| 4f9464d215 | |||
| e678d1dfdf | |||
| d9ab6e15c6 | |||
| e05e0b9f37 | |||
| 56cc253009 | |||
| 844bb86802 | |||
| 576e16d14d | |||
| a8f6a464aa | |||
| 9cb24c9e1b | |||
| 6590dcdb39 | |||
| ab9922ad2e | |||
| 3162cff96b | |||
| b65e956ad2 |
+30
-149
@@ -92,28 +92,25 @@ jobs:
|
|||||||
- run: npm run test:unit
|
- run: npm run test:unit
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
# Integration suite split into THREE parallel shards (2026-05-25, runner
|
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
|
||||||
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service
|
# The shards existed to parallelize ~8.5min of integration tests; once the
|
||||||
# set and runs alembic + a disjoint subset of integration tests. Shards
|
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
|
||||||
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
|
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
|
||||||
# stays single-threaded per shard but multiple shards run in parallel
|
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
|
||||||
# wall-clock. Approximate split — rebalance once --durations=15 output
|
# runner slots for no wall-clock gain. One job now: spin up once, install
|
||||||
# reveals which shard is the long pole.
|
# once, migrate once, run every integration test.
|
||||||
#
|
#
|
||||||
# Each shard's docker-ps filter uses its own unique job name to scope
|
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
|
||||||
# service-container resolution. act_runner appears to strip underscores
|
# containers by job name. act_runner strips underscores from job names when
|
||||||
# from job names when building container labels — `int_api` yielded
|
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
|
||||||
# zero matches on 2026-05-25 — so shards use no-separator names
|
# stays separator-free (`integration`). The step prints `docker ps -a` first
|
||||||
# (`intapi`, `intimp`, `intcore`) instead. Each step prints
|
# so a future naming-convention shift surfaces in the log without a
|
||||||
# `docker ps -a` first so a future naming-convention shift surfaces in
|
# guess-and-push cycle.
|
||||||
# the log without another guess-and-push cycle.
|
|
||||||
#
|
#
|
||||||
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT
|
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
|
||||||
# done — per ci-requirements.md, FC is the only Python consumer of that
|
# per ci-requirements.md, FC is the only Python consumer of that image and the
|
||||||
# image and the CI-Runner project's "add deps to image when used by >1
|
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
|
||||||
# project" rule keeps the install per-job.
|
integration:
|
||||||
|
|
||||||
intapi:
|
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -144,14 +141,14 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: API integration shard (resolve service IPs, migrate, test)
|
- name: Integration suite (resolve service IPs, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
echo "=== end landscape ==="
|
echo "=== end landscape ==="
|
||||||
PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||||
RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||||
test -n "$PG" && test -n "$RD"
|
test -n "$PG" && test -n "$RD"
|
||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||||
@@ -168,130 +165,14 @@ jobs:
|
|||||||
else
|
else
|
||||||
pip install -r requirements.txt pytest pytest-asyncio
|
pip install -r requirements.txt pytest pytest-asyncio
|
||||||
fi
|
fi
|
||||||
|
# Relax durability on the throwaway CI Postgres so the per-test
|
||||||
|
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
|
||||||
|
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
|
||||||
|
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
|
||||||
|
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
|
||||||
|
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
|
||||||
|
# surprise can't red the job; fabledcurator is the postgres image's
|
||||||
|
# bootstrap superuser.
|
||||||
|
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
pytest tests/test_api_*.py -v -m integration --durations=15
|
pytest tests/ -v -m integration --durations=15
|
||||||
|
|
||||||
intimp:
|
|
||||||
runs-on: python-ci
|
|
||||||
container:
|
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
||||||
env:
|
|
||||||
DB_USER: fabledcurator
|
|
||||||
DB_PASSWORD: ci_integration
|
|
||||||
DB_PORT: "5432"
|
|
||||||
DB_NAME: fabledcurator_test
|
|
||||||
SECRET_KEY: ci_integration_placeholder
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg16
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: fabledcurator
|
|
||||||
POSTGRES_PASSWORD: ci_integration
|
|
||||||
POSTGRES_DB: fabledcurator_test
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U fabledcurator"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 10
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
options: >-
|
|
||||||
--health-cmd "redis-cli ping"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Importer integration shard (resolve service IPs, migrate, test)
|
|
||||||
run: |
|
|
||||||
set -eux
|
|
||||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
|
||||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
|
||||||
echo "=== end landscape ==="
|
|
||||||
PG=$(docker ps --filter "name=intimp" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
|
||||||
RD=$(docker ps --filter "name=intimp" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
|
||||||
test -n "$PG" && test -n "$RD"
|
|
||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
|
||||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
|
||||||
test -n "$PG_IP" && test -n "$RD_IP"
|
|
||||||
export DB_HOST="$PG_IP"
|
|
||||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
|
||||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if command -v uv >/dev/null 2>&1; then
|
|
||||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
|
||||||
else
|
|
||||||
pip install -r requirements.txt pytest pytest-asyncio
|
|
||||||
fi
|
|
||||||
alembic upgrade head
|
|
||||||
pytest tests/test_importer*.py tests/test_import_*.py tests/test_migration_*.py tests/test_phash_*.py tests/test_sidecar_*.py tests/test_scan_*.py tests/test_archive_extractor.py tests/test_backfill_phash.py -v -m integration --durations=15
|
|
||||||
|
|
||||||
intcore:
|
|
||||||
runs-on: python-ci
|
|
||||||
container:
|
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
||||||
env:
|
|
||||||
DB_USER: fabledcurator
|
|
||||||
DB_PASSWORD: ci_integration
|
|
||||||
DB_PORT: "5432"
|
|
||||||
DB_NAME: fabledcurator_test
|
|
||||||
SECRET_KEY: ci_integration_placeholder
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg16
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: fabledcurator
|
|
||||||
POSTGRES_PASSWORD: ci_integration
|
|
||||||
POSTGRES_DB: fabledcurator_test
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U fabledcurator"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 10
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
options: >-
|
|
||||||
--health-cmd "redis-cli ping"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
|
||||||
run: |
|
|
||||||
set -eux
|
|
||||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
|
||||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
|
||||||
echo "=== end landscape ==="
|
|
||||||
PG=$(docker ps --filter "name=intcore" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
|
||||||
RD=$(docker ps --filter "name=intcore" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
|
||||||
test -n "$PG" && test -n "$RD"
|
|
||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
|
||||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
|
||||||
test -n "$PG_IP" && test -n "$RD_IP"
|
|
||||||
export DB_HOST="$PG_IP"
|
|
||||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
|
||||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if command -v uv >/dev/null 2>&1; then
|
|
||||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
|
||||||
else
|
|
||||||
pip install -r requirements.txt pytest pytest-asyncio
|
|
||||||
fi
|
|
||||||
alembic upgrade head
|
|
||||||
pytest tests/ -v -m integration --durations=15 \
|
|
||||||
--ignore-glob='tests/test_api_*.py' \
|
|
||||||
--ignore-glob='tests/test_importer*.py' \
|
|
||||||
--ignore-glob='tests/test_import_*.py' \
|
|
||||||
--ignore-glob='tests/test_migration_*.py' \
|
|
||||||
--ignore-glob='tests/test_phash_*.py' \
|
|
||||||
--ignore-glob='tests/test_sidecar_*.py' \
|
|
||||||
--ignore-glob='tests/test_scan_*.py' \
|
|
||||||
--ignore-glob='tests/test_archive_extractor.py' \
|
|
||||||
--ignore-glob='tests/test_backfill_phash.py'
|
|
||||||
|
|||||||
@@ -61,8 +61,12 @@ Thumbs.db
|
|||||||
|
|
||||||
# Claude Code per-user local overrides (shared .claude/settings.json is OK to commit)
|
# Claude Code per-user local overrides (shared .claude/settings.json is OK to commit)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
# Transient scheduler lock/state (committed by accident in 3f30327)
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
.claude/scheduled_tasks*.json
|
||||||
|
|
||||||
# Alembic / DB scratch
|
# Alembic / DB scratch
|
||||||
alembic/versions/__pycache__/
|
alembic/versions/__pycache__/
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-journal
|
*.sqlite-journal
|
||||||
|
.superpowers/
|
||||||
|
|||||||
+4
-1
@@ -18,13 +18,16 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
|
|
||||||
# System deps: ffmpeg (transcode + thumbnails, FC-2), unar (archives, FC-2),
|
# System deps: ffmpeg (transcode + thumbnails, FC-2), unar (archives, FC-2),
|
||||||
# libpq for psycopg, postgresql-client + zstd for FC-5 backup/restore
|
# libpq for psycopg, postgresql-client + zstd for FC-5 backup/restore
|
||||||
# (pg_dump + tar --zstd), image libs.
|
# (pg_dump + tar --zstd), image libs, megatools (mega.nz public-link downloads
|
||||||
|
# for off-platform file-host links, #830 — `megatools dl`; Debian-native, no
|
||||||
|
# external MEGA apt repo needed).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
unar \
|
unar \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
zstd \
|
zstd \
|
||||||
|
megatools \
|
||||||
libjpeg62-turbo \
|
libjpeg62-turbo \
|
||||||
libwebp7 \
|
libwebp7 \
|
||||||
libpng16-16 \
|
libpng16-16 \
|
||||||
|
|||||||
+25
-1
@@ -1,13 +1,28 @@
|
|||||||
"""Alembic environment — reads DATABASE_URL from app config."""
|
"""Alembic environment — reads DATABASE_URL from app config."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool, text
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from backend.app.config import get_config
|
from backend.app.config import get_config
|
||||||
from backend.app.models import Base
|
from backend.app.models import Base
|
||||||
|
|
||||||
|
# Fail a blocked migration FAST instead of hanging forever. Migrations run
|
||||||
|
# against the live DB while workers hold locks; 0040's `ALTER series_page` queued
|
||||||
|
# behind a tag-merge that held a series_page lock for minutes (the merge runs an
|
||||||
|
# unindexed full scan over image_record while repointing series_page) and hung
|
||||||
|
# with no timeout — silent, indefinite (operator-flagged 2026-06-07). With a
|
||||||
|
# lock_timeout a blocked DDL errors ("canceling statement due to lock timeout")
|
||||||
|
# and the entrypoint's `alembic upgrade head` exits non-zero, so the deploy
|
||||||
|
# retries / surfaces loudly rather than wedging. Override via env when a known
|
||||||
|
# slow-lock window is expected.
|
||||||
|
_MIGRATION_LOCK_TIMEOUT = os.environ.get("MIGRATION_LOCK_TIMEOUT", "30s")
|
||||||
|
if not re.fullmatch(r"\d+\s*(ms|s|min)?", _MIGRATION_LOCK_TIMEOUT.strip()):
|
||||||
|
_MIGRATION_LOCK_TIMEOUT = "30s" # ignore a malformed override
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
@@ -38,6 +53,15 @@ def run_migrations_online() -> None:
|
|||||||
poolclass=pool.NullPool,
|
poolclass=pool.NullPool,
|
||||||
)
|
)
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
|
# Session-level lock_timeout for every DDL statement in this run. Set
|
||||||
|
# (and commit) before alembic opens its own transaction so the GUC
|
||||||
|
# persists on this connection regardless of how alembic structures its
|
||||||
|
# transactions. Value is from our own env, so f-string interpolation is
|
||||||
|
# safe (and it's been pattern-validated above); SET takes no bind params.
|
||||||
|
connection.execute(
|
||||||
|
text(f"SET lock_timeout = '{_MIGRATION_LOCK_TIMEOUT}'")
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
context.configure(
|
context.configure(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""artist_visit: per-artist last-viewed timestamp for the "+N new" badge
|
||||||
|
|
||||||
|
Revision ID: 0034
|
||||||
|
Revises: 0033
|
||||||
|
Create Date: 2026-06-03
|
||||||
|
|
||||||
|
Powers the artists-directory "+N new since last visit" badge + ArtistView
|
||||||
|
banner. Single row per artist (no user_id yet — rule #47 multi-user ACL
|
||||||
|
is aspirational; widens to (user_id, artist_id) PK when User lands).
|
||||||
|
|
||||||
|
Seed every existing artist with `last_viewed_at = NOW()` so the badge
|
||||||
|
starts at 0 across the board — no noisy "you have 5000 unseen images"
|
||||||
|
on first deploy. New artists auto-get a row via
|
||||||
|
`ArtistService.find_or_create`.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0034"
|
||||||
|
down_revision: Union[str, None] = "0033"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"artist_visit",
|
||||||
|
sa.Column(
|
||||||
|
"artist_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("artist.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"last_viewed_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Seed: every existing artist starts "fully caught up". Without this,
|
||||||
|
# every operator with N artists would see N badges (worth of every
|
||||||
|
# image ever imported) on first deploy.
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO artist_visit (artist_id, last_viewed_at) "
|
||||||
|
"SELECT id, NOW() FROM artist"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("artist_visit")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""image_record.effective_date: materialized gallery sort key + index
|
||||||
|
|
||||||
|
Revision ID: 0035
|
||||||
|
Revises: 0034
|
||||||
|
Create Date: 2026-06-04
|
||||||
|
|
||||||
|
The gallery ordered/cursored on COALESCE(post.post_date,
|
||||||
|
image_record.created_at) across the Post outer join. That expression spans
|
||||||
|
two tables, so no index can serve it — every /scroll sorted a large slice
|
||||||
|
of the library, and the frontend fired ten of them serially per initial
|
||||||
|
load. Materialize the value into image_record.effective_date and index
|
||||||
|
(effective_date DESC, id DESC) so the cursor scroll is an index range scan.
|
||||||
|
|
||||||
|
Backfill = COALESCE(primary post's post_date, created_at) so existing rows
|
||||||
|
keep their exact ordering. New rows get the created_at-equivalent server
|
||||||
|
default; services/importer.py overrides it with the post's date when a
|
||||||
|
primary post with a date is linked.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0035"
|
||||||
|
down_revision: Union[str, None] = "0034"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add nullable first so the backfill can populate before NOT NULL.
|
||||||
|
op.add_column(
|
||||||
|
"image_record",
|
||||||
|
sa.Column("effective_date", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
# Pure set-based UPDATEs (no per-row params) — immune to the 65535
|
||||||
|
# bind-parameter ceiling regardless of library size.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE image_record AS ir
|
||||||
|
SET effective_date = COALESCE(p.post_date, ir.created_at)
|
||||||
|
FROM post AS p
|
||||||
|
WHERE ir.primary_post_id = p.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE image_record
|
||||||
|
SET effective_date = created_at
|
||||||
|
WHERE effective_date IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"image_record",
|
||||||
|
"effective_date",
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
)
|
||||||
|
# DESC/DESC matches the gallery's ORDER BY effective_date DESC, id DESC
|
||||||
|
# so the scroll is a forward index scan; raw SQL because alembic's
|
||||||
|
# column list doesn't express per-column DESC cleanly.
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_image_record_effective_date "
|
||||||
|
"ON image_record (effective_date DESC, id DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_image_record_effective_date", table_name="image_record")
|
||||||
|
op.drop_column("image_record", "effective_date")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""image_record.siglip_embedding: HNSW cosine index for "more like this"
|
||||||
|
|
||||||
|
Revision ID: 0036
|
||||||
|
Revises: 0035
|
||||||
|
Create Date: 2026-06-04
|
||||||
|
|
||||||
|
Gallery Phase 3 (visual similarity search) ranks images by
|
||||||
|
`siglip_embedding.cosine_distance(source_embedding)`. Without an index that's
|
||||||
|
a sequential scan computing a 1152-dim distance for every row — fine at small
|
||||||
|
scale, but it grows linearly with the library. Add an HNSW index with
|
||||||
|
`vector_cosine_ops` so the top-N nearest search is sub-50ms ANN.
|
||||||
|
|
||||||
|
1152 dims is under pgvector's 2000-dim HNSW limit, so HNSW (no training,
|
||||||
|
better recall than IVFFlat) is the right choice. ONE-TIME COST: building the
|
||||||
|
index over the existing embeddings (~57k vectors on the operator's library)
|
||||||
|
locks image_record for ~30-60s during this migration on deploy — acceptable
|
||||||
|
for a single-operator homelab. NULL embeddings (videos / not-yet-embedded
|
||||||
|
rows) are simply not indexed.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0036"
|
||||||
|
down_revision: Union[str, None] = "0035"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Raw SQL: alembic's create_index doesn't express the `USING hnsw (...
|
||||||
|
# vector_cosine_ops)` access-method + opclass cleanly. Must match the
|
||||||
|
# query's cosine_distance operator class to be usable by the planner.
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_image_record_siglip_hnsw "
|
||||||
|
"ON image_record USING hnsw (siglip_embedding vector_cosine_ops)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_image_record_siglip_hnsw", table_name="image_record")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""patreon_seen_media: per-source ledger of already-ingested Patreon media
|
||||||
|
|
||||||
|
Revision ID: 0037
|
||||||
|
Revises: 0036
|
||||||
|
Create Date: 2026-06-05
|
||||||
|
|
||||||
|
Native Patreon ingester (build step 2a). Replaces gallery-dl's
|
||||||
|
archive.sqlite3 with our own queryable table. The downloader upserts one
|
||||||
|
row per (source, media) so routine walks skip media we've already
|
||||||
|
processed; a future "recovery" mode bypasses the ledger to re-walk.
|
||||||
|
|
||||||
|
`filehash` is a 32-hex Patreon CDN MD5, OR a video sentinel of the form
|
||||||
|
``video:<post_id>:<media_id>`` — hence String(128). The unique
|
||||||
|
constraint on (source_id, filehash) is the dedup upsert key.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0037"
|
||||||
|
down_revision: Union[str, None] = "0036"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"patreon_seen_media",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"source_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("source.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("filehash", sa.String(128), nullable=False),
|
||||||
|
sa.Column("post_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"seen_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"source_id", "filehash", name="uq_patreon_seen_media_source_id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("patreon_seen_media")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""patreon_failed_media: per-source dead-letter ledger for failing Patreon media
|
||||||
|
|
||||||
|
Revision ID: 0038
|
||||||
|
Revises: 0037
|
||||||
|
Create Date: 2026-06-06
|
||||||
|
|
||||||
|
Plan #705 (#7). Media that keeps failing to download/validate (404'd CDN,
|
||||||
|
deleted post, geo-blocked Mux, persistently-corrupt bytes) gets recorded here
|
||||||
|
with an attempt counter; once it crosses the dead-letter threshold the ingester
|
||||||
|
skips it on routine walks (recovery still re-attempts). A clean download clears
|
||||||
|
the row. UNIQUE (source_id, filehash) is the upsert key (same media key the
|
||||||
|
seen-ledger uses).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0038"
|
||||||
|
down_revision: Union[str, None] = "0037"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"patreon_failed_media",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"source_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("source.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("filehash", sa.String(128), nullable=False),
|
||||||
|
sa.Column("attempts", sa.Integer, nullable=False, server_default="1"),
|
||||||
|
sa.Column("last_error", sa.Text, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"first_failed_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"last_failed_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"source_id", "filehash", name="uq_patreon_failed_media_source_id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("patreon_failed_media")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""library_audit_run: resume cursor + progress timestamp for chunked scans
|
||||||
|
|
||||||
|
Revision ID: 0039
|
||||||
|
Revises: 0038
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
|
||||||
|
scan_library_for_rule used to run one 2h pass that timed out on large libraries
|
||||||
|
and monopolized the concurrency-1 maintenance queue (operator-flagged). It now
|
||||||
|
runs short time-boxed chunks that re-enqueue: `resume_after_id` persists the
|
||||||
|
keyset cursor so the next chunk continues where it left off, and
|
||||||
|
`last_progress_at` lets the recovery sweep tell a progressing multi-chunk audit
|
||||||
|
from a genuinely stuck one.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0039"
|
||||||
|
down_revision: Union[str, None] = "0038"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"library_audit_run",
|
||||||
|
sa.Column(
|
||||||
|
"resume_after_id", sa.Integer, nullable=False, server_default="0"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"library_audit_run",
|
||||||
|
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("library_audit_run", "last_progress_at")
|
||||||
|
op.drop_column("library_audit_run", "resume_after_id")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""series chapters: chapter layer over series_page (FC-6.1)
|
||||||
|
|
||||||
|
Revision ID: 0040
|
||||||
|
Revises: 0039
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
|
||||||
|
A series (Tag kind='series') gains an ordered chapter layer. Reading order
|
||||||
|
becomes (series_chapter.chapter_number, series_page.page_number). Every existing
|
||||||
|
series is backfilled into a single auto-chapter (chapter_number=1) holding its
|
||||||
|
current flat pages, so no data is lost and the old flat ordering is preserved.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0040"
|
||||||
|
down_revision: Union[str, None] = "0039"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"series_chapter",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"series_tag_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("tag.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("chapter_number", sa.Integer, nullable=False),
|
||||||
|
sa.Column("title", sa.Text, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"is_placeholder", sa.Boolean, nullable=False, server_default="false"
|
||||||
|
),
|
||||||
|
sa.Column("stated_page_start", sa.Integer, nullable=True),
|
||||||
|
sa.Column("stated_page_end", sa.Integer, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_series_chapter_series_tag_id", "series_chapter", ["series_tag_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# New columns on series_page; chapter_id starts nullable so we can backfill.
|
||||||
|
op.add_column(
|
||||||
|
"series_page", sa.Column("chapter_id", sa.Integer, nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"series_page", sa.Column("stated_page", sa.Integer, nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
# One auto-chapter per existing series (any series_tag_id present in pages).
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO series_chapter "
|
||||||
|
"(series_tag_id, chapter_number, is_placeholder, created_at, updated_at) "
|
||||||
|
"SELECT DISTINCT series_tag_id, 1, false, now(), now() "
|
||||||
|
"FROM series_page"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Point every existing page at its series' auto-chapter.
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE series_page sp "
|
||||||
|
"SET chapter_id = sc.id "
|
||||||
|
"FROM series_chapter sc "
|
||||||
|
"WHERE sc.series_tag_id = sp.series_tag_id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now lock chapter_id down: NOT NULL + FK (cascade) + index.
|
||||||
|
op.alter_column("series_page", "chapter_id", nullable=False)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_series_page_chapter_id",
|
||||||
|
"series_page",
|
||||||
|
"series_chapter",
|
||||||
|
["chapter_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_series_page_chapter_id", "series_page", ["chapter_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_series_page_chapter_id", table_name="series_page")
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_series_page_chapter_id", "series_page", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_column("series_page", "stated_page")
|
||||||
|
op.drop_column("series_page", "chapter_id")
|
||||||
|
op.drop_index("ix_series_chapter_series_tag_id", table_name="series_chapter")
|
||||||
|
op.drop_table("series_chapter")
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""series suggestions: assisted-continuation matcher (FC-6.3)
|
||||||
|
|
||||||
|
Revision ID: 0041
|
||||||
|
Revises: 0040
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
|
||||||
|
A confirm-only queue of "this post may continue this series" hints, plus two
|
||||||
|
import_settings knobs (enable + score threshold) for the matcher.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0041"
|
||||||
|
down_revision: Union[str, None] = "0040"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"series_suggestion",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"post_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("post.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"series_tag_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("tag.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("score", sa.Float, nullable=False),
|
||||||
|
sa.Column("signals", sa.JSON, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(16), nullable=False, server_default="pending"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"post_id", "series_tag_id", name="uq_series_suggestion_post_series"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_series_suggestion_post_id", "series_suggestion", ["post_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_series_suggestion_series_tag_id",
|
||||||
|
"series_suggestion",
|
||||||
|
["series_tag_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_series_suggestion_status", "series_suggestion", ["status"]
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"series_suggest_enabled",
|
||||||
|
sa.Boolean,
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.true(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"series_suggest_threshold",
|
||||||
|
sa.Float,
|
||||||
|
nullable=False,
|
||||||
|
server_default="0.5",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("import_settings", "series_suggest_threshold")
|
||||||
|
op.drop_column("import_settings", "series_suggest_enabled")
|
||||||
|
op.drop_index("ix_series_suggestion_status", table_name="series_suggestion")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_series_suggestion_series_tag_id", table_name="series_suggestion"
|
||||||
|
)
|
||||||
|
op.drop_index("ix_series_suggestion_post_id", table_name="series_suggestion")
|
||||||
|
op.drop_table("series_suggestion")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""series chapter stated_part: operator-facing Part N label (FC-6.4)
|
||||||
|
|
||||||
|
Revision ID: 0042
|
||||||
|
Revises: 0041
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
|
||||||
|
A chapter's positional chapter_number is auto-managed (rewritten 1..N on
|
||||||
|
reorder/delete), so it can't double as the installment number the operator wants
|
||||||
|
to type (e.g. a series authored from a post that is Part 2). Add a nullable
|
||||||
|
stated_part alongside it — the same split as series_page.page_number (order) vs
|
||||||
|
series_page.stated_page (printed number). Nullable; the UI falls back to
|
||||||
|
chapter_number when unset.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0042"
|
||||||
|
down_revision: Union[str, None] = "0041"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"series_chapter", sa.Column("stated_part", sa.Integer, nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("series_chapter", "stated_part")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""post_attachment: per-post sha uniqueness (empty-post flood fix)
|
||||||
|
|
||||||
|
Revision ID: 0043
|
||||||
|
Revises: 0042
|
||||||
|
Create Date: 2026-06-08
|
||||||
|
|
||||||
|
PostAttachment.sha256 was GLOBALLY unique, so a non-art file the creator attaches
|
||||||
|
to many posts (a standard pdf/zip/link-card) only ever got ONE row — on the first
|
||||||
|
post — leaving every later post a bare shell (no image, no attachment). The native
|
||||||
|
Patreon backfill of Anduo surfaced 1589 such shells (operator-flagged 2026-06-08).
|
||||||
|
|
||||||
|
Switch to PER-POST uniqueness: the on-disk blob stays sha-deduped, but each post
|
||||||
|
gets its own row. Replace the unique sha256 index with a plain lookup index plus
|
||||||
|
two partial uniques — (post_id, sha256) for real posts and (sha256) for the
|
||||||
|
NULL-post filesystem case (still one row per file there).
|
||||||
|
|
||||||
|
Existing data has ≤1 row per sha (the old global unique), so the new partial
|
||||||
|
uniques can't be violated on upgrade — no data backfill needed here. The bare-post
|
||||||
|
shells themselves are removed by the separate prune-empty-posts cleanup tool.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0043"
|
||||||
|
down_revision: Union[str, None] = "0042"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the global unique index; recreate it as a plain (non-unique) lookup
|
||||||
|
# index so sha-based reads keep their index (matches the model's index=True).
|
||||||
|
op.drop_index("ix_post_attachment_sha256", table_name="post_attachment")
|
||||||
|
op.create_index(
|
||||||
|
"ix_post_attachment_sha256", "post_attachment", ["sha256"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_post_attachment_post_sha", "post_attachment",
|
||||||
|
["post_id", "sha256"], unique=True,
|
||||||
|
postgresql_where=sa.text("post_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_post_attachment_null_post_sha", "post_attachment",
|
||||||
|
["sha256"], unique=True,
|
||||||
|
postgresql_where=sa.text("post_id IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"uq_post_attachment_null_post_sha", table_name="post_attachment"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_post_attachment_post_sha", table_name="post_attachment"
|
||||||
|
)
|
||||||
|
op.drop_index("ix_post_attachment_sha256", table_name="post_attachment")
|
||||||
|
op.create_index(
|
||||||
|
"ix_post_attachment_sha256", "post_attachment", ["sha256"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""ml_settings.tagger_store_floor
|
||||||
|
|
||||||
|
The ingest confidence floor below which tagger predictions are not stored,
|
||||||
|
promoted from the TAGGER_STORE_FLOOR env var to a DB-backed, UI-tunable
|
||||||
|
setting. Default 0.70 (was an env default of 0.05): the suggestion path
|
||||||
|
already filters at 0.70 and the centroid/learned path covers low-confidence
|
||||||
|
preferred tags, so the sub-0.70 tail was redundant weight — it had grown
|
||||||
|
image_record's TOAST to ~100 GB. See plan-task #764.
|
||||||
|
|
||||||
|
Revision ID: 0044
|
||||||
|
Revises: 0043
|
||||||
|
Create Date: 2026-06-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0044"
|
||||||
|
down_revision: Union[str, None] = "0043"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"tagger_store_floor", sa.Float(),
|
||||||
|
nullable=False, server_default="0.7",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ml_settings", "tagger_store_floor")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""image_prediction table (DDL only — backfill runs as a background task)
|
||||||
|
|
||||||
|
Normalizes the per-image tagger predictions out of the JSON blob into a
|
||||||
|
queryable table (#768). This migration creates ONLY the table + indexes — it
|
||||||
|
is pure DDL and commits instantly, so web boots immediately.
|
||||||
|
|
||||||
|
The data backfill from the existing image_record.tagger_predictions JSON is
|
||||||
|
deliberately NOT done here. Doing it inline made the whole migration one
|
||||||
|
transaction over the ~100 GB TOAST: nothing committed until the very end, it
|
||||||
|
was invisible/unmonitorable mid-run, and an early MATERIALIZED-CTE form spilled
|
||||||
|
the full 100 GB to temp. Instead the backfill is the
|
||||||
|
backend.app.tasks.admin.backfill_image_predictions_task — batched by id window,
|
||||||
|
committed per chunk (visible progress + resumable), idempotent
|
||||||
|
(ON CONFLICT DO NOTHING). Trigger it from Settings → Maintenance once web is up.
|
||||||
|
|
||||||
|
The old image_record.tagger_predictions column is left in place (vestigial) and
|
||||||
|
dropped in a follow-up once the backfill + code cutover are verified — dropping
|
||||||
|
it needs an ACCESS EXCLUSIVE lock on the hot image_record table (the 0044 lock
|
||||||
|
class), so it's deferred to a quiesced-worker window.
|
||||||
|
|
||||||
|
Revision ID: 0045
|
||||||
|
Revises: 0044
|
||||||
|
Create Date: 2026-06-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0045"
|
||||||
|
down_revision: Union[str, None] = "0044"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"image_prediction",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"image_record_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("image_record.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("raw_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("category", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("score", sa.Float(), nullable=False),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"image_record_id", "raw_name", name="image_raw_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_image_prediction_image", "image_prediction", ["image_record_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_image_prediction_name_score", "image_prediction",
|
||||||
|
["raw_name", "score"],
|
||||||
|
)
|
||||||
|
# No data backfill here — see the module docstring. The one-time copy from
|
||||||
|
# image_record.tagger_predictions runs as backfill_image_predictions_task
|
||||||
|
# (batched, resumable, idempotent), kept out of this transaction so web boots
|
||||||
|
# without waiting on a ~100 GB pass.
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_image_prediction_name_score", "image_prediction")
|
||||||
|
op.drop_index("ix_image_prediction_image", "image_prediction")
|
||||||
|
op.drop_table("image_prediction")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""drop image_record.tagger_predictions (predictions normalized to image_prediction)
|
||||||
|
|
||||||
|
Final step of #768. The per-tag predictions now live in the image_prediction
|
||||||
|
table (backfilled from the JSON, read by suggestions + allowlist, written by
|
||||||
|
tag_and_embed). The old JSON column is dead weight — and it's the ~100 GB of
|
||||||
|
sub-0.70 score tail that bloated image_record's TOAST and broke DB backups
|
||||||
|
(#739). Dropping it is a fast catalog change; it does NOT reclaim the disk on
|
||||||
|
its own — run `VACUUM FULL image_record` (or pg_repack) afterward, off-hours,
|
||||||
|
to return the space to the OS so backups go small.
|
||||||
|
|
||||||
|
DROP COLUMN needs a brief ACCESS EXCLUSIVE lock on image_record; env.py's
|
||||||
|
lock_timeout guards it, so quiesce the ml-worker if a tagging run is in flight
|
||||||
|
(see the migration-lock reference). tagger_model_version is kept — it's the
|
||||||
|
"has this been tagged / is it current?" signal the backfill sweep reads.
|
||||||
|
|
||||||
|
Revision ID: 0046
|
||||||
|
Revises: 0045
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0046"
|
||||||
|
down_revision: Union[str, None] = "0045"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("image_record", "tagger_predictions")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Re-add the column empty. The JSON data is not restored (it lived only in
|
||||||
|
# this column); a downgrade would re-tag or backfill from image_prediction
|
||||||
|
# separately if ever needed.
|
||||||
|
op.add_column(
|
||||||
|
"image_record",
|
||||||
|
sa.Column("tagger_predictions", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""series chapters become cosmetic dividers; pages become one series-global run
|
||||||
|
|
||||||
|
FC-6.x reframe (#789). A series is now ONE flat, series-global ordered run of
|
||||||
|
pages; chapters stop owning pages and become labeled dividers anchored to the
|
||||||
|
page that begins them.
|
||||||
|
|
||||||
|
Migration (order matters — series_page.chapter_id cascades, so it must be
|
||||||
|
dropped BEFORE any chapter row is deleted, or pages would cascade away):
|
||||||
|
a. Renumber series_page.page_number to a series-global 1..N (ordered by the
|
||||||
|
OLD (chapter_number, page_number)).
|
||||||
|
b. Add series_chapter.anchor_page_id and populate it with each chapter's first
|
||||||
|
page (lowest new page_number).
|
||||||
|
c. Drop series_page.chapter_id (severs the cascade link).
|
||||||
|
d. Prune chapters that shouldn't become dividers: empty/placeholder ones (no
|
||||||
|
anchor) and the redundant unlabeled chapter that would sit at page 1.
|
||||||
|
e. Reshape series_chapter into the divider: drop chapter_number,
|
||||||
|
is_placeholder, stated_page_start/end; make anchor_page_id NOT NULL +
|
||||||
|
UNIQUE + FK→series_page ON DELETE CASCADE.
|
||||||
|
|
||||||
|
Revision ID: 0047
|
||||||
|
Revises: 0046
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0047"
|
||||||
|
down_revision: Union[str, None] = "0046"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# a. series-global page numbering, preserving the old reading order.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT sp.id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY sp.series_tag_id
|
||||||
|
ORDER BY sc.chapter_number, sp.page_number, sp.id
|
||||||
|
) AS rn
|
||||||
|
FROM series_page sp
|
||||||
|
JOIN series_chapter sc ON sc.id = sp.chapter_id
|
||||||
|
)
|
||||||
|
UPDATE series_page sp
|
||||||
|
SET page_number = ordered.rn
|
||||||
|
FROM ordered
|
||||||
|
WHERE sp.id = ordered.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# b. anchor each existing chapter at its first page (lowest new page_number).
|
||||||
|
op.add_column(
|
||||||
|
"series_chapter",
|
||||||
|
sa.Column("anchor_page_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
WITH firsts AS (
|
||||||
|
SELECT DISTINCT ON (sp.chapter_id)
|
||||||
|
sp.chapter_id, sp.id AS page_id
|
||||||
|
FROM series_page sp
|
||||||
|
ORDER BY sp.chapter_id, sp.page_number, sp.id
|
||||||
|
)
|
||||||
|
UPDATE series_chapter sc
|
||||||
|
SET anchor_page_id = firsts.page_id
|
||||||
|
FROM firsts
|
||||||
|
WHERE firsts.chapter_id = sc.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# c. sever the ownership link (drops the FK + index with the column) BEFORE
|
||||||
|
# pruning chapters, so deleting a chapter can't cascade-delete its pages.
|
||||||
|
op.drop_column("series_page", "chapter_id")
|
||||||
|
|
||||||
|
# d. prune chapters that don't become dividers: placeholders / empty ones
|
||||||
|
# (no anchor), and the unlabeled chapter that would land redundantly at
|
||||||
|
# page 1 (the series just starts — no divider needed there).
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM series_chapter sc
|
||||||
|
USING (
|
||||||
|
SELECT sc2.id
|
||||||
|
FROM series_chapter sc2
|
||||||
|
LEFT JOIN series_page sp ON sp.id = sc2.anchor_page_id
|
||||||
|
WHERE sc2.anchor_page_id IS NULL
|
||||||
|
OR (sp.page_number = 1
|
||||||
|
AND sc2.title IS NULL
|
||||||
|
AND sc2.stated_part IS NULL)
|
||||||
|
) gone
|
||||||
|
WHERE sc.id = gone.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# e. reshape into the divider model.
|
||||||
|
op.drop_column("series_chapter", "chapter_number")
|
||||||
|
op.drop_column("series_chapter", "is_placeholder")
|
||||||
|
op.drop_column("series_chapter", "stated_page_start")
|
||||||
|
op.drop_column("series_chapter", "stated_page_end")
|
||||||
|
op.alter_column("series_chapter", "anchor_page_id", nullable=False)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_series_chapter_anchor_page", "series_chapter", ["anchor_page_id"]
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_series_chapter_anchor_page",
|
||||||
|
"series_chapter",
|
||||||
|
"series_page",
|
||||||
|
["anchor_page_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Lossy: dividers can't be reconstructed as owning chapters. Collapse back to
|
||||||
|
# exactly one chapter per series that owns all its pages in order.
|
||||||
|
op.add_column(
|
||||||
|
"series_page", sa.Column("chapter_id", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_series_chapter_anchor_page", "series_chapter", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_series_chapter_anchor_page", "series_chapter", type_="unique"
|
||||||
|
)
|
||||||
|
op.drop_column("series_chapter", "anchor_page_id")
|
||||||
|
op.add_column(
|
||||||
|
"series_chapter",
|
||||||
|
sa.Column(
|
||||||
|
"chapter_number", sa.Integer(), nullable=False, server_default="1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"series_chapter",
|
||||||
|
sa.Column(
|
||||||
|
"is_placeholder", sa.Boolean(), nullable=False,
|
||||||
|
server_default="false",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"series_chapter",
|
||||||
|
sa.Column("stated_page_start", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"series_chapter",
|
||||||
|
sa.Column("stated_page_end", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM series_chapter")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO series_chapter (series_tag_id, chapter_number)
|
||||||
|
SELECT DISTINCT series_tag_id, 1 FROM series_page
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE series_page sp
|
||||||
|
SET chapter_id = sc.id
|
||||||
|
FROM series_chapter sc
|
||||||
|
WHERE sc.series_tag_id = sp.series_tag_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.alter_column("series_page", "chapter_id", nullable=False)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_series_page_chapter",
|
||||||
|
"series_page",
|
||||||
|
"series_chapter",
|
||||||
|
["chapter_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""series_page pending staging: status + nullable page_number (#789 Phase 2)
|
||||||
|
|
||||||
|
Pages added from a post no longer append straight into the run — they land
|
||||||
|
'pending' with a NULL page_number, staged grouped by their source post so the
|
||||||
|
operator can drop junk (text-free alts, bumpers) and place the keepers into the
|
||||||
|
sequence. A page only gets a series-global page_number once it's 'placed'.
|
||||||
|
|
||||||
|
Revision ID: 0048
|
||||||
|
Revises: 0047
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0048"
|
||||||
|
down_revision: Union[str, None] = "0047"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"series_page",
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(length=16), nullable=False,
|
||||||
|
server_default="placed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"series_page", "page_number",
|
||||||
|
existing_type=sa.Integer(), nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Lossy: pending pages are unsorted staging rows with no order — drop them.
|
||||||
|
op.execute("DELETE FROM series_page WHERE status = 'pending'")
|
||||||
|
op.alter_column(
|
||||||
|
"series_page", "page_number",
|
||||||
|
existing_type=sa.Integer(), nullable=False,
|
||||||
|
)
|
||||||
|
op.drop_column("series_page", "status")
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""external_link table — off-platform file-host links found in post bodies
|
||||||
|
|
||||||
|
Creators host the real files on mega.nz / Google Drive / MediaFire / Dropbox /
|
||||||
|
Pixeldrain and link them in the post text. This table records each such link
|
||||||
|
(so nothing is silently dropped), and doubles as the dedup + dead-letter ledger
|
||||||
|
the download worker (a later slice) walks. `url` keeps the FULL link including
|
||||||
|
the `#fragment` — mega.nz's decryption key lives there; truncating it makes the
|
||||||
|
file undownloadable.
|
||||||
|
|
||||||
|
CHECK whitelists for host + status include the full enum up front (incl. the
|
||||||
|
download-worker statuses) so the worker slice needs no constraint migration.
|
||||||
|
|
||||||
|
Revision ID: 0049
|
||||||
|
Revises: 0048
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0049"
|
||||||
|
down_revision: Union[str, None] = "0048"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"external_link",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"post_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("post.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"artist_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("artist.id", ondelete="SET NULL"), nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("host", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("label", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(length=16), nullable=False,
|
||||||
|
server_default="pending",
|
||||||
|
),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"attachment_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("post_attachment.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("duration_seconds", sa.Float(), nullable=True),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"host IN ('mega','gdrive','mediafire','dropbox','pixeldrain')",
|
||||||
|
name="ck_external_link_host",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('pending','downloading','downloaded','failed',"
|
||||||
|
"'skipped','dead')",
|
||||||
|
name="ck_external_link_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_external_link_post_id", "external_link", ["post_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_external_link_artist_id", "external_link", ["artist_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_external_link_status", "external_link", ["status"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_external_link_post_url", "external_link", ["post_id", "url"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_external_link_post_url", table_name="external_link")
|
||||||
|
op.drop_index("ix_external_link_status", table_name="external_link")
|
||||||
|
op.drop_index("ix_external_link_artist_id", table_name="external_link")
|
||||||
|
op.drop_index("ix_external_link_post_id", table_name="external_link")
|
||||||
|
op.drop_table("external_link")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""import_settings: per-host enable toggles for external file-host downloads
|
||||||
|
|
||||||
|
Operator levers (#830): disable a single host (e.g. mega.nz when it's
|
||||||
|
rate-limiting/banning) without touching the others. The worker reads these via
|
||||||
|
getattr and defaults to enabled, so the toggles default TRUE (works out of the
|
||||||
|
box, rule #26).
|
||||||
|
|
||||||
|
Revision ID: 0050
|
||||||
|
Revises: 0049
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0050"
|
||||||
|
down_revision: Union[str, None] = "0049"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for host in _HOSTS:
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
f"extdl_{host}_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.true(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for host in _HOSTS:
|
||||||
|
op.drop_column("import_settings", f"extdl_{host}_enabled")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""image_record: source_url + source_filehash (inline-image localization)
|
||||||
|
|
||||||
|
#830 Phase 2. To render a post body faithfully we serve LOCAL copies of inline
|
||||||
|
images instead of hotlinking the public CDN. The join key between a body
|
||||||
|
`<img src=CDN>` and the local file is the CDN's 32-hex filehash (the same
|
||||||
|
identity extract_media dedups by). Persist it (indexed) plus the full source
|
||||||
|
URL for provenance/debugging. Both NULL for filesystem-imported / pre-existing
|
||||||
|
rows — those fall back to hotlinking until re-downloaded.
|
||||||
|
|
||||||
|
Revision ID: 0051
|
||||||
|
Revises: 0050
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0051"
|
||||||
|
down_revision: Union[str, None] = "0050"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("image_record", sa.Column("source_url", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"image_record", sa.Column("source_filehash", sa.String(length=32), nullable=True)
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_image_record_source_filehash", "image_record", ["source_filehash"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_image_record_source_filehash", table_name="image_record")
|
||||||
|
op.drop_column("image_record", "source_filehash")
|
||||||
|
op.drop_column("image_record", "source_url")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""image_record: duration_seconds (Tier-1 video near-dup key)
|
||||||
|
|
||||||
|
#871. Videos previously deduped on sha256 only (pHash is images-only), so a
|
||||||
|
different encode/remux of the same video imported as a distinct record. Persist
|
||||||
|
the container duration so the importer can treat same-artist videos with matching
|
||||||
|
duration (+ aspect ratio) as the same content and dedup/supersede like images.
|
||||||
|
NULL for images and for video rows imported before this column existed (a
|
||||||
|
backfill re-probes those so they participate in dedup).
|
||||||
|
|
||||||
|
Revision ID: 0052
|
||||||
|
Revises: 0051
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0052"
|
||||||
|
down_revision: Union[str, None] = "0051"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"image_record", sa.Column("duration_seconds", sa.Float(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("image_record", "duration_seconds")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""ml_settings: video tagging knobs (cadence sampling + noise floor)
|
||||||
|
|
||||||
|
#747. Video tag quality/perf: sample frames at a fixed cadence (interval) so a
|
||||||
|
tag's frame-presence reflects real screen time, cap total frames so long videos
|
||||||
|
stay bounded, and keep a tag only if it appears in >= min_tag_frames sampled
|
||||||
|
frames. Operator-tunable via Settings → ML (replaces the VIDEO_ML_FRAMES env var).
|
||||||
|
|
||||||
|
Revision ID: 0053
|
||||||
|
Revises: 0052
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0053"
|
||||||
|
down_revision: Union[str, None] = "0052"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"video_frame_interval_seconds", sa.Float(), nullable=False,
|
||||||
|
server_default="4.0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"video_max_frames", sa.Integer(), nullable=False, server_default="64",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"video_min_tag_frames", sa.Integer(), nullable=False,
|
||||||
|
server_default="3",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ml_settings", "video_min_tag_frames")
|
||||||
|
op.drop_column("ml_settings", "video_max_frames")
|
||||||
|
op.drop_column("ml_settings", "video_frame_interval_seconds")
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""subscribestar_seen_media + subscribestar_failed_media: per-source ledgers
|
||||||
|
|
||||||
|
Revision ID: 0054
|
||||||
|
Revises: 0053
|
||||||
|
Create Date: 2026-06-17
|
||||||
|
|
||||||
|
SubscribeStar native ingester (phase 1 of the gallery-dl → native-core
|
||||||
|
migration). Mirrors the Patreon ledger tables (0037/0038): a seen-ledger so
|
||||||
|
routine walks skip already-ingested media (recovery bypasses it) and a
|
||||||
|
dead-letter ledger so persistently-failing media stops re-burning backfill
|
||||||
|
chunks. `filehash` is a CDN content hash when present, else a synthesized
|
||||||
|
``<post_id>:<filename>`` key — hence String(128). UNIQUE (source_id, filehash)
|
||||||
|
is the upsert key on each.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0054"
|
||||||
|
down_revision: Union[str, None] = "0053"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"subscribestar_seen_media",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"source_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("source.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("filehash", sa.String(128), nullable=False),
|
||||||
|
sa.Column("post_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"seen_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"source_id", "filehash", name="uq_subscribestar_seen_media_source_id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"subscribestar_failed_media",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"source_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("source.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("filehash", sa.String(128), nullable=False),
|
||||||
|
sa.Column("attempts", sa.Integer, nullable=False, server_default="1"),
|
||||||
|
sa.Column("last_error", sa.Text, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"first_failed_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"last_failed_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("NOW()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"source_id", "filehash", name="uq_subscribestar_failed_media_source_id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("subscribestar_failed_media")
|
||||||
|
op.drop_table("subscribestar_seen_media")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""image_provenance: from_attachment_id (which archive an image was extracted from)
|
||||||
|
|
||||||
|
Milestone #87. When an image is pulled out of a .zip/.rar, record WHICH archive
|
||||||
|
PostAttachment it came from, so the provenance UI can show the single archive a
|
||||||
|
file lives inside instead of every attachment on the post. Nullable FK with
|
||||||
|
ON DELETE SET NULL — a loose (non-archive) download leaves it NULL, and deleting
|
||||||
|
the archive attachment forgets the linkage without destroying the (image, post)
|
||||||
|
provenance edge. Existing rows are NULL until the reextract backfill stamps them.
|
||||||
|
|
||||||
|
Revision ID: 0055
|
||||||
|
Revises: 0054
|
||||||
|
Create Date: 2026-06-22
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0055"
|
||||||
|
down_revision: Union[str, None] = "0054"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"image_provenance",
|
||||||
|
sa.Column("from_attachment_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_image_provenance_from_attachment_id",
|
||||||
|
"image_provenance",
|
||||||
|
["from_attachment_id"],
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_image_provenance_from_attachment",
|
||||||
|
"image_provenance",
|
||||||
|
"post_attachment",
|
||||||
|
["from_attachment_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_image_provenance_from_attachment",
|
||||||
|
"image_provenance",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_image_provenance_from_attachment_id",
|
||||||
|
table_name="image_provenance",
|
||||||
|
)
|
||||||
|
op.drop_column("image_provenance", "from_attachment_id")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""tag_eval_run: persisted head-vs-centroid tagging eval runs (#1130)
|
||||||
|
|
||||||
|
Milestone #114 slice 1. A long ml-queue eval whose full report must SURVIVE
|
||||||
|
navigation, so the run + report live in a row the admin card rehydrates from
|
||||||
|
(mirrors library_audit_run). running -> ready / error.
|
||||||
|
|
||||||
|
Revision ID: 0056
|
||||||
|
Revises: 0055
|
||||||
|
Create Date: 2026-06-28
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision: str = "0056"
|
||||||
|
down_revision: Union[str, None] = "0055"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tag_eval_run",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("params", JSONB(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="running"),
|
||||||
|
sa.Column(
|
||||||
|
"started_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("report", JSONB(), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tag_eval_run_status", "tag_eval_run", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_tag_eval_run_status", table_name="tag_eval_run")
|
||||||
|
op.drop_table("tag_eval_run")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""tag_positive_confirmation: operator-affirmed correct positives (#1130)
|
||||||
|
|
||||||
|
Mirror of tag_suggestion_rejection. "Keep" on a doubted positive records here so
|
||||||
|
the eval's doubts list stops resurfacing confirmed-correct images every run.
|
||||||
|
|
||||||
|
Revision ID: 0057
|
||||||
|
Revises: 0056
|
||||||
|
Create Date: 2026-06-28
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0057"
|
||||||
|
down_revision: Union[str, None] = "0056"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tag_positive_confirmation",
|
||||||
|
sa.Column(
|
||||||
|
"image_record_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("image_record.id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"tag_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True, index=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"confirmed_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("tag_positive_confirmation")
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""tag_head + head_training_run: production heads that learn from tags (#114)
|
||||||
|
|
||||||
|
The eval (#1130) proved the frozen-embedding + trained-head spine; this lands its
|
||||||
|
production form. tag_head stores one logistic-regression head per concept (the
|
||||||
|
new suggestion source, replacing Camie + centroid); head_training_run tracks the
|
||||||
|
batch that (re)trains them. Adds two head-training tunables to ml_settings.
|
||||||
|
|
||||||
|
Revision ID: 0058
|
||||||
|
Revises: 0057
|
||||||
|
Create Date: 2026-06-28
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision: str = "0058"
|
||||||
|
down_revision: Union[str, None] = "0057"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_HEAD_DIM = 1152
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tag_head",
|
||||||
|
sa.Column(
|
||||||
|
"tag_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("embedding_version", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("weights", Vector(_HEAD_DIM), nullable=False),
|
||||||
|
sa.Column("bias", sa.Float(), nullable=False),
|
||||||
|
sa.Column("suggest_threshold", sa.Float(), nullable=False),
|
||||||
|
sa.Column("auto_apply_threshold", sa.Float(), nullable=True),
|
||||||
|
sa.Column("n_pos", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("n_neg", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("ap", sa.Float(), nullable=False),
|
||||||
|
sa.Column("precision_cv", sa.Float(), nullable=False),
|
||||||
|
sa.Column("recall", sa.Float(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"trained_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("metrics", JSONB(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"head_training_run",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("params", JSONB(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(length=16), nullable=False,
|
||||||
|
server_default="running",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"started_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("n_trained", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("n_skipped", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_head_training_run_status", "head_training_run", ["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Head-training tunables on the ml_settings singleton.
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"head_min_positives", sa.Integer(), nullable=False,
|
||||||
|
server_default="8",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"head_auto_apply_precision", sa.Float(), nullable=False,
|
||||||
|
server_default="0.97",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ml_settings", "head_auto_apply_precision")
|
||||||
|
op.drop_column("ml_settings", "head_min_positives")
|
||||||
|
op.drop_index("ix_head_training_run_status", table_name="head_training_run")
|
||||||
|
op.drop_table("head_training_run")
|
||||||
|
op.drop_table("tag_head")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""head_auto_apply_run + earned-auto-apply settings (#114)
|
||||||
|
|
||||||
|
A graduated head can apply its tag without a human, gated by a master switch +
|
||||||
|
a support floor. head_auto_apply_run tracks each sweep / dry-run preview.
|
||||||
|
|
||||||
|
Revision ID: 0059
|
||||||
|
Revises: 0058
|
||||||
|
Create Date: 2026-06-29
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision: str = "0059"
|
||||||
|
down_revision: Union[str, None] = "0058"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"head_auto_apply_run",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"dry_run", sa.Boolean(), nullable=False, server_default=sa.false()
|
||||||
|
),
|
||||||
|
sa.Column("params", JSONB(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(length=16), nullable=False,
|
||||||
|
server_default="running",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"started_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("n_applied", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("report", JSONB(), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_head_auto_apply_run_status", "head_auto_apply_run", ["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"head_auto_apply_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.true(), # opt-out: on by default (operator-asked)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"head_auto_apply_min_positives", sa.Integer(), nullable=False,
|
||||||
|
server_default="30",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ml_settings", "head_auto_apply_min_positives")
|
||||||
|
op.drop_column("ml_settings", "head_auto_apply_enabled")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_head_auto_apply_run_status", table_name="head_auto_apply_run"
|
||||||
|
)
|
||||||
|
op.drop_table("head_auto_apply_run")
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""head_metric + head_metrics_snapshot: auto-apply observability (#114)
|
||||||
|
|
||||||
|
Running misfire/under-fire counters per concept (captured at correction time,
|
||||||
|
since image_tag.source is lost on delete) + a daily per-concept time-series so
|
||||||
|
the operator can tune the precision target + support floor from real data.
|
||||||
|
|
||||||
|
Revision ID: 0060
|
||||||
|
Revises: 0059
|
||||||
|
Create Date: 2026-06-29
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0060"
|
||||||
|
down_revision: Union[str, None] = "0059"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"head_metric",
|
||||||
|
sa.Column(
|
||||||
|
"tag_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("n_misfires", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("n_underfires", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"head_metrics_snapshot",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"tag_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tag.id", ondelete="CASCADE"),
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"snapshot_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("n_auto_applied", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("n_misfires", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("n_underfires", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("ap", sa.Float(), nullable=True),
|
||||||
|
sa.Column("precision_cv", sa.Float(), nullable=True),
|
||||||
|
sa.Column("recall", sa.Float(), nullable=True),
|
||||||
|
sa.Column("n_pos", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_head_metrics_snapshot_tag_id", "head_metrics_snapshot", ["tag_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_head_metrics_snapshot_snapshot_at", "head_metrics_snapshot",
|
||||||
|
["snapshot_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_head_metrics_snapshot_snapshot_at", table_name="head_metrics_snapshot"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_head_metrics_snapshot_tag_id", table_name="head_metrics_snapshot"
|
||||||
|
)
|
||||||
|
op.drop_table("head_metrics_snapshot")
|
||||||
|
op.drop_table("head_metric")
|
||||||
@@ -25,6 +25,7 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
from .downloads import downloads_bp
|
from .downloads import downloads_bp
|
||||||
from .extension import extension_bp
|
from .extension import extension_bp
|
||||||
from .gallery import gallery_bp
|
from .gallery import gallery_bp
|
||||||
|
from .heads import heads_bp
|
||||||
from .import_admin import import_admin_bp
|
from .import_admin import import_admin_bp
|
||||||
from .ml_admin import ml_admin_bp
|
from .ml_admin import ml_admin_bp
|
||||||
from .platforms import platforms_bp
|
from .platforms import platforms_bp
|
||||||
@@ -36,6 +37,7 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
from .suggestions import suggestions_bp
|
from .suggestions import suggestions_bp
|
||||||
from .system_activity import system_activity_bp
|
from .system_activity import system_activity_bp
|
||||||
from .system_backup import system_backup_bp
|
from .system_backup import system_backup_bp
|
||||||
|
from .tag_eval import tag_eval_bp
|
||||||
from .tags import tags_bp
|
from .tags import tags_bp
|
||||||
from .thumbnails import thumbnails_bp
|
from .thumbnails import thumbnails_bp
|
||||||
return [
|
return [
|
||||||
@@ -56,6 +58,8 @@ def all_blueprints() -> list[Blueprint]:
|
|||||||
suggestions_bp,
|
suggestions_bp,
|
||||||
allowlist_bp,
|
allowlist_bp,
|
||||||
aliases_bp,
|
aliases_bp,
|
||||||
|
tag_eval_bp,
|
||||||
|
heads_bp,
|
||||||
ml_admin_bp,
|
ml_admin_bp,
|
||||||
thumbnails_bp,
|
thumbnails_bp,
|
||||||
sources_bp,
|
sources_bp,
|
||||||
|
|||||||
+239
-16
@@ -6,6 +6,7 @@ Five action surfaces:
|
|||||||
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
||||||
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
||||||
POST /api/admin/tags/prune-unused (Tier A)
|
POST /api/admin/tags/prune-unused (Tier A)
|
||||||
|
POST /api/admin/posts/prune-bare (Tier A)
|
||||||
POST /api/admin/tags/purge-legacy (Tier A)
|
POST /api/admin/tags/purge-legacy (Tier A)
|
||||||
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
|
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import Artist
|
from ..models import Artist
|
||||||
@@ -38,6 +39,31 @@ def _bulk_image_confirm_token(image_ids: list[int]) -> str:
|
|||||||
return digest[:8]
|
return digest[:8]
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_dry_run_op(service_fn, **service_kwargs):
|
||||||
|
"""Shared body for the Tier-A dry-run/apply endpoints: read the `dry_run`
|
||||||
|
flag, run the cleanup_service predicate under `run_sync`, and return its
|
||||||
|
result dict. The SAME `service_fn` drives both preview and apply (the flag
|
||||||
|
just toggles), so a handler physically can't let its preview diverge from
|
||||||
|
its delete (rule 93). Default False preserves the existing contract — the UI
|
||||||
|
always passes `dry_run` explicitly (true to preview, false to apply). Extra
|
||||||
|
service kwargs (e.g. `source_id`) pass straight through."""
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
dry_run = bool(body.get("dry_run", False))
|
||||||
|
async with get_session() as session:
|
||||||
|
result = await session.run_sync(
|
||||||
|
lambda sync_sess: service_fn(sync_sess, dry_run=dry_run, **service_kwargs)
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _queued(async_result):
|
||||||
|
"""Standard 202 for an operator-triggered maintenance task: hand the UI the
|
||||||
|
Celery task id so it can tail /maintenance/task-result (or the activity
|
||||||
|
dashboard) for the summary. (trigger_vacuum stays bespoke — the UI doesn't
|
||||||
|
poll it, so it returns no task id.)"""
|
||||||
|
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
|
@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
|
||||||
async def artist_cascade_delete(slug: str):
|
async def artist_cascade_delete(slug: str):
|
||||||
body = await request.get_json(silent=True) or {}
|
body = await request.get_json(silent=True) or {}
|
||||||
@@ -145,6 +171,30 @@ async def tag_merge(dest_id: int):
|
|||||||
if not isinstance(source_id, int) or source_id == dest_id:
|
if not isinstance(source_id, int) or source_id == dest_id:
|
||||||
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
|
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
|
||||||
|
|
||||||
|
# dry_run: non-mutating preview (counts + sample) so the operator can
|
||||||
|
# confirm the target before the irreversible merge (#8, rule 93 parity).
|
||||||
|
if body.get("dry_run"):
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
p = await TagService(session).merge_preview(
|
||||||
|
source_id=source_id, target_id=dest_id,
|
||||||
|
)
|
||||||
|
except TagValidationError as exc:
|
||||||
|
return _bad("tag_not_found", status=404, detail=str(exc))
|
||||||
|
return jsonify({
|
||||||
|
"preview": {
|
||||||
|
"source_id": p.source_id, "source_name": p.source_name,
|
||||||
|
"target_id": p.target_id, "target_name": p.target_name,
|
||||||
|
"compatible": p.compatible,
|
||||||
|
"images_moving": p.images_moving,
|
||||||
|
"images_already_on_target": p.images_already_on_target,
|
||||||
|
"source_total": p.source_total,
|
||||||
|
"series_pages": p.series_pages,
|
||||||
|
"will_alias": p.will_alias,
|
||||||
|
"sample_thumbnails": p.sample_thumbnails,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
try:
|
try:
|
||||||
result = await TagService(session).merge(
|
result = await TagService(session).merge(
|
||||||
@@ -192,16 +242,39 @@ async def tags_prune_unused():
|
|||||||
re-call with dry_run=false."""
|
re-call with dry_run=false."""
|
||||||
from ..services.cleanup_service import prune_unused_tags
|
from ..services.cleanup_service import prune_unused_tags
|
||||||
|
|
||||||
body = await request.get_json(silent=True) or {}
|
return await _run_dry_run_op(prune_unused_tags)
|
||||||
dry_run = bool(body.get("dry_run", False))
|
|
||||||
|
|
||||||
async with get_session() as session:
|
|
||||||
result = await session.run_sync(
|
@admin_bp.route("/posts/prune-bare", methods=["POST"])
|
||||||
lambda sync_sess: prune_unused_tags(
|
async def posts_prune_bare():
|
||||||
sync_sess, dry_run=dry_run,
|
"""Tier-A: delete bare posts — Post rows with no linked images (primary OR
|
||||||
)
|
provenance) and no attachments. Dry-run preview list IS the prompt: UI calls
|
||||||
)
|
with dry_run=true first, shows the count + sample, operator confirms by
|
||||||
return jsonify(result)
|
re-calling with dry_run=false. Same preview/apply-parity predicate as the
|
||||||
|
prune itself, so the preview can't diverge from the delete."""
|
||||||
|
from ..services.cleanup_service import prune_bare_posts
|
||||||
|
|
||||||
|
return await _run_dry_run_op(prune_bare_posts)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/posts/reconcile-duplicates", methods=["POST"])
|
||||||
|
async def posts_reconcile_duplicates():
|
||||||
|
"""Tier-A: unify duplicate post rows for the same real post — the gallery-dl
|
||||||
|
(attachment-id) + native (post-id) duplicates — onto ONE post-id-keyed keeper,
|
||||||
|
moving image/provenance/attachment/link rows over. Images are untouched.
|
||||||
|
dry_run=true returns {groups, posts_to_merge, sample}; dry_run=false applies
|
||||||
|
and returns {groups, merged, sample}. Optional source_id scopes to one source.
|
||||||
|
Same find_duplicate_post_groups predicate drives preview + apply (rule 93)."""
|
||||||
|
from ..services.cleanup_service import reconcile_duplicate_posts
|
||||||
|
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
raw_source = body.get("source_id")
|
||||||
|
try:
|
||||||
|
source_id = int(raw_source) if raw_source is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _bad("invalid_source_id", detail="source_id must be an integer")
|
||||||
|
|
||||||
|
return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
|
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
|
||||||
@@ -214,11 +287,161 @@ async def tags_purge_legacy():
|
|||||||
operator confirms with dry_run=false."""
|
operator confirms with dry_run=false."""
|
||||||
from ..services.cleanup_service import purge_legacy_tags
|
from ..services.cleanup_service import purge_legacy_tags
|
||||||
|
|
||||||
body = await request.get_json(silent=True) or {}
|
return await _run_dry_run_op(purge_legacy_tags)
|
||||||
dry_run = bool(body.get("dry_run", False))
|
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/tags/reset-content", methods=["POST"])
|
||||||
|
async def tags_reset_content():
|
||||||
|
"""Tier-A: delete ALL general + character tags (the Camie-suggestable
|
||||||
|
content vocabulary) so the operator can re-tag from scratch via
|
||||||
|
auto-suggest. fandom + series tags + series_page ordering are preserved,
|
||||||
|
and image_prediction rows are untouched so suggestions repopulate.
|
||||||
|
dry-run preview returns per-kind counts + applications + a sample so the
|
||||||
|
UI shows exactly what'll go before the operator confirms (dry_run=false).
|
||||||
|
Irreversible except via DB backup restore."""
|
||||||
|
from ..services.cleanup_service import reset_content_tagging
|
||||||
|
|
||||||
|
return await _run_dry_run_op(reset_content_tagging)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/tags/normalize", methods=["POST"])
|
||||||
|
async def tags_normalize():
|
||||||
|
"""#714: retro-normalize existing tags to the #701 canonical form (Title
|
||||||
|
Case + collapsed whitespace) and merge case/whitespace-variant duplicates.
|
||||||
|
|
||||||
|
dry_run=true (default) returns a projection inline — group/collision/rename
|
||||||
|
counts + a sample of the changes — so the UI shows exactly what'll happen.
|
||||||
|
dry_run=false dispatches the long-running maintenance task (the merge FK
|
||||||
|
repoints can touch many tags); the UI tails the activity dashboard for the
|
||||||
|
summary. Idempotent; back up first (the merges are irreversible)."""
|
||||||
|
from ..services.tag_service import normalize_existing_tags
|
||||||
|
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
dry_run = bool(body.get("dry_run", True))
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
async with get_session() as session:
|
||||||
|
result = await normalize_existing_tags(session, dry_run=True)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
from ..tasks.admin import normalize_tags_task
|
||||||
|
|
||||||
|
async_result = normalize_tags_task.delay()
|
||||||
|
return _queued(async_result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
|
||||||
|
async def db_stats():
|
||||||
|
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
||||||
|
so the operator can see when a VACUUM is worth running."""
|
||||||
|
from ..tasks.maintenance import VACUUM_TABLES
|
||||||
|
|
||||||
|
wanted = set(VACUUM_TABLES)
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
result = await session.run_sync(
|
rows = (await session.execute(text(
|
||||||
lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run)
|
"SELECT relname, n_live_tup, n_dead_tup, last_vacuum, "
|
||||||
)
|
"last_autovacuum, last_analyze FROM pg_stat_user_tables"
|
||||||
return jsonify(result)
|
))).all()
|
||||||
|
|
||||||
|
def _iso(v):
|
||||||
|
return v.isoformat() if v is not None else None
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
if r.relname not in wanted:
|
||||||
|
continue
|
||||||
|
live = r.n_live_tup or 0
|
||||||
|
dead = r.n_dead_tup or 0
|
||||||
|
total = live + dead
|
||||||
|
out.append({
|
||||||
|
"table": r.relname,
|
||||||
|
"live": live,
|
||||||
|
"dead": dead,
|
||||||
|
"dead_pct": round(100 * dead / total, 1) if total else 0.0,
|
||||||
|
"last_vacuum": _iso(r.last_vacuum),
|
||||||
|
"last_autovacuum": _iso(r.last_autovacuum),
|
||||||
|
"last_analyze": _iso(r.last_analyze),
|
||||||
|
})
|
||||||
|
out.sort(key=lambda t: t["dead"], reverse=True)
|
||||||
|
return jsonify({"tables": out})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/maintenance/vacuum", methods=["POST"])
|
||||||
|
async def trigger_vacuum():
|
||||||
|
"""Operator-triggered VACUUM (ANALYZE) over the high-churn tables — the
|
||||||
|
same maintenance-queue task the weekly Beat schedule runs."""
|
||||||
|
from ..tasks.maintenance import vacuum_analyze
|
||||||
|
|
||||||
|
vacuum_analyze.delay()
|
||||||
|
return jsonify({"status": "queued"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/maintenance/reextract-archives", methods=["POST"])
|
||||||
|
async def trigger_reextract_archives():
|
||||||
|
"""Operator-triggered re-extract (#713): PostAttachments that are actually
|
||||||
|
archives but were filed opaquely (pre magic-byte gate) get extracted and
|
||||||
|
their members linked to the post. Idempotent; runs on the maintenance queue."""
|
||||||
|
from ..tasks.admin import reextract_archive_attachments_task
|
||||||
|
|
||||||
|
async_result = reextract_archive_attachments_task.delay()
|
||||||
|
return _queued(async_result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/maintenance/prune-missing-files", methods=["POST"])
|
||||||
|
async def trigger_prune_missing_files():
|
||||||
|
"""Operator-triggered orphan repair (#859): delete ImageRecords whose backing
|
||||||
|
file is gone from disk (e.g. left by the external-attach unlink bug), so they
|
||||||
|
stop 404-ing on playback. The task aborts WITHOUT deleting if a large fraction
|
||||||
|
of files look missing (a filesystem/NFS stall). Maintenance queue;
|
||||||
|
operator-triggered only — never an unattended sweep."""
|
||||||
|
from ..tasks.admin import prune_missing_file_records_task
|
||||||
|
|
||||||
|
async_result = prune_missing_file_records_task.delay()
|
||||||
|
return _queued(async_result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
|
||||||
|
async def trigger_dedup_videos():
|
||||||
|
"""Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
|
||||||
|
what would be removed (groups / redundant count / reclaimable bytes) WITHOUT
|
||||||
|
deleting; dry_run=false applies it (re-link posts to the keeper, then delete
|
||||||
|
the redundant copies). Either way it first re-probes NULL-duration videos so
|
||||||
|
the existing library participates. Returns the Celery task id — poll
|
||||||
|
/maintenance/task-result/<id> for the summary."""
|
||||||
|
from ..tasks.admin import dedup_videos_task
|
||||||
|
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
|
||||||
|
async_result = dedup_videos_task.delay(dry_run=dry_run)
|
||||||
|
return _queued(async_result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/maintenance/purge-gated-previews", methods=["POST"])
|
||||||
|
async def trigger_purge_gated_previews():
|
||||||
|
"""Cleanup (#874 follow-up). Body {"dry_run": bool}: dry_run=true previews how
|
||||||
|
many blurred locked-preview images (grabbed from tier-gated Patreon posts
|
||||||
|
before the fix) would be removed WITHOUT deleting; dry_run=false applies it.
|
||||||
|
Re-walks every enabled Patreon source read-only and matches by content hash, so
|
||||||
|
real content downloaded when access existed is provably spared. Returns the
|
||||||
|
Celery task id — poll /maintenance/task-result/<id> for the summary."""
|
||||||
|
from ..tasks.admin import purge_gated_previews_task
|
||||||
|
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
|
||||||
|
async_result = purge_gated_previews_task.delay(dry_run=dry_run)
|
||||||
|
return _queued(async_result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/maintenance/task-result/<task_id>", methods=["GET"])
|
||||||
|
async def maintenance_task_result(task_id: str):
|
||||||
|
"""Poll a maintenance Celery task's result (the summary dict it returns).
|
||||||
|
Used by the video-dedup card to show the dry-run projection before apply."""
|
||||||
|
from ..celery_app import celery
|
||||||
|
|
||||||
|
res = celery.AsyncResult(task_id)
|
||||||
|
ready = res.ready()
|
||||||
|
return jsonify({
|
||||||
|
"ready": ready,
|
||||||
|
"successful": res.successful() if ready else None,
|
||||||
|
"result": res.result if (ready and res.successful()) else None,
|
||||||
|
})
|
||||||
|
|||||||
@@ -20,12 +20,37 @@ async def list_allowlist():
|
|||||||
"tag_name": r.tag_name,
|
"tag_name": r.tag_name,
|
||||||
"tag_kind": r.tag_kind,
|
"tag_kind": r.tag_kind,
|
||||||
"min_confidence": r.min_confidence,
|
"min_confidence": r.min_confidence,
|
||||||
|
"applied_count": r.applied_count,
|
||||||
|
"coverage_count": r.coverage_count,
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@allowlist_bp.route("/tags/<int:tag_id>/allowlist/coverage", methods=["GET"])
|
||||||
|
async def coverage(tag_id: int):
|
||||||
|
"""Live "at threshold T, a sweep would cover ~N images" projection for the
|
||||||
|
allowlist tuning dashboard. Defaults to the tag's stored threshold."""
|
||||||
|
raw = request.args.get("threshold")
|
||||||
|
async with get_session() as session:
|
||||||
|
svc = AllowlistService(session)
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
threshold = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "threshold must be a float"}), 400
|
||||||
|
if not (0 < threshold <= 1):
|
||||||
|
return jsonify({"error": "threshold must be in (0, 1]"}), 400
|
||||||
|
else:
|
||||||
|
row = await session.get(TagAllowlist, tag_id)
|
||||||
|
if row is None:
|
||||||
|
return jsonify({"error": "not on allowlist"}), 404
|
||||||
|
threshold = row.min_confidence
|
||||||
|
count = await svc.coverage(tag_id, threshold)
|
||||||
|
return jsonify({"count": count, "threshold": threshold})
|
||||||
|
|
||||||
|
|
||||||
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["GET"])
|
@allowlist_bp.route("/tags/<int:tag_id>/allowlist", methods=["GET"])
|
||||||
async def get_one(tag_id: int):
|
async def get_one(tag_id: int):
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
|
|||||||
@@ -154,12 +154,15 @@ async def audit_history():
|
|||||||
limit = min(int(request.args.get("limit", "20")), 100)
|
limit = min(int(request.args.get("limit", "20")), 100)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _bad("invalid_limit")
|
return _bad("invalid_limit")
|
||||||
|
# Optional rule filter so a card can reconnect to ITS latest run on mount
|
||||||
|
# (?rule=transparency&limit=1) — the audit survives navigation; the UI
|
||||||
|
# rehydrates from this rather than losing the in-flight scan.
|
||||||
|
rule = request.args.get("rule") or None
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
rows = (await session.execute(
|
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
|
||||||
select(LibraryAuditRun)
|
if rule is not None:
|
||||||
.order_by(LibraryAuditRun.id.desc())
|
stmt = stmt.where(LibraryAuditRun.rule == rule)
|
||||||
.limit(limit)
|
rows = (await session.execute(stmt.limit(limit))).scalars().all()
|
||||||
)).scalars().all()
|
|
||||||
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -121,13 +121,15 @@ async def delete_credential(platform: str):
|
|||||||
|
|
||||||
@credentials_bp.route("/<platform>/verify", methods=["POST"])
|
@credentials_bp.route("/<platform>/verify", methods=["POST"])
|
||||||
async def verify_credential(platform: str):
|
async def verify_credential(platform: str):
|
||||||
"""Test the stored credential by running gallery-dl --simulate
|
"""Test the stored credential against one of the platform's enabled sources,
|
||||||
against one of the platform's enabled sources. On success stamps
|
WITHOUT downloading. Routes through the platform's backend
|
||||||
last_verified. Returns {valid: bool|null, reason, last_verified?}.
|
(download_backends.verify_credential) — native ingester for Patreon, an
|
||||||
valid=null means "couldn't test" (no credential, or no enabled
|
authenticated API page; gallery-dl --simulate for the rest. On success
|
||||||
source to point at)."""
|
stamps last_verified. Returns {valid: bool|null, reason, last_verified?};
|
||||||
|
valid=null means "couldn't test" (no credential, no enabled source, or an
|
||||||
|
inconclusive network/drift result)."""
|
||||||
from ..models import Artist, Source
|
from ..models import Artist, Source
|
||||||
from ..services.gallery_dl import GalleryDLService, SourceConfig
|
from ..services.download_backends import verify_source_credential
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
if not await _ext_key_ok(session):
|
if not await _ext_key_ok(session):
|
||||||
@@ -154,14 +156,14 @@ async def verify_credential(platform: str):
|
|||||||
cookies_path = await svc.get_cookies_path(platform)
|
cookies_path = await svc.get_cookies_path(platform)
|
||||||
auth_token = await svc.get_token(platform)
|
auth_token = await svc.get_token(platform)
|
||||||
|
|
||||||
gdl = GalleryDLService(images_root=Path("/images"))
|
ok, message = await verify_source_credential(
|
||||||
ok, message = await gdl.verify(
|
platform=platform,
|
||||||
url=source.url,
|
url=source.url,
|
||||||
artist_slug=artist.slug,
|
artist_slug=artist.slug,
|
||||||
platform=platform,
|
config_overrides=source.config_overrides or {},
|
||||||
source_config=SourceConfig.from_dict(source.config_overrides or {}),
|
|
||||||
cookies_path=str(cookies_path) if cookies_path else None,
|
cookies_path=str(cookies_path) if cookies_path else None,
|
||||||
auth_token=auth_token,
|
auth_token=auth_token,
|
||||||
|
images_root=Path("/images"),
|
||||||
)
|
)
|
||||||
|
|
||||||
last_verified = None
|
last_verified = None
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ def _list_record(event: DownloadEvent, source: Source | None, artist: Artist | N
|
|||||||
"bytes_downloaded": event.bytes_downloaded,
|
"bytes_downloaded": event.bytes_downloaded,
|
||||||
"error": event.error,
|
"error": event.error,
|
||||||
"summary": _summary_from_metadata(event.metadata_),
|
"summary": _summary_from_metadata(event.metadata_),
|
||||||
|
# plan #709: mid-walk live counts for a RUNNING native-ingester event
|
||||||
|
# (None otherwise; phase 3 overwrites metadata with run_stats on finish).
|
||||||
|
"live": (event.metadata_ or {}).get("live"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+147
-43
@@ -1,4 +1,6 @@
|
|||||||
"""Gallery API: cursor scroll, timeline, jump, image detail."""
|
"""Gallery API: cursor scroll, timeline, jump, image detail, facets."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
@@ -8,46 +10,104 @@ from ..services.gallery_service import GalleryService
|
|||||||
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
||||||
|
|
||||||
|
|
||||||
|
def _image_json(i):
|
||||||
|
"""Serialize a GalleryImage for the scroll/similar list responses."""
|
||||||
|
return {
|
||||||
|
"id": i.id,
|
||||||
|
"sha256": i.sha256,
|
||||||
|
"mime": i.mime,
|
||||||
|
"width": i.width,
|
||||||
|
"height": i.height,
|
||||||
|
"created_at": i.created_at.isoformat(),
|
||||||
|
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
||||||
|
"thumbnail_url": i.thumbnail_url,
|
||||||
|
"artist": i.artist,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(raw):
|
||||||
|
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
|
||||||
|
Raises ValueError (→ 400) on a malformed value."""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_filters():
|
||||||
|
"""Parse the composable gallery filters from query args, returning
|
||||||
|
``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
|
||||||
|
|
||||||
|
The structured tag filter (#6) is AND-of-OR plus exclusions:
|
||||||
|
- `tag_id` accepts a single id or a comma-separated list — all ANDed
|
||||||
|
(the include common case; back-compat).
|
||||||
|
- `tag_or` is REPEATABLE; each instance is a comma-separated OR-group, and
|
||||||
|
the image must match at least one tag from EACH group (groups ANDed).
|
||||||
|
- `tag_not` is a comma-separated exclude list (image must carry none).
|
||||||
|
|
||||||
|
`media` is image|video; `sort` is newest|oldest; `platform` selects one
|
||||||
|
platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are
|
||||||
|
boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds
|
||||||
|
(date_to is widened by a day so the whole day is covered by the service's
|
||||||
|
half-open `< date_to`)."""
|
||||||
|
tag_raw = request.args.get("tag_id")
|
||||||
|
tag_ids = (
|
||||||
|
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
|
||||||
|
) or None
|
||||||
|
tag_or_groups = [
|
||||||
|
grp for raw in request.args.getlist("tag_or")
|
||||||
|
if (grp := [int(x) for x in raw.split(",") if x.strip()])
|
||||||
|
] or None
|
||||||
|
not_raw = request.args.get("tag_not")
|
||||||
|
tag_exclude = (
|
||||||
|
[int(x) for x in not_raw.split(",") if x.strip()] if not_raw else None
|
||||||
|
) or None
|
||||||
|
post_id_raw = request.args.get("post_id")
|
||||||
|
post_id = int(post_id_raw) if post_id_raw else None
|
||||||
|
artist_id_raw = request.args.get("artist_id")
|
||||||
|
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||||
|
media = request.args.get("media")
|
||||||
|
media_type = media if media in ("image", "video") else None
|
||||||
|
sort = request.args.get("sort")
|
||||||
|
sort = sort if sort in ("newest", "oldest") else "newest"
|
||||||
|
platform = request.args.get("platform") or None
|
||||||
|
untagged = request.args.get("untagged") in ("1", "true", "yes")
|
||||||
|
no_artist = request.args.get("no_artist") in ("1", "true", "yes")
|
||||||
|
date_from = _parse_date(request.args.get("date_from"))
|
||||||
|
date_to = _parse_date(request.args.get("date_to"))
|
||||||
|
if date_to is not None:
|
||||||
|
date_to += timedelta(days=1) # inclusive of the date_to calendar day
|
||||||
|
filters = {
|
||||||
|
"tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id,
|
||||||
|
"media_type": media_type,
|
||||||
|
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
|
||||||
|
"platform": platform,
|
||||||
|
"untagged": untagged, "no_artist": no_artist,
|
||||||
|
"date_from": date_from, "date_to": date_to,
|
||||||
|
}
|
||||||
|
return filters, sort
|
||||||
|
|
||||||
|
|
||||||
@gallery_bp.route("/scroll", methods=["GET"])
|
@gallery_bp.route("/scroll", methods=["GET"])
|
||||||
async def scroll():
|
async def scroll():
|
||||||
cursor = request.args.get("cursor") or None
|
cursor = request.args.get("cursor") or None
|
||||||
try:
|
try:
|
||||||
limit = int(request.args.get("limit", "50"))
|
limit = int(request.args.get("limit", "50"))
|
||||||
|
filters, sort = _parse_filters()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "limit must be an integer"}), 400
|
return jsonify({"error": "invalid filter or limit parameter"}), 400
|
||||||
tag_id_raw = request.args.get("tag_id")
|
|
||||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
|
||||||
post_id_raw = request.args.get("post_id")
|
|
||||||
post_id = int(post_id_raw) if post_id_raw else None
|
|
||||||
artist_id_raw = request.args.get("artist_id")
|
|
||||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
page = await svc.scroll(
|
page = await svc.scroll(
|
||||||
cursor=cursor, limit=limit, tag_id=tag_id,
|
cursor=cursor, limit=limit, sort=sort, **filters,
|
||||||
post_id=post_id, artist_id=artist_id,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"images": [
|
"images": [_image_json(i) for i in page.images],
|
||||||
{
|
|
||||||
"id": i.id,
|
|
||||||
"sha256": i.sha256,
|
|
||||||
"mime": i.mime,
|
|
||||||
"width": i.width,
|
|
||||||
"height": i.height,
|
|
||||||
"created_at": i.created_at.isoformat(),
|
|
||||||
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
|
||||||
"thumbnail_url": i.thumbnail_url,
|
|
||||||
"artist": i.artist,
|
|
||||||
}
|
|
||||||
for i in page.images
|
|
||||||
],
|
|
||||||
"next_cursor": page.next_cursor,
|
"next_cursor": page.next_cursor,
|
||||||
"date_groups": [
|
"date_groups": [
|
||||||
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
|
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
|
||||||
@@ -56,20 +116,46 @@ async def scroll():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@gallery_bp.route("/timeline", methods=["GET"])
|
@gallery_bp.route("/similar", methods=["GET"])
|
||||||
async def timeline():
|
async def similar():
|
||||||
tag_id_raw = request.args.get("tag_id")
|
"""Visual "more like this": images ranked by cosine distance to the
|
||||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
`similar_to` image's embedding. Composes with the scope filters (AND) but
|
||||||
post_id_raw = request.args.get("post_id")
|
ignores post_id and sort. Bounded top-N, no cursor."""
|
||||||
post_id = int(post_id_raw) if post_id_raw else None
|
try:
|
||||||
artist_id_raw = request.args.get("artist_id")
|
similar_to = int(request.args["similar_to"])
|
||||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
limit = int(request.args.get("limit", "100"))
|
||||||
|
filters, _sort = _parse_filters()
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return jsonify({"error": "similar_to query param required"}), 400
|
||||||
|
# post_id is the exclusive post-detail view — not a similarity scope.
|
||||||
|
scope = {k: v for k, v in filters.items() if k != "post_id"}
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
buckets = await svc.timeline(
|
images = await svc.similar(image_id=similar_to, limit=limit, **scope)
|
||||||
tag_id=tag_id, post_id=post_id, artist_id=artist_id
|
except ValueError as exc:
|
||||||
)
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
if images is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"images": [_image_json(i) for i in images],
|
||||||
|
"next_cursor": None,
|
||||||
|
"date_groups": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@gallery_bp.route("/timeline", methods=["GET"])
|
||||||
|
async def timeline():
|
||||||
|
try:
|
||||||
|
filters, _sort = _parse_filters()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "invalid filter parameter"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
svc = GalleryService(session)
|
||||||
|
try:
|
||||||
|
buckets = await svc.timeline(**filters)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
return jsonify(
|
return jsonify(
|
||||||
@@ -77,25 +163,43 @@ async def timeline():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@gallery_bp.route("/facets", methods=["GET"])
|
||||||
|
async def facets():
|
||||||
|
try:
|
||||||
|
filters, _sort = _parse_filters()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "invalid filter parameter"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
svc = GalleryService(session)
|
||||||
|
try:
|
||||||
|
f = await svc.facets(**filters)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"total": f.total,
|
||||||
|
"platforms": f.platforms,
|
||||||
|
"untagged": f.untagged,
|
||||||
|
"no_artist": f.no_artist,
|
||||||
|
"date_min": f.date_min.isoformat() if f.date_min else None,
|
||||||
|
"date_max": f.date_max.isoformat() if f.date_max else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@gallery_bp.route("/jump", methods=["GET"])
|
@gallery_bp.route("/jump", methods=["GET"])
|
||||||
async def jump():
|
async def jump():
|
||||||
try:
|
try:
|
||||||
year = int(request.args["year"])
|
year = int(request.args["year"])
|
||||||
month = int(request.args["month"])
|
month = int(request.args["month"])
|
||||||
|
filters, sort = _parse_filters()
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
return jsonify({"error": "year and month query params required"}), 400
|
return jsonify({"error": "year and month query params required"}), 400
|
||||||
tag_id_raw = request.args.get("tag_id")
|
|
||||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
|
||||||
post_id_raw = request.args.get("post_id")
|
|
||||||
post_id = int(post_id_raw) if post_id_raw else None
|
|
||||||
artist_id_raw = request.args.get("artist_id")
|
|
||||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = GalleryService(session)
|
svc = GalleryService(session)
|
||||||
try:
|
try:
|
||||||
cursor = await svc.jump_cursor(
|
cursor = await svc.jump_cursor(
|
||||||
year=year, month=month, tag_id=tag_id,
|
year=year, month=month, sort=sort, **filters,
|
||||||
post_id=post_id, artist_id=artist_id,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Heads API (#114): train + inspect the per-concept heads that power
|
||||||
|
suggestions (replacing Camie + centroid).
|
||||||
|
|
||||||
|
POST /api/heads/train — (re)train all eligible heads (one run at a time).
|
||||||
|
GET /api/heads — status: head count, last-trained, running run, the
|
||||||
|
per-concept head table (strength + auto-apply ready),
|
||||||
|
and recent training runs. The card rehydrates from
|
||||||
|
here so status survives navigation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
|
||||||
|
from ..extensions import get_session
|
||||||
|
from ..models import (
|
||||||
|
HeadAutoApplyRun,
|
||||||
|
HeadMetric,
|
||||||
|
HeadMetricsSnapshot,
|
||||||
|
HeadTrainingRun,
|
||||||
|
Tag,
|
||||||
|
TagHead,
|
||||||
|
)
|
||||||
|
from ..models.tag import image_tag
|
||||||
|
from ..services.ml.heads import (
|
||||||
|
HeadAutoApplyAlreadyRunning,
|
||||||
|
HeadAutoApplyDisabled,
|
||||||
|
HeadTrainingAlreadyRunning,
|
||||||
|
start_head_auto_apply_run,
|
||||||
|
start_head_training_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
heads_bp = Blueprint("heads", __name__, url_prefix="/api/heads")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_run(run: HeadTrainingRun) -> dict:
|
||||||
|
return {
|
||||||
|
"id": run.id,
|
||||||
|
"params": run.params,
|
||||||
|
"status": run.status,
|
||||||
|
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||||
|
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||||
|
"n_trained": run.n_trained,
|
||||||
|
"n_skipped": run.n_skipped,
|
||||||
|
"error": run.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@heads_bp.route("/train", methods=["POST"])
|
||||||
|
async def train():
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
params = body.get("params") or body or {}
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
run_id = await session.run_sync(
|
||||||
|
lambda s: start_head_training_run(s, params)
|
||||||
|
)
|
||||||
|
except HeadTrainingAlreadyRunning as running:
|
||||||
|
return jsonify({
|
||||||
|
"error": "training_already_running",
|
||||||
|
"running_id": int(running.args[0]),
|
||||||
|
}), 409
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"run_id": run_id, "status": "running"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@heads_bp.route("", methods=["GET"])
|
||||||
|
async def status():
|
||||||
|
async with get_session() as session:
|
||||||
|
count, last_trained = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(), func.max(TagHead.trained_at))
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
graduated = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count()).where(
|
||||||
|
TagHead.auto_apply_threshold.is_not(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
running = (
|
||||||
|
await session.execute(
|
||||||
|
select(HeadTrainingRun.id)
|
||||||
|
.where(HeadTrainingRun.status == "running")
|
||||||
|
.order_by(HeadTrainingRun.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
runs = (
|
||||||
|
await session.execute(
|
||||||
|
select(HeadTrainingRun)
|
||||||
|
.order_by(HeadTrainingRun.id.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
# The per-concept table: strongest first, capped for the admin card.
|
||||||
|
head_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
TagHead.tag_id, Tag.name, Tag.kind,
|
||||||
|
TagHead.n_pos, TagHead.n_neg, TagHead.ap,
|
||||||
|
TagHead.precision_cv, TagHead.recall,
|
||||||
|
TagHead.auto_apply_threshold, TagHead.trained_at,
|
||||||
|
)
|
||||||
|
.join(Tag, Tag.id == TagHead.tag_id)
|
||||||
|
.order_by(desc(TagHead.ap))
|
||||||
|
.limit(500)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
heads = [
|
||||||
|
{
|
||||||
|
"tag_id": r.tag_id,
|
||||||
|
"name": r.name,
|
||||||
|
"category": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
|
||||||
|
"n_pos": r.n_pos,
|
||||||
|
"n_neg": r.n_neg,
|
||||||
|
"ap": r.ap,
|
||||||
|
"precision": r.precision_cv,
|
||||||
|
"recall": r.recall,
|
||||||
|
"auto_apply": r.auto_apply_threshold is not None,
|
||||||
|
"trained_at": r.trained_at.isoformat() if r.trained_at else None,
|
||||||
|
}
|
||||||
|
for r in head_rows
|
||||||
|
]
|
||||||
|
return jsonify({
|
||||||
|
"head_count": count,
|
||||||
|
"graduated_count": graduated,
|
||||||
|
"last_trained_at": last_trained.isoformat() if last_trained else None,
|
||||||
|
"running_id": running,
|
||||||
|
"runs": [_serialize_run(r) for r in runs],
|
||||||
|
"heads": heads,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_apply_run(run: HeadAutoApplyRun) -> dict:
|
||||||
|
return {
|
||||||
|
"id": run.id,
|
||||||
|
"dry_run": run.dry_run,
|
||||||
|
"status": run.status,
|
||||||
|
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||||
|
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||||
|
"n_applied": run.n_applied,
|
||||||
|
"report": run.report,
|
||||||
|
"error": run.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@heads_bp.route("/auto-apply", methods=["POST"])
|
||||||
|
async def auto_apply():
|
||||||
|
"""Trigger an earned-auto-apply sweep. {dry_run:true} previews (writes
|
||||||
|
nothing); a real sweep needs head_auto_apply_enabled on."""
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
params = {"dry_run": bool(body.get("dry_run", False))}
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
run_id = await session.run_sync(
|
||||||
|
lambda s: start_head_auto_apply_run(s, params)
|
||||||
|
)
|
||||||
|
except HeadAutoApplyAlreadyRunning as running:
|
||||||
|
return jsonify({
|
||||||
|
"error": "auto_apply_already_running",
|
||||||
|
"running_id": int(running.args[0]),
|
||||||
|
}), 409
|
||||||
|
except HeadAutoApplyDisabled:
|
||||||
|
return jsonify({"error": "auto_apply_disabled"}), 400
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"run_id": run_id, "status": "running"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@heads_bp.route("/auto-apply", methods=["GET"])
|
||||||
|
async def auto_apply_status():
|
||||||
|
async with get_session() as session:
|
||||||
|
running = (
|
||||||
|
await session.execute(
|
||||||
|
select(HeadAutoApplyRun.id)
|
||||||
|
.where(HeadAutoApplyRun.status == "running")
|
||||||
|
.order_by(HeadAutoApplyRun.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
runs = (
|
||||||
|
await session.execute(
|
||||||
|
select(HeadAutoApplyRun)
|
||||||
|
.order_by(HeadAutoApplyRun.id.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return jsonify({
|
||||||
|
"running_id": running,
|
||||||
|
"runs": [_serialize_apply_run(r) for r in runs],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@heads_bp.route("/metrics", methods=["GET"])
|
||||||
|
async def metrics():
|
||||||
|
"""Auto-apply observability: per-concept current counts (volume, misfires,
|
||||||
|
under-fires, realized misfire rate, head quality) + the daily time-series so
|
||||||
|
the operator can tune the precision target + support floor from real data."""
|
||||||
|
async with get_session() as session:
|
||||||
|
head_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
TagHead.tag_id, Tag.name, TagHead.ap, TagHead.precision_cv,
|
||||||
|
TagHead.recall, TagHead.auto_apply_threshold, TagHead.n_pos,
|
||||||
|
).join(Tag, Tag.id == TagHead.tag_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
heads = {r.tag_id: r for r in head_rows}
|
||||||
|
metric_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
HeadMetric.tag_id, HeadMetric.n_misfires, HeadMetric.n_underfires
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
mets = {r.tag_id: r for r in metric_rows}
|
||||||
|
applied = dict(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(image_tag.c.tag_id, func.count())
|
||||||
|
.where(image_tag.c.source == "head_auto")
|
||||||
|
.group_by(image_tag.c.tag_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
names = {r.tag_id: r.name for r in head_rows}
|
||||||
|
# Names for metric-only tags (head pruned but corrections recorded).
|
||||||
|
missing = [t for t in mets if t not in names]
|
||||||
|
if missing:
|
||||||
|
for tid, nm in (
|
||||||
|
await session.execute(
|
||||||
|
select(Tag.id, Tag.name).where(Tag.id.in_(missing))
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
names[tid] = nm
|
||||||
|
|
||||||
|
concepts = []
|
||||||
|
for tid in set(heads) | set(mets):
|
||||||
|
h = heads.get(tid)
|
||||||
|
m = mets.get(tid)
|
||||||
|
n_applied = applied.get(tid, 0)
|
||||||
|
n_mis = m.n_misfires if m else 0
|
||||||
|
denom = n_applied + n_mis
|
||||||
|
concepts.append({
|
||||||
|
"tag_id": tid,
|
||||||
|
"name": names.get(tid, str(tid)),
|
||||||
|
"n_auto_applied": n_applied,
|
||||||
|
"n_misfires": n_mis,
|
||||||
|
"n_underfires": m.n_underfires if m else 0,
|
||||||
|
# Of everything this head ever auto-applied, the fraction you
|
||||||
|
# removed — the misfire rate (null until something fired).
|
||||||
|
"misfire_rate": round(n_mis / denom, 4) if denom else None,
|
||||||
|
"ap": h.ap if h else None,
|
||||||
|
"precision_cv": h.precision_cv if h else None,
|
||||||
|
"recall": h.recall if h else None,
|
||||||
|
"auto_apply": bool(h and h.auto_apply_threshold is not None),
|
||||||
|
"n_pos": h.n_pos if h else None,
|
||||||
|
})
|
||||||
|
concepts.sort(key=lambda c: (c["n_misfires"], c["n_auto_applied"]), reverse=True)
|
||||||
|
|
||||||
|
snaps = (
|
||||||
|
await session.execute(
|
||||||
|
select(HeadMetricsSnapshot)
|
||||||
|
.order_by(HeadMetricsSnapshot.snapshot_at.desc())
|
||||||
|
.limit(1000)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return jsonify({
|
||||||
|
"concepts": concepts,
|
||||||
|
"snapshots": [
|
||||||
|
{
|
||||||
|
"tag_id": s.tag_id,
|
||||||
|
"name": s.name,
|
||||||
|
"snapshot_at": s.snapshot_at.isoformat() if s.snapshot_at else None,
|
||||||
|
"n_auto_applied": s.n_auto_applied,
|
||||||
|
"n_misfires": s.n_misfires,
|
||||||
|
"n_underfires": s.n_underfires,
|
||||||
|
"ap": s.ap,
|
||||||
|
"precision_cv": s.precision_cv,
|
||||||
|
"recall": s.recall,
|
||||||
|
"n_pos": s.n_pos,
|
||||||
|
}
|
||||||
|
for s in snaps
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -13,6 +13,14 @@ _EDITABLE = (
|
|||||||
"suggestion_threshold_general",
|
"suggestion_threshold_general",
|
||||||
"centroid_similarity_threshold",
|
"centroid_similarity_threshold",
|
||||||
"min_reference_images",
|
"min_reference_images",
|
||||||
|
"tagger_store_floor",
|
||||||
|
"video_frame_interval_seconds",
|
||||||
|
"video_max_frames",
|
||||||
|
"video_min_tag_frames",
|
||||||
|
"head_min_positives",
|
||||||
|
"head_auto_apply_precision",
|
||||||
|
"head_auto_apply_enabled",
|
||||||
|
"head_auto_apply_min_positives",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,8 +38,16 @@ async def get_settings():
|
|||||||
"suggestion_threshold_general": s.suggestion_threshold_general,
|
"suggestion_threshold_general": s.suggestion_threshold_general,
|
||||||
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
||||||
"min_reference_images": s.min_reference_images,
|
"min_reference_images": s.min_reference_images,
|
||||||
|
"tagger_store_floor": s.tagger_store_floor,
|
||||||
|
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
||||||
|
"video_max_frames": s.video_max_frames,
|
||||||
|
"video_min_tag_frames": s.video_min_tag_frames,
|
||||||
"tagger_model_version": s.tagger_model_version,
|
"tagger_model_version": s.tagger_model_version,
|
||||||
"embedder_model_version": s.embedder_model_version,
|
"embedder_model_version": s.embedder_model_version,
|
||||||
|
"head_min_positives": s.head_min_positives,
|
||||||
|
"head_auto_apply_precision": s.head_auto_apply_precision,
|
||||||
|
"head_auto_apply_enabled": s.head_auto_apply_enabled,
|
||||||
|
"head_auto_apply_min_positives": s.head_auto_apply_min_positives,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,13 +63,61 @@ async def patch_settings():
|
|||||||
s = (
|
s = (
|
||||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
# Merge the patch over current values, then validate the result as a
|
||||||
|
# whole — the store-floor invariant couples three fields, so they
|
||||||
|
# can't be checked one at a time.
|
||||||
|
proposed = {f: getattr(s, f) for f in _EDITABLE}
|
||||||
for field in _EDITABLE:
|
for field in _EDITABLE:
|
||||||
if field in body:
|
if field in body:
|
||||||
setattr(s, field, body[field])
|
proposed[field] = body[field]
|
||||||
|
|
||||||
|
err = _validate(proposed)
|
||||||
|
if err is not None:
|
||||||
|
return jsonify({"error": err}), 400
|
||||||
|
|
||||||
|
for field in _EDITABLE:
|
||||||
|
setattr(s, field, proposed[field])
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return await get_settings()
|
return await get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(p: dict) -> str | None:
|
||||||
|
"""Returns an error string if the proposed settings are invalid, else None.
|
||||||
|
|
||||||
|
Invariant (plan-task #764): the per-category suggestion thresholds can't
|
||||||
|
drop below tagger_store_floor — nothing below the floor is stored, so a
|
||||||
|
lower threshold would silently surface nothing in that gap. The UI clamps
|
||||||
|
the sliders to the floor; this is the server-side backstop.
|
||||||
|
"""
|
||||||
|
floor = p["tagger_store_floor"]
|
||||||
|
if not (0.0 <= floor <= 1.0):
|
||||||
|
return "tagger_store_floor must be between 0 and 1"
|
||||||
|
for cat in ("character", "general"):
|
||||||
|
if p[f"suggestion_threshold_{cat}"] < floor:
|
||||||
|
return (
|
||||||
|
f"suggestion_threshold_{cat} cannot be below tagger_store_floor "
|
||||||
|
f"({floor}) — predictions below the floor are not stored"
|
||||||
|
)
|
||||||
|
# Video tagging (#747).
|
||||||
|
if p["video_frame_interval_seconds"] <= 0:
|
||||||
|
return "video_frame_interval_seconds must be > 0"
|
||||||
|
if p["video_max_frames"] < 1:
|
||||||
|
return "video_max_frames must be >= 1"
|
||||||
|
if p["video_min_tag_frames"] < 1:
|
||||||
|
return "video_min_tag_frames must be >= 1"
|
||||||
|
if p["video_min_tag_frames"] > p["video_max_frames"]:
|
||||||
|
return "video_min_tag_frames cannot exceed video_max_frames"
|
||||||
|
# Head training (#114).
|
||||||
|
if int(p["head_min_positives"]) < 1:
|
||||||
|
return "head_min_positives must be >= 1"
|
||||||
|
if not (0.5 <= float(p["head_auto_apply_precision"]) <= 0.999):
|
||||||
|
return "head_auto_apply_precision must be between 0.5 and 0.999"
|
||||||
|
if int(p["head_auto_apply_min_positives"]) < 1:
|
||||||
|
return "head_auto_apply_min_positives must be >= 1"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ml_admin_bp.route("/backfill", methods=["POST"])
|
@ml_admin_bp.route("/backfill", methods=["POST"])
|
||||||
async def trigger_backfill():
|
async def trigger_backfill():
|
||||||
from ..tasks.ml import backfill
|
from ..tasks.ml import backfill
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ async def list_posts():
|
|||||||
cursor = args.get("cursor") or None
|
cursor = args.get("cursor") or None
|
||||||
artist_id_raw = args.get("artist_id")
|
artist_id_raw = args.get("artist_id")
|
||||||
platform = args.get("platform") or None
|
platform = args.get("platform") or None
|
||||||
|
q = (args.get("q") or "").strip() or None
|
||||||
limit_raw = args.get("limit", "24")
|
limit_raw = args.get("limit", "24")
|
||||||
direction = args.get("direction", "older")
|
direction = args.get("direction", "older")
|
||||||
around_raw = args.get("around")
|
around_raw = args.get("around")
|
||||||
@@ -56,7 +57,7 @@ async def list_posts():
|
|||||||
if around_id is not None:
|
if around_id is not None:
|
||||||
result = await svc.around(
|
result = await svc.around(
|
||||||
post_id=around_id, artist_id=artist_id,
|
post_id=around_id, artist_id=artist_id,
|
||||||
platform=platform, limit=limit,
|
platform=platform, q=q, limit=limit,
|
||||||
)
|
)
|
||||||
if result is None:
|
if result is None:
|
||||||
return _bad("not_found", status=404, detail=f"post id={around_id}")
|
return _bad("not_found", status=404, detail=f"post id={around_id}")
|
||||||
@@ -64,7 +65,7 @@ async def list_posts():
|
|||||||
try:
|
try:
|
||||||
page = await svc.scroll(
|
page = await svc.scroll(
|
||||||
cursor=cursor, artist_id=artist_id,
|
cursor=cursor, artist_id=artist_id,
|
||||||
platform=platform, limit=limit, direction=direction,
|
platform=platform, q=q, limit=limit, direction=direction,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# Service raises ValueError for malformed cursors only;
|
# Service raises ValueError for malformed cursors only;
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ _EDITABLE_FIELDS = (
|
|||||||
"download_schedule_default_seconds",
|
"download_schedule_default_seconds",
|
||||||
"download_event_retention_days",
|
"download_event_retention_days",
|
||||||
"download_failure_warning_threshold",
|
"download_failure_warning_threshold",
|
||||||
|
"series_suggest_enabled",
|
||||||
|
"series_suggest_threshold",
|
||||||
|
"extdl_mega_enabled",
|
||||||
|
"extdl_gdrive_enabled",
|
||||||
|
"extdl_mediafire_enabled",
|
||||||
|
"extdl_dropbox_enabled",
|
||||||
|
"extdl_pixeldrain_enabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
||||||
|
_EXTDL_TOGGLE_FIELDS = (
|
||||||
|
"extdl_mega_enabled",
|
||||||
|
"extdl_gdrive_enabled",
|
||||||
|
"extdl_mediafire_enabled",
|
||||||
|
"extdl_dropbox_enabled",
|
||||||
|
"extdl_pixeldrain_enabled",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +62,13 @@ async def get_import_settings():
|
|||||||
"download_schedule_default_seconds": row.download_schedule_default_seconds,
|
"download_schedule_default_seconds": row.download_schedule_default_seconds,
|
||||||
"download_event_retention_days": row.download_event_retention_days,
|
"download_event_retention_days": row.download_event_retention_days,
|
||||||
"download_failure_warning_threshold": row.download_failure_warning_threshold,
|
"download_failure_warning_threshold": row.download_failure_warning_threshold,
|
||||||
|
"series_suggest_enabled": row.series_suggest_enabled,
|
||||||
|
"series_suggest_threshold": row.series_suggest_threshold,
|
||||||
|
"extdl_mega_enabled": row.extdl_mega_enabled,
|
||||||
|
"extdl_gdrive_enabled": row.extdl_gdrive_enabled,
|
||||||
|
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
|
||||||
|
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
|
||||||
|
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +119,22 @@ async def update_import_settings():
|
|||||||
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 100:
|
if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 100:
|
||||||
return _bad_int("download_failure_warning_threshold", 1, 100)
|
return _bad_int("download_failure_warning_threshold", 1, 100)
|
||||||
|
|
||||||
|
if "series_suggest_enabled" in body and not isinstance(
|
||||||
|
body["series_suggest_enabled"], bool
|
||||||
|
):
|
||||||
|
return jsonify(
|
||||||
|
{"error": "series_suggest_enabled must be a boolean"}
|
||||||
|
), 400
|
||||||
|
for tog in _EXTDL_TOGGLE_FIELDS:
|
||||||
|
if tog in body and not isinstance(body[tog], bool):
|
||||||
|
return jsonify({"error": f"{tog} must be a boolean"}), 400
|
||||||
|
if "series_suggest_threshold" in body:
|
||||||
|
v = body["series_suggest_threshold"]
|
||||||
|
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "series_suggest_threshold must be a number in [0, 1]"}
|
||||||
|
), 400
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
row = await ImportSettings.load(session)
|
row = await ImportSettings.load(session)
|
||||||
for field in _EDITABLE_FIELDS:
|
for field in _EDITABLE_FIELDS:
|
||||||
|
|||||||
+125
-15
@@ -85,6 +85,22 @@ async def create_source():
|
|||||||
return _bad("empty_url", detail=str(exc))
|
return _bad("empty_url", detail=str(exc))
|
||||||
except DuplicateSourceError as exc:
|
except DuplicateSourceError as exc:
|
||||||
return _bad("duplicate", status=409, existing_id=exc.existing_id)
|
return _bad("duplicate", status=409, existing_id=exc.existing_id)
|
||||||
|
|
||||||
|
# Immediate kickoff: a new enabled source is armed for backfill (#693)
|
||||||
|
# but would otherwise sit idle until the next scheduler tick (~60s).
|
||||||
|
# Enqueue the first walk now, skipping only if the platform is in a
|
||||||
|
# rate-limit cooldown (the scheduler picks it up when that clears).
|
||||||
|
dispatch_id = None
|
||||||
|
if record.enabled:
|
||||||
|
cooldowns = await active_platform_cooldowns(session)
|
||||||
|
if record.platform not in cooldowns:
|
||||||
|
session.add(DownloadEvent(source_id=record.id, status="pending"))
|
||||||
|
await session.commit()
|
||||||
|
dispatch_id = record.id
|
||||||
|
|
||||||
|
if dispatch_id is not None:
|
||||||
|
from ..tasks.download import download_source
|
||||||
|
download_source.delay(dispatch_id)
|
||||||
return jsonify(record.to_dict()), 201
|
return jsonify(record.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -122,29 +138,123 @@ async def delete_source(source_id: int):
|
|||||||
|
|
||||||
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
|
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
|
||||||
async def set_backfill(source_id: int):
|
async def set_backfill(source_id: int):
|
||||||
"""Plan #544: arm a source for backfill mode for the next N download
|
"""Plan #693/#697 + #830: start/stop a backfill, or start a recovery /
|
||||||
runs. Body: `{"runs": int}` (1..10, default 3). Returns the updated
|
recapture. Body: `{"action": "start" | "stop" | "recover" | "recapture"}`
|
||||||
source dict. While backfill_runs_remaining > 0, downloads use
|
(default "start"). 'start' walks the full post history in time-boxed chunks
|
||||||
gallery-dl's full-walk config (skip: True + 30-min timeout) instead
|
until it reaches the bottom (then the source shows 'complete'); 'recover' is
|
||||||
of the catch-up default (skip: "exit:20" + 14.5-min timeout)."""
|
the same walk but bypasses the Patreon seen-ledger to re-fetch
|
||||||
|
dropped-and-deleted near-dups under the current pHash threshold; 'recapture'
|
||||||
|
re-grabs EVERY post's body + external links and localizes on-disk inline
|
||||||
|
images WITHOUT re-downloading media; 'stop' cancels any back to tick mode.
|
||||||
|
Returns the updated source dict (incl. backfill_state / backfill_chunks /
|
||||||
|
backfill_bypass_seen / backfill_recapture)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..services.credential_service import CredentialService
|
||||||
|
from ..services.download_backends import (
|
||||||
|
uses_native_ingester,
|
||||||
|
verify_source_credential,
|
||||||
|
)
|
||||||
|
from .credentials import _get_crypto
|
||||||
|
|
||||||
payload = await request.get_json(silent=True) or {}
|
payload = await request.get_json(silent=True) or {}
|
||||||
runs = payload.get("runs", 3)
|
action = payload.get("action", "start")
|
||||||
try:
|
if action not in ("start", "stop", "recover", "recapture"):
|
||||||
runs = int(runs)
|
return _bad(
|
||||||
except (TypeError, ValueError):
|
"invalid_action",
|
||||||
return _bad("invalid_runs", detail="runs must be an integer")
|
detail="action must be 'start', 'stop', 'recover', or 'recapture'",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pre-flight (plan #703 #2): before arming a deep walk on a native-ingester
|
||||||
|
# platform (where verify is one cheap API page), refuse if the credential is
|
||||||
|
# DEFINITIVELY rejected — don't burn chunks against expired cookies. Proceed
|
||||||
|
# on valid OR inconclusive (a network blip shouldn't block). Gated to native
|
||||||
|
# platforms: gallery-dl verify is a slow --simulate subprocess, too heavy for
|
||||||
|
# an arm action. The credential read happens in a session that's CLOSED
|
||||||
|
# before the verify network call (don't hold a DB conn across the request).
|
||||||
|
if action in ("start", "recover", "recapture"):
|
||||||
|
async with get_session() as session:
|
||||||
|
rec = await SourceService(session).get(source_id)
|
||||||
|
if rec is None:
|
||||||
|
return _bad("not_found", status=404)
|
||||||
|
native = uses_native_ingester(rec.platform)
|
||||||
|
if native:
|
||||||
|
cred = CredentialService(session, _get_crypto())
|
||||||
|
cookies_path = await cred.get_cookies_path(rec.platform)
|
||||||
|
auth_token = await cred.get_token(rec.platform)
|
||||||
|
if native:
|
||||||
|
ok, message = await verify_source_credential(
|
||||||
|
platform=rec.platform,
|
||||||
|
url=rec.url,
|
||||||
|
artist_slug=rec.artist_slug,
|
||||||
|
config_overrides=rec.config_overrides or {},
|
||||||
|
cookies_path=str(cookies_path) if cookies_path else None,
|
||||||
|
auth_token=auth_token,
|
||||||
|
images_root=Path("/images"),
|
||||||
|
)
|
||||||
|
if ok is False:
|
||||||
|
return _bad("credential_rejected", detail=message, status=409)
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
try:
|
try:
|
||||||
record = await SourceService(session).set_backfill_runs(
|
svc = SourceService(session)
|
||||||
source_id, runs,
|
if action == "start":
|
||||||
)
|
record = await svc.start_backfill(source_id)
|
||||||
|
elif action == "recover":
|
||||||
|
record = await svc.start_recovery(source_id)
|
||||||
|
elif action == "recapture":
|
||||||
|
record = await svc.start_recapture(source_id)
|
||||||
|
else:
|
||||||
|
record = await svc.stop_backfill(source_id)
|
||||||
except LookupError:
|
except LookupError:
|
||||||
return _bad("not_found", status=404)
|
return _bad("not_found", status=404)
|
||||||
except ValueError as exc:
|
|
||||||
return _bad("invalid_runs", detail=str(exc))
|
|
||||||
return jsonify(record.to_dict())
|
return jsonify(record.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.route("/<int:source_id>/preview", methods=["POST"])
|
||||||
|
async def preview_source_endpoint(source_id: int):
|
||||||
|
"""Plan #708 B4: dry-run — count what a backfill WOULD download for a native
|
||||||
|
platform (Patreon today), without downloading. Walks the first few feed pages
|
||||||
|
and counts media not already in the seen/dead ledgers. Returns
|
||||||
|
{total_new, posts_scanned, pages_scanned, has_more, sample[]} or 409 + reason
|
||||||
|
(unresolvable campaign id / auth / drift). 400 for gallery-dl platforms (no
|
||||||
|
cheap dry-run — their verify is a slow --simulate)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..services.credential_service import CredentialService
|
||||||
|
from ..services.download_backends import preview_source, uses_native_ingester
|
||||||
|
from ..tasks._sync_engine import sync_session_factory
|
||||||
|
from .credentials import _get_crypto
|
||||||
|
|
||||||
|
async with get_session() as session:
|
||||||
|
rec = await SourceService(session).get(source_id)
|
||||||
|
if rec is None:
|
||||||
|
return _bad("not_found", status=404)
|
||||||
|
if not uses_native_ingester(rec.platform):
|
||||||
|
return _bad(
|
||||||
|
"unsupported",
|
||||||
|
detail="Preview is only available for native-ingester platforms.",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
cred = CredentialService(session, _get_crypto())
|
||||||
|
cookies_path = await cred.get_cookies_path(rec.platform)
|
||||||
|
|
||||||
|
# The walk + ledger reads are sync (run off the request loop); the process
|
||||||
|
# sync engine is the same one the download task uses.
|
||||||
|
result = await preview_source(
|
||||||
|
platform=rec.platform,
|
||||||
|
url=rec.url,
|
||||||
|
source_id=source_id,
|
||||||
|
config_overrides=rec.config_overrides or {},
|
||||||
|
cookies_path=str(cookies_path) if cookies_path else None,
|
||||||
|
images_root=Path("/images"),
|
||||||
|
sync_session_factory=sync_session_factory(),
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
return _bad("preview_failed", detail=result["error"], status=409)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
|
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
|
||||||
async def check_source(source_id: int):
|
async def check_source(source_id: int):
|
||||||
"""FC-3c: enqueue a download for this source.
|
"""FC-3c: enqueue a download for this source.
|
||||||
|
|||||||
@@ -3,16 +3,48 @@
|
|||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
|
from ..models import Tag, TagAllowlist
|
||||||
from ..services.ml.allowlist import AllowlistService
|
from ..services.ml.allowlist import AllowlistService
|
||||||
from ..services.ml.suggestions import SuggestionService
|
from ..services.ml.suggestions import SuggestionService
|
||||||
|
|
||||||
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
async def _accept_payload(session, svc, newly_added: bool, tag_id: int) -> dict:
|
||||||
|
"""Shape the accept/alias response. When accepting newly allowlists a tag,
|
||||||
|
include the coverage PROJECTION (at the tag's threshold) so the UI can show
|
||||||
|
a non-blocking "auto-applying to ~N images" toast — the actual apply runs
|
||||||
|
async via apply_allowlist_tags, so this is an estimate, not a post-hoc
|
||||||
|
count (#7)."""
|
||||||
|
payload = {"allowlisted": newly_added}
|
||||||
|
if newly_added:
|
||||||
|
tag = await session.get(Tag, tag_id)
|
||||||
|
row = await session.get(TagAllowlist, tag_id)
|
||||||
|
payload["tag_id"] = tag_id
|
||||||
|
payload["tag_name"] = tag.name if tag is not None else None
|
||||||
|
payload["projected_count"] = await svc.coverage(
|
||||||
|
tag_id, row.min_confidence if row is not None else 0.90,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
||||||
async def get_suggestions(image_id: int):
|
async def get_suggestions(image_id: int):
|
||||||
|
# ?min=<float> overrides the configured per-category thresholds so the typed
|
||||||
|
# tag-input dropdown can surface EVERY stored prediction (min=0), including
|
||||||
|
# low-confidence actions/features, in canonical formatting. Omitted → the
|
||||||
|
# curated above-threshold list the Suggestions panel uses.
|
||||||
|
override = None
|
||||||
|
raw_min = request.args.get("min")
|
||||||
|
if raw_min is not None:
|
||||||
|
try:
|
||||||
|
override = min(1.0, max(0.0, float(raw_min)))
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "min must be a float in [0,1]"}), 400
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
sl = await SuggestionService(session).for_image(image_id)
|
sl = await SuggestionService(session).for_image(
|
||||||
|
image_id, threshold_override=override
|
||||||
|
)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"by_category": {
|
"by_category": {
|
||||||
@@ -24,6 +56,15 @@ async def get_suggestions(image_id: int):
|
|||||||
"score": round(s.score, 4),
|
"score": round(s.score, 4),
|
||||||
"source": s.source,
|
"source": s.source,
|
||||||
"creates_new_tag": s.creates_new_tag,
|
"creates_new_tag": s.creates_new_tag,
|
||||||
|
# raw model key (alias is stored under this) + whether an
|
||||||
|
# operator alias produced this suggestion — drive the
|
||||||
|
# modal's "Treat as alias"/"Remove alias" affordances.
|
||||||
|
"raw_name": s.raw_name,
|
||||||
|
"via_alias": s.via_alias,
|
||||||
|
# operator dismissed this tag for this image — surfaced
|
||||||
|
# (not dropped) so the rail can show it rejected + offer
|
||||||
|
# one-click un-reject.
|
||||||
|
"rejected": s.rejected,
|
||||||
}
|
}
|
||||||
for s in items
|
for s in items
|
||||||
]
|
]
|
||||||
@@ -42,13 +83,15 @@ async def accept_suggestion(image_id: int):
|
|||||||
return jsonify({"error": "tag_id required"}), 400
|
return jsonify({"error": "tag_id required"}), 400
|
||||||
tag_id = body["tag_id"]
|
tag_id = body["tag_id"]
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
newly_added = await AllowlistService(session).accept(image_id, tag_id)
|
svc = AllowlistService(session)
|
||||||
|
newly_added = await svc.accept(image_id, tag_id)
|
||||||
|
payload = await _accept_payload(session, svc, newly_added, tag_id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if newly_added:
|
if newly_added:
|
||||||
from ..tasks.ml import apply_allowlist_tags
|
from ..tasks.ml import apply_allowlist_tags
|
||||||
|
|
||||||
apply_allowlist_tags.delay(tag_id=tag_id)
|
apply_allowlist_tags.delay(tag_id=tag_id)
|
||||||
return "", 204
|
return jsonify(payload)
|
||||||
|
|
||||||
|
|
||||||
@suggestions_bp.route(
|
@suggestions_bp.route(
|
||||||
@@ -59,19 +102,24 @@ async def alias_suggestion(image_id: int):
|
|||||||
required = {"alias_string", "alias_category", "canonical_tag_id"}
|
required = {"alias_string", "alias_category", "canonical_tag_id"}
|
||||||
if not body or not required.issubset(body):
|
if not body or not required.issubset(body):
|
||||||
return jsonify({"error": f"required: {sorted(required)}"}), 400
|
return jsonify({"error": f"required: {sorted(required)}"}), 400
|
||||||
|
canonical_tag_id = body["canonical_tag_id"]
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
newly_added = await AllowlistService(session).add_alias_and_accept(
|
svc = AllowlistService(session)
|
||||||
|
newly_added = await svc.add_alias_and_accept(
|
||||||
image_id,
|
image_id,
|
||||||
body["alias_string"],
|
body["alias_string"],
|
||||||
body["alias_category"],
|
body["alias_category"],
|
||||||
body["canonical_tag_id"],
|
canonical_tag_id,
|
||||||
|
)
|
||||||
|
payload = await _accept_payload(
|
||||||
|
session, svc, newly_added, canonical_tag_id,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if newly_added:
|
if newly_added:
|
||||||
from ..tasks.ml import apply_allowlist_tags
|
from ..tasks.ml import apply_allowlist_tags
|
||||||
|
|
||||||
apply_allowlist_tags.delay(tag_id=body["canonical_tag_id"])
|
apply_allowlist_tags.delay(tag_id=canonical_tag_id)
|
||||||
return "", 204
|
return jsonify(payload)
|
||||||
|
|
||||||
|
|
||||||
@suggestions_bp.route(
|
@suggestions_bp.route(
|
||||||
@@ -87,6 +135,21 @@ async def dismiss_suggestion(image_id: int):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@suggestions_bp.route(
|
||||||
|
"/images/<int:image_id>/suggestions/undismiss", methods=["POST"]
|
||||||
|
)
|
||||||
|
async def undismiss_suggestion(image_id: int):
|
||||||
|
"""Reverse a per-image dismissal (reject-recovery). Idempotent — undoing a
|
||||||
|
tag that isn't rejected is a no-op delete."""
|
||||||
|
body = await request.get_json()
|
||||||
|
if not body or "tag_id" not in body:
|
||||||
|
return jsonify({"error": "tag_id required"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
await AllowlistService(session).undismiss(image_id, body["tag_id"])
|
||||||
|
await session.commit()
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@suggestions_bp.route("/suggestions/bulk", methods=["POST"])
|
@suggestions_bp.route("/suggestions/bulk", methods=["POST"])
|
||||||
async def bulk_suggestions():
|
async def bulk_suggestions():
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ system_activity_bp = Blueprint(
|
|||||||
# absent.
|
# absent.
|
||||||
_QUEUE_NAMES = (
|
_QUEUE_NAMES = (
|
||||||
"default", "import", "thumbnail", "ml",
|
"default", "import", "thumbnail", "ml",
|
||||||
"download", "scan", "maintenance",
|
"download", "scan", "maintenance", "maintenance_long",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cache module-level so all requests share the cache between polls.
|
# Cache module-level so all requests share the cache between polls.
|
||||||
@@ -147,6 +147,7 @@ async def list_runs():
|
|||||||
"""Paginated task_run history. Query params:
|
"""Paginated task_run history. Query params:
|
||||||
queue=<name> filter to one queue
|
queue=<name> filter to one queue
|
||||||
status=<status> filter to one status (running/ok/error/timeout/retry)
|
status=<status> filter to one status (running/ok/error/timeout/retry)
|
||||||
|
task=<substr> case-insensitive substring match on task_name
|
||||||
limit=<int> default 50, max 200
|
limit=<int> default 50, max 200
|
||||||
before_id=<int> cursor for keyset pagination
|
before_id=<int> cursor for keyset pagination
|
||||||
|
|
||||||
@@ -161,6 +162,7 @@ async def list_runs():
|
|||||||
|
|
||||||
queue = request.args.get("queue")
|
queue = request.args.get("queue")
|
||||||
status = request.args.get("status")
|
status = request.args.get("status")
|
||||||
|
task = request.args.get("task")
|
||||||
before_id_raw = request.args.get("before_id")
|
before_id_raw = request.args.get("before_id")
|
||||||
before_id = int(before_id_raw) if before_id_raw else None
|
before_id = int(before_id_raw) if before_id_raw else None
|
||||||
|
|
||||||
@@ -170,6 +172,11 @@ async def list_runs():
|
|||||||
stmt = stmt.where(TaskRun.queue == queue)
|
stmt = stmt.where(TaskRun.queue == queue)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(TaskRun.status == status)
|
stmt = stmt.where(TaskRun.status == status)
|
||||||
|
if task:
|
||||||
|
# Task names contain literal underscores (download_source,
|
||||||
|
# vacuum_analyze) — escape LIKE wildcards so a search for
|
||||||
|
# "vacuum_analyze" doesn't treat "_" as a single-char match.
|
||||||
|
stmt = stmt.where(TaskRun.task_name.ilike(f"%{_escape_like(task)}%", escape="\\"))
|
||||||
if before_id is not None:
|
if before_id is not None:
|
||||||
stmt = stmt.where(TaskRun.id < before_id)
|
stmt = stmt.where(TaskRun.id < before_id)
|
||||||
stmt = stmt.limit(limit + 1)
|
stmt = stmt.limit(limit + 1)
|
||||||
@@ -225,6 +232,12 @@ async def list_failures():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_like(value: str) -> str:
|
||||||
|
"""Escape SQL LIKE/ILIKE metacharacters so user search text is matched
|
||||||
|
literally. Pairs with `escape="\\"` on the .ilike() call."""
|
||||||
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(r: TaskRun) -> dict:
|
def _row_to_dict(r: TaskRun) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Tag-eval API (#1130): trigger + revisit the head-vs-centroid eval.
|
||||||
|
|
||||||
|
The run + full report live in the tag_eval_run row, so the admin card rehydrates
|
||||||
|
from GET (history / detail) on mount — the report survives navigation rather than
|
||||||
|
living in transient frontend state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..extensions import get_session
|
||||||
|
from ..models import TagEvalRun
|
||||||
|
from ..services.ml.tag_eval import EvalAlreadyRunning, start_tag_eval_run
|
||||||
|
|
||||||
|
tag_eval_bp = Blueprint("tag_eval", __name__, url_prefix="/api/tag-eval")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(run: TagEvalRun, *, include_report: bool) -> dict:
|
||||||
|
out = {
|
||||||
|
"id": run.id,
|
||||||
|
"params": run.params,
|
||||||
|
"status": run.status,
|
||||||
|
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||||
|
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||||
|
"error": run.error,
|
||||||
|
}
|
||||||
|
if include_report:
|
||||||
|
out["report"] = run.report
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@tag_eval_bp.route("", methods=["POST"])
|
||||||
|
async def create():
|
||||||
|
body = await request.get_json(silent=True) or {}
|
||||||
|
params = body.get("params") or body or {}
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
run_id = await session.run_sync(
|
||||||
|
lambda s: start_tag_eval_run(s, params)
|
||||||
|
)
|
||||||
|
except EvalAlreadyRunning as running:
|
||||||
|
return jsonify({
|
||||||
|
"error": "eval_already_running",
|
||||||
|
"running_id": int(running.args[0]),
|
||||||
|
}), 409
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"run_id": run_id, "status": "running"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@tag_eval_bp.route("", methods=["GET"])
|
||||||
|
async def history():
|
||||||
|
try:
|
||||||
|
limit = min(int(request.args.get("limit", "20")), 100)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "invalid_limit"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(TagEvalRun).order_by(TagEvalRun.id.desc()).limit(limit)
|
||||||
|
)).scalars().all()
|
||||||
|
# List is light — no full report (the detail endpoint carries it).
|
||||||
|
return jsonify({"runs": [_serialize(r, include_report=False) for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
@tag_eval_bp.route("/<int:run_id>", methods=["GET"])
|
||||||
|
async def detail(run_id: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
run = await session.get(TagEvalRun, run_id)
|
||||||
|
if run is None:
|
||||||
|
return jsonify({"error": "not_found"}), 404
|
||||||
|
return jsonify(_serialize(run, include_report=True))
|
||||||
+329
-36
@@ -2,18 +2,23 @@
|
|||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import exists, select
|
from sqlalchemy import exists, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import Tag, TagKind
|
from ..models import Tag, TagKind, TagPositiveConfirmation
|
||||||
from ..models.tag_allowlist import TagAllowlist
|
from ..models.tag_allowlist import TagAllowlist
|
||||||
from ..services.bulk_tag_service import BulkTagService
|
from ..services.bulk_tag_service import BulkTagService
|
||||||
|
from ..services.ml.aliases import AliasService
|
||||||
|
from ..services.series_match_service import SeriesMatchService
|
||||||
from ..services.series_service import SeriesError, SeriesService
|
from ..services.series_service import SeriesError, SeriesService
|
||||||
from ..services.tag_directory_service import TagDirectoryService
|
from ..services.tag_directory_service import TagDirectoryService
|
||||||
|
from ..services.tag_query import serialize_tag
|
||||||
from ..services.tag_service import (
|
from ..services.tag_service import (
|
||||||
TagMergeConflict,
|
TagMergeConflict,
|
||||||
TagService,
|
TagService,
|
||||||
TagValidationError,
|
TagValidationError,
|
||||||
|
normalize_tag_name,
|
||||||
)
|
)
|
||||||
from ..utils.tag_prefix import parse_kind_prefix
|
from ..utils.tag_prefix import parse_kind_prefix
|
||||||
|
|
||||||
@@ -70,17 +75,7 @@ async def autocomplete():
|
|||||||
hits = await svc.autocomplete(q, kind=kind, limit=limit)
|
hits = await svc.autocomplete(q, kind=kind, limit=limit)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
[
|
[{**serialize_tag(h), "image_count": h.image_count} for h in hits]
|
||||||
{
|
|
||||||
"id": h.id,
|
|
||||||
"name": h.name,
|
|
||||||
"kind": h.kind,
|
|
||||||
"fandom_id": h.fandom_id,
|
|
||||||
"fandom_name": h.fandom_name,
|
|
||||||
"image_count": h.image_count,
|
|
||||||
}
|
|
||||||
for h in hits
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +136,11 @@ async def create_tag():
|
|||||||
|
|
||||||
fandom_id = body.get("fandom_id")
|
fandom_id = body.get("fandom_id")
|
||||||
|
|
||||||
|
# #701: Title-Case operator-entered tags. Only here (the explicit create
|
||||||
|
# endpoint), NOT in the shared find_or_create — the ML tagger uses that path
|
||||||
|
# and must keep the booru vocabulary's casing for allowlist matching.
|
||||||
|
name = normalize_tag_name(name)
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = TagService(session)
|
svc = TagService(session)
|
||||||
try:
|
try:
|
||||||
@@ -158,17 +158,7 @@ async def list_tags_for_image(image_id: int):
|
|||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = TagService(session)
|
svc = TagService(session)
|
||||||
tags = await svc.list_for_image(image_id)
|
tags = await svc.list_for_image(image_id)
|
||||||
return jsonify(
|
return jsonify([serialize_tag(t) for t in tags])
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": t.id,
|
|
||||||
"name": t.name,
|
|
||||||
"kind": t.kind.value,
|
|
||||||
"fandom_id": t.fandom_id,
|
|
||||||
}
|
|
||||||
for t in tags
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
|
@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
|
||||||
@@ -194,15 +184,79 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/images/<int:image_id>/tags/<int:tag_id>/confirm", methods=["POST"])
|
||||||
|
async def confirm_tag_on_image(image_id: int, tag_id: int):
|
||||||
|
"""Operator affirmed an applied tag is correct ("keep" on a doubted positive).
|
||||||
|
Idempotent; recorded so the eval's doubts list stops resurfacing it (#1130)."""
|
||||||
|
async with get_session() as session:
|
||||||
|
await session.execute(
|
||||||
|
pg_insert(TagPositiveConfirmation)
|
||||||
|
.values(image_record_id=image_id, tag_id=tag_id)
|
||||||
|
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/tags/<int:tag_id>", methods=["GET"])
|
||||||
|
async def get_tag(tag_id: int):
|
||||||
|
"""Resolve a single tag (used by the gallery to label its active
|
||||||
|
tag-filter chip)."""
|
||||||
|
async with get_session() as session:
|
||||||
|
tag = await session.get(Tag, tag_id)
|
||||||
|
if tag is None:
|
||||||
|
return jsonify({"error": "tag not found"}), 404
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"kind": tag.kind.value,
|
||||||
|
"fandom_id": tag.fandom_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/tags/<int:tag_id>/aliases", methods=["GET"])
|
||||||
|
async def list_tag_aliases(tag_id: int):
|
||||||
|
"""Model keys that fold into this tag (tag-side alias view). Remove via the
|
||||||
|
shared DELETE /api/aliases/<string>/<category>."""
|
||||||
|
async with get_session() as session:
|
||||||
|
if await session.get(Tag, tag_id) is None:
|
||||||
|
return jsonify({"error": "tag not found"}), 404
|
||||||
|
rows = await AliasService(session).list_for_tag(tag_id)
|
||||||
|
return jsonify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"alias_string": r.alias_string,
|
||||||
|
"alias_category": r.alias_category,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
|
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
|
||||||
async def rename_tag(tag_id: int):
|
async def update_tag(tag_id: int):
|
||||||
body = await request.get_json()
|
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
|
||||||
if not body or "name" not in body:
|
`fandom_id` (a fandom tag id, or null to clear — character tags only).
|
||||||
return jsonify({"error": "name required"}), 400
|
`merge: true` resolves a collision by merging into the existing tag.
|
||||||
|
"""
|
||||||
|
body = await request.get_json() or {}
|
||||||
|
has_name = "name" in body
|
||||||
|
has_fandom = "fandom_id" in body
|
||||||
|
if not has_name and not has_fandom:
|
||||||
|
return jsonify({"error": "name or fandom_id required"}), 400
|
||||||
|
do_merge = bool(body.get("merge"))
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
svc = TagService(session)
|
svc = TagService(session)
|
||||||
try:
|
try:
|
||||||
tag = await svc.rename(tag_id, body["name"])
|
tag = None
|
||||||
|
if has_name:
|
||||||
|
tag = await svc.rename(tag_id, body["name"])
|
||||||
|
if has_fandom:
|
||||||
|
tag = await svc.set_fandom(
|
||||||
|
tag_id, body["fandom_id"], merge=do_merge
|
||||||
|
)
|
||||||
except TagMergeConflict as exc:
|
except TagMergeConflict as exc:
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
@@ -219,7 +273,12 @@ async def rename_tag(tag_id: int):
|
|||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{"id": tag.id, "name": tag.name, "kind": tag.kind.value}
|
{
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"kind": tag.kind.value,
|
||||||
|
"fandom_id": tag.fandom_id,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -326,6 +385,31 @@ def _series_err(exc: SeriesError):
|
|||||||
return jsonify({"error": msg}), status
|
return jsonify({"error": msg}), status
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_int(body, key: str):
|
||||||
|
"""(value, error) — value is None when absent, error is (json, status)."""
|
||||||
|
if not body or body.get(key) is None:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
return int(body[key]), None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, (jsonify({"error": f"{key} must be an integer"}), 400)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int_list(body, key: str, *, max_ids: int = 500):
|
||||||
|
"""(list, error) for a required list of ints under `key`."""
|
||||||
|
if not body or key not in body:
|
||||||
|
return None, (jsonify({"error": f"{key} required"}), 400)
|
||||||
|
raw = body[key]
|
||||||
|
if not isinstance(raw, list) or not raw:
|
||||||
|
return None, (jsonify({"error": f"{key} must be a non-empty list"}), 400)
|
||||||
|
if len(raw) > max_ids:
|
||||||
|
return None, (jsonify({"error": f"too many ids (max {max_ids})"}), 400)
|
||||||
|
try:
|
||||||
|
return [int(x) for x in raw], None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, (jsonify({"error": f"{key} must be integers"}), 400)
|
||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
|
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
|
||||||
async def series_pages(tag_id: int):
|
async def series_pages(tag_id: int):
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
@@ -366,15 +450,26 @@ async def series_remove(tag_id: int):
|
|||||||
return jsonify({"removed_count": n})
|
return jsonify({"removed_count": n})
|
||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/series/<int:tag_id>/reorder", methods=["POST"])
|
@tags_bp.route("/series/<int:tag_id>/pages/number", methods=["POST"])
|
||||||
async def series_reorder(tag_id: int):
|
async def series_set_page_number(tag_id: int):
|
||||||
body = await request.get_json()
|
"""Set one placed page's number — the operator's value (sparse, gaps
|
||||||
ids, err = _parse_bulk_ids(body, max_ids=500)
|
allowed); pass page_number: null to leave it unnumbered."""
|
||||||
if err:
|
body = await request.get_json() or {}
|
||||||
return err
|
image_id, ierr = _opt_int(body, "image_id")
|
||||||
|
if ierr:
|
||||||
|
return ierr
|
||||||
|
if image_id is None:
|
||||||
|
return jsonify({"error": "image_id required"}), 400
|
||||||
|
if "page_number" not in body:
|
||||||
|
return jsonify({"error": "page_number required (may be null)"}), 400
|
||||||
|
page_number, perr = _opt_int(body, "page_number")
|
||||||
|
if perr:
|
||||||
|
return perr
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
try:
|
try:
|
||||||
await SeriesService(session).reorder(tag_id, ids)
|
await SeriesService(session).set_page_number(
|
||||||
|
tag_id, image_id, page_number
|
||||||
|
)
|
||||||
except SeriesError as exc:
|
except SeriesError as exc:
|
||||||
return _series_err(exc)
|
return _series_err(exc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -397,3 +492,201 @@ async def series_cover(tag_id: int):
|
|||||||
return _series_err(exc)
|
return _series_err(exc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- chapter dividers (FC-6.x) -------------------------------------------
|
||||||
|
# A chapter is a cosmetic divider anchored to the page that begins it; it owns
|
||||||
|
# no pages. Page ordering follows each page's operator-set number (the
|
||||||
|
# /pages/number endpoint), so there is no per-chapter reorder/merge — those are
|
||||||
|
# gone.
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
|
||||||
|
async def series_chapter_create(tag_id: int):
|
||||||
|
body = await request.get_json() or {}
|
||||||
|
anchor, aerr = _opt_int(body, "anchor_image_id")
|
||||||
|
if aerr:
|
||||||
|
return aerr
|
||||||
|
if anchor is None:
|
||||||
|
return jsonify({"error": "anchor_image_id required"}), 400
|
||||||
|
title = body.get("title")
|
||||||
|
if title is not None and not isinstance(title, str):
|
||||||
|
return jsonify({"error": "title must be a string"}), 400
|
||||||
|
part, perr = _opt_int(body, "stated_part")
|
||||||
|
if perr:
|
||||||
|
return perr
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
ch = await SeriesService(session).create_divider(
|
||||||
|
tag_id, anchor, title=title, stated_part=part,
|
||||||
|
)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify(ch)
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route(
|
||||||
|
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["PATCH"]
|
||||||
|
)
|
||||||
|
async def series_chapter_update(tag_id: int, chapter_id: int):
|
||||||
|
body = await request.get_json() or {}
|
||||||
|
kwargs: dict = {}
|
||||||
|
if "title" in body:
|
||||||
|
if body["title"] is not None and not isinstance(body["title"], str):
|
||||||
|
return jsonify({"error": "title must be a string"}), 400
|
||||||
|
kwargs.update(set_title=True, title=body["title"])
|
||||||
|
if "stated_part" in body:
|
||||||
|
part, perr = _opt_int(body, "stated_part")
|
||||||
|
if perr:
|
||||||
|
return perr
|
||||||
|
kwargs.update(set_part=True, stated_part=part)
|
||||||
|
if "anchor_image_id" in body:
|
||||||
|
anchor, aerr = _opt_int(body, "anchor_image_id")
|
||||||
|
if aerr:
|
||||||
|
return aerr
|
||||||
|
if anchor is None:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "anchor_image_id must be an integer"}
|
||||||
|
), 400
|
||||||
|
kwargs.update(set_anchor=True, anchor_image_id=anchor)
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
await SeriesService(session).update_divider(
|
||||||
|
tag_id, chapter_id, **kwargs
|
||||||
|
)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route(
|
||||||
|
"/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["DELETE"]
|
||||||
|
)
|
||||||
|
async def series_chapter_delete(tag_id: int, chapter_id: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
await SeriesService(session).delete_divider(tag_id, chapter_id)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- browse list + post→series flows (FC-6.2) -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series", methods=["GET"])
|
||||||
|
async def series_list():
|
||||||
|
args = request.args
|
||||||
|
sort = args.get("sort", "recent")
|
||||||
|
if sort not in ("recent", "name", "size"):
|
||||||
|
return jsonify({"error": "sort must be recent|name|size"}), 400
|
||||||
|
artist_id = None
|
||||||
|
if args.get("artist_id") is not None:
|
||||||
|
try:
|
||||||
|
artist_id = int(args["artist_id"])
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "artist_id must be an integer"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
rows = await SeriesService(session).list_series(
|
||||||
|
sort=sort, artist_id=artist_id
|
||||||
|
)
|
||||||
|
return jsonify({"series": rows})
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/from-post", methods=["POST"])
|
||||||
|
async def series_from_post():
|
||||||
|
body = await request.get_json()
|
||||||
|
post_id, err = _opt_int(body, "post_id")
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if post_id is None:
|
||||||
|
return jsonify({"error": "post_id required"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
out = await SeriesService(session).promote_post_to_series(post_id)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/<int:tag_id>/add-post", methods=["POST"])
|
||||||
|
async def series_add_post(tag_id: int):
|
||||||
|
body = await request.get_json()
|
||||||
|
post_id, err = _opt_int(body, "post_id")
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
if post_id is None:
|
||||||
|
return jsonify({"error": "post_id required"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
out = await SeriesService(session).add_post(tag_id, post_id)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/<int:tag_id>/pending/place", methods=["POST"])
|
||||||
|
async def series_place_pending(tag_id: int):
|
||||||
|
"""Place staged (pending) pages into the run, numbered sequentially from
|
||||||
|
`start_page` in the given order (#789). start_page null → unnumbered."""
|
||||||
|
body = await request.get_json()
|
||||||
|
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
start, serr = _opt_int(body, "start_page")
|
||||||
|
if serr:
|
||||||
|
return serr
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
n = await SeriesService(session).place_pending(
|
||||||
|
tag_id, ids, start_page=start
|
||||||
|
)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"placed_count": n})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- suggestion queue (FC-6.3) --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/suggestions", methods=["GET"])
|
||||||
|
async def series_suggestions_list():
|
||||||
|
async with get_session() as session:
|
||||||
|
rows = await SeriesMatchService(session).list_pending()
|
||||||
|
return jsonify({"suggestions": rows})
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/suggestions/<int:sid>/accept", methods=["POST"])
|
||||||
|
async def series_suggestion_accept(sid: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
out = await SeriesMatchService(session).accept(sid)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/suggestions/<int:sid>/dismiss", methods=["POST"])
|
||||||
|
async def series_suggestion_dismiss(sid: int):
|
||||||
|
async with get_session() as session:
|
||||||
|
try:
|
||||||
|
await SeriesMatchService(session).dismiss(sid)
|
||||||
|
except SeriesError as exc:
|
||||||
|
return _series_err(exc)
|
||||||
|
await session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/series/suggestions/rescan", methods=["POST"])
|
||||||
|
async def series_suggestions_rescan():
|
||||||
|
from ..tasks.admin import rescan_series_suggestions_task
|
||||||
|
|
||||||
|
res = rescan_series_suggestions_task.delay()
|
||||||
|
return jsonify({"task_id": res.id})
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def make_celery() -> Celery:
|
|||||||
"backend.app.tasks.maintenance",
|
"backend.app.tasks.maintenance",
|
||||||
"backend.app.tasks.ml",
|
"backend.app.tasks.ml",
|
||||||
"backend.app.tasks.download",
|
"backend.app.tasks.download",
|
||||||
|
"backend.app.tasks.external",
|
||||||
"backend.app.tasks.backup",
|
"backend.app.tasks.backup",
|
||||||
"backend.app.tasks.admin",
|
"backend.app.tasks.admin",
|
||||||
"backend.app.tasks.library_audit",
|
"backend.app.tasks.library_audit",
|
||||||
@@ -42,16 +43,51 @@ def make_celery() -> Celery:
|
|||||||
"backend.app.tasks.ml.*": {"queue": "ml"},
|
"backend.app.tasks.ml.*": {"queue": "ml"},
|
||||||
"backend.app.tasks.thumbnail.*": {"queue": "thumbnail"},
|
"backend.app.tasks.thumbnail.*": {"queue": "thumbnail"},
|
||||||
"backend.app.tasks.download.*": {"queue": "download"},
|
"backend.app.tasks.download.*": {"queue": "download"},
|
||||||
|
# External file-host fetches are downloads — same lane (they can run
|
||||||
|
# long, but the download worker already tolerates long backfills).
|
||||||
|
"backend.app.tasks.external.*": {"queue": "download"},
|
||||||
"backend.app.tasks.scan.*": {"queue": "scan"},
|
"backend.app.tasks.scan.*": {"queue": "scan"},
|
||||||
|
# `maintenance` is the QUICK lane — recovery sweeps, vacuum, cleanup
|
||||||
|
# (concurrency-1 on the scheduler). The long one-shots (DB backups,
|
||||||
|
# library audits, admin maintenance: normalize/re-extract/cascade-
|
||||||
|
# delete) run on a SEPARATE `maintenance_long` lane + worker so they
|
||||||
|
# can never starve the quick self-healing sweeps (operator-flagged
|
||||||
|
# 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours).
|
||||||
"backend.app.tasks.maintenance.*": {"queue": "maintenance"},
|
"backend.app.tasks.maintenance.*": {"queue": "maintenance"},
|
||||||
"backend.app.tasks.backup.*": {"queue": "maintenance"},
|
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
|
||||||
"backend.app.tasks.admin.*": {"queue": "maintenance"},
|
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
|
||||||
"backend.app.tasks.library_audit.*": {"queue": "maintenance"},
|
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
|
||||||
},
|
},
|
||||||
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
|
# Broker resilience (2026-06-24): a swarm overlay-network blip after a
|
||||||
|
# redeploy left Redis healthy but transiently unreachable, and a worker
|
||||||
|
# starting in that window crash-looped on the initial broker connect
|
||||||
|
# (kombu OperationalError) instead of waiting it out — needing a manual
|
||||||
|
# Redis reset to recover. Retry the broker FOREVER (None) on startup and
|
||||||
|
# at runtime so a transient outage self-heals when routing returns,
|
||||||
|
# rather than the worker exiting.
|
||||||
broker_connection_retry_on_startup=True,
|
broker_connection_retry_on_startup=True,
|
||||||
|
broker_connection_retry=True,
|
||||||
|
broker_connection_max_retries=None,
|
||||||
|
# Redis-transport socket options (apply to the BROKER connection): a
|
||||||
|
# short connect timeout + TCP keepalive so a dead/blocked socket is
|
||||||
|
# noticed and retried, and a periodic health check that proactively
|
||||||
|
# reconnects a live worker through a network hiccup.
|
||||||
|
broker_transport_options={
|
||||||
|
"socket_connect_timeout": 5,
|
||||||
|
"socket_timeout": 30,
|
||||||
|
"socket_keepalive": True,
|
||||||
|
"retry_on_timeout": True,
|
||||||
|
"health_check_interval": 30,
|
||||||
|
},
|
||||||
|
# Same hardening for the Redis RESULT backend (separate connection pool).
|
||||||
|
redis_socket_connect_timeout=5,
|
||||||
|
redis_socket_timeout=30,
|
||||||
|
redis_socket_keepalive=True,
|
||||||
|
redis_retry_on_timeout=True,
|
||||||
|
redis_backend_health_check_interval=30,
|
||||||
beat_schedule={
|
beat_schedule={
|
||||||
"recover-interrupted-tasks": {
|
"recover-interrupted-tasks": {
|
||||||
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
|
"task": "backend.app.tasks.maintenance.recover_interrupted_tasks",
|
||||||
@@ -73,6 +109,18 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.ml.apply_allowlist_tags",
|
"task": "backend.app.tasks.ml.apply_allowlist_tags",
|
||||||
"schedule": 86400.0,
|
"schedule": 86400.0,
|
||||||
},
|
},
|
||||||
|
"train-heads-nightly": {
|
||||||
|
"task": "backend.app.tasks.ml.scheduled_train_heads",
|
||||||
|
"schedule": 86400.0, # passive cadence; manual retrain stays available
|
||||||
|
},
|
||||||
|
"apply-head-tags-daily": {
|
||||||
|
"task": "backend.app.tasks.ml.scheduled_apply_head_tags",
|
||||||
|
"schedule": 86400.0, # no-op unless head_auto_apply_enabled
|
||||||
|
},
|
||||||
|
"snapshot-head-metrics-daily": {
|
||||||
|
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
|
||||||
|
"schedule": 86400.0,
|
||||||
|
},
|
||||||
"integrity-verify-weekly": {
|
"integrity-verify-weekly": {
|
||||||
"task": "backend.app.tasks.maintenance.verify_integrity",
|
"task": "backend.app.tasks.maintenance.verify_integrity",
|
||||||
"schedule": 604800.0, # weekly
|
"schedule": 604800.0, # weekly
|
||||||
@@ -97,6 +145,10 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.maintenance.prune_task_runs",
|
"task": "backend.app.tasks.maintenance.prune_task_runs",
|
||||||
"schedule": 86400.0, # daily
|
"schedule": 86400.0, # daily
|
||||||
},
|
},
|
||||||
|
"vacuum-analyze": {
|
||||||
|
"task": "backend.app.tasks.maintenance.vacuum_analyze",
|
||||||
|
"schedule": 604800.0, # weekly — reclaim dead-tuple bloat + refresh stats
|
||||||
|
},
|
||||||
"fc3h-backup-db-nightly": {
|
"fc3h-backup-db-nightly": {
|
||||||
"task": "backend.app.tasks.backup.backup_db_nightly",
|
"task": "backend.app.tasks.backup.backup_db_nightly",
|
||||||
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
|
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
|
||||||
@@ -116,6 +168,18 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.maintenance.recover_stalled_library_audit_runs",
|
"task": "backend.app.tasks.maintenance.recover_stalled_library_audit_runs",
|
||||||
"schedule": 300.0,
|
"schedule": 300.0,
|
||||||
},
|
},
|
||||||
|
"recover-stalled-tag-eval-runs": {
|
||||||
|
"task": "backend.app.tasks.maintenance.recover_stalled_tag_eval_runs",
|
||||||
|
"schedule": 300.0,
|
||||||
|
},
|
||||||
|
"recover-stalled-head-training-runs": {
|
||||||
|
"task": "backend.app.tasks.maintenance.recover_stalled_head_training_runs",
|
||||||
|
"schedule": 300.0,
|
||||||
|
},
|
||||||
|
"recover-stalled-head-auto-apply-runs": {
|
||||||
|
"task": "backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs",
|
||||||
|
"schedule": 300.0,
|
||||||
|
},
|
||||||
"recover-stalled-import-batches": {
|
"recover-stalled-import-batches": {
|
||||||
"task": "backend.app.tasks.maintenance.recover_stalled_import_batches",
|
"task": "backend.app.tasks.maintenance.recover_stalled_import_batches",
|
||||||
"schedule": 300.0,
|
"schedule": 300.0,
|
||||||
@@ -140,6 +204,21 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.thumbnail.backfill_thumbnails",
|
"task": "backend.app.tasks.thumbnail.backfill_thumbnails",
|
||||||
"schedule": 86400.0,
|
"schedule": 86400.0,
|
||||||
},
|
},
|
||||||
|
# External file-host downloads (#830): a steady sweep catches links
|
||||||
|
# the post-download hook missed (worker down, etc.); recovery re-tries
|
||||||
|
# dead links daily; retention prunes long-dead rows.
|
||||||
|
"extdl-sweep": {
|
||||||
|
"task": "backend.app.tasks.external.sweep_external_links",
|
||||||
|
"schedule": 600.0, # every 10 min
|
||||||
|
},
|
||||||
|
"extdl-recover-daily": {
|
||||||
|
"task": "backend.app.tasks.external.recover_external_links",
|
||||||
|
"schedule": 86400.0,
|
||||||
|
},
|
||||||
|
"extdl-prune-daily": {
|
||||||
|
"task": "backend.app.tasks.external.prune_external_links",
|
||||||
|
"schedule": 86400.0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,7 +69,15 @@ def _queue_for(task) -> str:
|
|||||||
return "ml"
|
return "ml"
|
||||||
if name.startswith("backend.app.tasks.thumbnail."):
|
if name.startswith("backend.app.tasks.thumbnail."):
|
||||||
return "thumbnail"
|
return "thumbnail"
|
||||||
if name.startswith("backend.app.tasks.download."):
|
if name.startswith((
|
||||||
|
"backend.app.tasks.download.",
|
||||||
|
# External file-host fetches share the download lane (celery_app
|
||||||
|
# routes external.* → download). Mirror it here or TaskRun.queue
|
||||||
|
# lies 'default' for them, so per-queue dashboard filters and the
|
||||||
|
# per-queue threshold override miss them — the same gap the
|
||||||
|
# 2026-06-02 audit fixed for backup/admin/library_audit.
|
||||||
|
"backend.app.tasks.external.",
|
||||||
|
)):
|
||||||
return "download"
|
return "download"
|
||||||
if name.startswith("backend.app.tasks.scan."):
|
if name.startswith("backend.app.tasks.scan."):
|
||||||
return "scan"
|
return "scan"
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
|
|
||||||
from .app_setting import AppSetting
|
from .app_setting import AppSetting
|
||||||
from .artist import Artist
|
from .artist import Artist
|
||||||
|
from .artist_visit import ArtistVisit
|
||||||
from .backup_run import BackupRun
|
from .backup_run import BackupRun
|
||||||
from .base import Base
|
from .base import Base
|
||||||
from .credential import Credential
|
from .credential import Credential
|
||||||
from .download_event import DownloadEvent
|
from .download_event import DownloadEvent
|
||||||
|
from .external_link import ExternalLink
|
||||||
|
from .head_auto_apply_run import HeadAutoApplyRun
|
||||||
|
from .head_metric import HeadMetric
|
||||||
|
from .head_metrics_snapshot import HeadMetricsSnapshot
|
||||||
|
from .head_training_run import HeadTrainingRun
|
||||||
|
from .image_prediction import ImagePrediction
|
||||||
from .image_provenance import ImageProvenance
|
from .image_provenance import ImageProvenance
|
||||||
from .image_record import ImageRecord
|
from .image_record import ImageRecord
|
||||||
from .import_batch import ImportBatch
|
from .import_batch import ImportBatch
|
||||||
@@ -13,13 +20,22 @@ from .import_settings import ImportSettings
|
|||||||
from .import_task import ImportTask
|
from .import_task import ImportTask
|
||||||
from .library_audit_run import LibraryAuditRun
|
from .library_audit_run import LibraryAuditRun
|
||||||
from .ml_settings import MLSettings
|
from .ml_settings import MLSettings
|
||||||
|
from .patreon_failed_media import PatreonFailedMedia
|
||||||
|
from .patreon_seen_media import PatreonSeenMedia
|
||||||
from .post import Post
|
from .post import Post
|
||||||
from .post_attachment import PostAttachment
|
from .post_attachment import PostAttachment
|
||||||
|
from .series_chapter import SeriesChapter
|
||||||
from .series_page import SeriesPage
|
from .series_page import SeriesPage
|
||||||
|
from .series_suggestion import SeriesSuggestion
|
||||||
from .source import Source
|
from .source import Source
|
||||||
|
from .subscribestar_failed_media import SubscribeStarFailedMedia
|
||||||
|
from .subscribestar_seen_media import SubscribeStarSeenMedia
|
||||||
from .tag import Tag, TagKind, image_tag
|
from .tag import Tag, TagKind, image_tag
|
||||||
from .tag_alias import TagAlias
|
from .tag_alias import TagAlias
|
||||||
from .tag_allowlist import TagAllowlist
|
from .tag_allowlist import TagAllowlist
|
||||||
|
from .tag_eval_run import TagEvalRun
|
||||||
|
from .tag_head import TagHead
|
||||||
|
from .tag_positive_confirmation import TagPositiveConfirmation
|
||||||
from .tag_reference_embedding import TagReferenceEmbedding
|
from .tag_reference_embedding import TagReferenceEmbedding
|
||||||
from .tag_suggestion_rejection import TagSuggestionRejection
|
from .tag_suggestion_rejection import TagSuggestionRejection
|
||||||
from .task_run import TaskRun
|
from .task_run import TaskRun
|
||||||
@@ -28,25 +44,41 @@ __all__ = [
|
|||||||
"Base",
|
"Base",
|
||||||
"AppSetting",
|
"AppSetting",
|
||||||
"Artist",
|
"Artist",
|
||||||
|
"ArtistVisit",
|
||||||
"BackupRun",
|
"BackupRun",
|
||||||
"Source",
|
"Source",
|
||||||
"Credential",
|
"Credential",
|
||||||
|
"PatreonFailedMedia",
|
||||||
|
"PatreonSeenMedia",
|
||||||
|
"SubscribeStarFailedMedia",
|
||||||
|
"SubscribeStarSeenMedia",
|
||||||
"Post",
|
"Post",
|
||||||
"PostAttachment",
|
"PostAttachment",
|
||||||
|
"SeriesChapter",
|
||||||
"SeriesPage",
|
"SeriesPage",
|
||||||
|
"SeriesSuggestion",
|
||||||
"ImageRecord",
|
"ImageRecord",
|
||||||
|
"ImagePrediction",
|
||||||
"ImageProvenance",
|
"ImageProvenance",
|
||||||
"Tag",
|
"Tag",
|
||||||
"TagKind",
|
"TagKind",
|
||||||
"image_tag",
|
"image_tag",
|
||||||
"DownloadEvent",
|
"DownloadEvent",
|
||||||
|
"ExternalLink",
|
||||||
"ImportBatch",
|
"ImportBatch",
|
||||||
"ImportTask",
|
"ImportTask",
|
||||||
"ImportSettings",
|
"ImportSettings",
|
||||||
"LibraryAuditRun",
|
"LibraryAuditRun",
|
||||||
"MLSettings",
|
"MLSettings",
|
||||||
|
"HeadAutoApplyRun",
|
||||||
|
"HeadMetric",
|
||||||
|
"HeadMetricsSnapshot",
|
||||||
|
"HeadTrainingRun",
|
||||||
"TagAlias",
|
"TagAlias",
|
||||||
"TagAllowlist",
|
"TagAllowlist",
|
||||||
|
"TagEvalRun",
|
||||||
|
"TagHead",
|
||||||
|
"TagPositiveConfirmation",
|
||||||
"TagReferenceEmbedding",
|
"TagReferenceEmbedding",
|
||||||
"TagSuggestionRejection",
|
"TagSuggestionRejection",
|
||||||
"TaskRun",
|
"TaskRun",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""ArtistVisit — per-artist 'last viewed' timestamp.
|
||||||
|
|
||||||
|
Powers the "+N new since last visit" badge on the artists directory and
|
||||||
|
the matching banner on `ArtistView`. One row per artist, single global
|
||||||
|
operator. When the multi-user model lands, the PK widens to
|
||||||
|
`(user_id, artist_id)` — currently aspirational only (no User model,
|
||||||
|
no services/access.py); operator approved skipping `user_id` for now
|
||||||
|
under rule #22 (breaking changes welcome).
|
||||||
|
|
||||||
|
Seed at migration time: every existing artist gets `last_viewed_at = NOW()`
|
||||||
|
so the badge starts at 0 across the board (no noisy "5000 unseen" on
|
||||||
|
first deploy). New artists also auto-get a row via
|
||||||
|
`ArtistService.find_or_create`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistVisit(Base):
|
||||||
|
__tablename__ = "artist_visit"
|
||||||
|
|
||||||
|
artist_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("artist.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
last_viewed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""ExternalLink — an off-platform file-host link found in a post body.
|
||||||
|
|
||||||
|
Creators host the actual files (films, packs) on mega.nz / Google Drive /
|
||||||
|
MediaFire / Dropbox / Pixeldrain and drop the link in the post text. This row
|
||||||
|
is the record that the link existed (so nothing is silently dropped), the
|
||||||
|
dedup + dead-letter ledger for fetching it, and the driver the download worker
|
||||||
|
walks. `url` keeps the FULL link including the `#fragment` (mega's decryption
|
||||||
|
key) — truncating it makes the file undownloadable.
|
||||||
|
|
||||||
|
status lifecycle: pending → downloading → downloaded | failed | dead
|
||||||
|
(too many attempts) | skipped (host disabled). `attachment_id` links the
|
||||||
|
captured file once a download lands (SET NULL so deleting the attachment
|
||||||
|
doesn't delete the link record).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
# Kept in sync with link_extract.SUPPORTED_HOSTS and the CHECK in migration 0049.
|
||||||
|
HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
|
||||||
|
STATUSES = ("pending", "downloading", "downloaded", "failed", "skipped", "dead")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalLink(Base):
|
||||||
|
__tablename__ = "external_link"
|
||||||
|
__table_args__ = (
|
||||||
|
# One row per (post, url). The full url (incl. #fragment) is the identity
|
||||||
|
# — the same file linked twice in a post collapses to one row.
|
||||||
|
Index("uq_external_link_post_url", "post_id", "url", unique=True),
|
||||||
|
Index("ix_external_link_status", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
post_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
artist_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
host: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
label: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, server_default="pending"
|
||||||
|
)
|
||||||
|
attempts: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default=text("0")
|
||||||
|
)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
attachment_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("post_attachment.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""HeadAutoApplyRun — persisted lifecycle of an earned-auto-apply sweep (#114).
|
||||||
|
|
||||||
|
A graduated head can apply its tag to images it scores above the head's
|
||||||
|
auto-apply threshold, without a human. This row tracks one such sweep (or a
|
||||||
|
dry-run PREVIEW of it) so the result survives navigation and the admin card can
|
||||||
|
show what fired / what would fire. Mirrors HeadTrainingRun. State machine:
|
||||||
|
running → ready / error. The `report` JSONB holds per-concept counts
|
||||||
|
(applied / projected / scanned).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HeadAutoApplyRun(Base):
|
||||||
|
__tablename__ = "head_auto_apply_run"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
# dry_run=True is a PREVIEW: scores + counts what WOULD apply, writes nothing
|
||||||
|
# (preview/apply parity, rule 93).
|
||||||
|
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="running", index=True
|
||||||
|
)
|
||||||
|
# running | ready | error
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
# Total tags applied across all heads this sweep (0 for a clean dry-run).
|
||||||
|
n_applied: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# Per-concept breakdown: [{tag_id, name, applied, scanned, threshold}, ...].
|
||||||
|
report: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""HeadMetric — running correction counters per concept (#114 observability).
|
||||||
|
|
||||||
|
Earned auto-apply fires graduated heads; to TUNE it we need to know how often a
|
||||||
|
head's auto-applied tag was wrong (the operator removed it = a MISFIRE) and how
|
||||||
|
often the operator had to add a tag a head exists for by hand (an UNDER-FIRE,
|
||||||
|
the head missed it). image_tag.source is lost when a row is deleted, so these
|
||||||
|
are captured as durable cumulative counters at correction time — they survive
|
||||||
|
head retrain/prune (keyed by tag, not by the head row). The daily snapshot reads
|
||||||
|
them into the time-series.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HeadMetric(Base):
|
||||||
|
__tablename__ = "head_metric"
|
||||||
|
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
# An auto-applied (source='head_auto') tag the operator later REMOVED.
|
||||||
|
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# A tag with a head that the operator added by HAND (the head missed it).
|
||||||
|
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""HeadMetricsSnapshot — a daily per-concept time-series point (#114).
|
||||||
|
|
||||||
|
The "amount of change over time" reporting the operator asked for: once a day,
|
||||||
|
record each concept's auto-applied VOLUME (current head_auto tags), cumulative
|
||||||
|
misfires/under-fires, and the head's measured quality. Plotting these rows over
|
||||||
|
time shows whether auto-apply is landing better/worse and whether tagging more is
|
||||||
|
sharpening a concept — the signal for tuning the precision target + support floor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HeadMetricsSnapshot(Base):
|
||||||
|
__tablename__ = "head_metrics_snapshot"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# Denormalized so a snapshot stays readable even if the tag is later renamed.
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
snapshot_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
||||||
|
)
|
||||||
|
# Current count of source='head_auto' applications still standing.
|
||||||
|
n_auto_applied: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# The head's measured quality at snapshot time (null if no head exists).
|
||||||
|
ap: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
precision_cv: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
recall: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
n_pos: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""HeadTrainingRun — persisted lifecycle of a head-training batch (#114).
|
||||||
|
|
||||||
|
Mirrors TagEvalRun so the run SURVIVES navigation and the admin card can show
|
||||||
|
live + historical status instead of holding it in transient frontend state.
|
||||||
|
Training is idempotent (it upserts tag_head rows), so a SIGKILL'd run is harmless
|
||||||
|
— a maintenance recovery sweep flips a stalled `running` row to `error`, and the
|
||||||
|
next run re-trains. State machine: running → ready / error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HeadTrainingRun(Base):
|
||||||
|
__tablename__ = "head_training_run"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
# Training parameters: {min_positives, neg_ratio, precision_target, ...}.
|
||||||
|
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="running", index=True
|
||||||
|
)
|
||||||
|
# running | ready | error
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
# How many concepts got a (re)trained head vs were skipped (too few labels).
|
||||||
|
n_trained: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
n_skipped: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# Last time the task made progress — the recovery sweep tells a live run from
|
||||||
|
# a SIGKILL'd one by this (mirrors TagEvalRun).
|
||||||
|
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""ImagePrediction — one row per (image, tagger vocab prediction).
|
||||||
|
|
||||||
|
Replaces the image_record.tagger_predictions JSON blob (#768). Storing the
|
||||||
|
raw Camie/booru vocab name (not a tag_id) preserves the suggestion read
|
||||||
|
path's semantics: raw_name → canonical Tag resolution happens at read time
|
||||||
|
via the alias map, and accepting a prediction can CREATE the Tag. The store
|
||||||
|
floor (ml_settings.tagger_store_floor) is applied at WRITE time, so only
|
||||||
|
predictions >= the floor land here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Float, ForeignKey, Index, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePrediction(Base):
|
||||||
|
__tablename__ = "image_prediction"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"image_record_id", "raw_name", name="image_raw_name",
|
||||||
|
),
|
||||||
|
# Per-image read (suggestion build) and the "images with tag X above
|
||||||
|
# Y" query the JSON blob never allowed.
|
||||||
|
Index("ix_image_prediction_image", "image_record_id"),
|
||||||
|
Index("ix_image_prediction_name_score", "raw_name", "score"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
image_record_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("image_record.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
)
|
||||||
|
# The raw tagger vocab key (booru form) — NOT a tag_id. Resolved to a
|
||||||
|
# canonical Tag at read time, exactly as the old JSON keys were.
|
||||||
|
raw_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
category: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
@@ -41,6 +41,16 @@ class ImageProvenance(Base):
|
|||||||
source_id: Mapped[int | None] = mapped_column(
|
source_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True
|
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
# The archive PostAttachment this image was extracted FROM, when it came
|
||||||
|
# out of a .zip/.rar rather than as a loose file (milestone #87). Lets the
|
||||||
|
# provenance UI show the exact archive a file lives inside instead of every
|
||||||
|
# attachment on the post. NULL for loose downloads and pre-backfill rows.
|
||||||
|
# SET NULL so deleting the archive attachment never destroys the (image,
|
||||||
|
# post) edge — it just forgets which archive it came from.
|
||||||
|
from_attachment_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("post_attachment.id", ondelete="SET NULL"),
|
||||||
|
nullable=True, index=True,
|
||||||
|
)
|
||||||
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
captured_at: Mapped[datetime] = mapped_column(
|
captured_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy import (
|
|||||||
BigInteger,
|
BigInteger,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
@@ -39,6 +40,10 @@ class ImageRecord(Base):
|
|||||||
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# Video container duration (seconds); NULL for images. The Tier-1 video
|
||||||
|
# near-dup key (#871): two videos of the same artist with matching duration
|
||||||
|
# (+ aspect) are the same content across re-encodes — dedup like image pHash.
|
||||||
|
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
|
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
|
||||||
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
|
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
|
||||||
@@ -49,6 +54,18 @@ class ImageRecord(Base):
|
|||||||
# Thumbnail (populated by FC-2)
|
# Thumbnail (populated by FC-2)
|
||||||
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Source provenance for downloaded media (#830 Phase 2). `source_url` is the
|
||||||
|
# CDN/origin URL the file was fetched from (debugging + future re-fetch).
|
||||||
|
# `source_filehash` is the URL's 32-hex CDN identity segment
|
||||||
|
# (utils.paths.filehash_from_url) — the JOIN KEY that maps a post body's
|
||||||
|
# inline `<img src=CDN>` back to this local copy so the rendered body serves
|
||||||
|
# our stored image instead of hotlinking the public source. Indexed for the
|
||||||
|
# render-time lookup. NULL for filesystem-imported / pre-Phase-2 rows.
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_filehash: Mapped[str | None] = mapped_column(
|
||||||
|
String(32), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Origin / provenance pointers
|
# Origin / provenance pointers
|
||||||
origin: Mapped[str] = mapped_column(Enum(*ORIGIN_CHOICES, name="origin_enum"), nullable=False)
|
origin: Mapped[str] = mapped_column(Enum(*ORIGIN_CHOICES, name="origin_enum"), nullable=False)
|
||||||
primary_post_id: Mapped[int | None] = mapped_column(
|
primary_post_id: Mapped[int | None] = mapped_column(
|
||||||
@@ -60,8 +77,10 @@ class ImageRecord(Base):
|
|||||||
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# ML fields (populated by FC-2's ml-worker)
|
# ML fields (populated by FC-2's ml-worker). Per-tag predictions live in the
|
||||||
tagger_predictions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
# normalized image_prediction table (#768) — the tagger_predictions JSON
|
||||||
|
# column was dropped in migration 0046. tagger_model_version stays as the
|
||||||
|
# "has this been tagged / is it current?" signal the backfill sweep reads.
|
||||||
tagger_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
tagger_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
# 1152 = SigLIP-so400m embedding dim. Swapping models in FC-2 may require
|
# 1152 = SigLIP-so400m embedding dim. Swapping models in FC-2 may require
|
||||||
# a column-width migration.
|
# a column-width migration.
|
||||||
@@ -74,6 +93,17 @@ class ImageRecord(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
# Denormalized gallery sort key = COALESCE(primary post's post_date,
|
||||||
|
# created_at) (alembic 0035). The gallery used to compute this as a
|
||||||
|
# COALESCE across the Post outer join on every /scroll, which can't use
|
||||||
|
# an index and re-sorted a large slice of the library per page (×10 with
|
||||||
|
# the old serial batching). Materializing it lets the cursor scroll read
|
||||||
|
# ix_image_record_effective_date directly. Maintained by the importer
|
||||||
|
# (services/importer.py _apply_sidecar) when a primary post with a date
|
||||||
|
# is linked; plain inserts keep the created_at-equivalent server default.
|
||||||
|
effective_date: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -64,6 +64,34 @@ class ImportSettings(Base):
|
|||||||
Integer, nullable=False, default=3,
|
Integer, nullable=False, default=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# FC-6.3 series continuation matcher. enabled gates the rescan; threshold is
|
||||||
|
# the weighted-score cut-off (0..1) above which a pending suggestion is made.
|
||||||
|
series_suggest_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True,
|
||||||
|
)
|
||||||
|
series_suggest_threshold: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# #830 off-platform file-host downloads — per-host enable lever (default on,
|
||||||
|
# rule #26). Column names are extdl_<host>_enabled so the worker reads them
|
||||||
|
# via getattr(settings, f"extdl_{host}_enabled", True).
|
||||||
|
extdl_mega_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default="true",
|
||||||
|
)
|
||||||
|
extdl_gdrive_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default="true",
|
||||||
|
)
|
||||||
|
extdl_mediafire_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default="true",
|
||||||
|
)
|
||||||
|
extdl_dropbox_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default="true",
|
||||||
|
)
|
||||||
|
extdl_pixeldrain_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True, server_default="true",
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def load(cls, session) -> ImportSettings:
|
async def load(cls, session) -> ImportSettings:
|
||||||
"""The singleton settings row (id=1), via an async session."""
|
"""The singleton settings row (id=1), via an async session."""
|
||||||
|
|||||||
@@ -35,3 +35,10 @@ class LibraryAuditRun(Base):
|
|||||||
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
|
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# Chunked-scan state (alembic 0039): keyset cursor the next chunk resumes
|
||||||
|
# from, and the last time a chunk made progress (so the recovery sweep can
|
||||||
|
# tell a progressing multi-chunk audit from a stuck one).
|
||||||
|
resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import CheckConstraint, DateTime, Float, Integer, String, func
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -28,9 +36,56 @@ class MLSettings(Base):
|
|||||||
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.55
|
Float, nullable=False, default=0.55
|
||||||
)
|
)
|
||||||
|
# Ingest floor: tagger predictions below this confidence are not stored
|
||||||
|
# (tagger.Tagger.infer). Default 0.70 — the suggestion path already
|
||||||
|
# filters at 0.70 and the centroid/learned path covers low-confidence
|
||||||
|
# preferred tags, so the sub-0.70 tail is redundant weight (it had
|
||||||
|
# bloated image_record's TOAST to ~100 GB; plan-task #764). Operator-
|
||||||
|
# tunable via Settings → ML; must stay ≤ the suggestion thresholds.
|
||||||
|
tagger_store_floor: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.70
|
||||||
|
)
|
||||||
min_reference_images: Mapped[int] = mapped_column(
|
min_reference_images: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, default=5
|
Integer, nullable=False, default=5
|
||||||
)
|
)
|
||||||
|
# Video tagging (#747). Sample one frame every N seconds (fixed CADENCE, not a
|
||||||
|
# fixed count) so a tag's frame-presence reflects real screen time regardless
|
||||||
|
# of video length; cap the total so a long video can't explode into hundreds
|
||||||
|
# of inferences (the cadence stretches past the cap). A tag is kept only if it
|
||||||
|
# appears in >= video_min_tag_frames sampled frames (≈ that many × interval
|
||||||
|
# seconds on screen) — duration-independent noise rejection. Operator-tunable.
|
||||||
|
video_frame_interval_seconds: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=4.0
|
||||||
|
)
|
||||||
|
video_max_frames: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=64
|
||||||
|
)
|
||||||
|
video_min_tag_frames: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=3
|
||||||
|
)
|
||||||
|
# Tagging-v2 head training (#114). The head is the suggestion source that
|
||||||
|
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
|
||||||
|
# needs >= head_min_positives labelled images before a head is trained;
|
||||||
|
# head_auto_apply_precision is the precision bar a head must clear (at some
|
||||||
|
# operating point) to "graduate" into earned auto-apply. Operator-tunable.
|
||||||
|
head_min_positives: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=8
|
||||||
|
)
|
||||||
|
head_auto_apply_precision: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.97
|
||||||
|
)
|
||||||
|
# Earned auto-apply (#114). A graduated head fires (tags images without a
|
||||||
|
# human) when this master switch is on AND the head has at least
|
||||||
|
# head_auto_apply_min_positives clean labels — so a precise-looking but
|
||||||
|
# under-supported low-N head can't spray tags across the library. ON by
|
||||||
|
# default (operator-asked 2026-06-29: opt-OUT, not opt-in); the support +
|
||||||
|
# measured-precision gates keep it safe, and every auto-tag is reversible.
|
||||||
|
head_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True
|
||||||
|
)
|
||||||
|
head_auto_apply_min_positives: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=30
|
||||||
|
)
|
||||||
tagger_model_version: Mapped[str] = mapped_column(
|
tagger_model_version: Mapped[str] = mapped_column(
|
||||||
String(128), nullable=False, default="camie-tagger-v2"
|
String(128), nullable=False, default="camie-tagger-v2"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""PatreonFailedMedia — per-source dead-letter ledger of Patreon media that
|
||||||
|
keeps failing to download/validate.
|
||||||
|
|
||||||
|
Plan #705 (#7). A media that fails every walk (404'd CDN URL, deleted post,
|
||||||
|
geo-blocked Mux stream, persistently-corrupt bytes) would otherwise re-error
|
||||||
|
forever and re-burn backfill chunks. After ``attempts`` reaches the dead-letter
|
||||||
|
threshold the ingester skips it on routine tick/backfill walks (recovery still
|
||||||
|
re-attempts it — the operator's "try everything again"). A later clean download
|
||||||
|
clears the row (the media recovered).
|
||||||
|
|
||||||
|
`filehash` is the same per-media key the seen-ledger uses (32-hex CDN MD5, or a
|
||||||
|
``video:`` / ``post:filename`` synthesized key) — hence String(128). UNIQUE
|
||||||
|
(source_id, filehash) is the upsert key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import DateTime
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PatreonFailedMedia(Base):
|
||||||
|
__tablename__ = "patreon_failed_media"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"source_id", "filehash", name="uq_patreon_failed_media_source_id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
source_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
first_failed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
last_failed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""PatreonSeenMedia — per-source ledger of Patreon media already
|
||||||
|
downloaded+processed.
|
||||||
|
|
||||||
|
Replaces gallery-dl's archive.sqlite3 with our own queryable table so
|
||||||
|
routine walks can skip media we've already ingested (and a future
|
||||||
|
"recovery" mode can deliberately bypass the ledger to re-walk).
|
||||||
|
|
||||||
|
`filehash` is normally a Patreon CDN MD5 (32 hex chars), but videos —
|
||||||
|
which have no stable content hash at discovery time — use a sentinel of
|
||||||
|
the form ``video:<post_id>:<media_id>``, hence String(128) rather than 32.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import DateTime
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PatreonSeenMedia(Base):
|
||||||
|
__tablename__ = "patreon_seen_media"
|
||||||
|
__table_args__ = (
|
||||||
|
# Dedup key the downloader upserts against: one ledger row per
|
||||||
|
# (source, media). A second sighting of the same media is a no-op.
|
||||||
|
UniqueConstraint("source_id", "filehash", name="uq_patreon_seen_media_source_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
source_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
seen_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -14,10 +14,12 @@ from sqlalchemy import (
|
|||||||
BigInteger,
|
BigInteger,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
func,
|
func,
|
||||||
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -26,6 +28,24 @@ from .base import Base
|
|||||||
|
|
||||||
class PostAttachment(Base):
|
class PostAttachment(Base):
|
||||||
__tablename__ = "post_attachment"
|
__tablename__ = "post_attachment"
|
||||||
|
# Dedup is PER-POST, not global (2026-06-08): the same non-art file attached
|
||||||
|
# to many posts gets one row per post over a single sha-addressed blob, so no
|
||||||
|
# post is left a bare shell. Partial uniques: (post_id, sha256) for real posts;
|
||||||
|
# (sha256) alone for the NULL-post filesystem case (one row per file there).
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_post_attachment_post_sha",
|
||||||
|
"post_id", "sha256",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("post_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"uq_post_attachment_null_post_sha",
|
||||||
|
"sha256",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("post_id IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
post_id: Mapped[int | None] = mapped_column(
|
post_id: Mapped[int | None] = mapped_column(
|
||||||
@@ -35,7 +55,7 @@ class PostAttachment(Base):
|
|||||||
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
sha256: Mapped[str] = mapped_column(
|
sha256: Mapped[str] = mapped_column(
|
||||||
String(64), nullable=False, unique=True, index=True
|
String(64), nullable=False, index=True
|
||||||
)
|
)
|
||||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
original_filename: Mapped[str] = mapped_column(Text, nullable=False)
|
original_filename: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""SeriesChapter — a cosmetic chapter DIVIDER within a series (FC-6.x reframe).
|
||||||
|
|
||||||
|
A series is ONE flat, series-global ordered run of SeriesPages. A chapter is NOT
|
||||||
|
a container — it owns no pages. It is a labeled divider anchored to the page that
|
||||||
|
BEGINS the chapter (anchor_page_id → series_page): "a new chapter starts here."
|
||||||
|
A page's chapter is derived at read time as the nearest preceding divider.
|
||||||
|
|
||||||
|
Dividers never affect page ordering or the series-global page numbers; they stay
|
||||||
|
pinned to their anchor page across reorders. anchor_page_id is UNIQUE — at most
|
||||||
|
one chapter begins at a given page — and FK-cascades, so removing the anchor page
|
||||||
|
from the series drops the divider (the chapter merges into the preceding run).
|
||||||
|
|
||||||
|
title is the optional chapter name; stated_part is the optional operator-facing
|
||||||
|
"Part N" label (shown instead of a derived ordinal when set).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesChapter(Base):
|
||||||
|
__tablename__ = "series_chapter"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
series_tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
anchor_page_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("series_page.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
stated_part: Mapped[int | None] = mapped_column(Integer, 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,14 +1,20 @@
|
|||||||
"""SeriesPage — ordered image membership for a series-kind Tag.
|
"""SeriesPage — ordered image membership for a series-kind Tag.
|
||||||
|
|
||||||
A series IS a Tag with kind='series'; series_page gives it ordered pages.
|
A series IS a Tag with kind='series'; series_page gives it a SINGLE flat,
|
||||||
An image belongs to at most one series (UNIQUE image_id). Cover = the
|
series-global ordered run of pages (FC-6.x divider reframe). An image belongs to
|
||||||
lowest page_number. page_number is an ordering key only (not unique) —
|
at most one series (UNIQUE image_id). Reading order is `page_number` alone — a
|
||||||
reorder rewrites 1..N wholesale.
|
series-wide ordering key (not unique), rewritten 1..N wholesale on reorder so a
|
||||||
|
reorder can't transiently collide on an index.
|
||||||
|
|
||||||
|
Chapters are cosmetic DIVIDERS anchored to a page (see SeriesChapter); they do
|
||||||
|
NOT own pages, so there is no chapter_id here — a page's chapter is derived at
|
||||||
|
read time as the nearest preceding divider. stated_page carries the printed page
|
||||||
|
number parsed from the source post, nullable when unknown.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, func
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base
|
||||||
@@ -26,7 +32,13 @@ class SeriesPage(Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
unique=True,
|
unique=True,
|
||||||
)
|
)
|
||||||
page_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
# 'placed' = in the series-global run (page_number set); 'pending' = staged
|
||||||
|
# from a post awaiting the operator's sort (page_number NULL). (#789 P2)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, server_default="placed"
|
||||||
|
)
|
||||||
|
page_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
stated_page: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""SeriesSuggestion — a confirm-only "this post may continue this series" hint.
|
||||||
|
|
||||||
|
The matcher (FC-6.3) scores a (post, candidate series) pair from several weighted
|
||||||
|
signals and, above the configured threshold, records a pending suggestion. The
|
||||||
|
operator confirms (→ the post is added as a chapter) or dismisses it; FC never
|
||||||
|
files a post into a series on its own. status is a plain string (no Postgres
|
||||||
|
ENUM — see the check-existing-enums lesson): pending | added | dismissed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 SeriesSuggestion(Base):
|
||||||
|
__tablename__ = "series_suggestion"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"post_id", "series_tag_id", name="uq_series_suggestion_post_series"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
post_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
series_tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
signals: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
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(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""SubscribeStarFailedMedia — per-source dead-letter ledger of SubscribeStar
|
||||||
|
media that keeps failing to download/validate.
|
||||||
|
|
||||||
|
Mirror of PatreonFailedMedia. Media that fails every walk (404'd CDN URL,
|
||||||
|
deleted post, 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 per-media key the seen-ledger uses (CDN content hash or a
|
||||||
|
synthesized ``<post_id>:<filename>`` key) — hence String(128). UNIQUE
|
||||||
|
(source_id, filehash) is the upsert key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import DateTime
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeStarFailedMedia(Base):
|
||||||
|
__tablename__ = "subscribestar_failed_media"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"source_id", "filehash", name="uq_subscribestar_failed_media_source_id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
source_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
first_failed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
last_failed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""SubscribeStarSeenMedia — per-source ledger of SubscribeStar media already
|
||||||
|
downloaded+processed.
|
||||||
|
|
||||||
|
Mirror of PatreonSeenMedia for the SubscribeStar 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.
|
||||||
|
|
||||||
|
`filehash` is a CDN content hash when the media URL carries one, else a
|
||||||
|
synthesized ``<post_id>:<filename>`` key (SubscribeStar URLs aren't always
|
||||||
|
content-addressed) — hence String(128) rather than 32.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import DateTime
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeStarSeenMedia(Base):
|
||||||
|
__tablename__ = "subscribestar_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_subscribestar_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,6 +1,6 @@
|
|||||||
"""TagAlias — maps a model's (name, category) prediction to the operator's
|
"""TagAlias — maps a model's (name, category) prediction to the operator's
|
||||||
canonical tag. Resolved at suggestion-read time so raw predictions stay
|
canonical tag. Resolved at suggestion-read time so the raw predictions stored
|
||||||
unmolested in image_record.tagger_predictions.
|
in image_prediction stay unmolested.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ class TagAllowlist(Base):
|
|||||||
tag_id: Mapped[int] = mapped_column(
|
tag_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
||||||
)
|
)
|
||||||
min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.95)
|
# Default auto-apply threshold for a newly-accepted tag. 0.90 (lowered from
|
||||||
|
# 0.95 on operator evidence 2026-06-07: 0.95 was too strict and skipped
|
||||||
|
# confident-enough applications). Per-tag value is still tunable in the
|
||||||
|
# allowlist table; existing rows keep whatever they were stored with.
|
||||||
|
min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.90)
|
||||||
added_at: Mapped[datetime] = mapped_column(
|
added_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""TagEvalRun — persisted lifecycle of a head-vs-centroid tagging eval (#1130).
|
||||||
|
|
||||||
|
Mirrors LibraryAuditRun so the result SURVIVES navigation: the run + its full
|
||||||
|
report live in this row, and the admin card rehydrates from it on mount instead
|
||||||
|
of holding the report in transient frontend state. State machine:
|
||||||
|
running → ready / error. The async ml-queue task writes `report` (JSONB) when
|
||||||
|
done; a maintenance recovery sweep flips a stalled `running` row to `error`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TagEvalRun(Base):
|
||||||
|
__tablename__ = "tag_eval_run"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
# The eval parameters: {concepts: [...], curve_points: [...], neg_ratio,
|
||||||
|
# cv_folds, ...} — echoed back so the report is self-describing.
|
||||||
|
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="running", index=True,
|
||||||
|
)
|
||||||
|
# running | ready | error
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
)
|
||||||
|
# The full result: per-concept metrics (head vs centroid), learning-curve
|
||||||
|
# points, and example image ids. Null until the task finishes.
|
||||||
|
report: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# Last time the task made progress — the recovery sweep tells a live run
|
||||||
|
# from a SIGKILL'd one by this (mirrors LibraryAuditRun).
|
||||||
|
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""TagHead — a small per-concept classifier trained on the operator's tags.
|
||||||
|
|
||||||
|
Milestone #114, tagging-v2: the production form of the head the eval (#1130)
|
||||||
|
proved. One row per concept (general or character) that has enough labelled
|
||||||
|
positives. The head is a logistic-regression boundary over the FROZEN SigLIP
|
||||||
|
embedding (L2-normalized), trained on the operator's positives + negatives
|
||||||
|
(rejections + sampled unlabeled). It REPLACES the Camie prediction + per-tag
|
||||||
|
centroid as the suggestion source — and unlike them it LEARNS: every accept /
|
||||||
|
reject re-trains it sharper.
|
||||||
|
|
||||||
|
Scoring (suggestion path, API worker, NO numpy): p = sigmoid(weights · x̂ + bias)
|
||||||
|
where x̂ is the L2-normalized image embedding. Surface as a suggestion when
|
||||||
|
p >= suggest_threshold; auto-apply only once auto_apply_threshold is set (the
|
||||||
|
head "graduated" — a precision-targeted operating point was achievable). The
|
||||||
|
thresholds come from CROSS-VALIDATED out-of-fold scores so they're honest, not
|
||||||
|
in-sample-optimistic; the deployable weights are fit on all data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
# Matches image_record.siglip_embedding's dimensionality — the head operates in
|
||||||
|
# the same space. A model-version change re-embeds AND retrains (embedding_version
|
||||||
|
# guards staleness).
|
||||||
|
HEAD_DIM = 1152
|
||||||
|
|
||||||
|
|
||||||
|
class TagHead(Base):
|
||||||
|
__tablename__ = "tag_head"
|
||||||
|
|
||||||
|
# One head per concept tag; cascade so deleting a tag retires its head.
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
# The embedding the head was trained against (image_record's
|
||||||
|
# embedder_model_version). A mismatch with the current embedder means the
|
||||||
|
# head is stale and must be retrained, not scored.
|
||||||
|
embedding_version: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
# Logistic-regression coefficients over the L2-normalized embedding, stored
|
||||||
|
# as a pgvector for compactness + a future in-DB dot-product path. NOT a
|
||||||
|
# similarity target, just a serialized weight vector.
|
||||||
|
weights: Mapped[list[float]] = mapped_column(Vector(HEAD_DIM), nullable=False)
|
||||||
|
bias: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
# Probability cutoff for SURFACING as a suggestion (F1-best on CV scores).
|
||||||
|
suggest_threshold: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
# Probability cutoff for EARNED auto-apply: the operating point that holds
|
||||||
|
# precision >= the configured target while maximizing recall. NULL = the head
|
||||||
|
# hasn't graduated (can't auto-apply without a human yet).
|
||||||
|
auto_apply_threshold: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
# Training-set sizes + cross-validated quality, surfaced in the admin card so
|
||||||
|
# the operator can see which concepts are strong / need more tags.
|
||||||
|
n_pos: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
n_neg: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
ap: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
# 'precision' is a SQL reserved word → store as precision_cv (the
|
||||||
|
# cross-validated precision at the suggest operating point).
|
||||||
|
precision_cv: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
recall: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
trained_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
# Extra detail (auto-apply operating point, F1, etc.) — non-load-bearing.
|
||||||
|
metrics: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""TagPositiveConfirmation — operator affirmed an applied tag is correct.
|
||||||
|
|
||||||
|
The mirror of TagSuggestionRejection (#1130). When the operator "keeps" a
|
||||||
|
positive the head doubts (low-scoring), record it so the eval's doubts list
|
||||||
|
stops resurfacing the same confirmed-correct images every run. Does not change
|
||||||
|
training (it's already a positive) — purely a "I've reviewed this" marker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TagPositiveConfirmation(Base):
|
||||||
|
__tablename__ = "tag_positive_confirmation"
|
||||||
|
|
||||||
|
image_record_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("image_record.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True, index=True
|
||||||
|
)
|
||||||
|
confirmed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
@@ -16,9 +16,45 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
ARCHIVE_EXTS = {".zip", ".cbz", ".rar", ".7z"}
|
ARCHIVE_EXTS = {".zip", ".cbz", ".rar", ".7z"}
|
||||||
|
|
||||||
|
# Magic-byte signatures, so an archive with a mangled / extension-less filename
|
||||||
|
# is still recognised. Patreon attachment download URLs sanitize to names like
|
||||||
|
# `01_https___www.patreon.com_media-u_v3_131083093`, whose `Path.suffix` is junk
|
||||||
|
# (`.com_media-u_v3_131083093`), never `.zip` — an extension-only gate filed
|
||||||
|
# those as opaque PostAttachments and NEVER extracted them (operator-flagged
|
||||||
|
# 2026-06-06). Detection is by extension first (cheap), then header sniff.
|
||||||
|
_RAR_MAGIC = b"Rar!\x1a\x07"
|
||||||
|
_7Z_MAGIC = b"7z\xbc\xaf\x27\x1c"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_archive_format(path: Path) -> str | None:
|
||||||
|
"""Return "zip" | "rar" | "7z" for an archive, else None.
|
||||||
|
|
||||||
|
Trusts a known extension first, then falls back to magic-byte sniffing so a
|
||||||
|
mis-named or extension-less archive is still handled. (zip covers .cbz too.)
|
||||||
|
"""
|
||||||
|
ext = Path(path).suffix.lower()
|
||||||
|
if ext in (".zip", ".cbz"):
|
||||||
|
return "zip"
|
||||||
|
if ext == ".rar":
|
||||||
|
return "rar"
|
||||||
|
if ext == ".7z":
|
||||||
|
return "7z"
|
||||||
|
try:
|
||||||
|
if zipfile.is_zipfile(path):
|
||||||
|
return "zip"
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
head = fh.read(8)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if head.startswith(_RAR_MAGIC):
|
||||||
|
return "rar"
|
||||||
|
if head.startswith(_7Z_MAGIC):
|
||||||
|
return "7z"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_archive(path: Path) -> bool:
|
def is_archive(path: Path) -> bool:
|
||||||
return Path(path).suffix.lower() in ARCHIVE_EXTS
|
return detect_archive_format(path) is not None
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -32,16 +68,16 @@ def extract_archive(path: Path):
|
|||||||
members: list[tuple[str, Path]] = []
|
members: list[tuple[str, Path]] = []
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
ext = Path(path).suffix.lower()
|
fmt = detect_archive_format(path)
|
||||||
if ext in (".zip", ".cbz"):
|
if fmt == "zip":
|
||||||
with zipfile.ZipFile(path) as zf:
|
with zipfile.ZipFile(path) as zf:
|
||||||
zf.extractall(base)
|
zf.extractall(base)
|
||||||
elif ext == ".rar":
|
elif fmt == "rar":
|
||||||
import rarfile
|
import rarfile
|
||||||
|
|
||||||
with rarfile.RarFile(path) as rf:
|
with rarfile.RarFile(path) as rf:
|
||||||
rf.extractall(base)
|
rf.extractall(base)
|
||||||
elif ext == ".7z":
|
elif fmt == "7z":
|
||||||
import py7zr
|
import py7zr
|
||||||
|
|
||||||
with py7zr.SevenZipFile(path, "r") as zf:
|
with py7zr.SevenZipFile(path, "r") as zf:
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import and_, exists, func, or_, select
|
from sqlalchemy import and_, case, exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import Artist, ImageRecord, Source
|
from ..models import Artist, ArtistVisit, ImageRecord, Source
|
||||||
from .gallery_service import thumbnail_url
|
from .gallery_service import thumbnail_url
|
||||||
|
|
||||||
_SEP = "|"
|
_SEP = "|"
|
||||||
@@ -58,9 +58,27 @@ class ArtistDirectoryService:
|
|||||||
raise ValueError("limit must be between 1 and 200")
|
raise ValueError("limit must be between 1 and 200")
|
||||||
|
|
||||||
count_col = func.count(ImageRecord.id).label("image_count")
|
count_col = func.count(ImageRecord.id).label("image_count")
|
||||||
|
# Unseen = images imported since the artist's last_viewed_at.
|
||||||
|
# NULL last_viewed_at (artist created before alembic 0034 seed
|
||||||
|
# or before find_or_create autoseed) defensively counts as
|
||||||
|
# "never visited" → all images unseen. Single grouped query, no
|
||||||
|
# N+1.
|
||||||
|
unseen_col = func.count(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
or_(
|
||||||
|
ArtistVisit.last_viewed_at.is_(None),
|
||||||
|
ImageRecord.created_at > ArtistVisit.last_viewed_at,
|
||||||
|
),
|
||||||
|
ImageRecord.id,
|
||||||
|
),
|
||||||
|
else_=None,
|
||||||
|
)
|
||||||
|
).label("unseen_count")
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Artist, count_col)
|
select(Artist, count_col, unseen_col)
|
||||||
.outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id)
|
.outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id)
|
||||||
|
.outerjoin(ArtistVisit, ArtistVisit.artist_id == Artist.id)
|
||||||
.group_by(Artist.id)
|
.group_by(Artist.id)
|
||||||
)
|
)
|
||||||
if q:
|
if q:
|
||||||
@@ -94,7 +112,7 @@ class ArtistDirectoryService:
|
|||||||
next_cursor = _encode(last_artist.name, last_artist.id)
|
next_cursor = _encode(last_artist.name, last_artist.id)
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
|
|
||||||
artist_ids = [a.id for a, _ in rows]
|
artist_ids = [a.id for a, _, _ in rows]
|
||||||
previews = await self._previews(artist_ids)
|
previews = await self._previews(artist_ids)
|
||||||
|
|
||||||
cards = [
|
cards = [
|
||||||
@@ -104,9 +122,10 @@ class ArtistDirectoryService:
|
|||||||
"slug": artist.slug,
|
"slug": artist.slug,
|
||||||
"is_subscription": bool(artist.is_subscription),
|
"is_subscription": bool(artist.is_subscription),
|
||||||
"image_count": int(image_count),
|
"image_count": int(image_count),
|
||||||
|
"unseen_count": int(unseen_count),
|
||||||
"preview_thumbnails": previews.get(artist.id, []),
|
"preview_thumbnails": previews.get(artist.id, []),
|
||||||
}
|
}
|
||||||
for artist, image_count in rows
|
for artist, image_count, unseen_count in rows
|
||||||
]
|
]
|
||||||
return DirectoryPage(cards=cards, next_cursor=next_cursor)
|
return DirectoryPage(cards=cards, next_cursor=next_cursor)
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ Dates come from Post.post_date via ImageProvenance.post_id.
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import and_, case, func, or_, select
|
from sqlalchemy import and_, case, func, or_, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
Artist,
|
Artist,
|
||||||
|
ArtistVisit,
|
||||||
ImageProvenance,
|
ImageProvenance,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
Post,
|
Post,
|
||||||
@@ -22,7 +23,9 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
from ..utils.slug import slugify
|
from ..utils.slug import slugify
|
||||||
from .gallery_service import decode_cursor, encode_cursor, thumbnail_url
|
from .db_helpers import get_or_create
|
||||||
|
from .gallery_service import thumbnail_url
|
||||||
|
from .pagination import decode_cursor, encode_cursor
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -122,6 +125,12 @@ class ArtistService:
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
# Mark this artist as "visited now"; the returned count is what
|
||||||
|
# the operator should see in the banner ("N new since last
|
||||||
|
# visit"). Done LAST so the read aggregates above all see the
|
||||||
|
# pre-visit state (cosmetic — none depend on visit data).
|
||||||
|
unseen_at_visit = await self._mark_visited_returning_unseen(aid)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": artist.id,
|
"id": artist.id,
|
||||||
"name": artist.name,
|
"name": artist.name,
|
||||||
@@ -129,6 +138,7 @@ class ArtistService:
|
|||||||
"is_subscription": bool(artist.is_subscription),
|
"is_subscription": bool(artist.is_subscription),
|
||||||
"image_count": int(image_count),
|
"image_count": int(image_count),
|
||||||
"post_count": int(post_count),
|
"post_count": int(post_count),
|
||||||
|
"unseen_count_at_visit": unseen_at_visit,
|
||||||
"date_range": {
|
"date_range": {
|
||||||
"min": dmin.isoformat() if dmin else None,
|
"min": dmin.isoformat() if dmin else None,
|
||||||
"max": dmax.isoformat() if dmax else None,
|
"max": dmax.isoformat() if dmax else None,
|
||||||
@@ -157,6 +167,39 @@ class ArtistService:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _mark_visited_returning_unseen(self, artist_id: int) -> int:
|
||||||
|
"""Read pre-visit `last_viewed_at`, count images added since,
|
||||||
|
then upsert `last_viewed_at = NOW()`. Returns the count BEFORE
|
||||||
|
the upsert so the banner has data to render.
|
||||||
|
|
||||||
|
Postgres UPSERT (`ON CONFLICT DO UPDATE`) keeps the write
|
||||||
|
atomic — no SELECT-then-INSERT race per
|
||||||
|
`reference_scalar_one_or_none_duplicates`.
|
||||||
|
"""
|
||||||
|
prev = (
|
||||||
|
await self.session.execute(
|
||||||
|
select(ArtistVisit.last_viewed_at).where(
|
||||||
|
ArtistVisit.artist_id == artist_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
count_stmt = select(func.count(ImageRecord.id)).where(
|
||||||
|
ImageRecord.artist_id == artist_id
|
||||||
|
)
|
||||||
|
if prev is not None:
|
||||||
|
count_stmt = count_stmt.where(ImageRecord.created_at > prev)
|
||||||
|
unseen = (await self.session.execute(count_stmt)).scalar_one()
|
||||||
|
|
||||||
|
upsert = pg_insert(ArtistVisit.__table__).values(artist_id=artist_id)
|
||||||
|
upsert = upsert.on_conflict_do_update(
|
||||||
|
index_elements=["artist_id"],
|
||||||
|
set_={"last_viewed_at": func.now()},
|
||||||
|
)
|
||||||
|
await self.session.execute(upsert)
|
||||||
|
await self.session.commit()
|
||||||
|
return int(unseen)
|
||||||
|
|
||||||
async def images(
|
async def images(
|
||||||
self, slug: str, cursor: str | None, limit: int = 60
|
self, slug: str, cursor: str | None, limit: int = 60
|
||||||
) -> ArtistImagesPage | None:
|
) -> ArtistImagesPage | None:
|
||||||
@@ -208,12 +251,10 @@ class ArtistService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def find_or_create(self, name: str) -> tuple[Artist, bool]:
|
async def find_or_create(self, name: str) -> tuple[Artist, bool]:
|
||||||
"""Return (artist, created). Slug-keyed; idempotent under races.
|
"""Return (artist, created). Slug-keyed; idempotent under races via the
|
||||||
|
shared race-safe db_helpers.get_or_create (savepoint + IntegrityError
|
||||||
Audit 2026-06-02: switched from session.rollback() to a
|
recovery). A new artist also seeds an ArtistVisit so the directory's
|
||||||
begin_nested savepoint + IntegrityError recovery so a lost
|
`+N new` badge starts at 0.
|
||||||
race doesn't unwind the calling request's surrounding work.
|
|
||||||
Mirrors importer._get_or_create.
|
|
||||||
"""
|
"""
|
||||||
cleaned = (name or "").strip()
|
cleaned = (name or "").strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
@@ -221,22 +262,26 @@ class ArtistService:
|
|||||||
slug = slugify(cleaned)
|
slug = slugify(cleaned)
|
||||||
|
|
||||||
select_existing = select(Artist).where(Artist.slug == slug)
|
select_existing = select(Artist).where(Artist.slug == slug)
|
||||||
existing = (await self.session.execute(select_existing)).scalar_one_or_none()
|
|
||||||
if existing is not None:
|
|
||||||
return existing, False
|
|
||||||
|
|
||||||
sp = await self.session.begin_nested()
|
async def _create() -> Artist:
|
||||||
try:
|
|
||||||
artist = Artist(name=cleaned, slug=slug)
|
artist = Artist(name=cleaned, slug=slug)
|
||||||
self.session.add(artist)
|
self.session.add(artist)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
await sp.commit()
|
# New artist starts "caught up" — seed ArtistVisit so the
|
||||||
except IntegrityError:
|
# directory's `+N new` badge stays at 0 until real new
|
||||||
await sp.rollback()
|
# content arrives. Without this, the unseen-count query
|
||||||
existing = (await self.session.execute(select_existing)).scalar_one()
|
# treats NULL last_viewed_at as "never visited" and would
|
||||||
return existing, False
|
# count every image imported in the same session.
|
||||||
await self.session.commit()
|
self.session.add(ArtistVisit(artist_id=artist.id))
|
||||||
return artist, True
|
await self.session.flush()
|
||||||
|
return artist
|
||||||
|
|
||||||
|
artist, created = await get_or_create(
|
||||||
|
self.session, select_existing, _create
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
await self.session.commit()
|
||||||
|
return artist, created
|
||||||
|
|
||||||
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
|
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
|
||||||
cleaned = (prefix or "").strip()
|
cleaned = (prefix or "").strip()
|
||||||
|
|||||||
@@ -15,17 +15,55 @@ lifecycle + soft/hard time limits + retention bookkeeping.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_BACKUPS_DIRNAME = "_backups"
|
_BACKUPS_DIRNAME = "_backups"
|
||||||
|
|
||||||
# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The
|
# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The Celery
|
||||||
# Celery soft limit signals the Python process; subprocess.Popen in a
|
# soft limit signals the Python process; subprocess.Popen in a blocking syscall
|
||||||
# blocking syscall ignores that signal. These bound the worst case.
|
# ignores that signal, so these bound the worst case directly. Each sits just
|
||||||
_DB_SUBPROCESS_TIMEOUT_S = 12 * 60 # 12 min (Celery soft is 10 min)
|
# UNDER its task's Celery soft_time_limit so the bounded-kill (_run_bounded) is
|
||||||
_IMAGES_SUBPROCESS_TIMEOUT_S = 7 * 60 * 60 # 7 hr (Celery soft is 6 hr)
|
# the primary guard and fires cleanly before Celery's soft/hard limits — which
|
||||||
|
# matters because an NFS D-state hang defeats even Celery's SIGKILL (the failure
|
||||||
|
# that wedged the maintenance lane for hours, #739).
|
||||||
|
# backup_db_task: soft=1800s / hard=2100s → 1700s
|
||||||
|
# backup_images_task: soft=21600s / hard=23400s → 21000s
|
||||||
|
_DB_SUBPROCESS_TIMEOUT_S = 1700 # ~28 min, under the 30-min DB soft limit
|
||||||
|
_IMAGES_SUBPROCESS_TIMEOUT_S = 21000 # ~5.8 hr, under the 6-hr images soft limit
|
||||||
|
# Grace after SIGKILL to reap the child. If it can't be reaped in this window
|
||||||
|
# (an uninterruptible NFS D-state — the failure mode that wedged the
|
||||||
|
# concurrency-1 maintenance lane for hours, operator-flagged 2026-06-07), we
|
||||||
|
# STOP waiting and fail fast, freeing the worker slot. The orphan is reaped by
|
||||||
|
# the OS once its blocking syscall clears.
|
||||||
|
_KILL_REAP_GRACE_S = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _run_bounded(cmd: list[str], timeout: int) -> None:
|
||||||
|
"""subprocess.run(check=True, timeout) whose reaper can't itself hang.
|
||||||
|
|
||||||
|
subprocess.run's timeout path SIGKILLs the child then blocks in wait() to
|
||||||
|
reap it — but a process stuck in uninterruptible I/O (NFS) can't be reaped,
|
||||||
|
so wait() blocks for hours. Here we bound the post-kill reap and re-raise
|
||||||
|
TimeoutExpired regardless, so the caller fails fast instead of wedging."""
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
try:
|
||||||
|
out, err = proc.communicate(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
try:
|
||||||
|
proc.communicate(timeout=_KILL_REAP_GRACE_S)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass # unkillable (D-state) — abandon the reap, fail fast
|
||||||
|
raise
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(
|
||||||
|
proc.returncode, cmd, output=out, stderr=err
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _libpq_url(sa_url: str) -> str:
|
def _libpq_url(sa_url: str) -> str:
|
||||||
@@ -83,15 +121,29 @@ def backup_db(
|
|||||||
to persist into BackupRun. Raises on subprocess failure."""
|
to persist into BackupRun. Raises on subprocess failure."""
|
||||||
ts = _now_ts()
|
ts = _now_ts()
|
||||||
out_dir = _backups_dir(images_root)
|
out_dir = _backups_dir(images_root)
|
||||||
sql_path = out_dir / f"fc_db_{ts}.sql"
|
# Custom format (-Fc): compressed (much smaller on NFS) and restored with
|
||||||
subprocess.run(
|
# pg_restore. The .dump extension marks it as non-SQL. The BackupRun field
|
||||||
[
|
# is still named sql_path — it's just "the db artifact path".
|
||||||
"pg_dump", "--no-owner", "--no-acl",
|
sql_path = out_dir / f"fc_db_{ts}.dump"
|
||||||
"-f", str(sql_path), _libpq_url(db_url),
|
# Dump to LOCAL disk first, then move the finished file to the (NFS) backups
|
||||||
],
|
# dir. pg_dump's long phase is then a DB-socket wait + local writes — both
|
||||||
capture_output=True, check=True,
|
# killable — instead of an NFS write that can hang uninterruptibly. Only the
|
||||||
timeout=_DB_SUBPROCESS_TIMEOUT_S,
|
# final move touches NFS, and it's a bounded single-file step.
|
||||||
)
|
fd, tmp_name = tempfile.mkstemp(prefix="fc_db_", suffix=".dump")
|
||||||
|
os.close(fd)
|
||||||
|
tmp_path = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
_run_bounded(
|
||||||
|
[
|
||||||
|
"pg_dump", "--no-owner", "--no-acl", "-Fc",
|
||||||
|
"-f", str(tmp_path), _libpq_url(db_url),
|
||||||
|
],
|
||||||
|
_DB_SUBPROCESS_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
shutil.move(str(tmp_path), str(sql_path))
|
||||||
|
finally:
|
||||||
|
if tmp_path.exists():
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
manifest_path = _write_manifest(
|
manifest_path = _write_manifest(
|
||||||
out_dir, kind="db", ts=ts, tag=tag, triggered_by=triggered_by,
|
out_dir, kind="db", ts=ts, tag=tag, triggered_by=triggered_by,
|
||||||
artifact_path=sql_path,
|
artifact_path=sql_path,
|
||||||
@@ -114,15 +166,17 @@ def backup_images(
|
|||||||
ts = _now_ts()
|
ts = _now_ts()
|
||||||
out_dir = _backups_dir(images_root)
|
out_dir = _backups_dir(images_root)
|
||||||
tar_path = out_dir / f"fc_images_{ts}.tar.zst"
|
tar_path = out_dir / f"fc_images_{ts}.tar.zst"
|
||||||
subprocess.run(
|
# No local-temp here (the archive is hundreds of GB — it can't stage in
|
||||||
|
# /tmp), but bounded-kill still applies so a tar wedged on NFS fails fast
|
||||||
|
# rather than holding the lane for hours.
|
||||||
|
_run_bounded(
|
||||||
[
|
[
|
||||||
"tar", "--zstd", "-cf", str(tar_path),
|
"tar", "--zstd", "-cf", str(tar_path),
|
||||||
"-C", str(images_root.parent), images_root.name,
|
"-C", str(images_root.parent), images_root.name,
|
||||||
f"--exclude={images_root.name}/_backups",
|
f"--exclude={images_root.name}/_backups",
|
||||||
f"--exclude={images_root.name}/_quarantine",
|
f"--exclude={images_root.name}/_quarantine",
|
||||||
],
|
],
|
||||||
capture_output=True, check=True,
|
_IMAGES_SUBPROCESS_TIMEOUT_S,
|
||||||
timeout=_IMAGES_SUBPROCESS_TIMEOUT_S,
|
|
||||||
)
|
)
|
||||||
manifest_path = _write_manifest(
|
manifest_path = _write_manifest(
|
||||||
out_dir, kind="images", ts=ts, tag=tag, triggered_by=triggered_by,
|
out_dir, kind="images", ts=ts, tag=tag, triggered_by=triggered_by,
|
||||||
@@ -139,8 +193,8 @@ def backup_images(
|
|||||||
|
|
||||||
|
|
||||||
def restore_db(*, db_url: str, sql_path: Path) -> None:
|
def restore_db(*, db_url: str, sql_path: Path) -> None:
|
||||||
"""Wipe public schema, then load from .sql. Raises on subprocess
|
"""Wipe public schema, then load from the custom-format dump. Raises on
|
||||||
failure; partial-restore state is the caller's concern."""
|
subprocess failure; partial-restore state is the caller's concern."""
|
||||||
libpq = _libpq_url(db_url)
|
libpq = _libpq_url(db_url)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
@@ -149,8 +203,9 @@ def restore_db(*, db_url: str, sql_path: Path) -> None:
|
|||||||
],
|
],
|
||||||
capture_output=True, check=True, timeout=120,
|
capture_output=True, check=True, timeout=120,
|
||||||
)
|
)
|
||||||
|
# Custom-format (-Fc) dumps are restored with pg_restore, not psql.
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["psql", libpq, "-f", str(sql_path)],
|
["pg_restore", "--no-owner", "--no-acl", "-d", libpq, str(sql_path)],
|
||||||
capture_output=True, check=True,
|
capture_output=True, check=True,
|
||||||
timeout=_DB_SUBPROCESS_TIMEOUT_S,
|
timeout=_DB_SUBPROCESS_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
"""Shared DB-access helpers for the async services.
|
||||||
|
|
||||||
|
`get_or_create` centralizes the race-safe find-or-create dance — SELECT, then on
|
||||||
|
a miss a savepoint INSERT that recovers (NOT a full rollback) when a concurrent
|
||||||
|
worker inserted the same row first. It was hand-rolled identically in
|
||||||
|
ArtistService, TagService and ExtensionService; divergent copies are exactly how
|
||||||
|
the duplicate-row / race bugs in [[reference_scalar_one_or_none_duplicates]] crept
|
||||||
|
in, so it lives in one place now (DRY pattern sweep 2026-06-09).
|
||||||
|
|
||||||
|
Note: this is the ASYNC sibling of `Importer._get_or_create` (sync, used by the
|
||||||
|
filesystem-import path). The two can't share an implementation across the
|
||||||
|
sync/async boundary; the importer one stays as the lone sync consumer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from sqlalchemy import Select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create[T](
|
||||||
|
session: AsyncSession,
|
||||||
|
select_stmt: Select,
|
||||||
|
factory: Callable[[], Awaitable[T]],
|
||||||
|
) -> tuple[T, bool]:
|
||||||
|
"""Race-safe find-or-create. Returns ``(row, created)``.
|
||||||
|
|
||||||
|
Run ``select_stmt`` (scalar_one_or_none); if a row exists, return it with
|
||||||
|
``created=False``. Otherwise open a SAVEPOINT and ``await factory()`` — which
|
||||||
|
must add its row(s), flush, and return the primary row. On ``IntegrityError``
|
||||||
|
(a concurrent worker inserted the same row first) roll back the SAVEPOINT —
|
||||||
|
NOT the outer transaction, which would lose the caller's surrounding work —
|
||||||
|
and re-run ``select_stmt`` (scalar_one) to return the row the other worker
|
||||||
|
created. The caller owns the outer commit.
|
||||||
|
|
||||||
|
A UNIQUE/partial-unique constraint matching ``select_stmt``'s predicate is
|
||||||
|
required for the recovery to trip; without it a duplicate slips through.
|
||||||
|
"""
|
||||||
|
existing = (await session.execute(select_stmt)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing, False
|
||||||
|
sp = await session.begin_nested()
|
||||||
|
try:
|
||||||
|
row = await factory()
|
||||||
|
await sp.commit()
|
||||||
|
return row, True
|
||||||
|
except IntegrityError:
|
||||||
|
await sp.rollback()
|
||||||
|
return (await session.execute(select_stmt)).scalar_one(), False
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Platform → download-backend dispatch (one place that knows which platforms
|
||||||
|
are served by the native FC ingester vs. the gallery-dl subprocess).
|
||||||
|
|
||||||
|
gallery-dl wasn't built to be driven by an automated scheduler — no native
|
||||||
|
checkpoint/resume, no structured logs, per-file HEADs that dominate wall-clock.
|
||||||
|
The native ingester (services/patreon_ingester.py, plan #697) replaces it for
|
||||||
|
Patreon and is the path we grow as more platforms migrate. To keep that
|
||||||
|
migration DRY, every caller that has to behave differently per backend —
|
||||||
|
download routing, the credential-verify probe, cursor handling — asks THIS
|
||||||
|
module instead of testing ``platform == "patreon"`` inline. When a platform gets
|
||||||
|
a native ingester, it moves into ``NATIVE_INGESTER_PLATFORMS`` here and both the
|
||||||
|
download path and verify switch over together.
|
||||||
|
|
||||||
|
The backend surfaces share a UNIFORM signature so a caller invokes the same
|
||||||
|
function regardless of platform:
|
||||||
|
- verify_credential(...) → (ok: bool|None, message: str)
|
||||||
|
- (download stays in download_service for now; uses_native_ingester() is the
|
||||||
|
shared predicate it routes on, so the decision lives here too.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .gallery_dl import DownloadResult, ErrorType
|
||||||
|
from .native_ingest_common import NativeIngestError
|
||||||
|
from .patreon_ingester import PatreonIngester
|
||||||
|
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
|
||||||
|
from .subscribestar_ingester import SubscribeStarIngester
|
||||||
|
|
||||||
|
# Platforms whose download + verify go through the native ingester rather than
|
||||||
|
# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord, pixiv,
|
||||||
|
# deviantart) until they migrate too.
|
||||||
|
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar"})
|
||||||
|
|
||||||
|
# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
|
||||||
|
# messages so the operator sees the exact lookup endpoint that was hit.
|
||||||
|
_CAMPAIGNS_API = "https://www.patreon.com/api/campaigns"
|
||||||
|
|
||||||
|
|
||||||
|
def _native_ingester_cls(platform: str):
|
||||||
|
"""The native ingester class for `platform` (uniform constructor signature).
|
||||||
|
A call-time lookup (not a module-level dict captured at import) so tests can
|
||||||
|
monkeypatch db_mod.PatreonIngester / SubscribeStarIngester and have the
|
||||||
|
dispatch pick up the replacement."""
|
||||||
|
return {
|
||||||
|
"patreon": PatreonIngester,
|
||||||
|
"subscribestar": SubscribeStarIngester,
|
||||||
|
}[platform]
|
||||||
|
|
||||||
|
|
||||||
|
def uses_native_ingester(platform: str) -> bool:
|
||||||
|
"""True when `platform` is served by the native ingester (not gallery-dl).
|
||||||
|
The single predicate the download path and verify both route on."""
|
||||||
|
return platform in NATIVE_INGESTER_PLATFORMS
|
||||||
|
|
||||||
|
|
||||||
|
async def run_download(
|
||||||
|
*,
|
||||||
|
ctx: dict,
|
||||||
|
source_config,
|
||||||
|
skip_value: bool | str,
|
||||||
|
mode: str | None,
|
||||||
|
gdl,
|
||||||
|
sync_session_factory,
|
||||||
|
) -> 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
|
||||||
|
each platform both downloads AND verifies (the seam that makes adding a
|
||||||
|
platform a bounded job).
|
||||||
|
|
||||||
|
Returns `(DownloadResult, resolved_campaign_id)`; `resolved_campaign_id` is
|
||||||
|
non-None only when a native vanity lookup ran this call (so phase 3 caches
|
||||||
|
it). Native platforms route through their ingester in `mode`
|
||||||
|
(tick/backfill/recovery); gallery-dl platforms run the subprocess. The caller
|
||||||
|
(download_service) prepares `source_config`/`skip_value`/`mode` from the
|
||||||
|
backfill state machine and owns phase 3.
|
||||||
|
"""
|
||||||
|
platform = ctx["platform"]
|
||||||
|
if uses_native_ingester(platform):
|
||||||
|
return await _run_native_ingester(
|
||||||
|
ctx, source_config, mode, gdl, sync_session_factory
|
||||||
|
)
|
||||||
|
result = await gdl.download(
|
||||||
|
url=ctx["url"],
|
||||||
|
artist_slug=ctx["artist_slug"],
|
||||||
|
platform=platform,
|
||||||
|
source_config=source_config,
|
||||||
|
cookies_path=ctx["cookies_path"],
|
||||||
|
auth_token=ctx["auth_token"],
|
||||||
|
skip_value=skip_value,
|
||||||
|
)
|
||||||
|
return result, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_native_campaign_id(
|
||||||
|
platform: str, url: str, cookies_path: str | None, overrides: dict,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""`(campaign_id, resolved_campaign_id)` for a native source. SubscribeStar's
|
||||||
|
feed id IS the creator URL (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 == "subscribestar":
|
||||||
|
return url, None
|
||||||
|
return await resolve_campaign_id_for_source(url, cookies_path, overrides)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_native_ingester(
|
||||||
|
ctx: dict, source_config, mode: str | None, gdl, sync_session_factory,
|
||||||
|
) -> 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;
|
||||||
|
SubscribeStar's feed id is the creator URL itself. A campaign id we cannot
|
||||||
|
resolve is a loud NOT_FOUND — never a silent empty success.
|
||||||
|
|
||||||
|
`resolved_campaign_id` is non-None only when a lookup ran this call, so phase
|
||||||
|
3 caches it the way the old gallery-dl retry did.
|
||||||
|
"""
|
||||||
|
platform = ctx["platform"]
|
||||||
|
overrides = ctx["config_overrides"] or {}
|
||||||
|
campaign_id, resolved_campaign_id = await _resolve_native_campaign_id(
|
||||||
|
platform, ctx["url"], ctx["cookies_path"], overrides
|
||||||
|
)
|
||||||
|
if not campaign_id:
|
||||||
|
# Only reachable for Patreon (SubscribeStar's campaign id is the URL).
|
||||||
|
url = ctx["url"]
|
||||||
|
vanity = extract_vanity(url)
|
||||||
|
return (
|
||||||
|
DownloadResult(
|
||||||
|
success=False,
|
||||||
|
url=url,
|
||||||
|
artist_slug=ctx["artist_slug"],
|
||||||
|
platform=platform,
|
||||||
|
error_type=ErrorType.NOT_FOUND,
|
||||||
|
error_message=(
|
||||||
|
f"Could not resolve Patreon campaign id. source_url={url!r}; "
|
||||||
|
f"vanity={vanity!r}; "
|
||||||
|
f"lookup=GET {_CAMPAIGNS_API}?filter[vanity]={vanity or ''} "
|
||||||
|
"(vanity lookup failed — cookies expired or creator moved?)"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Honor the operator's existing rate-limit knobs on the native path (plan
|
||||||
|
# #703): the global download_rate_limit_seconds (gallery-dl's `rate_limit`,
|
||||||
|
# here on the gdl service) paces media downloads; page fetches use the
|
||||||
|
# per-source sleep_request override, else `max(0.5, rate_limit/4)` — the same
|
||||||
|
# API-pacing default gallery-dl applied as its `sleep-request`.
|
||||||
|
rate_limit = gdl._rate_limit
|
||||||
|
request_sleep = (
|
||||||
|
source_config.sleep_request
|
||||||
|
if source_config.sleep_request is not None
|
||||||
|
else max(0.5, rate_limit / 4)
|
||||||
|
)
|
||||||
|
ingester = _native_ingester_cls(platform)(
|
||||||
|
images_root=gdl.images_root,
|
||||||
|
cookies_path=ctx["cookies_path"],
|
||||||
|
session_factory=sync_session_factory,
|
||||||
|
validate=gdl._validate_files,
|
||||||
|
rate_limit=rate_limit,
|
||||||
|
request_sleep=request_sleep,
|
||||||
|
)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
dl_result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: ingester.run(
|
||||||
|
source_id=ctx["source_id"],
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
artist_slug=ctx["artist_slug"],
|
||||||
|
url=ctx["url"],
|
||||||
|
mode=mode,
|
||||||
|
resume_cursor=source_config.resume_cursor,
|
||||||
|
time_budget_seconds=source_config.timeout,
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return dl_result, resolved_campaign_id
|
||||||
|
|
||||||
|
|
||||||
|
async def preview_source(
|
||||||
|
*,
|
||||||
|
platform: str,
|
||||||
|
url: str,
|
||||||
|
source_id: int,
|
||||||
|
config_overrides: dict | None,
|
||||||
|
cookies_path: str | None,
|
||||||
|
images_root: Path,
|
||||||
|
sync_session_factory,
|
||||||
|
page_limit: int = 3,
|
||||||
|
) -> dict:
|
||||||
|
"""Dry-run preview for a native platform (plan #708 B4): resolve the campaign
|
||||||
|
id, then walk a few pages counting media not already seen/dead — no download.
|
||||||
|
|
||||||
|
Returns the preview dict (total_new / posts_scanned / pages_scanned /
|
||||||
|
has_more / sample), or `{"error": msg}` on a resolve / auth / drift failure.
|
||||||
|
Native-only — the caller gates on `uses_native_ingester`.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
campaign_id, _ = await _resolve_native_campaign_id(
|
||||||
|
platform, url, cookies_path, config_overrides or {}
|
||||||
|
)
|
||||||
|
if not campaign_id:
|
||||||
|
vanity = extract_vanity(url)
|
||||||
|
return {
|
||||||
|
"error": (
|
||||||
|
f"Couldn't resolve the campaign id. source_url={url!r}; "
|
||||||
|
f"vanity={vanity!r}; lookup=GET {_CAMPAIGNS_API}?filter[vanity]={vanity or ''} "
|
||||||
|
"(cookies expired, or the creator moved/renamed?)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ingester = _native_ingester_cls(platform)(
|
||||||
|
images_root=images_root,
|
||||||
|
cookies_path=cookies_path,
|
||||||
|
session_factory=sync_session_factory,
|
||||||
|
)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: ingester.preview(source_id, campaign_id, page_limit=page_limit),
|
||||||
|
)
|
||||||
|
except NativeIngestError as exc:
|
||||||
|
return {"error": f"Couldn't preview: {exc}"}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_source_credential(
|
||||||
|
*,
|
||||||
|
platform: str,
|
||||||
|
url: str,
|
||||||
|
artist_slug: str,
|
||||||
|
config_overrides: dict | None,
|
||||||
|
cookies_path: str | None,
|
||||||
|
auth_token: str | None,
|
||||||
|
images_root: Path,
|
||||||
|
) -> tuple[bool | None, str]:
|
||||||
|
"""Uniform credential probe across backends. Returns `(ok, message)`:
|
||||||
|
True = authenticated, False = rejected, None = inconclusive (drift /
|
||||||
|
network / nothing to test). Callers don't branch on platform — they call
|
||||||
|
this and render the result.
|
||||||
|
"""
|
||||||
|
if uses_native_ingester(platform):
|
||||||
|
# Native ingester platforms verify via their own lightweight auth probe
|
||||||
|
# (one authenticated feed fetch). SubscribeStar's probe takes the creator
|
||||||
|
# URL directly; Patreon's resolves the campaign id first.
|
||||||
|
if platform == "subscribestar":
|
||||||
|
from .subscribestar_ingester import verify_subscribestar_credential
|
||||||
|
|
||||||
|
return await verify_subscribestar_credential(url, cookies_path, config_overrides)
|
||||||
|
from .patreon_ingester import verify_patreon_credential
|
||||||
|
|
||||||
|
return await verify_patreon_credential(url, cookies_path, config_overrides)
|
||||||
|
|
||||||
|
# gallery-dl platforms: --simulate one item; the extractor errors before it
|
||||||
|
# can list if auth is bad.
|
||||||
|
from .gallery_dl import GalleryDLService, SourceConfig
|
||||||
|
|
||||||
|
gdl = GalleryDLService(images_root=images_root)
|
||||||
|
return await gdl.verify(
|
||||||
|
url=url,
|
||||||
|
artist_slug=artist_slug,
|
||||||
|
platform=platform,
|
||||||
|
source_config=SourceConfig.from_dict(config_overrides or {}),
|
||||||
|
cookies_path=cookies_path,
|
||||||
|
auth_token=auth_token,
|
||||||
|
)
|
||||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -25,44 +24,25 @@ from sqlalchemy.orm import joinedload
|
|||||||
|
|
||||||
from ..models import Artist, DownloadEvent, Source
|
from ..models import Artist, DownloadEvent, Source
|
||||||
from .credential_service import CredentialService
|
from .credential_service import CredentialService
|
||||||
|
from .download_backends import run_download, uses_native_ingester
|
||||||
from .gallery_dl import (
|
from .gallery_dl import (
|
||||||
|
BACKFILL_CHUNK_SECONDS,
|
||||||
BACKFILL_SKIP_VALUE,
|
BACKFILL_SKIP_VALUE,
|
||||||
BACKFILL_TIMEOUT_SECONDS,
|
|
||||||
TICK_SKIP_VALUE,
|
TICK_SKIP_VALUE,
|
||||||
|
DownloadResult,
|
||||||
ErrorType,
|
ErrorType,
|
||||||
GalleryDLService,
|
GalleryDLService,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
extract_errors_warnings,
|
||||||
|
truncate_log,
|
||||||
)
|
)
|
||||||
from .importer import Importer
|
from .importer import Importer
|
||||||
from .patreon_resolver import resolve_campaign_id
|
|
||||||
from .platforms import auth_type_for
|
from .platforms import auth_type_for
|
||||||
from .scheduler_service import set_platform_cooldown
|
from .scheduler_service import set_platform_cooldown
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
_PATREON_VANITY_RE = re.compile(
|
|
||||||
r"^https?://(?:www\.)?patreon\.com/(?:c/)?(?!id:)([^/?#]+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_CAMPAIGN_ID_FAILURE_PATTERN = "failed to extract campaign id"
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_patreon_vanity(url: str) -> str | None:
|
|
||||||
m = _PATREON_VANITY_RE.match(url)
|
|
||||||
return m.group(1) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_campaign_id_failure(stdout: str, stderr: str) -> bool:
|
|
||||||
return _CAMPAIGN_ID_FAILURE_PATTERN in f"{stdout}\n{stderr}".lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _effective_url(platform: str, source_url: str, overrides: dict) -> str:
|
|
||||||
if platform == "patreon" and overrides.get("patreon_campaign_id"):
|
|
||||||
return f"https://www.patreon.com/id:{overrides['patreon_campaign_id']}"
|
|
||||||
return source_url
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadService:
|
class DownloadService:
|
||||||
"""Async orchestrator. The Celery task runs `asyncio.run(svc.download_source(N))`.
|
"""Async orchestrator. The Celery task runs `asyncio.run(svc.download_source(N))`.
|
||||||
|
|
||||||
@@ -77,12 +57,18 @@ class DownloadService:
|
|||||||
gdl: GalleryDLService,
|
gdl: GalleryDLService,
|
||||||
importer: Importer,
|
importer: Importer,
|
||||||
cred_service: CredentialService,
|
cred_service: CredentialService,
|
||||||
|
sync_session_factory=None,
|
||||||
):
|
):
|
||||||
self.async_session = async_session
|
self.async_session = async_session
|
||||||
self.sync_session = sync_session
|
self.sync_session = sync_session
|
||||||
self.gdl = gdl
|
self.gdl = gdl
|
||||||
self.importer = importer
|
self.importer = importer
|
||||||
self.cred_service = cred_service
|
self.cred_service = cred_service
|
||||||
|
# Sync sessionmaker the native Patreon ingester opens SHORT-LIVED
|
||||||
|
# sessions from for its seen-ledger reads/writes (never one held across
|
||||||
|
# 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
|
||||||
|
|
||||||
async def download_source(self, source_id: int) -> int:
|
async def download_source(self, source_id: int) -> int:
|
||||||
"""Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is."""
|
"""Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is."""
|
||||||
@@ -91,64 +77,107 @@ class DownloadService:
|
|||||||
return setup["event_id"]
|
return setup["event_id"]
|
||||||
ctx = setup
|
ctx = setup
|
||||||
|
|
||||||
|
# Release the phase-1 DB connections before the (up to ~19.5-min in
|
||||||
|
# backfill) gallery-dl subprocess. Held checked-out across that idle
|
||||||
|
# window, the asyncpg/psycopg connections get reaped by the server,
|
||||||
|
# and phase 3's first query then hits a dead socket
|
||||||
|
# (asyncpg ConnectionDoesNotExistError) → download_source autoretry →
|
||||||
|
# _phase1_setup's in-flight guard no-ops the retry → the event
|
||||||
|
# strands empty for the recovery sweep (Anduo #40014, 2026-06-04).
|
||||||
|
# pool_pre_ping can't help a *held* connection — it only validates on
|
||||||
|
# pool checkout. Closing returns them to the pool so phase 3 re-
|
||||||
|
# acquires a live one (the async task engine uses NullPool, the sync
|
||||||
|
# engine pre_ping + pool_recycle=300). This is what makes the
|
||||||
|
# "Phase 2 — no DB connection" contract in the class docstring true.
|
||||||
|
await self.async_session.close()
|
||||||
|
self.sync_session.close()
|
||||||
|
|
||||||
source_config = SourceConfig.from_dict(ctx["config_overrides"] or {})
|
source_config = SourceConfig.from_dict(ctx["config_overrides"] or {})
|
||||||
# alembic 0031 / plan #544: derive skip_value + timeout from the
|
# Backfill mode (plan #693): the source's `_backfill_state == "running"`
|
||||||
# source's backfill_runs_remaining counter. When > 0, walk the full
|
# selects a time-boxed deep-walk chunk — skip: True (walk full history)
|
||||||
# post history (skip: True + 1800s); when 0, exit gallery-dl after
|
# + the BACKFILL_CHUNK_SECONDS budget, resuming from the cursor
|
||||||
# 20 contiguous archived items (skip: "exit:20" + the default
|
# checkpoint (plan #689). State is the single source of truth; it stays
|
||||||
# 870s). Operator sets backfill via POST /api/sources/{id}/backfill.
|
# "running" across ticks until the walk reaches the bottom (phase 3
|
||||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
# flips it to "complete"). Otherwise tick mode: exit gallery-dl after
|
||||||
if backfill_remaining > 0:
|
# 20 contiguous archived items (skip: "exit:20" + the default 870s).
|
||||||
|
# Operator drives this via POST /api/sources/{id}/backfill {action}.
|
||||||
|
overrides = ctx["config_overrides"] or {}
|
||||||
|
in_backfill = overrides.get("_backfill_state") == "running"
|
||||||
|
# Recovery (plan #697) reuses the entire #693 backfill state machine —
|
||||||
|
# cursor checkpoint, time-boxed chunks, complete/stall lifecycle — and
|
||||||
|
# differs only in bypassing the tier-1 seen-ledger so dropped-and-deleted
|
||||||
|
# near-dups get re-fetched and re-evaluated under the CURRENT pHash
|
||||||
|
# threshold (tier-2 disk still spares files we kept). The
|
||||||
|
# `_backfill_bypass_seen` flag rides alongside the running backfill state;
|
||||||
|
# download mode is "recovery" when both are set.
|
||||||
|
bypass_seen = bool(overrides.get("_backfill_bypass_seen"))
|
||||||
|
# #830 recapture: re-grab post bodies/links + localize on-disk inline
|
||||||
|
# images, WITHOUT re-downloading media. Rides alongside the backfill
|
||||||
|
# state like _backfill_bypass_seen; mutually exclusive with it.
|
||||||
|
recapture = bool(overrides.get("_backfill_recapture"))
|
||||||
|
if in_backfill:
|
||||||
skip_value: bool | str = BACKFILL_SKIP_VALUE
|
skip_value: bool | str = BACKFILL_SKIP_VALUE
|
||||||
source_config.timeout = BACKFILL_TIMEOUT_SECONDS
|
source_config.timeout = BACKFILL_CHUNK_SECONDS
|
||||||
|
pending_cursor = overrides.get("_backfill_cursor")
|
||||||
|
if uses_native_ingester(ctx["platform"]) and pending_cursor:
|
||||||
|
source_config.resume_cursor = pending_cursor
|
||||||
else:
|
else:
|
||||||
skip_value = TICK_SKIP_VALUE
|
skip_value = TICK_SKIP_VALUE
|
||||||
|
|
||||||
effective_url = _effective_url(
|
# Phase 2 dispatch is uniform across backends (download_backends.
|
||||||
ctx["platform"], ctx["url"], ctx["config_overrides"] or {}
|
# run_download — the download counterpart to verify_source_credential):
|
||||||
|
# native platforms run their ingester in `mode` (zero per-file HEADs,
|
||||||
|
# native cursor/resume, loud drift detection); gallery-dl platforms run
|
||||||
|
# the subprocess. Either returns a DownloadResult-shaped object so phase 3
|
||||||
|
# is untouched. `mode` is None for gallery-dl (run_download ignores it).
|
||||||
|
mode: str | None = None
|
||||||
|
if uses_native_ingester(ctx["platform"]):
|
||||||
|
if in_backfill and bypass_seen:
|
||||||
|
mode = "recovery"
|
||||||
|
elif in_backfill and recapture:
|
||||||
|
mode = "recapture"
|
||||||
|
elif in_backfill:
|
||||||
|
mode = "backfill"
|
||||||
|
else:
|
||||||
|
mode = "tick"
|
||||||
|
dl_result, resolved_campaign_id = await self._run_download(
|
||||||
|
ctx=ctx, source_config=source_config, skip_value=skip_value, mode=mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
dl_result = await self.gdl.download(
|
# A backfill chunk that hit its time-box but made forward progress is
|
||||||
url=effective_url,
|
# NORMAL, not a failure — reclassify TIMEOUT → PARTIAL so it reads as
|
||||||
artist_slug=ctx["artist_slug"],
|
# "ok/progress" (PARTIAL maps to status "ok"), not a red error, and the
|
||||||
platform=ctx["platform"],
|
# next chunk just resumes from the new cursor (plan #693). A chunk that
|
||||||
source_config=source_config,
|
# timed out with NO progress stays TIMEOUT and feeds phase 3's
|
||||||
cookies_path=ctx["cookies_path"],
|
# stall-guard. Leave RATE_LIMITED alone so the platform-cooldown fires.
|
||||||
auth_token=ctx["auth_token"],
|
if in_backfill and dl_result.error_type == ErrorType.TIMEOUT:
|
||||||
skip_value=skip_value,
|
new_cursor = dl_result.cursor # plan #704: structured, not scraped
|
||||||
)
|
advanced = bool(
|
||||||
|
(new_cursor and new_cursor != overrides.get("_backfill_cursor"))
|
||||||
resolved_campaign_id: str | None = None
|
or dl_result.files_downloaded > 0
|
||||||
if (
|
)
|
||||||
ctx["platform"] == "patreon"
|
if advanced:
|
||||||
and not dl_result.success
|
dl_result.error_type = ErrorType.PARTIAL
|
||||||
and "patreon_campaign_id" not in (ctx["config_overrides"] or {})
|
dl_result.error_message = (
|
||||||
and _looks_like_campaign_id_failure(dl_result.stdout, dl_result.stderr)
|
f"Backfill chunk: {dl_result.files_downloaded} file(s) — continuing"
|
||||||
):
|
|
||||||
vanity = _extract_patreon_vanity(ctx["url"])
|
|
||||||
if vanity:
|
|
||||||
log.info(
|
|
||||||
"Attempting campaign-ID resolution for %s (%s)",
|
|
||||||
ctx["artist_slug"], vanity,
|
|
||||||
)
|
)
|
||||||
resolved_campaign_id = await resolve_campaign_id(
|
|
||||||
vanity, ctx["cookies_path"]
|
|
||||||
)
|
|
||||||
if resolved_campaign_id:
|
|
||||||
dl_result = await self.gdl.download(
|
|
||||||
url=f"https://www.patreon.com/id:{resolved_campaign_id}",
|
|
||||||
artist_slug=ctx["artist_slug"],
|
|
||||||
platform=ctx["platform"],
|
|
||||||
source_config=source_config,
|
|
||||||
cookies_path=ctx["cookies_path"],
|
|
||||||
auth_token=ctx["auth_token"],
|
|
||||||
skip_value=skip_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self._phase3_persist(
|
return await self._phase3_persist(
|
||||||
ctx["event_id"], ctx, dl_result, resolved_campaign_id,
|
ctx["event_id"], ctx, dl_result, resolved_campaign_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _run_download(
|
||||||
|
self, *, ctx: dict, source_config, skip_value, mode: str | None,
|
||||||
|
) -> tuple[DownloadResult, str | None]:
|
||||||
|
"""Phase-2 dispatch → `download_backends.run_download`, passing this
|
||||||
|
service's gdl + sync sessionmaker. Kept as a thin instance method so tests
|
||||||
|
can stub the whole phase-2 dispatch on the service, and so the per-backend
|
||||||
|
construction lives in download_backends (the backend registry)."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
async def _phase1_setup(self, source_id: int) -> dict[str, Any]:
|
async def _phase1_setup(self, source_id: int) -> dict[str, Any]:
|
||||||
source = (await self.async_session.execute(
|
source = (await self.async_session.execute(
|
||||||
select(Source).options(joinedload(Source.artist)).where(Source.id == source_id)
|
select(Source).options(joinedload(Source.artist)).where(Source.id == source_id)
|
||||||
@@ -245,7 +274,20 @@ class DownloadService:
|
|||||||
artist = self.sync_session.get(Artist, ctx["artist_id"])
|
artist = self.sync_session.get(Artist, ctx["artist_id"])
|
||||||
source_row = self.sync_session.get(Source, ctx["source_id"])
|
source_row = self.sync_session.get(Source, ctx["source_id"])
|
||||||
|
|
||||||
|
# Post-first (#856): on the native ingester the post-record is the sole
|
||||||
|
# writer of the post body/links, so the per-media import must NOT apply post
|
||||||
|
# fields. gallery-dl platforms keep writing them via the sidecar. The
|
||||||
|
# Importer is per-task, so this per-instance flag is safe. uses_native_ingester
|
||||||
|
# is the future-proof seam — a platform migrating onto the native core
|
||||||
|
# flips to post-first automatically (milestone #67, step 3).
|
||||||
|
self.importer.post_first = uses_native_ingester(ctx["platform"])
|
||||||
|
|
||||||
import_summary = {"attached": 0, "skipped": 0, "errors": 0}
|
import_summary = {"attached": 0, "skipped": 0, "errors": 0}
|
||||||
|
# Archives detected but captured WITHOUT extracting any image (probe
|
||||||
|
# rejected / corrupt / missing extractor backend). Surfaced on the event
|
||||||
|
# so a post showing "no images" beside a zip is diagnosable (plan
|
||||||
|
# follow-up 2026-06-06 — the recurring archive-association report).
|
||||||
|
unextracted_archives: list[dict] = []
|
||||||
bytes_downloaded = 0
|
bytes_downloaded = 0
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -299,6 +341,17 @@ class DownloadService:
|
|||||||
# mirror duplicate_hash cleanup so we don't keep two copies.
|
# mirror duplicate_hash cleanup so we don't keep two copies.
|
||||||
# Operator-flagged 2026-06-02 (Lustria OST zip).
|
# Operator-flagged 2026-06-02 (Lustria OST zip).
|
||||||
import_summary["attached"] += 1
|
import_summary["attached"] += 1
|
||||||
|
# An archive captured WITHOUT extracting any image carries a
|
||||||
|
# reason on result.error — record it so the event explains the
|
||||||
|
# "no images beside a zip" symptom instead of staying silent.
|
||||||
|
if result.error:
|
||||||
|
unextracted_archives.append(
|
||||||
|
{"file": path.name, "reason": result.error}
|
||||||
|
)
|
||||||
|
log.warning(
|
||||||
|
"archive captured unextracted (%s): %s",
|
||||||
|
path.name, result.error,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
|
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -341,15 +394,68 @@ class DownloadService:
|
|||||||
else:
|
else:
|
||||||
import_summary["errors"] += 1
|
import_summary["errors"] += 1
|
||||||
|
|
||||||
|
# Post-only records: media-less posts (pure-text) the native ingester
|
||||||
|
# captured so the artist archive is complete. Upsert each (keyed on
|
||||||
|
# external_post_id → updates the same Post a media import would create,
|
||||||
|
# never doubles). No file to clean up; the sidecar stays on disk.
|
||||||
|
for rec_str in getattr(dl_result, "post_record_paths", None) or []:
|
||||||
|
rec_path = Path(rec_str)
|
||||||
|
if not rec_path.exists(): # noqa: ASYNC240
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _upsert(p=rec_path):
|
||||||
|
return self.importer.upsert_post_record(
|
||||||
|
p, artist=artist, source=source_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
await loop.run_in_executor(None, _upsert)
|
||||||
|
|
||||||
|
# #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
|
||||||
|
# the duplicate file). Empty outside recapture mode.
|
||||||
|
relink_pairs = getattr(dl_result, "relink_source_paths", None) or []
|
||||||
|
relinked = 0
|
||||||
|
for rel_str, rel_url in relink_pairs:
|
||||||
|
rel_path = Path(rel_str)
|
||||||
|
if not rel_path.exists(): # noqa: ASYNC240
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _relink(p=rel_path, u=rel_url):
|
||||||
|
return self.importer.relink_source_filehash(p, u, artist=artist)
|
||||||
|
|
||||||
|
if await loop.run_in_executor(None, _relink):
|
||||||
|
relinked += 1
|
||||||
|
if relink_pairs:
|
||||||
|
# recapture diagnostic: how many on-disk images got their
|
||||||
|
# source_filehash backfilled (inline-image localization). < total is
|
||||||
|
# normal — files already carrying a filehash are skipped (NULL-only).
|
||||||
|
log.info(
|
||||||
|
"recap: relinked source_filehash on %d/%d on-disk image(s)",
|
||||||
|
relinked, len(relink_pairs),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kick the off-platform file-host downloader for any links this run
|
||||||
|
# recorded (mega/gdrive/…). Global + idempotent (only claims pending/
|
||||||
|
# retryable rows); the beat sweep is the backstop. Lazy import dodges a
|
||||||
|
# task-module import cycle.
|
||||||
|
from ..tasks.external import sweep_external_links
|
||||||
|
sweep_external_links.delay()
|
||||||
|
|
||||||
ev = (await self.async_session.execute(
|
ev = (await self.async_session.execute(
|
||||||
select(DownloadEvent).where(DownloadEvent.id == event_id)
|
select(DownloadEvent).where(DownloadEvent.id == event_id)
|
||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
|
|
||||||
run_stats = self.gdl._compute_run_stats(
|
# plan #704: the native ingester returns structured run_stats; only the
|
||||||
dl_result.return_code, dl_result.stdout, dl_result.stderr
|
# gallery-dl path needs the regex-over-stdout reconstruction.
|
||||||
)
|
if dl_result.run_stats is not None:
|
||||||
|
run_stats = dict(dl_result.run_stats)
|
||||||
|
else:
|
||||||
|
run_stats = self.gdl._compute_run_stats(
|
||||||
|
dl_result.return_code, dl_result.stdout, dl_result.stderr
|
||||||
|
)
|
||||||
run_stats["quarantined_count"] = dl_result.files_quarantined
|
run_stats["quarantined_count"] = dl_result.files_quarantined
|
||||||
stderr_summary = self.gdl._extract_errors_warnings(dl_result.stderr)
|
stderr_summary = extract_errors_warnings(dl_result.stderr)
|
||||||
|
|
||||||
# Plan #544: PARTIAL means the run downloaded ≥1 file but the
|
# Plan #544: PARTIAL means the run downloaded ≥1 file but the
|
||||||
# subprocess didn't finish in budget (typically wall-clock timeout
|
# subprocess didn't finish in budget (typically wall-clock timeout
|
||||||
@@ -369,50 +475,118 @@ class DownloadService:
|
|||||||
ev.metadata_ = {
|
ev.metadata_ = {
|
||||||
"run_stats": run_stats,
|
"run_stats": run_stats,
|
||||||
"error_type": dl_result.error_type.value if dl_result.error_type else None,
|
"error_type": dl_result.error_type.value if dl_result.error_type else None,
|
||||||
"stdout": self.gdl._truncate_log(dl_result.stdout) or None,
|
"stdout": truncate_log(dl_result.stdout) or None,
|
||||||
"stderr": self.gdl._truncate_log(dl_result.stderr) or None,
|
"stderr": truncate_log(dl_result.stderr) or None,
|
||||||
"stderr_errors_warnings": stderr_summary or None,
|
"stderr_errors_warnings": stderr_summary or None,
|
||||||
"duration_seconds": dl_result.duration_seconds,
|
"duration_seconds": dl_result.duration_seconds,
|
||||||
"quarantined_paths": dl_result.quarantined_paths or None,
|
"quarantined_paths": dl_result.quarantined_paths or None,
|
||||||
"import_summary": import_summary,
|
"import_summary": import_summary,
|
||||||
|
# Archives detected but captured without extracting an image — the
|
||||||
|
# recurring "post shows a zip but no images" report. Each entry is
|
||||||
|
# {file, reason}; None when every archive extracted cleanly.
|
||||||
|
"unextracted_archives": unextracted_archives or None,
|
||||||
}
|
}
|
||||||
await self._update_source_health(
|
await self._update_source_health(
|
||||||
source_id=ctx["source_id"], status=status, error_message=ev.error,
|
source_id=ctx["source_id"], status=status, error_message=ev.error,
|
||||||
error_type=dl_result.error_type.value if dl_result.error_type else None,
|
error_type=dl_result.error_type.value if dl_result.error_type else None,
|
||||||
|
retry_after_seconds=getattr(dl_result, "retry_after_seconds", None),
|
||||||
)
|
)
|
||||||
# Plan #544: backfill lifecycle — auto-complete when a clean
|
await self._apply_backfill_lifecycle(ctx, dl_result)
|
||||||
# backfill run drained the queue (gallery-dl exited 0 + zero files
|
|
||||||
# downloaded means there was nothing to fetch); otherwise decrement
|
|
||||||
# the counter. Next tick falls back to tick mode once it hits 0.
|
|
||||||
#
|
|
||||||
# Audit 2026-06-02 gating: VALIDATION_FAILED also exits the
|
|
||||||
# subprocess with return_code=0 and files_downloaded=0 (every
|
|
||||||
# file was quarantined), which used to match the auto-complete
|
|
||||||
# predicate exactly — zeroing the operator's armed budget on
|
|
||||||
# the FIRST quarantine run instead of decrementing. Require
|
|
||||||
# dl_result.success + no error_type so only genuinely-empty
|
|
||||||
# successful runs drain the counter.
|
|
||||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
|
||||||
if backfill_remaining > 0:
|
|
||||||
src = (await self.async_session.execute(
|
|
||||||
select(Source).where(Source.id == ctx["source_id"])
|
|
||||||
)).scalar_one()
|
|
||||||
queue_drained = (
|
|
||||||
dl_result.success
|
|
||||||
and dl_result.error_type is None
|
|
||||||
and dl_result.return_code == 0
|
|
||||||
and dl_result.files_downloaded == 0
|
|
||||||
)
|
|
||||||
if queue_drained:
|
|
||||||
src.backfill_runs_remaining = 0
|
|
||||||
else:
|
|
||||||
src.backfill_runs_remaining = max(0, backfill_remaining - 1)
|
|
||||||
await self.async_session.commit()
|
await self.async_session.commit()
|
||||||
return event_id
|
return event_id
|
||||||
|
|
||||||
|
async def _apply_backfill_lifecycle(self, ctx: dict, dl_result) -> None:
|
||||||
|
"""Backfill state machine (plan #693, building on the cursor of #689).
|
||||||
|
|
||||||
|
A backfill runs in time-boxed chunks while
|
||||||
|
`config_overrides["_backfill_state"] == "running"`. Each chunk:
|
||||||
|
- COMPLETES the walk → clean rc=0 (gallery-dl with skip:True exits 0
|
||||||
|
only after exhausting the newest→oldest walk; a chunk cut short by
|
||||||
|
its time-box returns success=False / rc<0 via TimeoutExpired). On
|
||||||
|
completion: state="complete", clear the cursor, return to tick mode.
|
||||||
|
- made PROGRESS (cursor advanced and/or files written) → stay
|
||||||
|
"running", checkpoint the new cursor, bump the chunk counter, and
|
||||||
|
spend one of the safety-cap chunks (backfill_runs_remaining). If the
|
||||||
|
cap is exhausted without finishing → state="stalled".
|
||||||
|
- made NO progress → increment the stall counter; two strikes →
|
||||||
|
state="stalled", clear the cursor (a wedged walk can't loop).
|
||||||
|
|
||||||
|
State is the single source of truth; the cursor lives only inside a
|
||||||
|
running backfill. config_overrides is reassigned (not mutated in place)
|
||||||
|
for SQLAlchemy JSON change detection.
|
||||||
|
"""
|
||||||
|
overrides = ctx["config_overrides"] or {}
|
||||||
|
if overrides.get("_backfill_state") != "running":
|
||||||
|
return # not backfilling — tick mode, nothing to do
|
||||||
|
|
||||||
|
old_cursor = overrides.get("_backfill_cursor")
|
||||||
|
cap_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||||
|
|
||||||
|
src = (await self.async_session.execute(
|
||||||
|
select(Source).where(Source.id == ctx["source_id"])
|
||||||
|
)).scalar_one()
|
||||||
|
new_overrides = dict(src.config_overrides or {})
|
||||||
|
chunks = int(new_overrides.get("_backfill_chunks", 0)) + 1
|
||||||
|
new_overrides["_backfill_chunks"] = chunks
|
||||||
|
# plan #704 (#5): _backfill_posts (the live progress badge) is OWNED by the
|
||||||
|
# ingester now — it writes a monotonic absolute mid-walk at each page
|
||||||
|
# boundary (ingest_core._checkpoint_posts), so the badge climbs DURING a
|
||||||
|
# chunk instead of jumping once per chunk here, and the re-walked resume
|
||||||
|
# page is no longer double-counted. new_overrides (read fresh above)
|
||||||
|
# carries the ingester's committed value forward untouched.
|
||||||
|
|
||||||
|
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)
|
||||||
|
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||||
|
# plan #697: a recovery walk shares this lifecycle; clear its bypass
|
||||||
|
# flag on completion so the next routine tick honors the seen-ledger.
|
||||||
|
new_overrides.pop("_backfill_bypass_seen", None)
|
||||||
|
# #830: same for the recapture flag.
|
||||||
|
new_overrides.pop("_backfill_recapture", None)
|
||||||
|
src.config_overrides = new_overrides
|
||||||
|
src.backfill_runs_remaining = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
# Did not finish. The native ingester checkpoints + resumes via cursor
|
||||||
|
# (carried structurally on the result, plan #704); gallery-dl platforms
|
||||||
|
# have no resumable cursor (every chunk re-walks from the top), so they
|
||||||
|
# advance only by the download archive growing.
|
||||||
|
new_cursor = (
|
||||||
|
dl_result.cursor if uses_native_ingester(ctx["platform"]) else None
|
||||||
|
)
|
||||||
|
advanced = bool(
|
||||||
|
(new_cursor and new_cursor != old_cursor)
|
||||||
|
or dl_result.files_downloaded > 0
|
||||||
|
)
|
||||||
|
if advanced:
|
||||||
|
if new_cursor:
|
||||||
|
new_overrides["_backfill_cursor"] = new_cursor
|
||||||
|
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||||
|
cap_remaining = max(0, cap_remaining - 1)
|
||||||
|
src.backfill_runs_remaining = cap_remaining
|
||||||
|
if cap_remaining == 0:
|
||||||
|
# Safety cap hit before reaching the bottom — pause, don't loop.
|
||||||
|
new_overrides["_backfill_state"] = "stalled"
|
||||||
|
else:
|
||||||
|
stalls = int(new_overrides.get("_backfill_cursor_stalls", 0)) + 1
|
||||||
|
if stalls >= 2:
|
||||||
|
new_overrides["_backfill_state"] = "stalled"
|
||||||
|
new_overrides.pop("_backfill_cursor", None)
|
||||||
|
new_overrides.pop("_backfill_cursor_stalls", None)
|
||||||
|
else:
|
||||||
|
new_overrides["_backfill_cursor_stalls"] = stalls
|
||||||
|
|
||||||
|
src.config_overrides = new_overrides
|
||||||
|
|
||||||
async def _update_source_health(
|
async def _update_source_health(
|
||||||
self, *, source_id: int, status: str, error_message: str | None,
|
self, *, source_id: int, status: str, error_message: str | None,
|
||||||
error_type: str | None = None,
|
error_type: str | None = None, retry_after_seconds: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""FC-3d: update Source.{consecutive_failures, last_error, last_checked_at}.
|
"""FC-3d: update Source.{consecutive_failures, last_error, last_checked_at}.
|
||||||
|
|
||||||
@@ -444,7 +618,17 @@ class DownloadService:
|
|||||||
# by error class without opening Logs per row.
|
# by error class without opening Logs per row.
|
||||||
source.error_type = error_type
|
source.error_type = error_type
|
||||||
if error_type == "rate_limited":
|
if error_type == "rate_limited":
|
||||||
await set_platform_cooldown(self.async_session, source.platform)
|
# plan #708 B1: honor the server's Retry-After when the native
|
||||||
|
# client surfaced one (clamped to a sane [60, 3600] window so a
|
||||||
|
# tiny hint can't leave the platform effectively un-cooled and a
|
||||||
|
# huge one can't strand it for hours); else the flat default.
|
||||||
|
if retry_after_seconds is not None:
|
||||||
|
seconds = int(min(max(retry_after_seconds, 60), 3600))
|
||||||
|
await set_platform_cooldown(
|
||||||
|
self.async_session, source.platform, seconds=seconds,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await set_platform_cooldown(self.async_session, source.platform)
|
||||||
elif status == "skipped":
|
elif status == "skipped":
|
||||||
source.last_error = None
|
source.last_error = None
|
||||||
source.last_checked_at = now
|
source.last_checked_at = now
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import Artist, Source
|
from ..models import Artist, Source
|
||||||
from ..utils.slug import slugify
|
from ..utils.slug import slugify
|
||||||
from .source_service import NEW_SOURCE_BACKFILL_RUNS
|
from .db_helpers import get_or_create
|
||||||
|
from .source_service import BACKFILL_MAX_CHUNKS
|
||||||
|
|
||||||
|
|
||||||
class UnknownPlatformError(Exception):
|
class UnknownPlatformError(Exception):
|
||||||
@@ -169,24 +169,16 @@ class ExtensionService:
|
|||||||
500 against uq_artist_slug.
|
500 against uq_artist_slug.
|
||||||
"""
|
"""
|
||||||
slug = slugify(raw_name)
|
slug = slugify(raw_name)
|
||||||
existing = (await self.session.execute(
|
|
||||||
select(Artist).where(Artist.slug == slug)
|
async def _create() -> Artist:
|
||||||
)).scalar_one_or_none()
|
|
||||||
if existing is not None:
|
|
||||||
return existing, False
|
|
||||||
sp = await self.session.begin_nested()
|
|
||||||
try:
|
|
||||||
artist = Artist(name=raw_name, slug=slug, is_subscription=True)
|
artist = Artist(name=raw_name, slug=slug, is_subscription=True)
|
||||||
self.session.add(artist)
|
self.session.add(artist)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
await sp.commit()
|
return artist
|
||||||
return artist, True
|
|
||||||
except IntegrityError:
|
return await get_or_create(
|
||||||
await sp.rollback()
|
self.session, select(Artist).where(Artist.slug == slug), _create
|
||||||
recovered = (await self.session.execute(
|
)
|
||||||
select(Artist).where(Artist.slug == slug)
|
|
||||||
)).scalar_one()
|
|
||||||
return recovered, False
|
|
||||||
|
|
||||||
async def _find_or_create_source(
|
async def _find_or_create_source(
|
||||||
self, *, artist_id: int, platform: str, url: str,
|
self, *, artist_id: int, platform: str, url: str,
|
||||||
@@ -194,40 +186,32 @@ class ExtensionService:
|
|||||||
"""Race-safe — same pattern as _find_or_create_artist above. The
|
"""Race-safe — same pattern as _find_or_create_artist above. The
|
||||||
uq_source_artist_platform_url constraint catches the duplicate
|
uq_source_artist_platform_url constraint catches the duplicate
|
||||||
insert; we roll the savepoint back and re-select."""
|
insert; we roll the savepoint back and re-select."""
|
||||||
existing = (await self.session.execute(
|
select_existing = select(Source).where(
|
||||||
select(Source).where(
|
Source.artist_id == artist_id,
|
||||||
Source.artist_id == artist_id,
|
Source.platform == platform,
|
||||||
Source.platform == platform,
|
Source.url == url,
|
||||||
Source.url == url,
|
)
|
||||||
)
|
|
||||||
)).scalar_one_or_none()
|
async def _create() -> Source:
|
||||||
if existing is not None:
|
# New subscription sources arm run-until-done backfill (plan #693)
|
||||||
return existing, False
|
# so the first ticks walk the full history (otherwise gallery-dl's
|
||||||
sp = await self.session.begin_nested()
|
# exit:20 short-circuits before the archive is built). Mirrors
|
||||||
try:
|
# SourceService.create — without it, Firefox quick-add on a creator
|
||||||
# New subscription sources arm a few backfill runs so the
|
# with >20 unsynced posts would surface as "check failed" with no
|
||||||
# first ticks walk the full history (otherwise gallery-dl's
|
# diagnosis. Audit 2026-06-02.
|
||||||
# exit:20 short-circuits before the archive is built).
|
|
||||||
# Mirrors SourceService.create — without it, Firefox quick-
|
|
||||||
# add on a creator with >20 unsynced posts would surface
|
|
||||||
# as "check failed" with no diagnosis. Audit 2026-06-02.
|
|
||||||
src = Source(
|
src = Source(
|
||||||
artist_id=artist_id, platform=platform,
|
artist_id=artist_id, platform=platform,
|
||||||
url=url, enabled=True,
|
url=url, enabled=True,
|
||||||
backfill_runs_remaining=NEW_SOURCE_BACKFILL_RUNS,
|
config_overrides={"_backfill_state": "running"},
|
||||||
|
backfill_runs_remaining=BACKFILL_MAX_CHUNKS,
|
||||||
)
|
)
|
||||||
self.session.add(src)
|
self.session.add(src)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
await sp.commit()
|
return src
|
||||||
except IntegrityError:
|
|
||||||
await sp.rollback()
|
src, created = await get_or_create(
|
||||||
recovered = (await self.session.execute(
|
self.session, select_existing, _create
|
||||||
select(Source).where(
|
)
|
||||||
Source.artist_id == artist_id,
|
if created:
|
||||||
Source.platform == platform,
|
await self.session.commit()
|
||||||
Source.url == url,
|
return src, created
|
||||||
)
|
|
||||||
)).scalar_one()
|
|
||||||
return recovered, False
|
|
||||||
await self.session.commit()
|
|
||||||
return src, True
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Fetchers for off-platform file hosts (mega / gdrive / mediafire / dropbox /
|
||||||
|
pixeldrain).
|
||||||
|
|
||||||
|
A shared, reusable subsystem: given an external_link URL, fetch the file(s) into
|
||||||
|
a destination directory and report the outcome. The download worker (separate
|
||||||
|
slice) drives these off the external_link ledger; any in-house downloader can
|
||||||
|
call `fetch_external()` directly.
|
||||||
|
|
||||||
|
No single tool covers all five hosts, so a small registry maps host → fetch
|
||||||
|
function behind one signature:
|
||||||
|
|
||||||
|
fetch_external(host, url, dest_dir, *, read_timeout, total_timeout, should_stop)
|
||||||
|
-> FetchResult
|
||||||
|
|
||||||
|
Backends:
|
||||||
|
- dropbox : force the direct-download variant (dl=1) + stream GET.
|
||||||
|
- pixeldrain : GET the /api/file/{id} endpoint.
|
||||||
|
- mediafire : scrape the download page for the direct link + stream GET.
|
||||||
|
- gdrive : gdown (handles the confirm-token + virus-scan interstitial).
|
||||||
|
- mega : `megatools dl` subprocess (public link incl. #key); needs the
|
||||||
|
`megatools` binary in the runtime image (Debian apt package).
|
||||||
|
|
||||||
|
Public links work credential-free (rule 26); per-host creds are a later Settings
|
||||||
|
concern. Plain-HTTP homelab — no secure-context API. The HTTP / gdown /
|
||||||
|
subprocess calls go through module-level seams so unit tests run without
|
||||||
|
network, gdown, or MEGAcmd.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CHUNK = 1 << 16
|
||||||
|
# Two distinct limits, because conflating them (the old single 3000s value) meant
|
||||||
|
# a stalled HTTP connection tied up a download-worker slot + the per-host lock for
|
||||||
|
# ~50 min before failing (operator-flagged 2026-06-17):
|
||||||
|
# * READ timeout — max idle gap between bytes on an HTTP socket. A stalled host
|
||||||
|
# (socket open, nothing flowing) is the common failure mode; a short read
|
||||||
|
# timeout fails it fast. This is what requests' `timeout` actually enforces —
|
||||||
|
# per-read, never a total.
|
||||||
|
# * TOTAL budget — generous wall-clock cap for a file that IS actively
|
||||||
|
# transferring (big films/packs). Enforced as a deadline across chunks, since
|
||||||
|
# no HTTP client timeout bounds the total. Also the subprocess total for mega.
|
||||||
|
_CONNECT_TIMEOUT = 30.0
|
||||||
|
_READ_TIMEOUT = 60.0
|
||||||
|
_TOTAL_TIMEOUT = 1800.0 # 30 min per fetch
|
||||||
|
_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
# MediaFire's download page embeds the real file URL in a download-button href.
|
||||||
|
_MEDIAFIRE_RE = re.compile(
|
||||||
|
r'href="(https://download[^"]+?\.mediafire\.com/[^"]+)"', re.IGNORECASE
|
||||||
|
)
|
||||||
|
_CD_FILENAME_RE = re.compile(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FetchResult:
|
||||||
|
files: list[Path] = field(default_factory=list)
|
||||||
|
bytes: int = 0
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.error is None and bool(self.files)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalFetchError(Exception):
|
||||||
|
"""A fetch failed in a way worth recording on the link's last_error."""
|
||||||
|
|
||||||
|
|
||||||
|
# -- seams (monkeypatched in tests) ----------------------------------------
|
||||||
|
|
||||||
|
def _http_get(url: str, *, timeout: float, headers: dict | None = None,
|
||||||
|
stream: bool = True) -> requests.Response:
|
||||||
|
hdrs = {"User-Agent": _USER_AGENT}
|
||||||
|
if headers:
|
||||||
|
hdrs.update(headers)
|
||||||
|
return requests.get(url, timeout=timeout, headers=hdrs, stream=stream)
|
||||||
|
|
||||||
|
|
||||||
|
def _gdown_download(url: str, out_dir: str) -> str | None:
|
||||||
|
"""Download a Google Drive url into out_dir via gdown; return the written
|
||||||
|
path. Imported lazily so the dep is optional at module-import time."""
|
||||||
|
# Lazy import: keeps gdown an optional dep that isn't needed to import this
|
||||||
|
# module (e.g. the no-DB unit lane that never exercises a real fetch).
|
||||||
|
import gdown
|
||||||
|
# A trailing sep tells gdown to keep the server-side filename inside out_dir.
|
||||||
|
return gdown.download(url, output=out_dir + os.sep, quiet=True, fuzzy=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_mega_get(url: str, out_dir: str, *, timeout: float) -> None:
|
||||||
|
"""Download a mega.nz public link (key in the #fragment) into out_dir via
|
||||||
|
`megatools dl` (the Debian `megatools` package). Raises ExternalFetchError
|
||||||
|
on non-zero exit."""
|
||||||
|
# Fixed argv (not shell): only `url` is external input, passed positionally,
|
||||||
|
# so there's no shell-injection surface.
|
||||||
|
proc = subprocess.run(
|
||||||
|
["megatools", "dl", "--path", out_dir, url],
|
||||||
|
capture_output=True, text=True, timeout=timeout, check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise ExternalFetchError(
|
||||||
|
f"megatools dl exit {proc.returncode}: {(proc.stderr or '').strip()[:300]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _safe_name(name: str, fallback: str) -> str:
|
||||||
|
name = os.path.basename((name or "").strip().strip('"'))
|
||||||
|
# Strip path separators / control chars; never empty.
|
||||||
|
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip(". ")
|
||||||
|
return name or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_from(resp: requests.Response, url: str, fallback: str) -> str:
|
||||||
|
cd = resp.headers.get("Content-Disposition", "")
|
||||||
|
m = _CD_FILENAME_RE.search(cd)
|
||||||
|
if m:
|
||||||
|
return _safe_name(m.group(1), fallback)
|
||||||
|
path_name = os.path.basename(urlsplit(url).path)
|
||||||
|
return _safe_name(path_name, fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_to_file(resp: requests.Response, dest: Path,
|
||||||
|
should_stop: Callable[[], bool],
|
||||||
|
*, deadline: float | None = None) -> int:
|
||||||
|
"""Stream a response body to `dest` (atomic via .part). Returns byte count.
|
||||||
|
Honors should_stop and the total-budget `deadline` (a time.monotonic() value)
|
||||||
|
between chunks; the partial file is removed on either abort."""
|
||||||
|
part = dest.with_name(dest.name + ".part")
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
with part.open("wb") as fh:
|
||||||
|
for chunk in resp.iter_content(chunk_size=_CHUNK):
|
||||||
|
if should_stop():
|
||||||
|
raise ExternalFetchError("stopped")
|
||||||
|
if deadline is not None and time.monotonic() > deadline:
|
||||||
|
raise ExternalFetchError("exceeded total fetch budget")
|
||||||
|
if chunk:
|
||||||
|
fh.write(chunk)
|
||||||
|
total += len(chunk)
|
||||||
|
except BaseException:
|
||||||
|
part.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
os.replace(part, dest)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _get_to_dir(url: str, dest_dir: Path, *, read_timeout: float,
|
||||||
|
total_timeout: float, should_stop: Callable[[], bool],
|
||||||
|
fallback: str, headers: dict | None = None) -> FetchResult:
|
||||||
|
# (connect, read): a short read timeout fails a stalled socket fast; the total
|
||||||
|
# budget is enforced separately as a deadline across chunks (requests has no
|
||||||
|
# total-download timeout).
|
||||||
|
resp = _http_get(
|
||||||
|
url, timeout=(_CONNECT_TIMEOUT, read_timeout), headers=headers, stream=True
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return FetchResult(error=f"HTTP {resp.status_code} for {url}")
|
||||||
|
name = _filename_from(resp, url, fallback)
|
||||||
|
dest = dest_dir / name
|
||||||
|
written = _stream_to_file(
|
||||||
|
resp, dest, should_stop, deadline=time.monotonic() + total_timeout
|
||||||
|
)
|
||||||
|
return FetchResult(files=[dest], bytes=written)
|
||||||
|
|
||||||
|
|
||||||
|
# -- per-host fetchers -----------------------------------------------------
|
||||||
|
|
||||||
|
def _fetch_dropbox(url: str, dest_dir: Path, *, read_timeout: float,
|
||||||
|
total_timeout: float,
|
||||||
|
should_stop: Callable[[], bool]) -> FetchResult:
|
||||||
|
# Force the direct-download variant: dl=1 (Dropbox serves an HTML preview
|
||||||
|
# for dl=0). Rewrite/insert the param rather than string-replace so ?dl=0,
|
||||||
|
# &dl=0, and a missing param all resolve.
|
||||||
|
parts = urlsplit(url)
|
||||||
|
q = dict(parse_qsl(parts.query))
|
||||||
|
q["dl"] = "1"
|
||||||
|
direct = urlunsplit(parts._replace(query=urlencode(q)))
|
||||||
|
return _get_to_dir(direct, dest_dir, read_timeout=read_timeout,
|
||||||
|
total_timeout=total_timeout, should_stop=should_stop,
|
||||||
|
fallback="dropbox-file")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_pixeldrain(url: str, dest_dir: Path, *, read_timeout: float,
|
||||||
|
total_timeout: float,
|
||||||
|
should_stop: Callable[[], bool]) -> FetchResult:
|
||||||
|
# /u/{id} (and /l/{id}) → the API file endpoint.
|
||||||
|
file_id = urlsplit(url).path.rstrip("/").split("/")[-1]
|
||||||
|
if not file_id:
|
||||||
|
return FetchResult(error=f"no pixeldrain id in {url}")
|
||||||
|
api = f"https://pixeldrain.com/api/file/{file_id}"
|
||||||
|
return _get_to_dir(api, dest_dir, read_timeout=read_timeout,
|
||||||
|
total_timeout=total_timeout, should_stop=should_stop,
|
||||||
|
fallback=f"{file_id}.bin")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_mediafire(url: str, dest_dir: Path, *, read_timeout: float,
|
||||||
|
total_timeout: float,
|
||||||
|
should_stop: Callable[[], bool]) -> FetchResult:
|
||||||
|
page = _http_get(url, timeout=(_CONNECT_TIMEOUT, read_timeout), stream=False)
|
||||||
|
if page.status_code != 200:
|
||||||
|
return FetchResult(error=f"HTTP {page.status_code} for mediafire page")
|
||||||
|
m = _MEDIAFIRE_RE.search(page.text or "")
|
||||||
|
if not m:
|
||||||
|
return FetchResult(error="mediafire direct link not found on page")
|
||||||
|
return _get_to_dir(m.group(1), dest_dir, read_timeout=read_timeout,
|
||||||
|
total_timeout=total_timeout, should_stop=should_stop,
|
||||||
|
fallback="mediafire-file")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_gdrive(url: str, dest_dir: Path, *, read_timeout: float,
|
||||||
|
total_timeout: float,
|
||||||
|
should_stop: Callable[[], bool]) -> FetchResult:
|
||||||
|
# gdown manages its own HTTP session/timeouts; the task's celery hard limit is
|
||||||
|
# the outer backstop. read_timeout/total_timeout are accepted for a uniform
|
||||||
|
# registry signature but not separately enforceable here.
|
||||||
|
out = _gdown_download(url, str(dest_dir))
|
||||||
|
if not out:
|
||||||
|
return FetchResult(error="gdown returned no file (quota / private?)")
|
||||||
|
p = Path(out)
|
||||||
|
if not p.exists():
|
||||||
|
return FetchResult(error=f"gdown reported {out} but it is missing")
|
||||||
|
return FetchResult(files=[p], bytes=p.stat().st_size)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_mega(url: str, dest_dir: Path, *, read_timeout: float,
|
||||||
|
total_timeout: float,
|
||||||
|
should_stop: Callable[[], bool]) -> FetchResult:
|
||||||
|
before = set(dest_dir.iterdir()) if dest_dir.exists() else set()
|
||||||
|
# megatools is a subprocess: its timeout IS a total wall-clock cap (the read
|
||||||
|
# timeout has no analogue here), so the total budget applies directly.
|
||||||
|
_run_mega_get(url, str(dest_dir), timeout=total_timeout)
|
||||||
|
new = [p for p in dest_dir.iterdir() if p not in before and p.is_file()]
|
||||||
|
if not new:
|
||||||
|
return FetchResult(error="mega-get wrote no new file")
|
||||||
|
return FetchResult(files=new, bytes=sum(p.stat().st_size for p in new))
|
||||||
|
|
||||||
|
|
||||||
|
_REGISTRY: dict[str, Callable[..., FetchResult]] = {
|
||||||
|
"dropbox": _fetch_dropbox,
|
||||||
|
"pixeldrain": _fetch_pixeldrain,
|
||||||
|
"mediafire": _fetch_mediafire,
|
||||||
|
"gdrive": _fetch_gdrive,
|
||||||
|
"mega": _fetch_mega,
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_HOSTS = tuple(_REGISTRY)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_external(host: str, url: str, dest_dir: Path, *,
|
||||||
|
read_timeout: float = _READ_TIMEOUT,
|
||||||
|
total_timeout: float = _TOTAL_TIMEOUT,
|
||||||
|
should_stop: Callable[[], bool] = lambda: False) -> FetchResult:
|
||||||
|
"""Fetch `url` (a `host` link) into `dest_dir`. Returns a FetchResult; never
|
||||||
|
raises — any backend error (transport, read/total timeout, non-200, scrape
|
||||||
|
miss, subprocess failure, stop) is captured on `.error` so the worker can
|
||||||
|
record it and move on.
|
||||||
|
|
||||||
|
`read_timeout` fails a stalled HTTP socket fast (idle gap between bytes);
|
||||||
|
`total_timeout` is the generous wall-clock cap for a large file that is
|
||||||
|
actively transferring (and the subprocess total for mega)."""
|
||||||
|
fetcher = _REGISTRY.get(host)
|
||||||
|
if fetcher is None:
|
||||||
|
return FetchResult(error=f"unsupported host {host!r}")
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
return fetcher(url, dest_dir, read_timeout=read_timeout,
|
||||||
|
total_timeout=total_timeout, should_stop=should_stop)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return FetchResult(error=f"transport error: {exc}")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return FetchResult(error="timed out")
|
||||||
|
except ExternalFetchError as exc:
|
||||||
|
return FetchResult(error=str(exc))
|
||||||
|
except Exception as exc: # never let a backend quirk kill the worker
|
||||||
|
log.warning("external fetch (%s) failed for %s: %s", host, url, exc)
|
||||||
|
return FetchResult(error=f"{type(exc).__name__}: {exc}")
|
||||||
@@ -11,9 +11,15 @@ expected to write.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
JPEG_HEAD = b"\xff\xd8\xff"
|
JPEG_HEAD = b"\xff\xd8\xff"
|
||||||
JPEG_TAIL = b"\xff\xd9"
|
JPEG_TAIL = b"\xff\xd9"
|
||||||
|
|
||||||
@@ -108,3 +114,52 @@ def validate_file(path: Path) -> ValidationResult:
|
|||||||
return ValidationResult(ok=True, format="webp", size=size)
|
return ValidationResult(ok=True, format="webp", size=size)
|
||||||
|
|
||||||
return ValidationResult(ok=True, format=None, size=size)
|
return ValidationResult(ok=True, format=None, size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def quarantine_file(
|
||||||
|
images_root: Path,
|
||||||
|
path: Path,
|
||||||
|
artist_slug: str,
|
||||||
|
platform: str,
|
||||||
|
*,
|
||||||
|
url: str | None,
|
||||||
|
result: ValidationResult,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Move a validation-failed file to `_quarantine/<slug>/<platform>` and write
|
||||||
|
a `.quarantine.json` provenance sidecar next to it.
|
||||||
|
|
||||||
|
Returns the destination path, or None if the move itself failed (file left
|
||||||
|
in place — the caller decides what to report). The caller has already run
|
||||||
|
`validate_file` and seen `result.ok is False`. ONE implementation for both
|
||||||
|
download backends — gallery-dl's batch post-process and the native ingester's
|
||||||
|
per-media path — so the quarantine layout + provenance sidecar can't drift.
|
||||||
|
"""
|
||||||
|
quarantine_root = images_root / "_quarantine" / artist_slug / platform
|
||||||
|
try:
|
||||||
|
quarantine_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = quarantine_root / path.name
|
||||||
|
counter = 1
|
||||||
|
while dest.exists():
|
||||||
|
dest = quarantine_root / f"{path.stem}.{counter}{path.suffix}"
|
||||||
|
counter += 1
|
||||||
|
shutil.move(str(path), str(dest))
|
||||||
|
sidecar = dest.with_suffix(dest.suffix + ".quarantine.json")
|
||||||
|
sidecar.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"original_path": str(path),
|
||||||
|
"source_url": url,
|
||||||
|
"artist_slug": artist_slug,
|
||||||
|
"platform": platform,
|
||||||
|
"format": result.format,
|
||||||
|
"reason": result.reason,
|
||||||
|
"size": result.size,
|
||||||
|
"quarantined_at": datetime.now(UTC).isoformat(),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
log.error("Failed to quarantine %s: %s. File left in place.", path, exc)
|
||||||
|
return None
|
||||||
|
return dest
|
||||||
|
|||||||
+178
-129
@@ -22,7 +22,7 @@ from datetime import UTC, datetime
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .file_validator import is_validatable, validate_file
|
from .file_validator import is_validatable, quarantine_file, validate_file
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,6 +39,12 @@ class ErrorType(StrEnum):
|
|||||||
HTTP_ERROR = "http_error"
|
HTTP_ERROR = "http_error"
|
||||||
UNSUPPORTED_URL = "unsupported_url"
|
UNSUPPORTED_URL = "unsupported_url"
|
||||||
VALIDATION_FAILED = "validation_failed"
|
VALIDATION_FAILED = "validation_failed"
|
||||||
|
# Native Patreon ingester only (plan #697): a response parsed as JSON but
|
||||||
|
# didn't match the JSON:API shape the ingester depends on (missing `data`,
|
||||||
|
# media lacking `file_name`/`url`, etc.). Distinct from AUTH_ERROR — the fix
|
||||||
|
# is updating the ingester's field-set/parser, not rotating credentials. The
|
||||||
|
# contract test guards against silently shipping this.
|
||||||
|
API_DRIFT = "api_drift"
|
||||||
# Run made real progress (downloaded ≥1 file) but did not finish in the
|
# Run made real progress (downloaded ≥1 file) but did not finish in the
|
||||||
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
|
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
|
||||||
# mapping classifies this as "ok" because the next tick continues.
|
# mapping classifies this as "ok" because the next tick continues.
|
||||||
@@ -55,33 +61,36 @@ class ErrorType(StrEnum):
|
|||||||
# 20 contiguous HEADs is still negligible.
|
# 20 contiguous HEADs is still negligible.
|
||||||
TICK_SKIP_VALUE = "exit:20"
|
TICK_SKIP_VALUE = "exit:20"
|
||||||
|
|
||||||
# Backfill mode (operator-triggered deep scan): walk the full history.
|
# Backfill mode (operator-triggered deep scan): walk the full history,
|
||||||
# Source.backfill_runs_remaining > 0 selects this mode; the longer
|
# one TIME-BOXED CHUNK per run (plan #693). config_overrides["_backfill_state"]
|
||||||
# timeout below absorbs creators with thousands of posts.
|
# == "running" selects this mode; the cursor checkpoint (plan #689) lets each
|
||||||
|
# chunk resume where the last stopped, so the walk advances across chunks
|
||||||
|
# until gallery-dl exits cleanly (= reached the bottom).
|
||||||
#
|
#
|
||||||
# 30 seconds shy of Celery's hard `time_limit=1200` on download_source
|
# The chunk budget is deliberately FAR below download_source's Celery
|
||||||
# (tasks/download.py:33). subprocess.run MUST raise TimeoutExpired
|
# soft_time_limit (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py), not
|
||||||
# before Celery SIGKILLs the worker — same rationale as the tick
|
# "just under" it. Hitting this budget is the NORMAL chunk boundary, not a
|
||||||
# default at line 74. The audit (2026-06-02) caught this at 1800,
|
# failure: subprocess.run raises TimeoutExpired (which captures partial
|
||||||
# guaranteeing SIGKILL on any backfill that ran to its subprocess
|
# stdout/stderr + the last emitted cursor), and download_service reclassifies
|
||||||
# budget: stdout/stderr lost, backfill_runs_remaining never
|
# a chunk that made progress as PARTIAL (status "ok"), not an error. The huge
|
||||||
# decrements, recovery sweep stamps generic "stranded" 30 min later.
|
# headroom means a stuck file or a slow chunk can never let Celery's
|
||||||
# Recreates the exact Knuxy #38275 failure mode the tick 870s default
|
# SoftTimeLimitExceeded preempt TimeoutExpired (the failure mode behind Knuxy
|
||||||
# was added to prevent. backfill_runs_remaining=3 still gives ~58
|
# #38275 / Anduo #39912/#40411). Earlier we ran one ~1170s run-to-the-wall
|
||||||
# minutes of cumulative walk across three runs for prolific creators.
|
# per arming; that died as a timeout error every time on large catalogs.
|
||||||
BACKFILL_SKIP_VALUE = True
|
BACKFILL_SKIP_VALUE = True
|
||||||
BACKFILL_TIMEOUT_SECONDS = 1170
|
BACKFILL_CHUNK_SECONDS = 600
|
||||||
|
|
||||||
|
|
||||||
# 30 seconds shy of download_source's Celery soft_time_limit (900s, see
|
# Sits well below download_source's Celery soft_time_limit
|
||||||
# tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before
|
# (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py). subprocess.run MUST
|
||||||
# Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race,
|
# raise TimeoutExpired before Celery raises SoftTimeLimitExceeded —
|
||||||
# SIGKILLs the worker, in-memory stdout/stderr is lost, and the
|
# otherwise Celery wins the race, SIGKILLs the worker, in-memory
|
||||||
# DownloadEvent ends up empty-logged with "stranded by recovery sweep"
|
# stdout/stderr is lost, and the DownloadEvent ends up empty-logged with
|
||||||
# 18 minutes later (operator-flagged 2026-05-31, Knuxy event #38275).
|
# "stranded by recovery sweep" (operator-flagged 2026-05-31, Knuxy event
|
||||||
# The 30s buffer absorbs scheduler jitter / GC pauses without making
|
# #38275; recurred in backfill mode as Anduo #39912). Per-source bumps
|
||||||
# legitimately-long-running syncs timeout-friendlier. Per-source bumps
|
# still live in source.config_overrides for legitimately long syncs —
|
||||||
# still live in source.config_overrides for legitimately long syncs.
|
# keep any override below the soft limit, or the soft-limit salvage path
|
||||||
|
# in tasks/download.py (_finalize_soft_limited) is the only safety net.
|
||||||
_DEFAULT_GDL_TIMEOUT_SECONDS = 870
|
_DEFAULT_GDL_TIMEOUT_SECONDS = 870
|
||||||
|
|
||||||
|
|
||||||
@@ -94,6 +103,17 @@ class SourceConfig:
|
|||||||
Source.backfill_runs_remaining column at the download_service layer
|
Source.backfill_runs_remaining column at the download_service layer
|
||||||
and is passed to _build_config_for_source as `skip_value`. Same for
|
and is passed to _build_config_for_source as `skip_value`. Same for
|
||||||
the per-run subprocess timeout.
|
the per-run subprocess timeout.
|
||||||
|
|
||||||
|
`resume_cursor` is RUNTIME state, not persisted operator config: the
|
||||||
|
cursor-paged backfill checkpoint (see parse_last_cursor + the
|
||||||
|
download_service backfill lifecycle). It is consumed only by the native
|
||||||
|
Patreon ingester (the sole cursor-paged platform; gallery-dl's Patreon path
|
||||||
|
was removed at the #697 cutover): on a backfill/recovery run the ingester
|
||||||
|
resumes its newest→oldest walk from that pagination cursor instead of
|
||||||
|
restarting from the top. download_service threads it from
|
||||||
|
config_overrides["_backfill_cursor"]; SourceConfig deliberately does NOT read
|
||||||
|
it in from_dict (config_overrides also holds operator config, and
|
||||||
|
resume_cursor must only apply in backfill/recovery mode).
|
||||||
"""
|
"""
|
||||||
content_types: list[str] = field(default_factory=lambda: ["all"])
|
content_types: list[str] = field(default_factory=lambda: ["all"])
|
||||||
sleep: float | None = None
|
sleep: float | None = None
|
||||||
@@ -102,6 +122,7 @@ class SourceConfig:
|
|||||||
filename_pattern: str | None = None
|
filename_pattern: str | None = None
|
||||||
save_metadata: bool = True
|
save_metadata: bool = True
|
||||||
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
|
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
|
||||||
|
resume_cursor: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> SourceConfig:
|
def from_dict(cls, data: dict) -> SourceConfig:
|
||||||
@@ -126,6 +147,17 @@ class DownloadResult:
|
|||||||
files_quarantined: int = 0
|
files_quarantined: int = 0
|
||||||
quarantined_paths: list[str] = field(default_factory=list)
|
quarantined_paths: list[str] = field(default_factory=list)
|
||||||
written_paths: list[str] = field(default_factory=list)
|
written_paths: list[str] = field(default_factory=list)
|
||||||
|
# Native ingester only: post-only sidecar paths for posts that have NO
|
||||||
|
# downloadable media (pure-text posts), so the importer can still upsert the
|
||||||
|
# Post + its body. Empty on the gallery-dl path. Phase 3 imports these via
|
||||||
|
# Importer.upsert_post_record (keyed on external_post_id → updates, never
|
||||||
|
# doubles, the same Post a media import would create).
|
||||||
|
post_record_paths: list[str] = field(default_factory=list)
|
||||||
|
# Native ingester, recapture mode only (#830): (on-disk path, CDN source_url)
|
||||||
|
# pairs for already-present media whose ImageRecord.source_filehash should be
|
||||||
|
# backfilled (inline-image localization) WITHOUT re-download or unlink. Empty
|
||||||
|
# on the gallery-dl path and outside recapture.
|
||||||
|
relink_source_paths: list[tuple[str, str]] = field(default_factory=list)
|
||||||
stdout: str = ""
|
stdout: str = ""
|
||||||
stderr: str = ""
|
stderr: str = ""
|
||||||
return_code: int = 0
|
return_code: int = 0
|
||||||
@@ -134,6 +166,51 @@ class DownloadResult:
|
|||||||
duration_seconds: float = 0.0
|
duration_seconds: float = 0.0
|
||||||
started_at: str | None = None
|
started_at: str | None = None
|
||||||
completed_at: str | None = None
|
completed_at: str | None = None
|
||||||
|
# Plan #704 — structured fields the NATIVE ingester populates directly (None
|
||||||
|
# on the gallery-dl path, which keeps the regex-over-stdout route). When set,
|
||||||
|
# phase 3 reads these instead of scraping the text it would otherwise have to
|
||||||
|
# reconstruct: `run_stats` mirrors _compute_run_stats' shape; `cursor` is the
|
||||||
|
# backfill checkpoint the ingester knows exactly (no parse_last_cursor).
|
||||||
|
run_stats: dict | None = None
|
||||||
|
cursor: str | None = None
|
||||||
|
posts_processed: int = 0
|
||||||
|
# Plan #708 B1 — the server's Retry-After seconds on a RATE_LIMITED result, so
|
||||||
|
# the platform cooldown matches the hint instead of a flat default. None when
|
||||||
|
# unknown (no header, or not a rate-limit failure).
|
||||||
|
retry_after_seconds: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_errors_warnings(stderr: str) -> str:
|
||||||
|
"""Keep only the `[error]`/`[warning]` lines from a captured stderr blob.
|
||||||
|
|
||||||
|
A generic download-log helper (module-level so the native-ingester result
|
||||||
|
path can shape its logs without reaching through a GalleryDLService instance).
|
||||||
|
"""
|
||||||
|
if not stderr:
|
||||||
|
return ""
|
||||||
|
kept = [
|
||||||
|
line for line in stderr.splitlines()
|
||||||
|
if "][error]" in line.lower() or "][warning]" in line.lower()
|
||||||
|
]
|
||||||
|
return "\n".join(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_log(text: str, max_bytes: int = 500_000) -> str:
|
||||||
|
"""Cap a captured log to `max_bytes`, eliding the middle (head + tail kept)."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
encoded = text.encode("utf-8")
|
||||||
|
if len(encoded) <= max_bytes:
|
||||||
|
return text
|
||||||
|
half = max_bytes // 2
|
||||||
|
head = encoded[:half].decode("utf-8", errors="ignore")
|
||||||
|
tail = encoded[-half:].decode("utf-8", errors="ignore")
|
||||||
|
head_lines = head.count("\n")
|
||||||
|
tail_lines = tail.count("\n")
|
||||||
|
total_lines = text.count("\n")
|
||||||
|
elided = max(0, total_lines - head_lines - tail_lines)
|
||||||
|
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
|
||||||
|
return head + marker + tail
|
||||||
|
|
||||||
|
|
||||||
def _summarize_validation_failures(failures: list[dict]) -> str:
|
def _summarize_validation_failures(failures: list[dict]) -> str:
|
||||||
@@ -150,6 +227,40 @@ def _summarize_validation_failures(failures: list[dict]) -> str:
|
|||||||
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
|
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
|
||||||
|
|
||||||
|
|
||||||
|
# (parse_last_cursor was removed in plan #704: the native ingester now carries
|
||||||
|
# its checkpoint cursor as a structured DownloadResult.cursor field, so there is
|
||||||
|
# no log text to scrape — and gallery-dl platforms never had a cursor.)
|
||||||
|
|
||||||
|
|
||||||
|
def make_run_stats(
|
||||||
|
*,
|
||||||
|
exit_code: int = 0,
|
||||||
|
downloaded_count: int = 0,
|
||||||
|
skipped_count: int = 0,
|
||||||
|
per_item_failures: int = 0,
|
||||||
|
warning_count: int = 0,
|
||||||
|
tier_gated_count: int = 0,
|
||||||
|
quarantined_count: int = 0,
|
||||||
|
dead_lettered_count: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""The canonical `run_stats` dict shape, in ONE place.
|
||||||
|
|
||||||
|
Both result producers — gallery-dl's `_compute_run_stats` (log scrape) and the
|
||||||
|
native ingester's per-outcome tally (ingest_core) — build through this so the
|
||||||
|
key set can't drift between backends. Phase 3 + the Logs UI read these keys.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"downloaded_count": downloaded_count,
|
||||||
|
"skipped_count": skipped_count,
|
||||||
|
"per_item_failures": per_item_failures,
|
||||||
|
"warning_count": warning_count,
|
||||||
|
"tier_gated_count": tier_gated_count,
|
||||||
|
"quarantined_count": quarantined_count,
|
||||||
|
"dead_lettered_count": dead_lettered_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class GalleryDLService:
|
class GalleryDLService:
|
||||||
"""Service for executing gallery-dl downloads."""
|
"""Service for executing gallery-dl downloads."""
|
||||||
|
|
||||||
@@ -183,21 +294,12 @@ class GalleryDLService:
|
|||||||
"permission denied", "tier required", "pledge required",
|
"permission denied", "tier required", "pledge required",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Per-platform defaults. Lifted from GS — same six platforms FC supports.
|
# Per-platform defaults for the gallery-dl-backed platforms. Patreon was
|
||||||
|
# removed at the plan-#697 cutover — it now uses the native ingester
|
||||||
|
# (services/patreon_ingester.py), not gallery-dl.
|
||||||
PLATFORM_DEFAULTS = {
|
PLATFORM_DEFAULTS = {
|
||||||
"patreon": {
|
# subscribestar removed — it's a native-ingester platform now (#71); the
|
||||||
"content_types": ["images", "image_large", "attachments", "postfile", "content"],
|
# remaining entries are the gallery-dl platforms not yet migrated.
|
||||||
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
|
|
||||||
"filename": "{num:>02}_{filename}.{extension}",
|
|
||||||
"videos": True,
|
|
||||||
"embeds": True,
|
|
||||||
"cursor": True,
|
|
||||||
},
|
|
||||||
"subscribestar": {
|
|
||||||
"content_types": ["all"],
|
|
||||||
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
|
|
||||||
"filename": "{num:>02}_{filename}.{extension}",
|
|
||||||
},
|
|
||||||
"hentaifoundry": {
|
"hentaifoundry": {
|
||||||
"content_types": ["all"],
|
"content_types": ["all"],
|
||||||
"directory": [],
|
"directory": [],
|
||||||
@@ -251,7 +353,12 @@ class GalleryDLService:
|
|||||||
"skip": True,
|
"skip": True,
|
||||||
"sleep": self._rate_limit,
|
"sleep": self._rate_limit,
|
||||||
"sleep-request": max(0.5, self._rate_limit / 4),
|
"sleep-request": max(0.5, self._rate_limit / 4),
|
||||||
"retries": 3,
|
# 2 (not 3) retries — a stuck CDN host shouldn't burn a whole
|
||||||
|
# backfill chunk on one file. Anduo #40838 spent ~600s on a
|
||||||
|
# single image (4 extractor HEAD retries × 30s + 4 downloader
|
||||||
|
# GET retries × 120s); halving retries + the downloader
|
||||||
|
# timeout below caps a wedged file at ~1-2 min instead.
|
||||||
|
"retries": 2,
|
||||||
"timeout": 30.0,
|
"timeout": 30.0,
|
||||||
"verify": True,
|
"verify": True,
|
||||||
"postprocessors": [
|
"postprocessors": [
|
||||||
@@ -266,27 +373,17 @@ class GalleryDLService:
|
|||||||
"downloader": {
|
"downloader": {
|
||||||
"part": True,
|
"part": True,
|
||||||
"part-directory": str(self._config_dir / "temp"),
|
"part-directory": str(self._config_dir / "temp"),
|
||||||
"retries": 3,
|
# See the extractor retries note above (Anduo #40838): 2
|
||||||
"timeout": 120.0,
|
# retries + a 60s read-timeout (was 120) so a stalled
|
||||||
# Forward Patreon as Referer/Origin to yt-dlp when it
|
# connection fails fast. 60s is a per-read timeout, not a
|
||||||
# fetches video manifests. Operator-flagged 2026-06-01
|
# transfer cap — large GIFs that keep streaming are unaffected.
|
||||||
# (DaferQ patreon): video posts hosted on Mux carry a JWT
|
"retries": 2,
|
||||||
# playback restriction that checks Referer/Origin on every
|
"timeout": 60.0,
|
||||||
# request — not just the token signature. gallery-dl's
|
# NOTE: the Patreon/Mux yt-dlp Referer/Origin forwarding lived
|
||||||
# HEAD probe to stream.mux.com returns 200 (the token is
|
# here until the plan-#697 cutover. It was Patreon-specific (and
|
||||||
# valid), but yt-dlp's actual GET-with-Range to fetch the
|
# would have wrongly tagged the other platforms' yt-dlp fetches);
|
||||||
# m3u8 manifest 403s because yt-dlp sends its own default
|
# Patreon video is now handled by the native ingester's
|
||||||
# Referer, which Mux's policy rejects. Forcing the right
|
# downloader (patreon_downloader._VIDEO_HEADERS), so it's gone.
|
||||||
# headers fixes the headers-only case; Mux IP-range
|
|
||||||
# restrictions are unfixable from here.
|
|
||||||
"ytdl": {
|
|
||||||
"raw-options": {
|
|
||||||
"http_headers": {
|
|
||||||
"Referer": "https://www.patreon.com/",
|
|
||||||
"Origin": "https://www.patreon.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"output": {"progress": True},
|
"output": {"progress": True},
|
||||||
}
|
}
|
||||||
@@ -338,14 +435,7 @@ class GalleryDLService:
|
|||||||
|
|
||||||
platform_section = config["extractor"].setdefault(platform, {})
|
platform_section = config["extractor"].setdefault(platform, {})
|
||||||
|
|
||||||
if platform == "patreon":
|
if platform == "hentaifoundry":
|
||||||
if "all" in source_config.content_types:
|
|
||||||
platform_section["files"] = [
|
|
||||||
"images", "image_large", "attachments", "postfile", "content",
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
platform_section["files"] = source_config.content_types
|
|
||||||
elif platform == "hentaifoundry":
|
|
||||||
if "pictures" in source_config.content_types or "all" in source_config.content_types:
|
if "pictures" in source_config.content_types or "all" in source_config.content_types:
|
||||||
platform_section["include"] = "all"
|
platform_section["include"] = "all"
|
||||||
|
|
||||||
@@ -505,10 +595,6 @@ class GalleryDLService:
|
|||||||
if not written_paths:
|
if not written_paths:
|
||||||
return quarantined_relpaths, failures
|
return quarantined_relpaths, failures
|
||||||
|
|
||||||
quarantine_root = (
|
|
||||||
self.images_root / "_quarantine" / artist_slug / platform
|
|
||||||
)
|
|
||||||
|
|
||||||
for path in written_paths:
|
for path in written_paths:
|
||||||
if not is_validatable(path):
|
if not is_validatable(path):
|
||||||
continue
|
continue
|
||||||
@@ -519,34 +605,14 @@ class GalleryDLService:
|
|||||||
continue
|
continue
|
||||||
if result.ok:
|
if result.ok:
|
||||||
continue
|
continue
|
||||||
try:
|
# Shared move+sidecar (file_validator.quarantine_file) — same impl the
|
||||||
quarantine_root.mkdir(parents=True, exist_ok=True)
|
# native ingester uses, so the layout + provenance sidecar can't drift.
|
||||||
dest = quarantine_root / path.name
|
dest = quarantine_file(
|
||||||
counter = 1
|
self.images_root, path, artist_slug, platform,
|
||||||
while dest.exists():
|
url=url, result=result,
|
||||||
dest = quarantine_root / f"{path.stem}.{counter}{path.suffix}"
|
)
|
||||||
counter += 1
|
if dest is None:
|
||||||
path.rename(dest)
|
continue # move failed → left in place, not counted
|
||||||
sidecar = dest.with_suffix(dest.suffix + ".quarantine.json")
|
|
||||||
sidecar.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"original_path": str(path),
|
|
||||||
"source_url": url,
|
|
||||||
"artist_slug": artist_slug,
|
|
||||||
"platform": platform,
|
|
||||||
"format": result.format,
|
|
||||||
"reason": result.reason,
|
|
||||||
"size": result.size,
|
|
||||||
"quarantined_at": datetime.now(UTC).isoformat(),
|
|
||||||
},
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
log.error("Failed to quarantine %s: %s. File left in place.", path, exc)
|
|
||||||
continue
|
|
||||||
|
|
||||||
log.warning(
|
log.warning(
|
||||||
"Quarantined corrupt file: %s → %s (%s: %s)",
|
"Quarantined corrupt file: %s → %s (%s: %s)",
|
||||||
path, dest, result.format, result.reason,
|
path, dest, result.format, result.reason,
|
||||||
@@ -584,41 +650,24 @@ class GalleryDLService:
|
|||||||
if "][warning]" in line.lower() and "not allowed to view post" in line.lower()
|
if "][warning]" in line.lower() and "not allowed to view post" in line.lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return make_run_stats(
|
||||||
"exit_code": return_code,
|
exit_code=return_code,
|
||||||
"downloaded_count": self._count_downloaded_files(stdout),
|
downloaded_count=self._count_downloaded_files(stdout),
|
||||||
"skipped_count": skipped_stdout + skipped_stderr,
|
skipped_count=skipped_stdout + skipped_stderr,
|
||||||
"per_item_failures": per_item_failures,
|
per_item_failures=per_item_failures,
|
||||||
"warning_count": warning_count,
|
warning_count=warning_count,
|
||||||
"tier_gated_count": tier_gated_count,
|
tier_gated_count=tier_gated_count,
|
||||||
}
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_errors_warnings(stderr: str) -> str:
|
def _extract_errors_warnings(stderr: str) -> str:
|
||||||
if not stderr:
|
# Thin delegator to the module-level helper (kept for existing callers).
|
||||||
return ""
|
return extract_errors_warnings(stderr)
|
||||||
kept = [
|
|
||||||
line for line in stderr.splitlines()
|
|
||||||
if "][error]" in line.lower() or "][warning]" in line.lower()
|
|
||||||
]
|
|
||||||
return "\n".join(kept)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _truncate_log(text: str, max_bytes: int = 500_000) -> str:
|
def _truncate_log(text: str, max_bytes: int = 500_000) -> str:
|
||||||
if not text:
|
# Thin delegator to the module-level helper (kept for existing callers).
|
||||||
return text
|
return truncate_log(text, max_bytes)
|
||||||
encoded = text.encode("utf-8")
|
|
||||||
if len(encoded) <= max_bytes:
|
|
||||||
return text
|
|
||||||
half = max_bytes // 2
|
|
||||||
head = encoded[:half].decode("utf-8", errors="ignore")
|
|
||||||
tail = encoded[-half:].decode("utf-8", errors="ignore")
|
|
||||||
head_lines = head.count("\n")
|
|
||||||
tail_lines = tail.count("\n")
|
|
||||||
total_lines = text.count("\n")
|
|
||||||
elided = max(0, total_lines - head_lines - tail_lines)
|
|
||||||
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
|
|
||||||
return head + marker + tail
|
|
||||||
|
|
||||||
async def download(
|
async def download(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -14,45 +14,45 @@ Decoding rejects malformed cursors with a ValueError; the API layer
|
|||||||
translates that to HTTP 400.
|
translates that to HTTP 400.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from sqlalchemy import Select, and_, exists, func, or_, select
|
from sqlalchemy import Select, and_, distinct, exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from ..models import Artist, ImageProvenance, ImageRecord, Post, Tag
|
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
|
from .pagination import decode_cursor, encode_cursor
|
||||||
|
from .tag_query import (
|
||||||
|
fandom_join_alias,
|
||||||
|
image_in_any_tag_scope,
|
||||||
|
image_in_tag_scope,
|
||||||
|
serialize_tag,
|
||||||
|
tag_columns,
|
||||||
|
)
|
||||||
|
|
||||||
CURSOR_SEPARATOR = "|"
|
# Reserved `platform` filter value selecting images with NO platformed
|
||||||
|
# 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
|
||||||
def encode_cursor(effective_date: datetime, image_id: int) -> str:
|
# bucket is selectable. Underscore-wrapped so it can't collide with a real
|
||||||
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
|
# gallery-dl platform name (patreon/pixiv/...).
|
||||||
return base64.urlsafe_b64encode(raw.encode()).decode()
|
UNSOURCED_PLATFORM = "__unsourced__"
|
||||||
|
|
||||||
|
|
||||||
def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
|
||||||
try:
|
|
||||||
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
|
||||||
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
|
|
||||||
return datetime.fromisoformat(ts_part), int(id_part)
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError(f"invalid cursor: {cursor!r}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _effective_date_col():
|
def _effective_date_col():
|
||||||
"""SQL expression: COALESCE(post.post_date, image_record.created_at).
|
"""The materialized gallery sort key: image_record.effective_date
|
||||||
|
(alembic 0035) = COALESCE(primary post's post_date, created_at),
|
||||||
|
maintained at write time by the importer.
|
||||||
|
|
||||||
Used as the canonical sort/group/filter key across the gallery so
|
Canonical sort/group/filter key across the gallery so images attached
|
||||||
images backfilled with primary_post_id (e.g. via tag_apply phase 4)
|
to a post surface at their original publish date, not their FC import
|
||||||
surface at their original publish date, not their FC import date.
|
date — and, now that it's a single indexed column rather than a
|
||||||
Images without a Post (or with Post.post_date NULL) fall back to
|
COALESCE across the Post outer join, the cursor scroll is an index
|
||||||
image_record.created_at and still order coherently against
|
range scan instead of a full re-sort per page.
|
||||||
post-attached ones.
|
|
||||||
"""
|
"""
|
||||||
return func.coalesce(Post.post_date, ImageRecord.created_at)
|
return ImageRecord.effective_date
|
||||||
|
|
||||||
|
|
||||||
def _outer_join_primary_post(stmt: Select) -> Select:
|
def _outer_join_primary_post(stmt: Select) -> Select:
|
||||||
@@ -91,6 +91,16 @@ class TimelineBucket:
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GalleryFacets:
|
||||||
|
total: int # images matching the FULL active filter
|
||||||
|
platforms: list[dict] # [{"value": str|None, "count": int}], null = unsourced
|
||||||
|
untagged: int # how many the Untagged flag would isolate
|
||||||
|
no_artist: int # how many the No-artist flag would isolate
|
||||||
|
date_min: datetime | None
|
||||||
|
date_max: datetime | None
|
||||||
|
|
||||||
|
|
||||||
def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str:
|
def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str:
|
||||||
"""Return the URL to fetch a thumbnail.
|
"""Return the URL to fetch a thumbnail.
|
||||||
|
|
||||||
@@ -117,13 +127,116 @@ def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str
|
|||||||
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
|
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
|
||||||
|
|
||||||
|
|
||||||
def _require_single_filter(tag_id, post_id, artist_id) -> None:
|
def image_url(path: str) -> str:
|
||||||
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
|
"""Return the URL to fetch the full-size original from /images.
|
||||||
|
|
||||||
|
The on-disk `path` mirrors the source tree (artist/post folders), so it can
|
||||||
|
contain characters that are special in a URL — most importantly '#' (post
|
||||||
|
titles like 'BLUE#59'), but also spaces, '?', '%'. The serve_image route
|
||||||
|
URL-decodes its <path:subpath> fine, but only if the browser sends the whole
|
||||||
|
path; an unencoded '#' is parsed as a fragment, so '#59/01_timelapse.jpg'
|
||||||
|
never reaches the server and the original 404s while the (hash-named)
|
||||||
|
thumbnail still loads. Percent-encode the path, keeping '/' as the segment
|
||||||
|
separator. Operator-flagged 2026-06-12."""
|
||||||
|
rel = path.split("/images/", 1)[-1]
|
||||||
|
return f"/images/{quote(rel, safe='/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _require_single_filter(
|
||||||
|
tag_ids, post_id, artist_id, tag_or_groups=None, tag_exclude=None,
|
||||||
|
) -> None:
|
||||||
|
"""post_id is the post-detail view — it can't combine with the
|
||||||
|
composable filters. tag_ids / tag_or_groups / tag_exclude + artist_id
|
||||||
|
(+ media_type) compose freely (AND)."""
|
||||||
|
if post_id is not None and (
|
||||||
|
tag_ids or artist_id is not None or tag_or_groups or tag_exclude
|
||||||
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"tag_id, post_id, artist_id are mutually exclusive"
|
"post_id cannot be combined with tag or artist filters"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_scope(
|
||||||
|
stmt, *, tag_ids, post_id, artist_id, media_type,
|
||||||
|
tag_or_groups=None, tag_exclude=None,
|
||||||
|
platform=None, untagged=False, no_artist=False,
|
||||||
|
date_from=None, date_to=None,
|
||||||
|
):
|
||||||
|
"""Apply the composable gallery filters to a statement.
|
||||||
|
|
||||||
|
All clauses are correlated EXISTS / scalar predicates on ImageRecord, so
|
||||||
|
they AND together without row-multiplication and don't require any join to
|
||||||
|
be present on `stmt` (the artist/platform paths alias Post/Source inside
|
||||||
|
their own EXISTS).
|
||||||
|
|
||||||
|
Tag filtering is one structured model (#6): AND-of-OR plus exclusions.
|
||||||
|
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag
|
||||||
|
(the AND-of-singletons "include" common case; light editor + back-compat).
|
||||||
|
- tag_or_groups: list of OR-groups; the image must carry AT LEAST ONE tag
|
||||||
|
from EACH group — one EXISTS(tag_id IN group) per group, AND'd across
|
||||||
|
groups. (advanced editor)
|
||||||
|
- tag_exclude: image must carry NONE of these — a single NOT EXISTS(tag_id
|
||||||
|
IN exclude). (light "exclude" chips + advanced NOT)
|
||||||
|
- post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
|
||||||
|
by _require_single_filter).
|
||||||
|
- media_type: 'image' | 'video' narrows by mime prefix.
|
||||||
|
- platform: EXISTS a provenance→source with that platform; the
|
||||||
|
UNSOURCED_PLATFORM sentinel inverts it (NO platformed provenance).
|
||||||
|
- untagged: NOT EXISTS any image_tag row.
|
||||||
|
- no_artist: ImageRecord.artist_id IS NULL.
|
||||||
|
- date_from / date_to: half-open [from, to) bounds on effective_date.
|
||||||
|
"""
|
||||||
|
# Every tag clause goes through image_in_tag_scope/_any: a fandom tag also
|
||||||
|
# matches images carrying any of its characters (Tag.fandom_id). Include,
|
||||||
|
# OR-group, and exclude are all symmetric on that membership.
|
||||||
|
for tid in tag_ids or []:
|
||||||
|
stmt = stmt.where(image_in_tag_scope(tid))
|
||||||
|
for group in tag_or_groups or []:
|
||||||
|
if not group:
|
||||||
|
continue # an empty OR-group would match nothing; treat as absent
|
||||||
|
stmt = stmt.where(image_in_any_tag_scope(group))
|
||||||
|
if tag_exclude:
|
||||||
|
stmt = stmt.where(~image_in_any_tag_scope(tag_exclude))
|
||||||
|
prov = _provenance_clause(post_id, artist_id)
|
||||||
|
if prov is not None:
|
||||||
|
stmt = stmt.where(prov)
|
||||||
|
if media_type == "image":
|
||||||
|
stmt = stmt.where(ImageRecord.mime.like("image/%"))
|
||||||
|
elif media_type == "video":
|
||||||
|
stmt = stmt.where(ImageRecord.mime.like("video/%"))
|
||||||
|
if platform is not None:
|
||||||
|
stmt = stmt.where(_platform_clause(platform))
|
||||||
|
if untagged:
|
||||||
|
stmt = stmt.where(
|
||||||
|
~exists().where(image_tag.c.image_record_id == ImageRecord.id)
|
||||||
|
)
|
||||||
|
if no_artist:
|
||||||
|
stmt = stmt.where(ImageRecord.artist_id.is_(None))
|
||||||
|
eff = _effective_date_col()
|
||||||
|
if date_from is not None:
|
||||||
|
stmt = stmt.where(eff >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
stmt = stmt.where(eff < date_to)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_clause(platform):
|
||||||
|
"""Correlated EXISTS on a provenance row whose Source carries `platform`.
|
||||||
|
The UNSOURCED_PLATFORM sentinel inverts to NOT EXISTS(any sourced
|
||||||
|
provenance) — i.e. filesystem-imported content with no platform."""
|
||||||
|
src = aliased(Source)
|
||||||
|
if platform == UNSOURCED_PLATFORM:
|
||||||
|
return ~exists().where(
|
||||||
|
ImageProvenance.image_record_id == ImageRecord.id,
|
||||||
|
ImageProvenance.source_id == src.id,
|
||||||
|
)
|
||||||
|
return exists().where(
|
||||||
|
ImageProvenance.image_record_id == ImageRecord.id,
|
||||||
|
ImageProvenance.source_id == src.id,
|
||||||
|
src.platform == platform,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provenance_clause(post_id, artist_id):
|
def _provenance_clause(post_id, artist_id):
|
||||||
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
||||||
matching provenance rows is returned exactly once and the
|
matching provenance rows is returned exactly once and the
|
||||||
@@ -155,6 +268,27 @@ def _provenance_clause(post_id, artist_id):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
|
||||||
|
"""Build GalleryImage list from (record, posted_at, eff_date) rows + the
|
||||||
|
artist hydration map. Shared by scroll() and similar()."""
|
||||||
|
return [
|
||||||
|
GalleryImage(
|
||||||
|
id=record.id,
|
||||||
|
path=record.path,
|
||||||
|
sha256=record.sha256,
|
||||||
|
mime=record.mime,
|
||||||
|
width=record.width,
|
||||||
|
height=record.height,
|
||||||
|
created_at=record.created_at,
|
||||||
|
effective_date=eff_date,
|
||||||
|
posted_at=posted_at,
|
||||||
|
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||||
|
artist=artists.get(record.id),
|
||||||
|
)
|
||||||
|
for record, posted_at, eff_date in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||||
"""Map image_id -> {"name","slug"} via the canonical
|
"""Map image_id -> {"name","slug"} via the canonical
|
||||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||||
@@ -179,35 +313,55 @@ class GalleryService:
|
|||||||
self,
|
self,
|
||||||
cursor: str | None,
|
cursor: str | None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
tag_id: int | None = None,
|
tag_ids: list[int] | None = None,
|
||||||
post_id: int | None = None,
|
post_id: int | None = None,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
sort: str = "newest",
|
||||||
|
tag_or_groups: list[list[int]] | None = None,
|
||||||
|
tag_exclude: list[int] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
untagged: bool = False,
|
||||||
|
no_artist: bool = False,
|
||||||
|
date_from: datetime | None = None,
|
||||||
|
date_to: datetime | None = None,
|
||||||
) -> GalleryPage:
|
) -> GalleryPage:
|
||||||
if limit < 1 or limit > 200:
|
if limit < 1 or limit > 200:
|
||||||
raise ValueError("limit must be between 1 and 200")
|
raise ValueError("limit must be between 1 and 200")
|
||||||
_require_single_filter(tag_id, post_id, artist_id)
|
_require_single_filter(
|
||||||
|
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
||||||
|
)
|
||||||
|
|
||||||
eff = _effective_date_col()
|
eff = _effective_date_col()
|
||||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||||
stmt = _outer_join_primary_post(stmt)
|
stmt = _outer_join_primary_post(stmt)
|
||||||
if tag_id is not None:
|
stmt = _apply_scope(
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
image_tag.c.tag_id == tag_id
|
artist_id=artist_id, media_type=media_type,
|
||||||
)
|
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
||||||
prov = _provenance_clause(post_id, artist_id)
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
if prov is not None:
|
date_from=date_from, date_to=date_to,
|
||||||
stmt = stmt.where(prov)
|
)
|
||||||
|
|
||||||
|
descending = sort != "oldest"
|
||||||
if cursor:
|
if cursor:
|
||||||
cur_ts, cur_id = decode_cursor(cursor)
|
cur_ts, cur_id = decode_cursor(cursor)
|
||||||
stmt = stmt.where(
|
# The cursor is just (last eff, last id); the request's sort
|
||||||
or_(
|
# decides which side of it the next page lies on.
|
||||||
eff < cur_ts,
|
if descending:
|
||||||
and_(eff == cur_ts, ImageRecord.id < cur_id),
|
stmt = stmt.where(
|
||||||
|
or_(eff < cur_ts, and_(eff == cur_ts, ImageRecord.id < cur_id))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(eff > cur_ts, and_(eff == cur_ts, ImageRecord.id > cur_id))
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1)
|
if descending:
|
||||||
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
||||||
|
stmt = stmt.limit(limit + 1)
|
||||||
rows = (await self.session.execute(stmt)).all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
@@ -219,22 +373,7 @@ class GalleryService:
|
|||||||
artists = await _artists_for(
|
artists = await _artists_for(
|
||||||
self.session, [r[0].id for r in rows]
|
self.session, [r[0].id for r in rows]
|
||||||
)
|
)
|
||||||
images = [
|
images = _gallery_images(rows, artists)
|
||||||
GalleryImage(
|
|
||||||
id=record.id,
|
|
||||||
path=record.path,
|
|
||||||
sha256=record.sha256,
|
|
||||||
mime=record.mime,
|
|
||||||
width=record.width,
|
|
||||||
height=record.height,
|
|
||||||
created_at=record.created_at,
|
|
||||||
effective_date=eff_date,
|
|
||||||
posted_at=posted_at,
|
|
||||||
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
|
||||||
artist=artists.get(record.id),
|
|
||||||
)
|
|
||||||
for record, posted_at, eff_date in rows
|
|
||||||
]
|
|
||||||
return GalleryPage(
|
return GalleryPage(
|
||||||
images=images,
|
images=images,
|
||||||
next_cursor=next_cursor,
|
next_cursor=next_cursor,
|
||||||
@@ -243,9 +382,17 @@ class GalleryService:
|
|||||||
|
|
||||||
async def timeline(
|
async def timeline(
|
||||||
self,
|
self,
|
||||||
tag_id: int | None = None,
|
tag_ids: list[int] | None = None,
|
||||||
post_id: int | None = None,
|
post_id: int | None = None,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
tag_or_groups: list[list[int]] | None = None,
|
||||||
|
tag_exclude: list[int] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
untagged: bool = False,
|
||||||
|
no_artist: bool = False,
|
||||||
|
date_from: datetime | None = None,
|
||||||
|
date_to: datetime | None = None,
|
||||||
) -> list[TimelineBucket]:
|
) -> list[TimelineBucket]:
|
||||||
eff = _effective_date_col()
|
eff = _effective_date_col()
|
||||||
year_col = func.date_part("year", eff).label("yr")
|
year_col = func.date_part("year", eff).label("yr")
|
||||||
@@ -254,25 +401,33 @@ class GalleryService:
|
|||||||
year_col, month_col, func.count(ImageRecord.id).label("cnt")
|
year_col, month_col, func.count(ImageRecord.id).label("cnt")
|
||||||
)
|
)
|
||||||
stmt = _outer_join_primary_post(stmt)
|
stmt = _outer_join_primary_post(stmt)
|
||||||
_require_single_filter(tag_id, post_id, artist_id)
|
_require_single_filter(
|
||||||
if tag_id is not None:
|
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
)
|
||||||
image_tag.c.tag_id == tag_id
|
stmt = _apply_scope(
|
||||||
)
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
prov = _provenance_clause(post_id, artist_id)
|
artist_id=artist_id, media_type=media_type,
|
||||||
if prov is not None:
|
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
||||||
stmt = stmt.where(prov)
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
|
)
|
||||||
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
||||||
rows = (await self.session.execute(stmt)).all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
|
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
|
||||||
|
|
||||||
async def jump_cursor(
|
async def jump_cursor(
|
||||||
self, year: int, month: int, tag_id: int | None = None,
|
self, year: int, month: int, tag_ids: list[int] | None = None,
|
||||||
post_id: int | None = None, artist_id: int | None = None,
|
post_id: int | None = None, artist_id: int | None = None,
|
||||||
|
media_type: str | None = None, sort: str = "newest",
|
||||||
|
tag_or_groups: list[list[int]] | None = None,
|
||||||
|
tag_exclude: list[int] | None = None,
|
||||||
|
platform: str | None = None, untagged: bool = False,
|
||||||
|
no_artist: bool = False, date_from: datetime | None = None,
|
||||||
|
date_to: datetime | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Returns a cursor that, when passed to scroll(), positions at the
|
"""Returns a cursor that, when passed to scroll() with the same sort,
|
||||||
first image of the given year-month (by effective_date, not
|
positions at the first image of the given year-month. None if the
|
||||||
created_at). None if the bucket is empty.
|
bucket is empty.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import extract
|
from sqlalchemy import extract
|
||||||
|
|
||||||
@@ -282,34 +437,189 @@ class GalleryService:
|
|||||||
extract("month", eff) == month,
|
extract("month", eff) == month,
|
||||||
)
|
)
|
||||||
stmt = _outer_join_primary_post(stmt)
|
stmt = _outer_join_primary_post(stmt)
|
||||||
_require_single_filter(tag_id, post_id, artist_id)
|
_require_single_filter(
|
||||||
if tag_id is not None:
|
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
)
|
||||||
image_tag.c.tag_id == tag_id
|
stmt = _apply_scope(
|
||||||
)
|
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||||
prov = _provenance_clause(post_id, artist_id)
|
artist_id=artist_id, media_type=media_type,
|
||||||
if prov is not None:
|
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
||||||
stmt = stmt.where(prov)
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
|
date_from=date_from, date_to=date_to,
|
||||||
first = (await self.session.execute(stmt)).first()
|
)
|
||||||
|
descending = sort != "oldest"
|
||||||
|
if descending:
|
||||||
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
||||||
|
first = (await self.session.execute(stmt.limit(1))).first()
|
||||||
if first is None:
|
if first is None:
|
||||||
return None
|
return None
|
||||||
record, eff_date = first
|
record, eff_date = first
|
||||||
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
|
# Cursor is exclusive; nudge the id one past the boundary row (in the
|
||||||
# is the first result in the next scroll().
|
# scan direction) so the row itself is the first result of scroll().
|
||||||
return encode_cursor(eff_date, record.id + 1)
|
boundary = record.id + 1 if descending else record.id - 1
|
||||||
|
return encode_cursor(eff_date, boundary)
|
||||||
|
|
||||||
|
async def facets(
|
||||||
|
self, *, tag_ids: list[int] | None = None,
|
||||||
|
post_id: int | None = None, artist_id: int | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
tag_or_groups: list[list[int]] | None = None,
|
||||||
|
tag_exclude: list[int] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
untagged: bool = False, no_artist: bool = False,
|
||||||
|
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||||
|
) -> GalleryFacets:
|
||||||
|
"""Live facet counts scoped to the current filter. Each facet GROUP is
|
||||||
|
computed with all OTHER active filters applied but its OWN selection
|
||||||
|
ignored ("minus-self"), so sibling options stay visible/switchable.
|
||||||
|
No outer join is needed — every clause is a correlated EXISTS or a
|
||||||
|
column predicate on ImageRecord.
|
||||||
|
"""
|
||||||
|
_require_single_filter(
|
||||||
|
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
|
||||||
|
)
|
||||||
|
common = {
|
||||||
|
"tag_ids": tag_ids, "post_id": post_id,
|
||||||
|
"artist_id": artist_id, "media_type": media_type,
|
||||||
|
"tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
|
||||||
|
}
|
||||||
|
|
||||||
|
# total — the full active filter (the headline result count).
|
||||||
|
total = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.count(ImageRecord.id)), **common,
|
||||||
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
# platforms — scope minus the platform selection. Inner-join
|
||||||
|
# provenance→source and COUNT(DISTINCT image) per platform (a
|
||||||
|
# cross-posted image counts under each of its platforms).
|
||||||
|
plat_scope = {
|
||||||
|
**common, "untagged": untagged, "no_artist": no_artist,
|
||||||
|
"date_from": date_from, "date_to": date_to,
|
||||||
|
}
|
||||||
|
src = aliased(Source)
|
||||||
|
plat_stmt = (
|
||||||
|
select(src.platform, func.count(distinct(ImageRecord.id)))
|
||||||
|
.select_from(ImageRecord)
|
||||||
|
.join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id)
|
||||||
|
.join(src, src.id == ImageProvenance.source_id)
|
||||||
|
)
|
||||||
|
plat_stmt = _apply_scope(plat_stmt, **plat_scope).group_by(src.platform)
|
||||||
|
platforms = [
|
||||||
|
{"value": p, "count": c}
|
||||||
|
for p, c in (await self.session.execute(plat_stmt)).all()
|
||||||
|
]
|
||||||
|
# Unsourced (filesystem) bucket — same minus-platform scope.
|
||||||
|
unsourced = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.count(ImageRecord.id)), **plat_scope,
|
||||||
|
platform=UNSOURCED_PLATFORM,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
if unsourced:
|
||||||
|
platforms.append({"value": None, "count": unsourced})
|
||||||
|
|
||||||
|
# curation flags — each minus its OWN flag.
|
||||||
|
untagged_count = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.count(ImageRecord.id)), **common,
|
||||||
|
platform=platform, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to, untagged=True,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
no_artist_count = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.count(ImageRecord.id)), **common,
|
||||||
|
platform=platform, untagged=untagged,
|
||||||
|
date_from=date_from, date_to=date_to, no_artist=True,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
# date bounds — scope minus the date params (those drive the picker).
|
||||||
|
eff = _effective_date_col()
|
||||||
|
dmin, dmax = (await self.session.execute(
|
||||||
|
_apply_scope(
|
||||||
|
select(func.min(eff), func.max(eff)), **common,
|
||||||
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
)
|
||||||
|
)).one()
|
||||||
|
|
||||||
|
return GalleryFacets(
|
||||||
|
total=total, platforms=platforms,
|
||||||
|
untagged=untagged_count, no_artist=no_artist_count,
|
||||||
|
date_min=dmin, date_max=dmax,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def similar(
|
||||||
|
self, image_id: int, limit: int = 100, *,
|
||||||
|
tag_ids: list[int] | None = None, artist_id: int | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
tag_or_groups: list[list[int]] | None = None,
|
||||||
|
tag_exclude: list[int] | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
untagged: bool = False, no_artist: bool = False,
|
||||||
|
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||||
|
) -> list[GalleryImage] | None:
|
||||||
|
"""Visual "more like this": images ranked by cosine distance to
|
||||||
|
`image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
|
||||||
|
No ML inference here; the embedding was computed at import.
|
||||||
|
|
||||||
|
Returns None if the source image doesn't exist (→ 404), [] if it has
|
||||||
|
no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
|
||||||
|
scope filters (AND) but REPLACES the date sort — always nearest-first,
|
||||||
|
bounded to `limit` (no cursor; distance-ranking has no date cursor).
|
||||||
|
"""
|
||||||
|
if limit < 1 or limit > 200:
|
||||||
|
raise ValueError("limit must be between 1 and 200")
|
||||||
|
src = await self.session.get(ImageRecord, image_id)
|
||||||
|
if src is None:
|
||||||
|
return None
|
||||||
|
if src.siglip_embedding is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||||
|
eff = _effective_date_col()
|
||||||
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||||
|
stmt = _outer_join_primary_post(stmt)
|
||||||
|
stmt = stmt.where(
|
||||||
|
ImageRecord.siglip_embedding.is_not(None),
|
||||||
|
ImageRecord.id != image_id,
|
||||||
|
)
|
||||||
|
stmt = _apply_scope(
|
||||||
|
stmt, tag_ids=tag_ids, post_id=None,
|
||||||
|
artist_id=artist_id, media_type=media_type,
|
||||||
|
tag_or_groups=tag_or_groups, tag_exclude=tag_exclude,
|
||||||
|
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(distance.asc()).limit(limit)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||||
|
return _gallery_images(rows, artists)
|
||||||
|
|
||||||
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||||
record = await self.session.get(ImageRecord, image_id)
|
record = await self.session.get(ImageRecord, image_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
return None
|
return None
|
||||||
|
# Self-join Tag to resolve a character's fandom NAME (not just id) so the
|
||||||
|
# modal chip can label it without an N+1 (shared tag_query helpers).
|
||||||
|
fandom_alias = fandom_join_alias()
|
||||||
tag_stmt = (
|
tag_stmt = (
|
||||||
select(Tag)
|
select(*tag_columns(fandom_alias))
|
||||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
.select_from(
|
||||||
|
Tag.__table__
|
||||||
|
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
||||||
|
.outerjoin(fandom_alias, Tag.fandom_id == fandom_alias.c.id)
|
||||||
|
)
|
||||||
.where(image_tag.c.image_record_id == image_id)
|
.where(image_tag.c.image_record_id == image_id)
|
||||||
.order_by(Tag.kind.asc(), Tag.name.asc())
|
.order_by(Tag.kind.asc(), Tag.name.asc())
|
||||||
)
|
)
|
||||||
tags = (await self.session.execute(tag_stmt)).scalars().all()
|
tags = (await self.session.execute(tag_stmt)).all()
|
||||||
# Fetch the canonical post.post_date for this image (if any) so
|
# Fetch the canonical post.post_date for this image (if any) so
|
||||||
# the modal can show "Posted on <date>" alongside import date.
|
# the modal can show "Posted on <date>" alongside import date.
|
||||||
posted_at = None
|
posted_at = None
|
||||||
@@ -336,38 +646,26 @@ class GalleryService:
|
|||||||
"height": record.height,
|
"height": record.height,
|
||||||
"size_bytes": record.size_bytes,
|
"size_bytes": record.size_bytes,
|
||||||
"integrity_status": record.integrity_status,
|
"integrity_status": record.integrity_status,
|
||||||
|
# Phase 3: lets the modal hide the "Related"/find-similar surface
|
||||||
|
# for images that have no embedding yet (videos / pending ML).
|
||||||
|
"has_embedding": record.siglip_embedding is not None,
|
||||||
"created_at": record.created_at.isoformat(),
|
"created_at": record.created_at.isoformat(),
|
||||||
"posted_at": posted_at.isoformat() if posted_at else None,
|
"posted_at": posted_at.isoformat() if posted_at else None,
|
||||||
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||||
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
|
"image_url": image_url(record.path),
|
||||||
"artist": (
|
"artist": (
|
||||||
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||||
if artist is not None else None
|
if artist is not None else None
|
||||||
),
|
),
|
||||||
"tags": [
|
"tags": [serialize_tag(t) for t in tags],
|
||||||
{
|
|
||||||
"id": t.id,
|
|
||||||
"name": t.name,
|
|
||||||
"kind": t.kind.value if hasattr(t.kind, "value") else t.kind,
|
|
||||||
"fandom_id": t.fandom_id,
|
|
||||||
}
|
|
||||||
for t in tags
|
|
||||||
],
|
|
||||||
"neighbors": neighbors,
|
"neighbors": neighbors,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _neighbors(self, record: ImageRecord) -> dict:
|
async def _neighbors(self, record: ImageRecord) -> dict:
|
||||||
# Compute the boundary image's effective_date in Python (one query
|
# The boundary image's sort key is materialized on the row now
|
||||||
# below + the SELECT we already have on `record`) and use it for
|
# (alembic 0035) — read it directly instead of re-deriving COALESCE
|
||||||
# the neighbor comparison. Cheaper than re-deriving in SQL via
|
# via an extra Post lookup.
|
||||||
# correlated subquery.
|
boundary_eff = record.effective_date
|
||||||
boundary_eff = record.created_at
|
|
||||||
if record.primary_post_id is not None:
|
|
||||||
post_date = (await self.session.execute(
|
|
||||||
select(Post.post_date).where(Post.id == record.primary_post_id)
|
|
||||||
)).scalar_one_or_none()
|
|
||||||
if post_date is not None:
|
|
||||||
boundary_eff = post_date
|
|
||||||
|
|
||||||
eff = _effective_date_col()
|
eff = _effective_date_col()
|
||||||
prev_stmt = _outer_join_primary_post(
|
prev_stmt = _outer_join_primary_post(
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ from enum import StrEnum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
Artist,
|
Artist,
|
||||||
|
ExternalLink,
|
||||||
ImageProvenance,
|
ImageProvenance,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
ImportSettings,
|
ImportSettings,
|
||||||
@@ -31,12 +32,19 @@ from ..models import (
|
|||||||
Source,
|
Source,
|
||||||
)
|
)
|
||||||
from ..utils import safe_probe
|
from ..utils import safe_probe
|
||||||
from ..utils.paths import derive_subdir, derive_top_level_artist, hash_suffixed_name
|
from ..utils.paths import (
|
||||||
|
derive_subdir,
|
||||||
|
derive_top_level_artist,
|
||||||
|
filehash_from_url,
|
||||||
|
hash_suffixed_name,
|
||||||
|
safe_ext,
|
||||||
|
)
|
||||||
from ..utils.phash import compute_phash, find_similar
|
from ..utils.phash import compute_phash, find_similar
|
||||||
from ..utils.sidecar import find_sidecar, parse_sidecar
|
from ..utils.sidecar import find_sidecar, parse_sidecar
|
||||||
from ..utils.slug import slugify
|
from ..utils.slug import slugify
|
||||||
from .archive_extractor import extract_archive, is_archive
|
from .archive_extractor import extract_archive, is_archive
|
||||||
from .attachment_store import AttachmentStore
|
from .attachment_store import AttachmentStore
|
||||||
|
from .link_extract import extract_external_links
|
||||||
from .thumbnailer import Thumbnailer
|
from .thumbnailer import Thumbnailer
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -71,6 +79,21 @@ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
|||||||
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
|
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
|
||||||
ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS
|
ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS
|
||||||
|
|
||||||
|
# A "high-resolution files" pack often wraps per-chapter .rar/.zip inside one
|
||||||
|
# outer archive (incase #718) — recurse into nested archives so their images
|
||||||
|
# land instead of being silently dropped. Cap the depth so a maliciously
|
||||||
|
# deep-nested archive can't recurse forever; the bomb-probe re-runs per level.
|
||||||
|
_ARCHIVE_MAX_DEPTH = 3
|
||||||
|
|
||||||
|
# Tier-1 video near-dup (#871). pHash is images-only, so videos deduped on sha256
|
||||||
|
# alone — a different encode/remux of the same clip imported as a distinct record.
|
||||||
|
# Identity = container duration within a tight tolerance + matching aspect ratio,
|
||||||
|
# scoped to the same artist (codec/bitrate are NOT part of identity — the point is
|
||||||
|
# to match ACROSS re-encodes). Quality axis for supersede = pixel dimensions, same
|
||||||
|
# as image pHash. Tight tolerances because a wrong video merge is destructive.
|
||||||
|
_VIDEO_DUP_DURATION_TOL_SECONDS = 1.0
|
||||||
|
_VIDEO_DUP_ASPECT_TOL = 0.02
|
||||||
|
|
||||||
|
|
||||||
def is_supported(path: Path) -> bool:
|
def is_supported(path: Path) -> bool:
|
||||||
return path.suffix.lower() in ALL_EXTS
|
return path.suffix.lower() in ALL_EXTS
|
||||||
@@ -82,27 +105,9 @@ def is_video(path: Path) -> bool:
|
|||||||
|
|
||||||
def _safe_ext(path: Path) -> str:
|
def _safe_ext(path: Path) -> str:
|
||||||
"""Conservatively extract a file extension for PostAttachment.ext
|
"""Conservatively extract a file extension for PostAttachment.ext
|
||||||
(varchar(32)).
|
(varchar(32)). Thin wrapper over the shared `utils.paths.safe_ext` (kept for
|
||||||
|
the Path-typed call sites + the [[path_suffix_sanitize]] memory pointer)."""
|
||||||
gallery-dl produces some filenames with URL-encoded query-string
|
return safe_ext(path)
|
||||||
artifacts embedded into the basename (e.g.
|
|
||||||
`79507046_media_..._https___www.patreon.com_media-u_Z0FBQUFBQm5q...`).
|
|
||||||
`Path.suffix` finds the LAST dot and returns everything after, which
|
|
||||||
in those cases yields a 50+ char "extension" of mostly base64-ish
|
|
||||||
junk. That blows the column. Operator-flagged 2026-05-25.
|
|
||||||
|
|
||||||
Real extensions are short and alphanumeric. We accept anything ≤ 16
|
|
||||||
chars where every post-dot character is alphanumeric; anything else
|
|
||||||
means the input wasn't a real extension and we return the empty
|
|
||||||
string. ext is nullable-ish (empty string still satisfies NOT NULL)
|
|
||||||
and consumers should treat "" as "no known extension".
|
|
||||||
"""
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
if not suffix or len(suffix) > 16:
|
|
||||||
return ""
|
|
||||||
if not all(c.isalnum() for c in suffix[1:]):
|
|
||||||
return ""
|
|
||||||
return suffix
|
|
||||||
|
|
||||||
|
|
||||||
def _mime_for(path: Path) -> str:
|
def _mime_for(path: Path) -> str:
|
||||||
@@ -159,6 +164,14 @@ class Importer:
|
|||||||
self.thumbnailer = thumbnailer
|
self.thumbnailer = thumbnailer
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.deep = deep
|
self.deep = deep
|
||||||
|
# Post-first ingest (#856, milestone #67): on the NATIVE core ingester the
|
||||||
|
# post-record (`upsert_post_record`) is the SOLE writer of a post's
|
||||||
|
# body/links/metadata; the per-media import only links image provenance +
|
||||||
|
# localization, NOT the post body. download_service sets this True for
|
||||||
|
# native platforms before the phase-3 media loop. Default False keeps the
|
||||||
|
# gallery-dl path writing post fields via `_apply_sidecar` (unchanged).
|
||||||
|
# Safe as a per-instance flag: Importer is constructed per Celery task.
|
||||||
|
self.post_first = False
|
||||||
self.attachments = AttachmentStore(images_root)
|
self.attachments = AttachmentStore(images_root)
|
||||||
# phash near-dup candidate cache. Archive imports call _import_media
|
# phash near-dup candidate cache. Archive imports call _import_media
|
||||||
# per-member; without this cache the per-member SELECT *FROM
|
# per-member; without this cache the per-member SELECT *FROM
|
||||||
@@ -318,6 +331,32 @@ class Importer:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _sync_external_links(self, post: Post) -> None:
|
||||||
|
"""Record off-platform file-host links (mega/gdrive/mediafire/dropbox/
|
||||||
|
pixeldrain) found in the post body, so they're never silently dropped
|
||||||
|
and the download worker can fetch them later. Shared by every platform's
|
||||||
|
import (runs off Post.description). INSERT-MISSING only — an existing row
|
||||||
|
keeps its status/attempts (a re-import must not reset a link already
|
||||||
|
downloaded or dead-lettered). Identity is (post_id, url); the full url
|
||||||
|
incl. #fragment is preserved by the extractor."""
|
||||||
|
links = extract_external_links(post.description)
|
||||||
|
if not links:
|
||||||
|
return
|
||||||
|
self.session.flush() # ensure post.id is assigned before we reference it
|
||||||
|
existing = set(self.session.execute(
|
||||||
|
select(ExternalLink.url).where(ExternalLink.post_id == post.id)
|
||||||
|
).scalars().all())
|
||||||
|
for link in links:
|
||||||
|
if link.url in existing:
|
||||||
|
continue
|
||||||
|
self.session.add(ExternalLink(
|
||||||
|
post_id=post.id,
|
||||||
|
artist_id=post.artist_id,
|
||||||
|
host=link.host,
|
||||||
|
url=link.url,
|
||||||
|
label=link.label,
|
||||||
|
))
|
||||||
|
|
||||||
def import_one(self, source: Path) -> ImportResult:
|
def import_one(self, source: Path) -> ImportResult:
|
||||||
"""Dispatch by kind. Media → normal pipeline. Archive → extract
|
"""Dispatch by kind. Media → normal pipeline. Archive → extract
|
||||||
media members (one Post via the archive-adjacent sidecar) and
|
media members (one Post via the archive-adjacent sidecar) and
|
||||||
@@ -374,22 +413,39 @@ class Importer:
|
|||||||
artist = self._resolve_artist(source)
|
artist = self._resolve_artist(source)
|
||||||
post = self._post_for_sidecar(source, artist)
|
post = self._post_for_sidecar(source, artist)
|
||||||
sha = _sha256_of(source)
|
sha = _sha256_of(source)
|
||||||
select_existing = select(PostAttachment).where(PostAttachment.sha256 == sha)
|
post_id = post.id if post else None
|
||||||
|
# Dedup is PER-POST, not global: a non-art file the creator attaches to
|
||||||
|
# many posts (a standard pdf/zip/link-card) must get a row on EVERY post
|
||||||
|
# so none is left a bare shell. The on-disk blob stays sha-deduped
|
||||||
|
# (attachments.store is sha-addressed + idempotent); only the rows are
|
||||||
|
# per-post. PostAttachment's unique is partial (post_id, sha256) for
|
||||||
|
# real posts and (sha256) for the NULL-post filesystem case. Before
|
||||||
|
# 2026-06-08 the global UNIQUE(sha256) left every post after the first
|
||||||
|
# with no attachment row → 1589 empty Anduo shells.
|
||||||
|
if post_id is not None:
|
||||||
|
select_existing = select(PostAttachment).where(
|
||||||
|
PostAttachment.post_id == post_id,
|
||||||
|
PostAttachment.sha256 == sha,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
select_existing = select(PostAttachment).where(
|
||||||
|
PostAttachment.post_id.is_(None),
|
||||||
|
PostAttachment.sha256 == sha,
|
||||||
|
)
|
||||||
existing = self.session.execute(select_existing).scalar_one_or_none()
|
existing = self.session.execute(select_existing).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
return ImportResult(status="attached")
|
return ImportResult(status="attached")
|
||||||
# Savepoint + IntegrityError recovery — PostAttachment.sha256 is
|
# Savepoint + IntegrityError recovery — the partial UNIQUE means two
|
||||||
# UNIQUE, so two workers can both pass the SELECT and only the
|
# workers can both pass the SELECT and only the second INSERT fails.
|
||||||
# second INSERT fails. Without savepoint, the outer transaction
|
# Without savepoint, the outer transaction poisons and the calling task
|
||||||
# poisons and the calling task crashes. attachments.store is
|
# crashes. attachments.store is sha-addressed so both workers race to
|
||||||
# sha-addressed so both workers race to write the same target
|
# write the same target path; shutil.copy2 + rename is idempotent.
|
||||||
# path; shutil.copy2 + rename is idempotent. Audit 2026-06-02.
|
|
||||||
sp = self.session.begin_nested()
|
sp = self.session.begin_nested()
|
||||||
try:
|
try:
|
||||||
stored = self.attachments.store(source, sha)
|
stored = self.attachments.store(source, sha)
|
||||||
self.session.add(PostAttachment(
|
self.session.add(PostAttachment(
|
||||||
post_id=post.id if post else None,
|
post_id=post_id,
|
||||||
artist_id=artist.id if artist else None,
|
artist_id=artist.id if artist else None,
|
||||||
sha256=sha,
|
sha256=sha,
|
||||||
path=stored,
|
path=stored,
|
||||||
@@ -402,7 +458,7 @@ class Importer:
|
|||||||
sp.commit()
|
sp.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
sp.rollback()
|
sp.rollback()
|
||||||
# Lost the race — the other worker's row is canonical.
|
# Lost the race — the other worker's row for this (post, sha) wins.
|
||||||
self.session.execute(select_existing).scalar_one()
|
self.session.execute(select_existing).scalar_one()
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
return ImportResult(status="attached")
|
return ImportResult(status="attached")
|
||||||
@@ -443,30 +499,218 @@ class Importer:
|
|||||||
self._capture_attachment(
|
self._capture_attachment(
|
||||||
source, post=post, artist=artist_use, resolved=True,
|
source, post=post, artist=artist_use, resolved=True,
|
||||||
)
|
)
|
||||||
return ImportResult(status="attached")
|
reason = f"archive probe rejected, captured unextracted: {probe.reason}"
|
||||||
|
log.warning("%s: %s", source.name, reason)
|
||||||
|
return ImportResult(status="attached", error=reason)
|
||||||
|
|
||||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||||
post = self._post_for_sidecar(source, artist_use)
|
post = self._post_for_sidecar(source, artist_use)
|
||||||
member_ids: list[int] = []
|
member_ids: list[int] = []
|
||||||
with extract_archive(source) as members:
|
# Every member image touched (new + superseded + deduped), so the
|
||||||
for _name, member_path in members:
|
# from_attachment_id stamp below covers files that already existed in the
|
||||||
if not is_supported(member_path):
|
# library and were merely re-linked to this post — those matter most
|
||||||
continue # non-media preserved via the stored archive
|
# (the HR copy a bundle re-ships). Separate from member_ids, which is
|
||||||
res = self._import_media(
|
# the NEWLY-imported subset feeding the ImportResult contract.
|
||||||
member_path, source, explicit_source=source_row,
|
member_record_ids: set[int] = set()
|
||||||
)
|
# Per-outcome tally so the "no images" reason names the ACTUAL cause
|
||||||
if res.status in ("imported", "superseded") and res.image_id:
|
# (#718): nested-archive packs, all-deduped (benign), unsupported formats,
|
||||||
member_ids.append(res.image_id)
|
# or failed/corrupt members — instead of one catch-all string.
|
||||||
|
counts = {
|
||||||
|
"media": 0, "deduped": 0, "unsupported": 0,
|
||||||
|
"failed": 0, "nested": 0, "nested_rejected": 0,
|
||||||
|
}
|
||||||
|
self._collect_archive_members(
|
||||||
|
source, attribution=source, source_row=source_row,
|
||||||
|
depth=0, member_ids=member_ids, counts=counts,
|
||||||
|
member_record_ids=member_record_ids,
|
||||||
|
)
|
||||||
# Preserve the archive itself (links to the same Post/Artist).
|
# Preserve the archive itself (links to the same Post/Artist).
|
||||||
self._capture_attachment(
|
self._capture_attachment(
|
||||||
source, post=post, artist=artist_use, resolved=True
|
source, post=post, artist=artist_use, resolved=True
|
||||||
)
|
)
|
||||||
|
# Stamp each member's provenance row for THIS post with the archive it
|
||||||
|
# came out of (milestone #87). Done as a post-pass rather than threaded
|
||||||
|
# through _import_media/_apply_sidecar so the many dedup/supersede
|
||||||
|
# branches stay untouched. NULL-only so a re-extract never re-stamps and
|
||||||
|
# the backfill (reextract task → this same path) is idempotent. Nested
|
||||||
|
# members link to this OUTER archive — the only one stored as a blob.
|
||||||
|
self._stamp_member_archive(
|
||||||
|
post.id if post is not None else None, source, member_record_ids,
|
||||||
|
)
|
||||||
if member_ids:
|
if member_ids:
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
status="imported", image_id=member_ids[0],
|
status="imported", image_id=member_ids[0],
|
||||||
member_image_ids=member_ids,
|
member_image_ids=member_ids,
|
||||||
)
|
)
|
||||||
return ImportResult(status="attached")
|
# No NEW images landed. Name WHY precisely so a post showing "no images"
|
||||||
|
# beside an archive is diagnosable — and DON'T flag the benign all-deduped
|
||||||
|
# case as a problem: those images already exist in the library and were
|
||||||
|
# re-linked to this post (enrich-on-duplicate), so the post DOES show them.
|
||||||
|
if counts["media"] == 0 and counts["nested"] == 0:
|
||||||
|
reason = ("archive yielded no members (unsupported/corrupt, or the "
|
||||||
|
"extractor backend failed)")
|
||||||
|
elif counts["deduped"] and not (
|
||||||
|
counts["unsupported"] or counts["failed"] or counts["nested_rejected"]
|
||||||
|
):
|
||||||
|
log.info(
|
||||||
|
"%s: all %d image member(s) already in library (deduped + linked "
|
||||||
|
"to this post) — archive preserved, no new import",
|
||||||
|
source.name, counts["deduped"],
|
||||||
|
)
|
||||||
|
return ImportResult(status="attached")
|
||||||
|
else:
|
||||||
|
reason = (
|
||||||
|
f"no new images — media={counts['media']} deduped={counts['deduped']} "
|
||||||
|
f"unsupported/non-media={counts['unsupported']} failed={counts['failed']} "
|
||||||
|
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']}"
|
||||||
|
)
|
||||||
|
log.warning("%s: %s", source.name, reason)
|
||||||
|
return ImportResult(status="attached", error=reason)
|
||||||
|
|
||||||
|
def _collect_archive_members(
|
||||||
|
self, archive_path: Path, *, attribution: Path,
|
||||||
|
source_row: Source | None, depth: int,
|
||||||
|
member_ids: list[int], counts: dict,
|
||||||
|
member_record_ids: set[int],
|
||||||
|
) -> None:
|
||||||
|
"""Extract `archive_path` and import its image/video members, RECURSING
|
||||||
|
into nested archives (#718). Members attribute to `attribution` — the
|
||||||
|
OUTER archive's path, so its sidecar resolves them to the right Post even
|
||||||
|
when they came from a nested archive. Depth-capped + bomb-probed per
|
||||||
|
nested level. Mutates `member_ids` and `counts` in place.
|
||||||
|
|
||||||
|
extract_archive is fail-soft, but each level is wrapped so one bad nested
|
||||||
|
archive can't abort the whole import."""
|
||||||
|
try:
|
||||||
|
with extract_archive(archive_path) as members:
|
||||||
|
for _name, member_path in members:
|
||||||
|
if not member_path.is_file():
|
||||||
|
continue # directory entries etc. — not media
|
||||||
|
if is_archive(member_path):
|
||||||
|
counts["nested"] += 1
|
||||||
|
if depth + 1 > _ARCHIVE_MAX_DEPTH:
|
||||||
|
counts["nested_rejected"] += 1
|
||||||
|
log.warning(
|
||||||
|
"nested archive past depth cap %d, skipped: %s",
|
||||||
|
_ARCHIVE_MAX_DEPTH, member_path.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
probe = safe_probe.probe_archive(member_path)
|
||||||
|
if not probe.ok:
|
||||||
|
counts["nested_rejected"] += 1
|
||||||
|
log.warning(
|
||||||
|
"nested archive rejected (%s): %s",
|
||||||
|
probe.reason, member_path.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._collect_archive_members(
|
||||||
|
member_path, attribution=attribution,
|
||||||
|
source_row=source_row, depth=depth + 1,
|
||||||
|
member_ids=member_ids, counts=counts,
|
||||||
|
member_record_ids=member_record_ids,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
counts["media"] += 1
|
||||||
|
if not is_supported(member_path):
|
||||||
|
counts["unsupported"] += 1
|
||||||
|
continue # non-media preserved via the stored archive
|
||||||
|
res = self._import_media(
|
||||||
|
member_path, attribution, explicit_source=source_row,
|
||||||
|
)
|
||||||
|
if res.status in ("imported", "superseded") and res.image_id:
|
||||||
|
member_ids.append(res.image_id)
|
||||||
|
member_record_ids.add(res.image_id)
|
||||||
|
elif res.status == "skipped" and res.skip_reason in (
|
||||||
|
SkipReason.duplicate_hash, SkipReason.duplicate_phash
|
||||||
|
):
|
||||||
|
counts["deduped"] += 1
|
||||||
|
# A deduped member still links provenance to this post
|
||||||
|
# (enrich-on-duplicate); record it so its archive origin
|
||||||
|
# gets stamped too.
|
||||||
|
if res.image_id:
|
||||||
|
member_record_ids.add(res.image_id)
|
||||||
|
else:
|
||||||
|
counts["failed"] += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 — defensive per level; keep going
|
||||||
|
log.warning(
|
||||||
|
"archive extraction failed for %s (depth %d): %s",
|
||||||
|
archive_path.name, depth, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stamp_member_archive(
|
||||||
|
self, post_id: int | None, archive_source: Path, member_record_ids: set[int],
|
||||||
|
) -> None:
|
||||||
|
"""Record which archive each extracted member came from (milestone #87).
|
||||||
|
|
||||||
|
Resolves the archive's own PostAttachment (by post + sha — it was just
|
||||||
|
captured) and stamps from_attachment_id on every member's provenance row
|
||||||
|
FOR THIS POST. NULL-only, so re-extracting the same archive (the backfill
|
||||||
|
path) never overwrites and stays idempotent. No-op when the archive isn't
|
||||||
|
post-attached (filesystem import with no post) or yielded no members.
|
||||||
|
"""
|
||||||
|
if post_id is None or not member_record_ids:
|
||||||
|
return
|
||||||
|
sha = _sha256_of(archive_source)
|
||||||
|
att_id = self.session.execute(
|
||||||
|
select(PostAttachment.id).where(
|
||||||
|
PostAttachment.post_id == post_id,
|
||||||
|
PostAttachment.sha256 == sha,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if att_id is None:
|
||||||
|
return
|
||||||
|
self.session.execute(
|
||||||
|
update(ImageProvenance)
|
||||||
|
.where(
|
||||||
|
ImageProvenance.image_record_id.in_(member_record_ids),
|
||||||
|
ImageProvenance.post_id == post_id,
|
||||||
|
ImageProvenance.from_attachment_id.is_(None),
|
||||||
|
)
|
||||||
|
.values(from_attachment_id=att_id)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _video_aspect_matches(w, h, cw, ch) -> bool:
|
||||||
|
"""True when two (w,h) pairs share an aspect ratio within tolerance.
|
||||||
|
Missing dims → don't block (duration is the primary signal)."""
|
||||||
|
if not (w and h and cw and ch):
|
||||||
|
return True
|
||||||
|
return abs((w / h) - (cw / ch)) <= _VIDEO_DUP_ASPECT_TOL
|
||||||
|
|
||||||
|
def _find_similar_video(
|
||||||
|
self, duration: float | None, width: int | None, height: int | None,
|
||||||
|
artist_id: int | None,
|
||||||
|
) -> tuple[str, int | None]:
|
||||||
|
"""Tier-1 video near-dup (#871). Find a same-artist video whose duration
|
||||||
|
matches within tolerance AND whose aspect ratio matches — treat it as the
|
||||||
|
same content (a different encode/remux). Returns find_similar's contract:
|
||||||
|
("none"|"larger_exists"|"smaller_exists", id), quality judged by pixel
|
||||||
|
dimensions so a higher-res copy supersedes a smaller one."""
|
||||||
|
if duration is None or artist_id is None:
|
||||||
|
return ("none", None)
|
||||||
|
lo = duration - _VIDEO_DUP_DURATION_TOL_SECONDS
|
||||||
|
hi = duration + _VIDEO_DUP_DURATION_TOL_SECONDS
|
||||||
|
rows = self.session.execute(
|
||||||
|
select(ImageRecord.id, ImageRecord.width, ImageRecord.height)
|
||||||
|
.where(
|
||||||
|
ImageRecord.artist_id == artist_id,
|
||||||
|
ImageRecord.mime.like("video/%"),
|
||||||
|
ImageRecord.duration_seconds.is_not(None),
|
||||||
|
ImageRecord.duration_seconds >= lo,
|
||||||
|
ImageRecord.duration_seconds <= hi,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
w, h = width or 0, height or 0
|
||||||
|
for cid, cw, ch in rows:
|
||||||
|
if not self._video_aspect_matches(width, height, cw, ch):
|
||||||
|
continue
|
||||||
|
cw0, ch0 = cw or 0, ch or 0
|
||||||
|
if cw0 >= w and ch0 >= h:
|
||||||
|
return ("larger_exists", cid)
|
||||||
|
if w > cw0 or h > ch0:
|
||||||
|
return ("smaller_exists", cid)
|
||||||
|
return ("none", None)
|
||||||
|
|
||||||
def _import_media(
|
def _import_media(
|
||||||
self, source: Path, attribution_path: Path,
|
self, source: Path, attribution_path: Path,
|
||||||
@@ -486,13 +730,13 @@ class Importer:
|
|||||||
|
|
||||||
# Compute file dimensions (images only) and apply filters.
|
# Compute file dimensions (images only) and apply filters.
|
||||||
width = height = None
|
width = height = None
|
||||||
|
duration = None # video container duration (Tier-1 near-dup key, #871)
|
||||||
has_alpha = False
|
has_alpha = False
|
||||||
if is_video(source):
|
if is_video(source):
|
||||||
# Layer-3 isolation: validate the container via ffprobe (a
|
# Layer-3 isolation: validate the container via ffprobe (a
|
||||||
# separate process) before the rest of the pipeline touches
|
# separate process) before the rest of the pipeline touches
|
||||||
# it. A corrupt video that would crash a decoder is rejected
|
# it. A corrupt video that would crash a decoder is rejected
|
||||||
# cleanly here, and we capture width/height for free (the
|
# cleanly here, and we capture width/height + duration for free.
|
||||||
# importer didn't previously record video dimensions).
|
|
||||||
probe = safe_probe.probe_video(source)
|
probe = safe_probe.probe_video(source)
|
||||||
if not probe.ok:
|
if not probe.ok:
|
||||||
if probe.crashed:
|
if probe.crashed:
|
||||||
@@ -504,7 +748,7 @@ class Importer:
|
|||||||
status="skipped", skip_reason=SkipReason.invalid_image,
|
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||||
error=probe.reason,
|
error=probe.reason,
|
||||||
)
|
)
|
||||||
width, height = probe.width, probe.height
|
width, height, duration = probe.width, probe.height, probe.duration
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with Image.open(source) as im:
|
with Image.open(source) as im:
|
||||||
@@ -546,12 +790,30 @@ class Importer:
|
|||||||
error=f"{pct:.2%} transparent",
|
error=f"{pct:.2%} transparent",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Artist anchored to the attribution path (folder→artist), resolved
|
||||||
|
# UP-FRONT so the enrich-on-duplicate branches link provenance with the
|
||||||
|
# right artist even when the sidecar carries none — which is now the norm
|
||||||
|
# for archive members under post-first (the per-media sidecar is minimal).
|
||||||
|
# Without this a cross-posted / re-packed archive image deduped and was
|
||||||
|
# left UNLINKED from the new post = a post showing "no images" (#718).
|
||||||
|
_artist_name = derive_top_level_artist(attribution_path, self.import_root)
|
||||||
|
path_artist = self._upsert_artist(_artist_name) if _artist_name else None
|
||||||
|
|
||||||
# Hash dedup (exact).
|
# Hash dedup (exact).
|
||||||
sha = _sha256_of(source)
|
sha = _sha256_of(source)
|
||||||
existing_stmt = select(ImageRecord).where(ImageRecord.sha256 == sha)
|
existing_stmt = select(ImageRecord).where(ImageRecord.sha256 == sha)
|
||||||
existing = self.session.execute(existing_stmt).scalar_one_or_none()
|
existing = self.session.execute(existing_stmt).scalar_one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
if not self.deep:
|
if not self.deep:
|
||||||
|
# Enrich-on-duplicate (parity with attach_in_place): a re-scanned
|
||||||
|
# file that is a byte-dup of an existing image still links its
|
||||||
|
# post via _apply_sidecar, so a cross-posted image shows on every
|
||||||
|
# post.
|
||||||
|
self._apply_sidecar(
|
||||||
|
existing, attribution_path, path_artist,
|
||||||
|
explicit_source=explicit_source,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
||||||
image_id=existing.id, error="sha256 already present",
|
image_id=existing.id, error="sha256 already present",
|
||||||
@@ -578,6 +840,14 @@ class Importer:
|
|||||||
candidates, self.settings.phash_threshold,
|
candidates, self.settings.phash_threshold,
|
||||||
)
|
)
|
||||||
if rel == "larger_exists":
|
if rel == "larger_exists":
|
||||||
|
# Enrich-on-duplicate (parity with attach_in_place).
|
||||||
|
larger = self.session.get(ImageRecord, match_id)
|
||||||
|
if larger is not None:
|
||||||
|
self._apply_sidecar(
|
||||||
|
larger, attribution_path, path_artist,
|
||||||
|
explicit_source=explicit_source,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
status="skipped",
|
status="skipped",
|
||||||
skip_reason=SkipReason.duplicate_phash,
|
skip_reason=SkipReason.duplicate_phash,
|
||||||
@@ -590,6 +860,32 @@ class Importer:
|
|||||||
return ImportResult(
|
return ImportResult(
|
||||||
status="superseded", image_id=match_id
|
status="superseded", image_id=match_id
|
||||||
)
|
)
|
||||||
|
elif duration is not None:
|
||||||
|
# Tier-1 video near-dup (#871): same-artist, matching duration+aspect
|
||||||
|
# → same content across re-encodes. Mirror the image flow.
|
||||||
|
rel, match_id = self._find_similar_video(
|
||||||
|
duration, width, height,
|
||||||
|
path_artist.id if path_artist else None,
|
||||||
|
)
|
||||||
|
if rel == "larger_exists":
|
||||||
|
larger = self.session.get(ImageRecord, match_id)
|
||||||
|
if larger is not None:
|
||||||
|
self._apply_sidecar(
|
||||||
|
larger, attribution_path, path_artist,
|
||||||
|
explicit_source=explicit_source,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
return ImportResult(
|
||||||
|
status="skipped", skip_reason=SkipReason.duplicate_phash,
|
||||||
|
image_id=match_id,
|
||||||
|
error="video near-duplicate (duration+aspect) of existing equal/larger video",
|
||||||
|
)
|
||||||
|
if rel == "smaller_exists":
|
||||||
|
target = self.session.get(ImageRecord, match_id)
|
||||||
|
self._supersede(
|
||||||
|
target, source, sha, None, width, height, duration=duration,
|
||||||
|
)
|
||||||
|
return ImportResult(status="superseded", image_id=match_id)
|
||||||
|
|
||||||
dest = self._copy_to_library(source, sha, attribution_path)
|
dest = self._copy_to_library(source, sha, attribution_path)
|
||||||
|
|
||||||
@@ -601,6 +897,7 @@ class Importer:
|
|||||||
mime=_mime_for(source),
|
mime=_mime_for(source),
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
|
duration_seconds=duration,
|
||||||
origin="imported_filesystem",
|
origin="imported_filesystem",
|
||||||
integrity_status="unknown",
|
integrity_status="unknown",
|
||||||
)
|
)
|
||||||
@@ -608,13 +905,11 @@ class Importer:
|
|||||||
self.session.flush()
|
self.session.flush()
|
||||||
self._phash_cache_append(phash, width, height, record.id)
|
self._phash_cache_append(phash, width, height, record.id)
|
||||||
|
|
||||||
# Folder→artist (anchored to attribution_path).
|
# Folder→artist (path_artist resolved up-front above); bind it to the
|
||||||
artist = None
|
# new record so it carries the canonical Artist.
|
||||||
artist_name = derive_top_level_artist(
|
if path_artist is not None and record.artist_id is None:
|
||||||
attribution_path, self.import_root
|
record.artist_id = path_artist.id
|
||||||
)
|
self.session.flush()
|
||||||
if artist_name:
|
|
||||||
artist = self._attach_artist(record, artist_name)
|
|
||||||
|
|
||||||
# Sidecar provenance (best-effort; never fails the import).
|
# Sidecar provenance (best-effort; never fails the import).
|
||||||
# explicit_source lets the FC-3c download path bind the new
|
# explicit_source lets the FC-3c download path bind the new
|
||||||
@@ -624,7 +919,7 @@ class Importer:
|
|||||||
# subscription-downloaded zip previously lost subscription
|
# subscription-downloaded zip previously lost subscription
|
||||||
# linkage if the on-disk layout didn't match assumptions.
|
# linkage if the on-disk layout didn't match assumptions.
|
||||||
self._apply_sidecar(
|
self._apply_sidecar(
|
||||||
record, attribution_path, artist, explicit_source=explicit_source,
|
record, attribution_path, path_artist, explicit_source=explicit_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Thumbnail is queued separately by the calling task; the importer
|
# Thumbnail is queued separately by the calling task; the importer
|
||||||
@@ -673,6 +968,79 @@ class Importer:
|
|||||||
self.session.commit()
|
self.session.commit()
|
||||||
return ImportResult(status="refreshed", image_id=existing.id)
|
return ImportResult(status="refreshed", image_id=existing.id)
|
||||||
|
|
||||||
|
def _apply_post_fields(self, post: Post, sd) -> None:
|
||||||
|
"""Write a parsed sidecar's post-level fields onto a Post — the SINGLE
|
||||||
|
predicate shared by BOTH ingest paths: the per-media path (_apply_sidecar)
|
||||||
|
and the post-record path (upsert_post_record). Fill-with-non-empty:
|
||||||
|
parse_sidecar yields None for empty values, so a None field is left
|
||||||
|
untouched (an empty feed body never wipes a populated one). raw_metadata +
|
||||||
|
external-link sync always run (latest snapshot). Keeping ONE copy stops
|
||||||
|
the two paths from diverging on how a post body/links get stored — they
|
||||||
|
were verbatim duplicates (#842 DRY pass; see [[feedback_preview_apply_parity]]).
|
||||||
|
"""
|
||||||
|
if sd.post_url is not None:
|
||||||
|
post.post_url = sd.post_url
|
||||||
|
if sd.post_title is not None:
|
||||||
|
post.post_title = sd.post_title
|
||||||
|
if sd.post_date is not None:
|
||||||
|
post.post_date = sd.post_date
|
||||||
|
if sd.description is not None:
|
||||||
|
post.description = sd.description
|
||||||
|
if sd.attachment_count is not None:
|
||||||
|
post.attachment_count = sd.attachment_count
|
||||||
|
post.raw_metadata = sd.raw
|
||||||
|
self._sync_external_links(post)
|
||||||
|
|
||||||
|
def upsert_post_record(
|
||||||
|
self, sidecar: Path, *, artist: Artist | None = None,
|
||||||
|
source: Source | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Upsert the Post for a post-ONLY sidecar (a media-less post), so the
|
||||||
|
artist archive includes text posts (their body + external links).
|
||||||
|
|
||||||
|
Reuses `_find_or_create_post` (keyed on external_post_id) so it UPDATES
|
||||||
|
the SAME Post a media import would create — never doubles. Fields are
|
||||||
|
FILLED, never clobbered with empty: parse_sidecar yields None for empty
|
||||||
|
values, and a None field is left untouched (an empty feed body never
|
||||||
|
wipes a populated one). Returns True if a Post was upserted, False if the
|
||||||
|
sidecar was unusable (parse failure / no artist)."""
|
||||||
|
try:
|
||||||
|
data = json.loads(sidecar.read_text("utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("sidecar JSON is not an object")
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("post-record sidecar parse failed for %s: %s", sidecar, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
sd = parse_sidecar(data)
|
||||||
|
|
||||||
|
if artist is None:
|
||||||
|
name = self._sidecar_artist_name(data)
|
||||||
|
artist = self._upsert_artist(name) if name else None
|
||||||
|
if artist is None:
|
||||||
|
log.warning("post-record sidecar %s has no artist; skipping", sidecar)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if source is not None:
|
||||||
|
src = source
|
||||||
|
else:
|
||||||
|
platform = sd.platform or "unknown"
|
||||||
|
src = self._lookup_source_for_sidecar(
|
||||||
|
artist_id=artist.id, platform=platform,
|
||||||
|
)
|
||||||
|
|
||||||
|
epid = sd.external_post_id or sidecar.stem
|
||||||
|
post = self._find_or_create_post(
|
||||||
|
source_id=src.id if src else None,
|
||||||
|
external_post_id=epid,
|
||||||
|
artist_id=artist.id,
|
||||||
|
)
|
||||||
|
if post.artist_id is None:
|
||||||
|
post.artist_id = artist.id
|
||||||
|
self._apply_post_fields(post, sd)
|
||||||
|
self.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
def attach_in_place(
|
def attach_in_place(
|
||||||
self,
|
self,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -723,6 +1091,7 @@ class Importer:
|
|||||||
|
|
||||||
# Format / dimension / transparency filters (mirror _import_media).
|
# Format / dimension / transparency filters (mirror _import_media).
|
||||||
width = height = None
|
width = height = None
|
||||||
|
duration = None # video container duration (Tier-1 near-dup key, #871)
|
||||||
has_alpha = False
|
has_alpha = False
|
||||||
if not is_video(path):
|
if not is_video(path):
|
||||||
try:
|
try:
|
||||||
@@ -754,6 +1123,14 @@ class Importer:
|
|||||||
status="skipped", skip_reason=SkipReason.too_transparent,
|
status="skipped", skip_reason=SkipReason.too_transparent,
|
||||||
error=f"{pct:.2%} transparent",
|
error=f"{pct:.2%} transparent",
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Best-effort probe for dims + duration so downloaded videos can dedup
|
||||||
|
# (#871). LENIENT: unlike _import_media this path does not reject on a
|
||||||
|
# probe failure — preserve the existing "import the downloaded video"
|
||||||
|
# behavior; we just skip dedup when we can't read the duration.
|
||||||
|
vp = safe_probe.probe_video(path)
|
||||||
|
if vp.ok:
|
||||||
|
width, height, duration = vp.width, vp.height, vp.duration
|
||||||
|
|
||||||
# Hash dedup
|
# Hash dedup
|
||||||
sha = _sha256_of(path)
|
sha = _sha256_of(path)
|
||||||
@@ -761,6 +1138,15 @@ class Importer:
|
|||||||
select(ImageRecord).where(ImageRecord.sha256 == sha)
|
select(ImageRecord).where(ImageRecord.sha256 == sha)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
|
# Enrich-on-duplicate (image_provenance docstring / spec §3): the
|
||||||
|
# same bytes appearing in another post must show on THAT post too,
|
||||||
|
# so append a provenance row for the new post rather than dropping
|
||||||
|
# the linkage. primary_post_id stays on the first post — _apply_sidecar
|
||||||
|
# only sets it when NULL. Without this the new post is left with no
|
||||||
|
# image AND (if its other content also deduped) no attachment, i.e. a
|
||||||
|
# bare shell — operator-flagged 2026-06-08 (1589 empty Anduo posts).
|
||||||
|
self._apply_sidecar(existing, path, artist, explicit_source=source)
|
||||||
|
self.session.commit()
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
||||||
image_id=existing.id, error="sha256 already present",
|
image_id=existing.id, error="sha256 already present",
|
||||||
@@ -781,6 +1167,15 @@ class Importer:
|
|||||||
candidates, self.settings.phash_threshold,
|
candidates, self.settings.phash_threshold,
|
||||||
)
|
)
|
||||||
if rel == "larger_exists":
|
if rel == "larger_exists":
|
||||||
|
# Enrich-on-duplicate: link the near-dup's post to the
|
||||||
|
# existing larger image so it shows on both (see duplicate_hash
|
||||||
|
# above). smaller_exists already links via _supersede.
|
||||||
|
larger = self.session.get(ImageRecord, match_id)
|
||||||
|
if larger is not None:
|
||||||
|
self._apply_sidecar(
|
||||||
|
larger, path, artist, explicit_source=source,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
status="skipped",
|
status="skipped",
|
||||||
skip_reason=SkipReason.duplicate_phash,
|
skip_reason=SkipReason.duplicate_phash,
|
||||||
@@ -794,6 +1189,30 @@ class Importer:
|
|||||||
new_path=path, artist=artist, source_row=source,
|
new_path=path, artist=artist, source_row=source,
|
||||||
)
|
)
|
||||||
return ImportResult(status="superseded", image_id=match_id)
|
return ImportResult(status="superseded", image_id=match_id)
|
||||||
|
elif duration is not None:
|
||||||
|
# Tier-1 video near-dup (#871): same content re-downloaded from another
|
||||||
|
# source/encode. artist is the subscription artist (passed explicitly).
|
||||||
|
rel, match_id = self._find_similar_video(
|
||||||
|
duration, width, height, artist.id if artist else None,
|
||||||
|
)
|
||||||
|
if rel == "larger_exists":
|
||||||
|
larger = self.session.get(ImageRecord, match_id)
|
||||||
|
if larger is not None:
|
||||||
|
self._apply_sidecar(larger, path, artist, explicit_source=source)
|
||||||
|
self.session.commit()
|
||||||
|
return ImportResult(
|
||||||
|
status="skipped", skip_reason=SkipReason.duplicate_phash,
|
||||||
|
image_id=match_id,
|
||||||
|
error="video near-duplicate (duration+aspect) of existing equal/larger video",
|
||||||
|
)
|
||||||
|
if rel == "smaller_exists":
|
||||||
|
target = self.session.get(ImageRecord, match_id)
|
||||||
|
self._supersede(
|
||||||
|
target, path, sha, None, width, height,
|
||||||
|
new_path=path, artist=artist, source_row=source,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
return ImportResult(status="superseded", image_id=match_id)
|
||||||
|
|
||||||
# Create record — the path IS where the file lives.
|
# Create record — the path IS where the file lives.
|
||||||
record = ImageRecord(
|
record = ImageRecord(
|
||||||
@@ -804,6 +1223,7 @@ class Importer:
|
|||||||
mime=_mime_for(path),
|
mime=_mime_for(path),
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
|
duration_seconds=duration,
|
||||||
origin="downloaded",
|
origin="downloaded",
|
||||||
integrity_status="unknown",
|
integrity_status="unknown",
|
||||||
)
|
)
|
||||||
@@ -832,17 +1252,6 @@ class Importer:
|
|||||||
self.session.flush()
|
self.session.flush()
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
def _attach_artist(self, record: ImageRecord, artist_name: str) -> Artist:
|
|
||||||
"""Upsert the Artist and set it as the image's canonical artist.
|
|
||||||
Artist-kind tags were retired in FC-2d-vii-c — the Artist row is
|
|
||||||
the single source of truth. Returns the Artist (sidecar
|
|
||||||
provenance attaches its Source to it)."""
|
|
||||||
artist = self._upsert_artist(artist_name)
|
|
||||||
if record.artist_id is None:
|
|
||||||
record.artist_id = artist.id
|
|
||||||
self.session.flush()
|
|
||||||
return artist
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sidecar_artist_name(data: dict) -> str | None:
|
def _sidecar_artist_name(data: dict) -> str | None:
|
||||||
for k in ("artist", "author_name"):
|
for k in ("artist", "author_name"):
|
||||||
@@ -860,6 +1269,35 @@ class Importer:
|
|||||||
return sv.strip()
|
return sv.strip()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def relink_source_filehash(
|
||||||
|
self, path: Path, source_url: str, *, artist: Artist | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""#830 recapture: backfill source_url + source_filehash on an EXISTING
|
||||||
|
on-disk image's ImageRecord (matched by sha256) so its post-body inline
|
||||||
|
`<img src=CDN>` remaps to the local copy at render time — WITHOUT
|
||||||
|
re-downloading and WITHOUT unlinking the file (unlike the import path's
|
||||||
|
duplicate-hash branch). NULL-only, mirroring _apply_sidecar: never
|
||||||
|
clobbers an already-set filehash. Returns True only when a row was
|
||||||
|
updated. No-op when the file is gone, the url has no filehash, no record
|
||||||
|
matches the bytes, or the record already carries a filehash.
|
||||||
|
"""
|
||||||
|
fh = filehash_from_url(source_url)
|
||||||
|
if not fh:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
sha = _sha256_of(path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
record = self.session.execute(
|
||||||
|
select(ImageRecord).where(ImageRecord.sha256 == sha)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if record is None or record.source_filehash is not None:
|
||||||
|
return False
|
||||||
|
record.source_url = source_url
|
||||||
|
record.source_filehash = fh
|
||||||
|
self.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
def _apply_sidecar(
|
def _apply_sidecar(
|
||||||
self,
|
self,
|
||||||
record: ImageRecord,
|
record: ImageRecord,
|
||||||
@@ -902,6 +1340,14 @@ class Importer:
|
|||||||
if record.artist_id is None:
|
if record.artist_id is None:
|
||||||
record.artist_id = artist.id
|
record.artist_id = artist.id
|
||||||
|
|
||||||
|
# #830 Phase 2: persist the file's CDN source identity (NULL-only, so a
|
||||||
|
# re-import / enrich-on-duplicate never clobbers it) — the filehash is
|
||||||
|
# the join key that lets post_feed_service remap the body's inline
|
||||||
|
# `<img src=CDN>` to this local copy at render time.
|
||||||
|
if sd.source_url and record.source_filehash is None:
|
||||||
|
record.source_url = sd.source_url
|
||||||
|
record.source_filehash = filehash_from_url(sd.source_url)
|
||||||
|
|
||||||
if explicit_source is not None:
|
if explicit_source is not None:
|
||||||
src = explicit_source
|
src = explicit_source
|
||||||
else:
|
else:
|
||||||
@@ -916,17 +1362,14 @@ class Importer:
|
|||||||
external_post_id=epid,
|
external_post_id=epid,
|
||||||
artist_id=artist.id,
|
artist_id=artist.id,
|
||||||
)
|
)
|
||||||
if sd.post_url is not None:
|
# Post-first (#856): on the native path the post-record owns the body/
|
||||||
post.post_url = sd.post_url
|
# links/metadata, so the per-media import must NOT write post fields — its
|
||||||
if sd.post_title is not None:
|
# minimal sidecar carries no body, and applying it would clobber
|
||||||
post.post_title = sd.post_title
|
# raw_metadata + re-run link-sync off empty data. The image still links
|
||||||
if sd.post_date is not None:
|
# provenance + localization below. gallery-dl (post_first False) is
|
||||||
post.post_date = sd.post_date
|
# unchanged: its sidecar IS the only body source, so it still applies.
|
||||||
if sd.description is not None:
|
if not self.post_first:
|
||||||
post.description = sd.description
|
self._apply_post_fields(post, sd)
|
||||||
if sd.attachment_count is not None:
|
|
||||||
post.attachment_count = sd.attachment_count
|
|
||||||
post.raw_metadata = sd.raw
|
|
||||||
|
|
||||||
# Race-safe (image_record_id, post_id) upsert — mirrors the
|
# Race-safe (image_record_id, post_id) upsert — mirrors the
|
||||||
# _find_or_create_source/post savepoint pattern. The plain
|
# _find_or_create_source/post savepoint pattern. The plain
|
||||||
@@ -960,6 +1403,14 @@ class Importer:
|
|||||||
sp.rollback()
|
sp.rollback()
|
||||||
if record.primary_post_id is None:
|
if record.primary_post_id is None:
|
||||||
record.primary_post_id = post.id
|
record.primary_post_id = post.id
|
||||||
|
# Keep the denormalized gallery sort key (alembic 0035) aligned with
|
||||||
|
# the primary post's publish date so /scroll orders off
|
||||||
|
# ix_image_record_effective_date instead of COALESCE-ing across the
|
||||||
|
# post join. Only override when THIS post is the primary AND carries
|
||||||
|
# a date; otherwise the column keeps its created_at-equivalent server
|
||||||
|
# default (matches the old COALESCE(post_date, created_at) fallback).
|
||||||
|
if record.primary_post_id == post.id and post.post_date is not None:
|
||||||
|
record.effective_date = post.post_date
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
|
|
||||||
def _copy_to_library(
|
def _copy_to_library(
|
||||||
@@ -988,6 +1439,7 @@ class Importer:
|
|||||||
*, new_path: Path | None = None,
|
*, new_path: Path | None = None,
|
||||||
artist: Artist | None = None,
|
artist: Artist | None = None,
|
||||||
source_row: Source | None = None,
|
source_row: Source | None = None,
|
||||||
|
duration: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Replace `existing`'s file with the larger `source`, keeping the
|
"""Replace `existing`'s file with the larger `source`, keeping the
|
||||||
row id (so tags/series/curation stay attached). ML is cleared so
|
row id (so tags/series/curation stay attached). ML is cleared so
|
||||||
@@ -1020,13 +1472,23 @@ class Importer:
|
|||||||
existing.mime = _mime_for(source)
|
existing.mime = _mime_for(source)
|
||||||
existing.width = width
|
existing.width = width
|
||||||
existing.height = height
|
existing.height = height
|
||||||
|
existing.duration_seconds = duration # #871: keep the kept copy's duration
|
||||||
existing.thumbnail_path = None
|
existing.thumbnail_path = None
|
||||||
existing.integrity_status = "unknown"
|
existing.integrity_status = "unknown"
|
||||||
existing.tagger_predictions = None
|
|
||||||
existing.tagger_model_version = None
|
existing.tagger_model_version = None
|
||||||
existing.siglip_embedding = None
|
existing.siglip_embedding = None
|
||||||
existing.siglip_model_version = None
|
existing.siglip_model_version = None
|
||||||
existing.centroid_scores = None
|
existing.centroid_scores = None
|
||||||
|
# #768: predictions also live in the normalized image_prediction table
|
||||||
|
# now — clear them so a re-imported file re-derives a fresh set.
|
||||||
|
from sqlalchemy import delete as _delete
|
||||||
|
|
||||||
|
from ..models import ImagePrediction as _ImagePrediction
|
||||||
|
self.session.execute(
|
||||||
|
_delete(_ImagePrediction).where(
|
||||||
|
_ImagePrediction.image_record_id == existing.id
|
||||||
|
)
|
||||||
|
)
|
||||||
# created_at intentionally preserved; updated_at auto-bumps.
|
# created_at intentionally preserved; updated_at auto-bumps.
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,854 @@
|
|||||||
|
"""Platform-agnostic native-ingest core (plan #706, build on #697/#703/#704/#705).
|
||||||
|
|
||||||
|
The orchestration that drives a native subscription walk — page a feed →
|
||||||
|
extract media → tiered skip (seen-ledger / on-disk / dead-letter) → download →
|
||||||
|
mark-seen / record-failures / checkpoint-cursor → return a gallery-dl-shaped
|
||||||
|
`DownloadResult`, across tick/backfill/recovery modes — is identical for every
|
||||||
|
platform. Only four things are platform-specific, and they're INJECTED at
|
||||||
|
construction by a thin adapter (e.g. `PatreonIngester`):
|
||||||
|
|
||||||
|
- `client` — `.iter_posts(feed_id, cursor)` yielding `(post, included,
|
||||||
|
page_cursor)` + `.extract_media(post, included) -> [media]`.
|
||||||
|
- `downloader`— `.download_post(post, media, artist_slug, is_seen,
|
||||||
|
should_stop) -> [MediaOutcome]` (status in downloaded/
|
||||||
|
skipped_seen/skipped_disk/quarantined/error;
|
||||||
|
`.path`/`.error`/`.post_id`). `should_stop()` is polled
|
||||||
|
between media so the time-box is honoured mid-post.
|
||||||
|
- ledger — `seen_model` + `failed_model` SQLAlchemy models (+ their
|
||||||
|
on-conflict UNIQUE constraint names) and a `ledger_key(media)`.
|
||||||
|
- failure map — the adapter overrides `_failure_result` (platform exception
|
||||||
|
→ DownloadResult.error_type) and supplies `error_base` (the
|
||||||
|
exception type the walk catches) + `platform` (result label).
|
||||||
|
|
||||||
|
Everything DB touches a SHORT-LIVED sync session from the injected sessionmaker —
|
||||||
|
never held across a network fetch ([[db-connection-held-across-subprocess]]).
|
||||||
|
Plain-HTTP homelab: no secure-context Web API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select, text
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
|
from .gallery_dl import DownloadResult, ErrorType, make_run_stats
|
||||||
|
from .native_ingest_common import NativeAuthError, NativeDriftError
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Stop a tick after this many CONTIGUOUS already-have-it media (seen-ledger or
|
||||||
|
# on-disk) — the cheap native equivalent of gallery-dl's `exit:20`, now free of
|
||||||
|
# per-file HEADs. Headroom against paywalled/undownloadable items interleaving.
|
||||||
|
_TICK_SEEN_THRESHOLD = 20
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
DEAD_LETTER_THRESHOLD = 3
|
||||||
|
# last_error is Text but bound it so a giant traceback doesn't bloat the row.
|
||||||
|
_ERROR_MAX = 1000
|
||||||
|
|
||||||
|
# plan #709: throttle the live-progress write to the running DownloadEvent to one
|
||||||
|
# every ~5s — a steady cadence for the Downloads view regardless of how big/slow a
|
||||||
|
# page is (page boundaries can be minutes apart on image-dense backfills, so a
|
||||||
|
# page-tied update would lurch). Trivial churn (~one single-row UPDATE / 5s).
|
||||||
|
_LIVE_PROGRESS_INTERVAL = 5.0
|
||||||
|
|
||||||
|
# Post-body schema-drift canary (#862). Patreon's body lives in
|
||||||
|
# content/content_json_string with NO post_type gate, so a field rename (as
|
||||||
|
# content→content_json_string already was) zeroes EVERY body at once — across
|
||||||
|
# every artist, every walk. If a native walk records at least this many posts
|
||||||
|
# and extracts a body from NONE of them, treat it as that break (fail the run
|
||||||
|
# API_DRIFT) rather than silently archiving empties. A *fraction* threshold would
|
||||||
|
# false-positive on gallery/art creators who legitimately post images with no
|
||||||
|
# caption, so the gate is "zero across a minimum sample": a real creator nearly
|
||||||
|
# always has SOME text across this many posts, a broken parser has none. Set high
|
||||||
|
# enough that a small tick (a few new posts) can't trip it — only a backfill /
|
||||||
|
# recapture (the operator's schema-test flow) reaches the sample.
|
||||||
|
_CANARY_MIN_SAMPLE = 30
|
||||||
|
|
||||||
|
|
||||||
|
class Ingester:
|
||||||
|
"""Generic native-ingest orchestration. Subclass with a platform adapter
|
||||||
|
(see the module docstring) — or construct directly with the keyword seams."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client,
|
||||||
|
downloader,
|
||||||
|
session_factory: Callable[[], object],
|
||||||
|
seen_model,
|
||||||
|
failed_model,
|
||||||
|
seen_constraint: str,
|
||||||
|
failed_constraint: str,
|
||||||
|
ledger_key: Callable[[object], str],
|
||||||
|
platform: str,
|
||||||
|
error_base: type[Exception],
|
||||||
|
drift_label: str | None = None,
|
||||||
|
):
|
||||||
|
self.client = client
|
||||||
|
self.downloader = downloader
|
||||||
|
self.session_factory = session_factory
|
||||||
|
self._seen_model = seen_model
|
||||||
|
self._failed_model = failed_model
|
||||||
|
self._seen_constraint = seen_constraint
|
||||||
|
self._failed_constraint = failed_constraint
|
||||||
|
self._ledger_key = ledger_key
|
||||||
|
self._platform = platform
|
||||||
|
self._error_base = error_base
|
||||||
|
# Human label for the API_DRIFT message ("<label> changed — ingester needs
|
||||||
|
# update"). Defaults to the platform name; adapters pass a richer phrase
|
||||||
|
# (e.g. "Patreon API", "SubscribeStar markup").
|
||||||
|
self._drift_label = drift_label or platform
|
||||||
|
|
||||||
|
# -- public ------------------------------------------------------------
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_id: int,
|
||||||
|
campaign_id: str,
|
||||||
|
artist_slug: str,
|
||||||
|
url: str,
|
||||||
|
mode: str,
|
||||||
|
resume_cursor: str | None = None,
|
||||||
|
time_budget_seconds: float = 870.0,
|
||||||
|
seen_threshold: int = _TICK_SEEN_THRESHOLD,
|
||||||
|
posts_base: int = 0,
|
||||||
|
event_id: int | None = None,
|
||||||
|
) -> DownloadResult:
|
||||||
|
"""Walk + download for one source, returning a gallery-dl-shaped result.
|
||||||
|
|
||||||
|
`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
|
||||||
|
text" walk: it bypasses the post-record gate (so EVERY post's body +
|
||||||
|
external links are re-captured / detail-fetched) but KEEPS the media
|
||||||
|
seen-ledger — on-disk media is NOT re-downloaded, only surfaced so its
|
||||||
|
ImageRecord's source_filehash can be backfilled for inline-image
|
||||||
|
localization. The walk stops on:
|
||||||
|
- budget exhaustion (time_budget_seconds) → TIMEOUT / PARTIAL
|
||||||
|
- tick early-out (seen_threshold contiguous seen) → success
|
||||||
|
- reaching the bottom of the feed → success (rc 0)
|
||||||
|
A client-level failure (drift / auth / network) fails the whole run loud.
|
||||||
|
"""
|
||||||
|
bypass_seen = mode == "recovery"
|
||||||
|
recapture = mode == "recapture"
|
||||||
|
# Both recovery and recapture re-capture EVERY post's body + links — they
|
||||||
|
# bypass the post-record seen-gate (recovery via bypass_seen, recapture
|
||||||
|
# explicitly). A plain backfill stays gated (capture once per post).
|
||||||
|
recapture_records = bypass_seen or recapture
|
||||||
|
# Only deep walks checkpoint their cursor mid-flight (plan #705 #6); a
|
||||||
|
# tick has no resumable backfill state.
|
||||||
|
checkpoint = mode in ("backfill", "recovery", "recapture")
|
||||||
|
ledger_key = self._ledger_key
|
||||||
|
# POST-FIRST CONTRACT (milestone #67): these two optional seams make a
|
||||||
|
# platform "post-first" on the native core ingester — the post-record is
|
||||||
|
# the single authoritative writer of the post body/links/metadata, and the
|
||||||
|
# per-media sidecar carries image identity only (download_service flips
|
||||||
|
# importer.post_first via uses_native_ingester, so the import side follows
|
||||||
|
# automatically). A platform migrating off gallery-dl onto the native core
|
||||||
|
# adopts post-first by implementing BOTH:
|
||||||
|
# client.post_record_key(post) -> (ledger_key, post_id) | None (gate)
|
||||||
|
# downloader.write_post_record(post, artist_slug) -> PostRecordOutcome
|
||||||
|
# Absent on stub clients/downloaders (unit tests) and on not-yet-migrated
|
||||||
|
# platforms → media-less posts are skipped as before and the body still
|
||||||
|
# comes from the per-media sidecar (gallery-dl path). See [[post-first-ingest-contract]].
|
||||||
|
post_record_key = getattr(self.client, "post_record_key", None)
|
||||||
|
write_post_record = getattr(self.downloader, "write_post_record", None)
|
||||||
|
# #874: optional client seam — skip tier-gated posts (the account can't
|
||||||
|
# view them, so Patreon serves only blurred locked-preview media) ENTIRELY:
|
||||||
|
# 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)
|
||||||
|
start = time.monotonic()
|
||||||
|
last_live = start # plan #709: last live-progress write timestamp
|
||||||
|
log_lines: list[str] = []
|
||||||
|
written: list[str] = []
|
||||||
|
post_records: list[str] = []
|
||||||
|
quarantined_paths: list[str] = []
|
||||||
|
# #830 recapture: (on-disk path, CDN source_url) pairs for already-present
|
||||||
|
# media, so phase 3 can backfill the ImageRecord's source_filehash WITHOUT
|
||||||
|
# re-downloading or unlinking the file. Empty outside recapture mode.
|
||||||
|
relink: list[tuple[str, str]] = []
|
||||||
|
downloaded = 0
|
||||||
|
errors = 0
|
||||||
|
quarantined = 0
|
||||||
|
dead_lettered = 0
|
||||||
|
skipped_count = 0
|
||||||
|
posts_processed = 0
|
||||||
|
# Post-body schema-drift canary counters (#862, native ingester only —
|
||||||
|
# gallery-dl walks never enter the post-record block below so these stay 0
|
||||||
|
# and the canary can't fire there). posts_recorded = post-records attempted
|
||||||
|
# this walk; posts_with_body = how many yielded a non-empty body.
|
||||||
|
posts_recorded = 0
|
||||||
|
posts_with_body = 0
|
||||||
|
# Tier-gated posts skipped entirely this walk (#874) — surfaced in the
|
||||||
|
# run summary for diagnostics ("a lot of these now").
|
||||||
|
gated_skipped = 0
|
||||||
|
# Net-new posts THIS chunk for the live progress badge (plan #704 #5);
|
||||||
|
# excludes the re-walked resume page so _backfill_posts stays a monotonic
|
||||||
|
# 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
|
||||||
|
consecutive_seen = 0
|
||||||
|
emitted_cursor: str | None = None
|
||||||
|
reached_bottom = False
|
||||||
|
budget_hit = False
|
||||||
|
early_out = False
|
||||||
|
stopped = False # plan #708 B4: operator hit Stop mid-walk
|
||||||
|
cancel_armed = False # latched once we observe a live "running" state
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
*, success: bool, return_code: int,
|
||||||
|
error_type: ErrorType | None, error_message: str | None,
|
||||||
|
) -> DownloadResult:
|
||||||
|
# plan #704: return STRUCTURED data — phase 3 reads run_stats/cursor
|
||||||
|
# directly instead of regex-scraping a reconstructed stdout. stdout
|
||||||
|
# stays a human-readable summary (no fake `Cursor:` lines).
|
||||||
|
return DownloadResult(
|
||||||
|
success=success,
|
||||||
|
url=url,
|
||||||
|
artist_slug=artist_slug,
|
||||||
|
platform=self._platform,
|
||||||
|
files_downloaded=downloaded,
|
||||||
|
files_quarantined=quarantined,
|
||||||
|
quarantined_paths=list(quarantined_paths),
|
||||||
|
written_paths=written,
|
||||||
|
post_record_paths=list(post_records),
|
||||||
|
relink_source_paths=list(relink),
|
||||||
|
stdout="\n".join(log_lines),
|
||||||
|
stderr="",
|
||||||
|
return_code=return_code,
|
||||||
|
error_type=error_type,
|
||||||
|
error_message=error_message,
|
||||||
|
duration_seconds=time.monotonic() - start,
|
||||||
|
cursor=emitted_cursor,
|
||||||
|
posts_processed=posts_processed,
|
||||||
|
run_stats=make_run_stats(
|
||||||
|
exit_code=return_code,
|
||||||
|
downloaded_count=downloaded,
|
||||||
|
skipped_count=skipped_count,
|
||||||
|
per_item_failures=errors,
|
||||||
|
quarantined_count=quarantined,
|
||||||
|
dead_lettered_count=dead_lettered,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# #899 L1: emit run milestones through the real logger (not only the
|
||||||
|
# in-memory log_lines → DownloadResult.stdout, which is persisted to the
|
||||||
|
# DownloadEvent ONLY at phase 3). A worker SIGKILL/OOM/hard-time-limit
|
||||||
|
# mid-walk would otherwise leave NO trace; these land in the container log
|
||||||
|
# in real time regardless of whether the event gets finalized.
|
||||||
|
log.info(
|
||||||
|
"%s ingest START (%s): source=%s campaign=%s resume_cursor=%s",
|
||||||
|
self._platform, mode, source_id, campaign_id, resume_cursor,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for post, included, page_cursor in self.client.iter_posts(
|
||||||
|
campaign_id, cursor=resume_cursor
|
||||||
|
):
|
||||||
|
# Checkpoint the cursor that FETCHED this page the moment we
|
||||||
|
# START it — so a chunk cut mid-page resumes the page, not the one
|
||||||
|
# after it. Carried as DownloadResult.cursor (plan #704).
|
||||||
|
if page_cursor and page_cursor != emitted_cursor:
|
||||||
|
emitted_cursor = page_cursor
|
||||||
|
# #899 L1: a per-page breadcrumb in the container log (pages can
|
||||||
|
# be minutes apart on image-dense backfills) — survives a worker
|
||||||
|
# kill so the operator sees how far a since-died walk got.
|
||||||
|
log.info(
|
||||||
|
"%s ingest progress (%s, source=%s): posts=%d downloaded=%d "
|
||||||
|
"skipped=%d errors=%d quarantined=%d gated=%d cursor=%s",
|
||||||
|
self._platform, mode, source_id, posts_processed, downloaded,
|
||||||
|
skipped_count, errors, quarantined, gated_skipped, emitted_cursor,
|
||||||
|
)
|
||||||
|
# plan #705 #6: persist the cursor at each page boundary so a
|
||||||
|
# worker SIGKILL mid-chunk resumes near the crash, not the
|
||||||
|
# chunk start. (phase 3 still writes the final cursor — same
|
||||||
|
# value; this is the crash-safety net.) plan #704 #5: persist
|
||||||
|
# the live posts count alongside it so the badge climbs DURING
|
||||||
|
# the chunk, not only when it ends.
|
||||||
|
if checkpoint:
|
||||||
|
# plan #708 B4: an operator Stop pops `_backfill_state` —
|
||||||
|
# bail at the page boundary (progress already checkpointed)
|
||||||
|
# before more network work, so the live chunk halts
|
||||||
|
# promptly instead of running to its time-box. LATCH on the
|
||||||
|
# first observed "running" state, so a run invoked WITHOUT a
|
||||||
|
# running state (a unit test, or a stale call) never
|
||||||
|
# spuriously self-cancels. A short SELECT, never held.
|
||||||
|
if self._still_running(source_id):
|
||||||
|
cancel_armed = True
|
||||||
|
elif cancel_armed:
|
||||||
|
stopped = True
|
||||||
|
break
|
||||||
|
self._checkpoint_cursor(source_id, emitted_cursor)
|
||||||
|
self._checkpoint_posts(source_id, posts_base + chunk_new_posts)
|
||||||
|
|
||||||
|
# Time-box check at the post boundary (coarse, like a gallery-dl
|
||||||
|
# chunk). Backfill/recovery resume from emitted_cursor next chunk.
|
||||||
|
if time.monotonic() - start >= time_budget_seconds:
|
||||||
|
budget_hit = True
|
||||||
|
break
|
||||||
|
|
||||||
|
posts_processed += 1
|
||||||
|
# The resume page (its cursor == resume_cursor) was already
|
||||||
|
# counted by the chunk that checkpointed it — don't re-count it
|
||||||
|
# into the persisted badge (plan #704 #5). First chunk has
|
||||||
|
# resume_cursor None, so everything counts.
|
||||||
|
if not (resume_cursor and page_cursor == resume_cursor):
|
||||||
|
chunk_new_posts += 1
|
||||||
|
# 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
|
||||||
|
# decision: gated content leaves no trace; a later walk re-ingests
|
||||||
|
# it for real once access is gained). Skipped BEFORE the
|
||||||
|
# post-record block so gated posts never inflate the #862 body
|
||||||
|
# canary's sample. post_is_gated gates only on an explicit
|
||||||
|
# current_user_can_view=False (missing/None → viewable).
|
||||||
|
if post_is_gated and post_is_gated(post):
|
||||||
|
gated_skipped += 1
|
||||||
|
log_lines.append(
|
||||||
|
f" post {post.get('id')} — gated (skipped, no access)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# Capture the post body + external links ONCE per post (gated by
|
||||||
|
# the synthetic post key in the seen-ledger), for EVERY post —
|
||||||
|
# whether or not it has downloadable media. This is what makes a
|
||||||
|
# backfill/recovery re-walk RECAPTURE bodies + links for posts
|
||||||
|
# whose media is already on disk: re-downloading existing media
|
||||||
|
# never fills links the system never had, so the body recapture
|
||||||
|
# has to ride the walk itself. Detail-fetch (for an empty feed
|
||||||
|
# body) happens at most once per post — the gate then spares it on
|
||||||
|
# later walks. bypass_seen (recovery) re-captures unconditionally.
|
||||||
|
if post_record_key and write_post_record:
|
||||||
|
rk = post_record_key(post)
|
||||||
|
if rk is not None:
|
||||||
|
pkey, ppid = rk
|
||||||
|
already = (
|
||||||
|
set() if recapture_records
|
||||||
|
else self._seen_keys(source_id, [pkey])
|
||||||
|
)
|
||||||
|
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)])
|
||||||
|
# Per-post handling line in the run stdout (the existing
|
||||||
|
# "Raw stdout" panel) — the downloader already read the
|
||||||
|
# post; we only format its outcome here. post_type beside
|
||||||
|
# a 0-char body is the "why is this one empty" answer.
|
||||||
|
log_lines.append(
|
||||||
|
f" post {ppid} [{rec.post_type or '?'}] "
|
||||||
|
f"body: {rec.body_chars} chars"
|
||||||
|
+ ("" if rec.body_chars else " — EMPTY")
|
||||||
|
+ (f" — {rec.title}" if rec.title else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
media = self.client.extract_media(post, included)
|
||||||
|
if not media:
|
||||||
|
continue
|
||||||
|
|
||||||
|
keys = [ledger_key(m) for m in media]
|
||||||
|
# Recovery bypasses BOTH the seen-ledger AND the dead-letter
|
||||||
|
# ledger (the operator's "try everything again"); routine walks
|
||||||
|
# skip seen + dead media (tier-1 + tier-1.5, plan #705 #7).
|
||||||
|
dead = set() if bypass_seen else self._dead_keys(source_id, keys)
|
||||||
|
seen = (
|
||||||
|
set()
|
||||||
|
if bypass_seen
|
||||||
|
else self._seen_keys(source_id, keys)
|
||||||
|
)
|
||||||
|
skip = seen | dead
|
||||||
|
|
||||||
|
def _is_skip(m, _skip=skip) -> bool:
|
||||||
|
return ledger_key(m) in _skip
|
||||||
|
|
||||||
|
# Honour the time-box DURING a media-dense post too, not only at
|
||||||
|
# the per-post boundary below — else one heavy post can blow the
|
||||||
|
# chunk budget out to the Celery soft limit (Pocketacer, 2026-06-07).
|
||||||
|
outcomes = self.downloader.download_post(
|
||||||
|
post, media, artist_slug, is_seen=_is_skip,
|
||||||
|
should_stop=lambda: time.monotonic() - start >= time_budget_seconds,
|
||||||
|
recapture=recapture,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
for media_item, outcome in zip(media, outcomes, strict=False):
|
||||||
|
key = ledger_key(media_item)
|
||||||
|
if key in dead:
|
||||||
|
dead_lettered += 1 # skipped because previously dead
|
||||||
|
if outcome.status == "downloaded":
|
||||||
|
downloaded += 1
|
||||||
|
if outcome.path is not None:
|
||||||
|
written.append(str(outcome.path))
|
||||||
|
to_mark.append((key, media_item.post_id))
|
||||||
|
to_clear.append(key)
|
||||||
|
consecutive_seen = 0
|
||||||
|
elif outcome.status == "skipped_disk":
|
||||||
|
# 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.
|
||||||
|
to_mark.append((key, media_item.post_id))
|
||||||
|
to_clear.append(key)
|
||||||
|
skipped_count += 1
|
||||||
|
consecutive_seen += 1
|
||||||
|
# #830 recapture: surface (on-disk path, CDN url) so phase
|
||||||
|
# 3 can backfill source_filehash for inline-image
|
||||||
|
# localization — a SEPARATE non-deleting channel, never the
|
||||||
|
# import list (which would unlink the file, per above).
|
||||||
|
if recapture and outcome.path is not None:
|
||||||
|
relink.append((str(outcome.path), media_item.url))
|
||||||
|
elif outcome.status == "skipped_seen":
|
||||||
|
skipped_count += 1
|
||||||
|
consecutive_seen += 1
|
||||||
|
elif outcome.status == "quarantined":
|
||||||
|
# New content that failed validation (corrupt) — counted
|
||||||
|
# distinctly so the run surfaces a real quarantined total.
|
||||||
|
# Not marked seen (a later walk may re-fetch a fixed file);
|
||||||
|
# it IS new content, so it breaks the run-of-seen. Counts
|
||||||
|
# toward the dead-letter ledger (plan #705 #7).
|
||||||
|
quarantined += 1
|
||||||
|
if outcome.path is not None:
|
||||||
|
quarantined_paths.append(str(outcome.path))
|
||||||
|
to_fail.append((key, media_item.post_id, outcome.error or "quarantined"))
|
||||||
|
consecutive_seen = 0
|
||||||
|
elif outcome.status == "error":
|
||||||
|
errors += 1
|
||||||
|
to_fail.append((key, media_item.post_id, outcome.error or "error"))
|
||||||
|
# An error neither advances nor resets the run-of-seen.
|
||||||
|
|
||||||
|
if mode == "tick" and consecutive_seen >= seen_threshold:
|
||||||
|
early_out = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Persist ledger changes AFTER the network fetch, on short
|
||||||
|
# sessions: mark downloaded/on-disk seen, clear any dead-letter
|
||||||
|
# for recovered media, and record failures (plan #705 #7).
|
||||||
|
if to_mark:
|
||||||
|
self._mark_seen(source_id, to_mark)
|
||||||
|
if to_clear:
|
||||||
|
self._clear_failures(source_id, to_clear)
|
||||||
|
if to_fail:
|
||||||
|
self._record_failures(source_id, to_fail)
|
||||||
|
|
||||||
|
# plan #709: time-throttled live progress to the running event so
|
||||||
|
# the Downloads view ticks ~every 5s, independent of page size.
|
||||||
|
now = time.monotonic()
|
||||||
|
if event_id is not None and (now - last_live) >= _LIVE_PROGRESS_INTERVAL:
|
||||||
|
last_live = now
|
||||||
|
self._write_live_progress(event_id, {
|
||||||
|
"downloaded": downloaded,
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"errors": errors,
|
||||||
|
"quarantined": quarantined,
|
||||||
|
"posts": posts_processed,
|
||||||
|
})
|
||||||
|
|
||||||
|
if early_out:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
reached_bottom = True
|
||||||
|
except self._error_base as exc:
|
||||||
|
# The platform's client-error base — _failure_result (adapter)
|
||||||
|
# maps it to a typed error.
|
||||||
|
return self._failure_result(exc, _result)
|
||||||
|
|
||||||
|
# plan #708 B4: a Stop already popped the backfill state (incl. cursor +
|
||||||
|
# posts), so don't re-write them — return PARTIAL (reads as "ok/progress",
|
||||||
|
# the lifecycle no-ops since state is gone) instead of a false "complete".
|
||||||
|
if stopped:
|
||||||
|
log.info(
|
||||||
|
"%s ingest STOPPED by operator (%s, source=%s): %d file(s) this chunk",
|
||||||
|
self._platform, mode, source_id, downloaded,
|
||||||
|
)
|
||||||
|
return _result(
|
||||||
|
success=False, return_code=-1,
|
||||||
|
error_type=ErrorType.PARTIAL,
|
||||||
|
error_message=f"Stopped by operator: {downloaded} file(s) this chunk",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Final authoritative posts count for the badge — captures the last page
|
||||||
|
# after the last boundary write and the time-box break (plan #704 #5).
|
||||||
|
if checkpoint:
|
||||||
|
self._checkpoint_posts(source_id, posts_base + chunk_new_posts)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
log_lines.append(f"{errors} media item(s) failed")
|
||||||
|
if quarantined:
|
||||||
|
log_lines.append(f"{quarantined} media item(s) quarantined (invalid)")
|
||||||
|
if dead_lettered:
|
||||||
|
log_lines.append(f"{dead_lettered} media item(s) skipped (dead-lettered)")
|
||||||
|
summary = (
|
||||||
|
f"{self._platform} ingest ({mode}): {downloaded} downloaded, "
|
||||||
|
f"{skipped_count} skipped, {quarantined} quarantined, "
|
||||||
|
f"{dead_lettered} dead-lettered, {errors} error(s), "
|
||||||
|
f"{posts_processed} post(s), {len(post_records)} post-record(s), "
|
||||||
|
f"{len(relink)} relinked"
|
||||||
|
# Body-capture health (#862): even below the canary's red-alarm
|
||||||
|
# threshold, surfacing the ratio makes a partial extraction regression
|
||||||
|
# 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 "")
|
||||||
|
+ (", reached end" if reached_bottom else "")
|
||||||
|
+ (", time-boxed" if budget_hit else "")
|
||||||
|
)
|
||||||
|
log_lines.append(summary)
|
||||||
|
# #899 L1: also to the container log (survives event-finalization failure).
|
||||||
|
log.info("%s (source=%s)", summary, source_id)
|
||||||
|
|
||||||
|
if budget_hit:
|
||||||
|
# A chunk that hit its time-box but made forward progress is a
|
||||||
|
# NORMAL chunk boundary, not a failure (PARTIAL → status "ok"); the
|
||||||
|
# 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.
|
||||||
|
made_progress = downloaded > 0 or emitted_cursor != resume_cursor
|
||||||
|
if made_progress:
|
||||||
|
return _result(
|
||||||
|
success=False, return_code=-1,
|
||||||
|
error_type=ErrorType.PARTIAL,
|
||||||
|
error_message=(
|
||||||
|
f"Backfill chunk: {downloaded} file(s) — continuing"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _result(
|
||||||
|
success=False, return_code=-1,
|
||||||
|
error_type=ErrorType.TIMEOUT,
|
||||||
|
error_message="Chunk timed out with no progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Post-body schema-drift canary (#862): a native walk recorded a
|
||||||
|
# meaningful sample of posts but extracted a body from NONE of them. Since
|
||||||
|
# the body field has no post_type gate, that's the signature of Patreon
|
||||||
|
# renaming/restructuring the body field (as content→content_json_string
|
||||||
|
# already was) — fail RED (API_DRIFT: "fix is the field-set/parser, not
|
||||||
|
# creds") so the breakage screams instead of silently archiving empties.
|
||||||
|
# Only reached on an otherwise-clean walk (timeout/stop/error returned
|
||||||
|
# above), so it never masks a more specific failure.
|
||||||
|
if posts_recorded >= _CANARY_MIN_SAMPLE and posts_with_body == 0:
|
||||||
|
msg = (
|
||||||
|
f"Post-body canary: extracted a body from 0 of {posts_recorded} "
|
||||||
|
"posts — Patreon's body field shape likely changed; the ingester "
|
||||||
|
"needs a field-set/parser update."
|
||||||
|
)
|
||||||
|
log_lines.append(msg)
|
||||||
|
log.error("%s (artist=%s)", msg, artist_slug)
|
||||||
|
return _result(
|
||||||
|
success=False, return_code=-1,
|
||||||
|
error_type=ErrorType.API_DRIFT, error_message=msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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=None, error_message=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- preview (dry-run) -------------------------------------------------
|
||||||
|
|
||||||
|
def preview(
|
||||||
|
self,
|
||||||
|
source_id: int,
|
||||||
|
campaign_id: str,
|
||||||
|
*,
|
||||||
|
page_limit: int = 3,
|
||||||
|
sample_size: int = 10,
|
||||||
|
) -> dict:
|
||||||
|
"""Dry-run (plan #708 B4): walk up to `page_limit` pages and count media
|
||||||
|
NOT already in the seen/dead ledgers, WITHOUT downloading anything.
|
||||||
|
|
||||||
|
Read-only — only the seen/dead SELECTs touch the DB (short sessions). Lets
|
||||||
|
an operator gauge "is this source worth a backfill?" cheaply. Returns:
|
||||||
|
{total_new, posts_scanned, pages_scanned, has_more,
|
||||||
|
sample: [{title, date, new}, ...]} # sample = posts with new media
|
||||||
|
A client-level failure (auth/drift) propagates to the caller.
|
||||||
|
"""
|
||||||
|
total_new = 0
|
||||||
|
posts_scanned = 0
|
||||||
|
pages_scanned = 0
|
||||||
|
has_more = False
|
||||||
|
sample: list[dict] = []
|
||||||
|
unset = object()
|
||||||
|
last_page: object = unset
|
||||||
|
# #874: same gated-post gate as run() — the preview must not count
|
||||||
|
# blurred locked-preview media as "new", or it would overstate a gated
|
||||||
|
# source's backlog (preview/apply parity, rule 93).
|
||||||
|
post_is_gated = getattr(self.client, "post_is_gated", None)
|
||||||
|
for post, included, page_cursor in self.client.iter_posts(
|
||||||
|
campaign_id, cursor=None
|
||||||
|
):
|
||||||
|
if page_cursor != last_page:
|
||||||
|
last_page = page_cursor
|
||||||
|
pages_scanned += 1
|
||||||
|
if pages_scanned > page_limit:
|
||||||
|
has_more = True
|
||||||
|
pages_scanned = page_limit
|
||||||
|
break
|
||||||
|
posts_scanned += 1
|
||||||
|
if post_is_gated and post_is_gated(post):
|
||||||
|
continue
|
||||||
|
media = self.client.extract_media(post, included)
|
||||||
|
if not media:
|
||||||
|
continue
|
||||||
|
keys = [self._ledger_key(m) for m in media]
|
||||||
|
skip = self._seen_keys(source_id, keys) | self._dead_keys(source_id, keys)
|
||||||
|
new_count = sum(1 for m in media if self._ledger_key(m) not in skip)
|
||||||
|
total_new += new_count
|
||||||
|
if new_count > 0 and len(sample) < sample_size:
|
||||||
|
meta = self.client.post_meta(post)
|
||||||
|
sample.append(
|
||||||
|
{
|
||||||
|
"title": meta.get("title") or "(untitled)",
|
||||||
|
"date": meta.get("date"),
|
||||||
|
"new": new_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"total_new": total_new,
|
||||||
|
"posts_scanned": posts_scanned,
|
||||||
|
"pages_scanned": pages_scanned,
|
||||||
|
"has_more": has_more,
|
||||||
|
"sample": sample,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- failure mapping (adapter overrides) -------------------------------
|
||||||
|
|
||||||
|
def _failure_result(self, exc: Exception, _result) -> DownloadResult:
|
||||||
|
"""Map a platform client-error to a loud, typed failed DownloadResult —
|
||||||
|
NEVER a silent zero-download "success". The mapping is shared across
|
||||||
|
platforms via the NativeAuthError/NativeDriftError taxonomy (the platform
|
||||||
|
client raises subclasses), so a new platform gets it for free:
|
||||||
|
- NativeAuthError → AUTH_ERROR (rotate the credential)
|
||||||
|
- NativeDriftError → API_DRIFT (the ingester/scraper needs updating)
|
||||||
|
- HTTP 429 / 404 → RATE_LIMITED / NOT_FOUND
|
||||||
|
- other HTTP status→ HTTP_ERROR; transport failure → NETWORK_ERROR
|
||||||
|
Auth/Drift are matched first (they also carry a status_code in some paths).
|
||||||
|
"""
|
||||||
|
message = str(exc)
|
||||||
|
if isinstance(exc, NativeAuthError):
|
||||||
|
error_type = ErrorType.AUTH_ERROR
|
||||||
|
elif isinstance(exc, NativeDriftError):
|
||||||
|
error_type = ErrorType.API_DRIFT
|
||||||
|
message = f"{self._drift_label} changed — ingester needs update: {message}"
|
||||||
|
else:
|
||||||
|
status = getattr(exc, "status_code", None)
|
||||||
|
if status == 429:
|
||||||
|
error_type = ErrorType.RATE_LIMITED
|
||||||
|
elif status == 404:
|
||||||
|
error_type = ErrorType.NOT_FOUND
|
||||||
|
elif status is not None:
|
||||||
|
error_type = ErrorType.HTTP_ERROR
|
||||||
|
else:
|
||||||
|
error_type = ErrorType.NETWORK_ERROR
|
||||||
|
log.warning("%s ingest failed (%s): %s", self._platform, error_type.value, message)
|
||||||
|
result = _result(
|
||||||
|
success=False, return_code=1,
|
||||||
|
error_type=error_type, error_message=message,
|
||||||
|
)
|
||||||
|
# plan #708 B1: carry the server's Retry-After up to the cooldown.
|
||||||
|
if error_type == ErrorType.RATE_LIMITED:
|
||||||
|
result.retry_after_seconds = getattr(exc, "retry_after", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- seen-ledger (short-lived sessions) --------------------------------
|
||||||
|
|
||||||
|
def _seen_keys(self, source_id: int, keys: list[str]) -> set[str]:
|
||||||
|
"""Which of `keys` are already in the seen-ledger for this source.
|
||||||
|
|
||||||
|
One short SELECT on its own session — opened and closed without any
|
||||||
|
network in between (the GETs happen after, in download_post).
|
||||||
|
"""
|
||||||
|
if not keys:
|
||||||
|
return set()
|
||||||
|
with self.session_factory() as session:
|
||||||
|
rows = session.execute(
|
||||||
|
select(self._seen_model.filehash).where(
|
||||||
|
self._seen_model.source_id == source_id,
|
||||||
|
self._seen_model.filehash.in_(keys),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
def _checkpoint_cursor(self, source_id: int, cursor: str) -> None:
|
||||||
|
"""Persist the in-progress backfill cursor mid-walk (plan #705 #6).
|
||||||
|
|
||||||
|
ATOMIC, single-key UPDATE: cast the JSON column to jsonb, set just
|
||||||
|
`_backfill_cursor`, cast back — so it never clobbers operator config or
|
||||||
|
the other backfill keys (no read-modify-write race). The in-flight guard
|
||||||
|
means only this source's one download runs at a time; a concurrent
|
||||||
|
operator stop is benign (a stray cursor with no `_backfill_state` is
|
||||||
|
ignored by tick mode and cleared on the next start).
|
||||||
|
"""
|
||||||
|
with self.session_factory() as session:
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE source SET config_overrides = jsonb_set("
|
||||||
|
" coalesce(config_overrides::jsonb, '{}'::jsonb),"
|
||||||
|
" '{_backfill_cursor}', to_jsonb(cast(:cur AS text))"
|
||||||
|
")::json WHERE id = :sid"
|
||||||
|
),
|
||||||
|
{"cur": cursor, "sid": source_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def _write_live_progress(self, event_id: int, counts: dict) -> None:
|
||||||
|
"""Throttled mid-walk write of live counts to the RUNNING download_event
|
||||||
|
(plan #709) so the Downloads view shows progress before the chunk
|
||||||
|
finishes. A short session (never held across the walk); the `status =
|
||||||
|
'running'` guard avoids clobbering an event phase 3 already finalized.
|
||||||
|
`metadata` is JSONB — jsonb_set sets just the `live` key, leaving the rest
|
||||||
|
for phase 3 to overwrite with the final run_stats."""
|
||||||
|
with self.session_factory() as session:
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE download_event SET metadata = jsonb_set("
|
||||||
|
" coalesce(metadata, '{}'::jsonb), '{live}',"
|
||||||
|
" cast(:live AS jsonb)) "
|
||||||
|
"WHERE id = :eid AND status = 'running'"
|
||||||
|
),
|
||||||
|
{"live": json.dumps(counts), "eid": event_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def _still_running(self, source_id: int) -> bool:
|
||||||
|
"""True while the source is armed for a deep walk (plan #708 B4).
|
||||||
|
|
||||||
|
An operator Stop (`source_service.stop_backfill`) pops `_backfill_state`,
|
||||||
|
so a False here means "cancel this chunk now". One short SELECT on its own
|
||||||
|
session — never held across the walk
|
||||||
|
([[db-connection-held-across-subprocess]])."""
|
||||||
|
with self.session_factory() as session:
|
||||||
|
state = session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT config_overrides::jsonb ->> '_backfill_state' "
|
||||||
|
"FROM source WHERE id = :sid"
|
||||||
|
),
|
||||||
|
{"sid": source_id},
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return state == "running"
|
||||||
|
|
||||||
|
def _checkpoint_posts(self, source_id: int, posts: int) -> None:
|
||||||
|
"""Persist the live backfill posts-processed count mid-walk (plan #704 #5).
|
||||||
|
|
||||||
|
Same atomic single-key jsonb_set dance as _checkpoint_cursor, on the
|
||||||
|
`_backfill_posts` key (cast to a JSON number) — so the progress badge
|
||||||
|
climbs DURING a chunk without clobbering operator config or the cursor.
|
||||||
|
The ingester OWNS this key now; download_service no longer accumulates it
|
||||||
|
post-chunk (which lagged a whole chunk and over-counted the resume page).
|
||||||
|
"""
|
||||||
|
with self.session_factory() as session:
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE source SET config_overrides = jsonb_set("
|
||||||
|
" coalesce(config_overrides::jsonb, '{}'::jsonb),"
|
||||||
|
" '{_backfill_posts}', to_jsonb(cast(:posts AS int))"
|
||||||
|
")::json WHERE id = :sid"
|
||||||
|
),
|
||||||
|
{"posts": posts, "sid": source_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
ON CONFLICT DO NOTHING against the (source_id, filehash) UNIQUE so a
|
||||||
|
re-sighting — or a concurrent walk — is a harmless no-op
|
||||||
|
([[scalar_one_or_none-duplicates]]: never check-then-insert without the
|
||||||
|
DB constraint backing it). De-dup the batch locally first so a single
|
||||||
|
page can't present the same key twice to one INSERT.
|
||||||
|
"""
|
||||||
|
seen_local: set[str] = set()
|
||||||
|
values = []
|
||||||
|
for key, post_id in items:
|
||||||
|
if key in seen_local:
|
||||||
|
continue
|
||||||
|
seen_local.add(key)
|
||||||
|
values.append(
|
||||||
|
{"source_id": source_id, "filehash": key, "post_id": post_id}
|
||||||
|
)
|
||||||
|
if not values:
|
||||||
|
return
|
||||||
|
with self.session_factory() as session:
|
||||||
|
stmt = pg_insert(self._seen_model).values(values)
|
||||||
|
stmt = stmt.on_conflict_do_nothing(constraint=self._seen_constraint)
|
||||||
|
session.execute(stmt)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# -- dead-letter ledger (plan #705 #7) ---------------------------------
|
||||||
|
|
||||||
|
def _dead_keys(self, source_id: int, keys: list[str]) -> set[str]:
|
||||||
|
"""Which of `keys` have failed >= DEAD_LETTER_THRESHOLD times (dead).
|
||||||
|
One short SELECT; recovery never calls this (it re-attempts dead media)."""
|
||||||
|
if not keys:
|
||||||
|
return set()
|
||||||
|
with self.session_factory() as session:
|
||||||
|
rows = session.execute(
|
||||||
|
select(self._failed_model.filehash).where(
|
||||||
|
self._failed_model.source_id == source_id,
|
||||||
|
self._failed_model.filehash.in_(keys),
|
||||||
|
self._failed_model.attempts >= DEAD_LETTER_THRESHOLD,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
def _record_failures(
|
||||||
|
self, source_id: int, items: list[tuple[str, str, str]]
|
||||||
|
) -> None:
|
||||||
|
"""Upsert-increment the dead-letter ledger for failed media. On conflict
|
||||||
|
bump `attempts` and refresh last_error/last_failed_at (UNIQUE backs the
|
||||||
|
upsert — no check-then-insert). De-dup the batch (one row/key, last error
|
||||||
|
wins)."""
|
||||||
|
by_key: dict[str, str] = {}
|
||||||
|
for key, _post_id, err in items:
|
||||||
|
by_key[key] = (err or "")[:_ERROR_MAX]
|
||||||
|
if not by_key:
|
||||||
|
return
|
||||||
|
values = [
|
||||||
|
{"source_id": source_id, "filehash": k, "attempts": 1, "last_error": e}
|
||||||
|
for k, e in by_key.items()
|
||||||
|
]
|
||||||
|
with self.session_factory() as session:
|
||||||
|
stmt = pg_insert(self._failed_model).values(values)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
constraint=self._failed_constraint,
|
||||||
|
set_={
|
||||||
|
"attempts": self._failed_model.attempts + 1,
|
||||||
|
"last_error": stmt.excluded.last_error,
|
||||||
|
"last_failed_at": func.now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.execute(stmt)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def _clear_failures(self, source_id: int, keys: list[str]) -> None:
|
||||||
|
"""Drop dead-letter rows for media that just downloaded cleanly — they
|
||||||
|
recovered. A no-op DELETE for keys that were never failing."""
|
||||||
|
unique = list(dict.fromkeys(keys))
|
||||||
|
if not unique:
|
||||||
|
return
|
||||||
|
with self.session_factory() as session:
|
||||||
|
session.execute(
|
||||||
|
delete(self._failed_model).where(
|
||||||
|
self._failed_model.source_id == source_id,
|
||||||
|
self._failed_model.filehash.in_(unique),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Extract off-platform file-host links from a post body.
|
||||||
|
|
||||||
|
Pure (no I/O) so it's unit-testable and reusable by EVERY in-house downloader's
|
||||||
|
import path — every platform stores its body in `Post.description`, so running
|
||||||
|
this there covers them all. Finds links to the supported external file hosts in
|
||||||
|
a post's HTML body (both `<a href="...">` anchors and bare URLs in text),
|
||||||
|
unwraps a Patreon outbound-redirect wrapper, and preserves the FULL url
|
||||||
|
including the `#fragment` (mega.nz puts the decryption key there) and the query
|
||||||
|
string — without those a mega download is impossible.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from html import unescape
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
# Supported file-host enum values (kept in sync with the external_link CHECK).
|
||||||
|
SUPPORTED_HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
|
||||||
|
|
||||||
|
# Bare-domain suffix → canonical host label. Matched against the URL netloc
|
||||||
|
# (exact or as a dotted suffix, so www./dl. subdomains resolve too).
|
||||||
|
_HOST_MAP = {
|
||||||
|
"mega.nz": "mega",
|
||||||
|
"mega.co.nz": "mega",
|
||||||
|
"drive.google.com": "gdrive",
|
||||||
|
"mediafire.com": "mediafire",
|
||||||
|
"dropbox.com": "dropbox",
|
||||||
|
"pixeldrain.com": "pixeldrain",
|
||||||
|
}
|
||||||
|
|
||||||
|
# `<a href="...">label</a>` — DOTALL so a label spanning tags/newlines is caught.
|
||||||
|
_HREF_RE = re.compile(
|
||||||
|
r"""<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*>(.*?)</a>""",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
# Bare http(s) URL in text. Stops at whitespace, quotes, angle brackets, and
|
||||||
|
# closing brackets — but KEEPS `#`, `&`, `?`, `=` so fragments/queries survive.
|
||||||
|
_URL_RE = re.compile(r"""https?://[^\s"'<>)\]}]+""", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExtractedLink:
|
||||||
|
host: str # one of SUPPORTED_HOSTS
|
||||||
|
url: str # full url incl. query + #fragment
|
||||||
|
label: str | None # visible anchor text, when present
|
||||||
|
|
||||||
|
|
||||||
|
def _netloc(url: str) -> str:
|
||||||
|
# Lowercase host without credentials or port.
|
||||||
|
return urlsplit(url).netloc.lower().split("@")[-1].split(":")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def host_for(url: str) -> str | None:
|
||||||
|
"""Canonical host label for a url, or None if it's not a supported host."""
|
||||||
|
netloc = _netloc(url)
|
||||||
|
for suffix, host in _HOST_MAP.items():
|
||||||
|
if netloc == suffix or netloc.endswith("." + suffix):
|
||||||
|
return host
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap(url: str) -> str:
|
||||||
|
"""Unwrap a Patreon outbound-redirect wrapper to its inner target, so a
|
||||||
|
wrapped mega/gdrive link resolves to the real host. Patreon has used both
|
||||||
|
`www.patreon.com/...?url=<encoded>` and the `l.patreon.com` shim; check the
|
||||||
|
common target-param names. Non-Patreon urls pass through untouched."""
|
||||||
|
netloc = _netloc(url)
|
||||||
|
if not (netloc == "patreon.com" or netloc.endswith(".patreon.com")):
|
||||||
|
return url
|
||||||
|
qs = parse_qs(urlsplit(url).query)
|
||||||
|
for key in ("url", "u", "ext_url", "redirect", "target"):
|
||||||
|
vals = qs.get(key)
|
||||||
|
if vals and vals[0]:
|
||||||
|
return unescape(vals[0]).strip()
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def extract_external_links(html: str | None) -> list[ExtractedLink]:
|
||||||
|
"""All supported-host links in `html`, de-duplicated by url (first wins, so
|
||||||
|
an anchor's label is kept over a later bare sighting of the same url)."""
|
||||||
|
if not html:
|
||||||
|
return []
|
||||||
|
found: dict[str, ExtractedLink] = {}
|
||||||
|
|
||||||
|
# 1) Anchors first — they carry a human label ("Mega - Streamable").
|
||||||
|
for raw_href, inner in _HREF_RE.findall(html):
|
||||||
|
url = _unwrap(unescape(raw_href).strip())
|
||||||
|
host = host_for(url)
|
||||||
|
if host is None:
|
||||||
|
continue
|
||||||
|
label = unescape(_TAG_RE.sub("", inner)).strip() or None
|
||||||
|
found.setdefault(url, ExtractedLink(host=host, url=url, label=label))
|
||||||
|
|
||||||
|
# 2) Bare URLs pasted in text (no label). Trailing prose punctuation is
|
||||||
|
# trimmed; the href values already captured above de-dup away here.
|
||||||
|
for raw in _URL_RE.findall(html):
|
||||||
|
url = _unwrap(unescape(raw).strip().rstrip(".,;"))
|
||||||
|
host = host_for(url)
|
||||||
|
if host is None:
|
||||||
|
continue
|
||||||
|
found.setdefault(url, ExtractedLink(host=host, url=url, label=None))
|
||||||
|
|
||||||
|
return list(found.values())
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Alias resolution + CRUD.
|
"""Alias resolution + CRUD.
|
||||||
|
|
||||||
A tag_alias maps (model_name, model_category) -> canonical Tag. Resolution
|
A tag_alias maps (model_name, model_category) -> canonical Tag. Resolution
|
||||||
happens at suggestion-read time so raw tagger_predictions stay unmolested.
|
happens at suggestion-read time so the raw image_prediction rows stay unmolested.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
@@ -81,6 +81,31 @@ class AliasService:
|
|||||||
.where(TagAlias.alias_category == alias_category)
|
.where(TagAlias.alias_category == alias_category)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def list_for_tag(self, canonical_tag_id: int) -> Sequence[AliasRow]:
|
||||||
|
"""Aliases that resolve TO this tag — drives the tag-side 'Aliases'
|
||||||
|
view (see/remove the model keys that fold into a tag)."""
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
TagAlias.alias_string,
|
||||||
|
TagAlias.alias_category,
|
||||||
|
TagAlias.canonical_tag_id,
|
||||||
|
Tag.name,
|
||||||
|
)
|
||||||
|
.join(Tag, Tag.id == TagAlias.canonical_tag_id)
|
||||||
|
.where(TagAlias.canonical_tag_id == canonical_tag_id)
|
||||||
|
.order_by(TagAlias.alias_string.asc())
|
||||||
|
)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
return [
|
||||||
|
AliasRow(
|
||||||
|
alias_string=r[0],
|
||||||
|
alias_category=r[1],
|
||||||
|
canonical_tag_id=r[2],
|
||||||
|
canonical_tag_name=r[3],
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
async def list_all(self) -> Sequence[AliasRow]:
|
async def list_all(self) -> Sequence[AliasRow]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ image_tag AND to tag_allowlist; per-image removal/dismiss writes a rejection.
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import and_, delete, distinct, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...models import Tag, TagAllowlist, TagSuggestionRejection
|
from ...models import (
|
||||||
|
ImagePrediction,
|
||||||
|
MLSettings,
|
||||||
|
Tag,
|
||||||
|
TagAlias,
|
||||||
|
TagAllowlist,
|
||||||
|
TagSuggestionRejection,
|
||||||
|
)
|
||||||
from ...models.tag import image_tag
|
from ...models.tag import image_tag
|
||||||
from .aliases import AliasService
|
from .aliases import AliasService
|
||||||
|
|
||||||
@@ -20,6 +27,8 @@ class AllowlistRow:
|
|||||||
tag_name: str
|
tag_name: str
|
||||||
tag_kind: str
|
tag_kind: str
|
||||||
min_confidence: float
|
min_confidence: float
|
||||||
|
applied_count: int # image_tag rows currently carrying this tag
|
||||||
|
coverage_count: int # images a sweep WOULD cover at min_confidence
|
||||||
|
|
||||||
|
|
||||||
class AllowlistService:
|
class AllowlistService:
|
||||||
@@ -80,6 +89,12 @@ class AllowlistService:
|
|||||||
)
|
)
|
||||||
await self.session.execute(stmt)
|
await self.session.execute(stmt)
|
||||||
|
|
||||||
|
async def undismiss(self, image_id: int, tag_id: int) -> None:
|
||||||
|
"""Undo a per-image dismissal — drop the TagSuggestionRejection so the
|
||||||
|
suggestion reverts to a live (un-rejected) state. Backs the rail's
|
||||||
|
one-click reject-recovery (operator-asked 2026-06-27)."""
|
||||||
|
await self._clear_rejection(image_id, tag_id)
|
||||||
|
|
||||||
async def reject_applied_tag(self, image_id: int, tag_id: int) -> None:
|
async def reject_applied_tag(self, image_id: int, tag_id: int) -> None:
|
||||||
"""Operator removed an applied tag from an image. Remove the
|
"""Operator removed an applied tag from an image. Remove the
|
||||||
image_tag row AND record a rejection so the allowlist won't
|
image_tag row AND record a rejection so the allowlist won't
|
||||||
@@ -91,18 +106,69 @@ class AllowlistService:
|
|||||||
)
|
)
|
||||||
await self.dismiss(image_id, tag_id)
|
await self.dismiss(image_id, tag_id)
|
||||||
|
|
||||||
|
async def _store_floor(self) -> float:
|
||||||
|
return (
|
||||||
|
await self.session.execute(
|
||||||
|
select(MLSettings.tagger_store_floor).where(MLSettings.id == 1)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
async def update_threshold(
|
async def update_threshold(
|
||||||
self, tag_id: int, min_confidence: float
|
self, tag_id: int, min_confidence: float
|
||||||
) -> None:
|
) -> None:
|
||||||
row = await self.session.get(TagAllowlist, tag_id)
|
row = await self.session.get(TagAllowlist, tag_id)
|
||||||
if row is not None:
|
if row is not None:
|
||||||
row.min_confidence = min_confidence
|
# An allowlist tag can't auto-apply more permissively than the
|
||||||
|
# ingest store floor — predictions below tagger_store_floor aren't
|
||||||
|
# stored, so a lower min_confidence would behave identically to the
|
||||||
|
# floor. Clamp so the stored threshold matches actual behavior
|
||||||
|
# (#764).
|
||||||
|
floor = await self._store_floor()
|
||||||
|
row.min_confidence = max(min_confidence, floor)
|
||||||
|
|
||||||
async def remove(self, tag_id: int) -> None:
|
async def remove(self, tag_id: int) -> None:
|
||||||
await self.session.execute(
|
await self.session.execute(
|
||||||
delete(TagAllowlist).where(TagAllowlist.tag_id == tag_id)
|
delete(TagAllowlist).where(TagAllowlist.tag_id == tag_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _coverage_match(self, tag: Tag):
|
||||||
|
"""The predicate over image_prediction rows that resolve to `tag`,
|
||||||
|
mirroring tasks.ml._confidence_for_tag's resolution: a prediction whose
|
||||||
|
raw_name equals the tag name (any category), OR an alias maps
|
||||||
|
(raw_name, category) -> this tag. Returns a SQLAlchemy boolean clause.
|
||||||
|
"""
|
||||||
|
alias_rows = (
|
||||||
|
await self.session.execute(
|
||||||
|
select(TagAlias.alias_string, TagAlias.alias_category).where(
|
||||||
|
TagAlias.canonical_tag_id == tag.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
name_clause = ImagePrediction.raw_name == tag.name
|
||||||
|
alias_clauses = [
|
||||||
|
and_(
|
||||||
|
ImagePrediction.raw_name == a,
|
||||||
|
ImagePrediction.category == c,
|
||||||
|
)
|
||||||
|
for a, c in alias_rows
|
||||||
|
]
|
||||||
|
return or_(name_clause, *alias_clauses) if alias_clauses else name_clause
|
||||||
|
|
||||||
|
async def coverage(self, tag_id: int, threshold: float) -> int:
|
||||||
|
"""How many distinct images a sweep WOULD cover for this tag at
|
||||||
|
`threshold`: images with a resolving prediction scoring >= threshold.
|
||||||
|
The gross candidate pool (NOT minus already-applied/rejected) — it's
|
||||||
|
the tuning signal for "lower the threshold and ~N more images qualify".
|
||||||
|
"""
|
||||||
|
tag = await self.session.get(Tag, tag_id)
|
||||||
|
if tag is None:
|
||||||
|
return 0
|
||||||
|
match = await self._coverage_match(tag)
|
||||||
|
stmt = select(
|
||||||
|
func.count(distinct(ImagePrediction.image_record_id))
|
||||||
|
).where(ImagePrediction.score >= threshold, match)
|
||||||
|
return (await self.session.execute(stmt)).scalar_one()
|
||||||
|
|
||||||
async def list_all(self) -> Sequence[AllowlistRow]:
|
async def list_all(self) -> Sequence[AllowlistRow]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
@@ -115,12 +181,33 @@ class AllowlistService:
|
|||||||
.order_by(Tag.name.asc())
|
.order_by(Tag.name.asc())
|
||||||
)
|
)
|
||||||
rows = (await self.session.execute(stmt)).all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
return [
|
tag_ids = [r[0] for r in rows]
|
||||||
AllowlistRow(
|
|
||||||
tag_id=r[0],
|
# Applied counts in ONE grouped query (vs N per-row counts).
|
||||||
tag_name=r[1],
|
applied: dict[int, int] = {}
|
||||||
tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]),
|
if tag_ids:
|
||||||
min_confidence=r[3],
|
applied = dict(
|
||||||
|
(
|
||||||
|
await self.session.execute(
|
||||||
|
select(image_tag.c.tag_id, func.count())
|
||||||
|
.where(image_tag.c.tag_id.in_(tag_ids))
|
||||||
|
.group_by(image_tag.c.tag_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
)
|
)
|
||||||
for r in rows
|
|
||||||
]
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
# Coverage is per-tag (alias set differs); allowlist is small.
|
||||||
|
cov = await self.coverage(r[0], r[3])
|
||||||
|
result.append(
|
||||||
|
AllowlistRow(
|
||||||
|
tag_id=r[0],
|
||||||
|
tag_name=r[1],
|
||||||
|
tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]),
|
||||||
|
min_confidence=r[3],
|
||||||
|
applied_count=applied.get(r[0], 0),
|
||||||
|
coverage_count=cov,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""SigLIP SO400M image-embedding wrapper (PyTorch CPU).
|
"""SigLIP SO400M image-embedding wrapper (PyTorch CPU).
|
||||||
|
|
||||||
Direct port of ImageRepo's siglip.py. torch/transformers are imported
|
torch/transformers are imported lazily inside load() so this module can be
|
||||||
lazily inside load() so this module can be imported in the web container
|
imported in the web container (which never runs inference) without paying the
|
||||||
(which never runs inference) without paying the torch import cost.
|
torch import cost.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -13,6 +13,11 @@ from PIL import Image, ImageFile
|
|||||||
|
|
||||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
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). Keep
|
||||||
|
# N_replicas × this within the cores allotted to ML to avoid oversubscription.
|
||||||
|
_INTRA_OP_THREADS = 4
|
||||||
|
|
||||||
MODEL_NAME = os.environ.get(
|
MODEL_NAME = os.environ.get(
|
||||||
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
|
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
|
||||||
)
|
)
|
||||||
@@ -37,6 +42,9 @@ class Embedder:
|
|||||||
from transformers import AutoModel, SiglipImageProcessor
|
from transformers import AutoModel, SiglipImageProcessor
|
||||||
|
|
||||||
self._torch = torch
|
self._torch = torch
|
||||||
|
# Bound torch's CPU thread pool (see _INTRA_OP_THREADS) so each replica
|
||||||
|
# stays a predictable core consumer on a shared node.
|
||||||
|
torch.set_num_threads(_INTRA_OP_THREADS)
|
||||||
# FC's embedder only does IMAGE inference — never text. AutoProcessor
|
# FC's embedder only does IMAGE inference — never text. AutoProcessor
|
||||||
# loads the full processor including SiglipTokenizer, which requires
|
# loads the full processor including SiglipTokenizer, which requires
|
||||||
# the sentencepiece library at import time even if we never call it.
|
# the sentencepiece library at import time even if we never call it.
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
"""Production heads: train + score the per-concept classifiers (#114).
|
||||||
|
|
||||||
|
The eval (#1130, tag_eval.py) proved the spine; this is its production form.
|
||||||
|
- TRAIN (sync, ml worker — needs scikit-learn): for every general/character tag
|
||||||
|
with enough labelled positives, fit a logistic-regression head on the FROZEN
|
||||||
|
SigLIP embeddings (positives + negatives = rejections + sampled unlabeled),
|
||||||
|
derive an honest suggest threshold + earned-auto-apply point from CROSS-
|
||||||
|
VALIDATED scores, and upsert a TagHead row. Reuses tag_eval's proven data
|
||||||
|
loaders + metric helpers so production heads match the eval's measured numbers.
|
||||||
|
- SCORE (async, API worker — numpy via pgvector, NO scikit-learn): score one
|
||||||
|
image's embedding against all current heads → the suggestions the rail shows,
|
||||||
|
REPLACING Camie predictions + per-tag centroids.
|
||||||
|
|
||||||
|
scikit-learn is imported lazily inside the train path so the API worker can still
|
||||||
|
import this module to enqueue training + to score (scoring needs only numpy).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ...models import (
|
||||||
|
HeadAutoApplyRun,
|
||||||
|
HeadTrainingRun,
|
||||||
|
ImageRecord,
|
||||||
|
MLSettings,
|
||||||
|
Tag,
|
||||||
|
TagHead,
|
||||||
|
TagKind,
|
||||||
|
TagSuggestionRejection,
|
||||||
|
)
|
||||||
|
from ...models.tag import image_tag
|
||||||
|
from .tag_eval import (
|
||||||
|
_auto_apply_point,
|
||||||
|
_ids_with_tag,
|
||||||
|
_l2norm,
|
||||||
|
_load_embeddings,
|
||||||
|
_metrics_from_scores,
|
||||||
|
_rejected_ids,
|
||||||
|
_safe_folds,
|
||||||
|
_sample_unlabeled,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_NEG_RATIO = 3
|
||||||
|
DEFAULT_CV_FOLDS = 5
|
||||||
|
MIN_POSITIVES_FLOOR = 8 # hard floor; settings.head_min_positives can raise it
|
||||||
|
_UNLABELED_POOL = 4000
|
||||||
|
_EXAMPLES_MIN = 8 # need at least this many embedded +/- to fit a head
|
||||||
|
|
||||||
|
# Only these tag kinds get heads (the surfaced suggestion categories).
|
||||||
|
_HEAD_KINDS = (TagKind.general, TagKind.character)
|
||||||
|
# tag.kind -> the suggestion category the rail groups under.
|
||||||
|
_CATEGORY = {TagKind.general: "general", TagKind.character: "character"}
|
||||||
|
|
||||||
|
|
||||||
|
class HeadTrainingAlreadyRunning(Exception):
|
||||||
|
"""Raised by start_head_training_run when a run is already in flight."""
|
||||||
|
|
||||||
|
|
||||||
|
def start_head_training_run(session: Session, params: dict[str, Any]) -> int:
|
||||||
|
"""Create a HeadTrainingRun (status='running') + dispatch the ml-queue task.
|
||||||
|
Returns the run id. One training run at a time (light guard)."""
|
||||||
|
existing = session.execute(
|
||||||
|
select(HeadTrainingRun.id).where(HeadTrainingRun.status == "running")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise HeadTrainingAlreadyRunning(existing)
|
||||||
|
norm = _normalize_params(session, params)
|
||||||
|
run = HeadTrainingRun(
|
||||||
|
params=norm, status="running", last_progress_at=datetime.now(UTC)
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
session.flush()
|
||||||
|
run_id = run.id
|
||||||
|
from ...tasks.ml import train_heads as _task
|
||||||
|
_task.delay(run_id)
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(session: Session) -> MLSettings:
|
||||||
|
return session.execute(
|
||||||
|
select(MLSettings).where(MLSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
params = params or {}
|
||||||
|
s = _settings(session)
|
||||||
|
try:
|
||||||
|
min_pos = max(MIN_POSITIVES_FLOOR, int(params.get("min_positives", s.head_min_positives)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
min_pos = max(MIN_POSITIVES_FLOOR, s.head_min_positives)
|
||||||
|
try:
|
||||||
|
neg_ratio = max(1, int(params.get("neg_ratio", DEFAULT_NEG_RATIO)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
neg_ratio = DEFAULT_NEG_RATIO
|
||||||
|
try:
|
||||||
|
cv_folds = max(2, int(params.get("cv_folds", DEFAULT_CV_FOLDS)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cv_folds = DEFAULT_CV_FOLDS
|
||||||
|
try:
|
||||||
|
precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), 0.5), 0.999)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
precision_target = s.head_auto_apply_precision
|
||||||
|
return {
|
||||||
|
"min_positives": min_pos,
|
||||||
|
"neg_ratio": neg_ratio,
|
||||||
|
"cv_folds": cv_folds,
|
||||||
|
"precision_target": round(precision_target, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _embedder_version(session: Session) -> str:
|
||||||
|
return _settings(session).embedder_model_version
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_tag_ids(session: Session, min_pos: int) -> list[int]:
|
||||||
|
"""Concept tags (general/character) with >= min_pos labelled images — the
|
||||||
|
set that gets a head. Counts all sources; source-aware filtering (#1133) is
|
||||||
|
a separate, optional refinement."""
|
||||||
|
rows = session.execute(
|
||||||
|
select(Tag.id)
|
||||||
|
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
||||||
|
.where(Tag.kind.in_(_HEAD_KINDS))
|
||||||
|
.group_by(Tag.id)
|
||||||
|
.having(func.count(image_tag.c.image_record_id) >= min_pos)
|
||||||
|
).all()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def train_all_heads(
|
||||||
|
session: Session, params: dict[str, Any], run: HeadTrainingRun | None = None
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""(Re)train a head for every eligible concept; prune heads whose tag is no
|
||||||
|
longer eligible. Commits per head so a SIGKILL leaves trained heads durable
|
||||||
|
(training is idempotent). Returns {n_trained, n_skipped}."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
cfg = _normalize_params(session, params)
|
||||||
|
embedding_version = _embedder_version(session)
|
||||||
|
eligible = _eligible_tag_ids(session, cfg["min_positives"])
|
||||||
|
eligible_set = set(eligible)
|
||||||
|
trained = 0
|
||||||
|
skipped = 0
|
||||||
|
for i, tag_id in enumerate(eligible):
|
||||||
|
try:
|
||||||
|
ok = train_head(session, tag_id, embedding_version, cfg, np)
|
||||||
|
except Exception:
|
||||||
|
log.exception("train_head failed for tag %d", tag_id)
|
||||||
|
ok = False
|
||||||
|
session.commit()
|
||||||
|
trained += int(ok)
|
||||||
|
skipped += int(not ok)
|
||||||
|
if run is not None and i % 10 == 0:
|
||||||
|
run.last_progress_at = datetime.now(UTC)
|
||||||
|
session.commit()
|
||||||
|
# Retire heads whose concept dropped out of the eligible set (lost its
|
||||||
|
# positives, or the tag was re-kinded) so stale heads can't keep suggesting.
|
||||||
|
if eligible_set:
|
||||||
|
session.execute(delete(TagHead).where(TagHead.tag_id.not_in(eligible_set)))
|
||||||
|
else:
|
||||||
|
session.execute(delete(TagHead))
|
||||||
|
session.commit()
|
||||||
|
return {"n_trained": trained, "n_skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
def train_head(
|
||||||
|
session: Session, tag_id: int, embedding_version: str, cfg: dict, np
|
||||||
|
) -> bool:
|
||||||
|
"""Fit + upsert one head. Returns True if a head was written, False if the
|
||||||
|
concept had too few usable examples to train (the row is then removed)."""
|
||||||
|
from sklearn.linear_model import LogisticRegression
|
||||||
|
from sklearn.model_selection import StratifiedKFold, cross_val_predict
|
||||||
|
|
||||||
|
pos_ids = _ids_with_tag(session, tag_id)
|
||||||
|
if len(pos_ids) < cfg["min_positives"]:
|
||||||
|
session.execute(delete(TagHead).where(TagHead.tag_id == tag_id))
|
||||||
|
return False
|
||||||
|
|
||||||
|
pos_set = set(pos_ids)
|
||||||
|
rejected = [i for i in _rejected_ids(session, tag_id) if i not in pos_set]
|
||||||
|
want_neg = max(len(pos_ids) * cfg["neg_ratio"], _EXAMPLES_MIN * 4)
|
||||||
|
sampled = _sample_unlabeled(
|
||||||
|
session, pos_set | set(rejected), min(_UNLABELED_POOL, want_neg)
|
||||||
|
)
|
||||||
|
neg_ids = rejected + [i for i in sampled if i not in pos_set]
|
||||||
|
|
||||||
|
emb = _load_embeddings(session, pos_ids + neg_ids)
|
||||||
|
pos = [emb[i] for i in pos_ids if i in emb]
|
||||||
|
neg = [emb[i] for i in neg_ids if i in emb]
|
||||||
|
if len(pos) < _EXAMPLES_MIN or len(neg) < _EXAMPLES_MIN:
|
||||||
|
session.execute(delete(TagHead).where(TagHead.tag_id == tag_id))
|
||||||
|
return False
|
||||||
|
|
||||||
|
X = np.vstack(pos + neg).astype(np.float32)
|
||||||
|
y = np.array([1] * len(pos) + [0] * len(neg))
|
||||||
|
Xn = _l2norm(X, np)
|
||||||
|
|
||||||
|
clf = LogisticRegression(max_iter=1000, class_weight="balanced")
|
||||||
|
cv = StratifiedKFold(
|
||||||
|
n_splits=_safe_folds(y, cfg["cv_folds"], np), shuffle=True, random_state=0
|
||||||
|
)
|
||||||
|
# Honest thresholds from out-of-fold scores; deployable weights from a final
|
||||||
|
# fit on ALL the data.
|
||||||
|
cv_probs = cross_val_predict(clf, Xn, y, cv=cv, method="predict_proba")[:, 1]
|
||||||
|
metrics = _metrics_from_scores(y, cv_probs, np)
|
||||||
|
auto = _auto_apply_point(y, cv_probs, cfg["precision_target"], np)
|
||||||
|
clf.fit(Xn, y)
|
||||||
|
|
||||||
|
head = session.get(TagHead, tag_id)
|
||||||
|
if head is None:
|
||||||
|
head = TagHead(tag_id=tag_id)
|
||||||
|
session.add(head)
|
||||||
|
head.embedding_version = embedding_version
|
||||||
|
head.weights = clf.coef_[0].astype(np.float32).tolist()
|
||||||
|
head.bias = float(clf.intercept_[0])
|
||||||
|
head.suggest_threshold = float(metrics["threshold"])
|
||||||
|
head.auto_apply_threshold = float(auto["threshold"]) if auto else None
|
||||||
|
head.n_pos = len(pos)
|
||||||
|
head.n_neg = len(neg)
|
||||||
|
head.ap = float(metrics["ap"])
|
||||||
|
head.precision_cv = float(metrics["precision"])
|
||||||
|
head.recall = float(metrics["recall"])
|
||||||
|
head.trained_at = datetime.now(UTC)
|
||||||
|
head.metrics = {"f1": metrics["f1"], "auto_apply": auto}
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Scoring (async, API worker) -----------------------------------------
|
||||||
|
# Score one image against every current head to produce the rail's suggestions.
|
||||||
|
# A tiny in-process cache holds the stacked weight matrix keyed on (count,
|
||||||
|
# max(trained_at)) so a retrain invalidates it without per-request weight loads.
|
||||||
|
_HEADS_CACHE: dict[str, Any] = {"key": None, "heads": None}
|
||||||
|
|
||||||
|
|
||||||
|
async def _current_heads(session: AsyncSession, embedding_version: str):
|
||||||
|
"""Stacked (W, b, thresholds, tag_id/name/category) for heads matching the
|
||||||
|
current embedding, cached until the next retrain."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sig = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(), func.max(TagHead.trained_at)).where(
|
||||||
|
TagHead.embedding_version == embedding_version
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
key = f"{embedding_version}:{sig[0]}:{sig[1].isoformat() if sig[1] else '-'}"
|
||||||
|
cached = _HEADS_CACHE.get("heads")
|
||||||
|
if cached is not None and _HEADS_CACHE.get("key") == key:
|
||||||
|
return cached
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
TagHead.tag_id, Tag.name, Tag.kind,
|
||||||
|
TagHead.weights, TagHead.bias,
|
||||||
|
TagHead.suggest_threshold, TagHead.auto_apply_threshold,
|
||||||
|
)
|
||||||
|
.join(Tag, Tag.id == TagHead.tag_id)
|
||||||
|
.where(TagHead.embedding_version == embedding_version)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not rows:
|
||||||
|
loaded = {"W": None, "rows": []}
|
||||||
|
else:
|
||||||
|
W = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in rows])
|
||||||
|
b = np.asarray([r.bias for r in rows], dtype=np.float32)
|
||||||
|
thr = np.asarray([r.suggest_threshold for r in rows], dtype=np.float32)
|
||||||
|
meta = [
|
||||||
|
{
|
||||||
|
"tag_id": r.tag_id,
|
||||||
|
"name": r.name,
|
||||||
|
"category": _CATEGORY.get(r.kind, "general"),
|
||||||
|
"auto_apply_threshold": r.auto_apply_threshold,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
loaded = {"W": W, "b": b, "thr": thr, "meta": meta}
|
||||||
|
_HEADS_CACHE["key"] = key
|
||||||
|
_HEADS_CACHE["heads"] = loaded
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
async def score_image(
|
||||||
|
session: AsyncSession, image_id: int, threshold_override: float | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Suggestions for one image from the trained heads: [{tag_id, name,
|
||||||
|
category, score}], ranked. A concept surfaces when its score clears the
|
||||||
|
head's own suggest_threshold — or, when threshold_override is given (the
|
||||||
|
typed-dropdown "show everything" mode), that flat floor instead (0 → every
|
||||||
|
head). Empty if the image has no embedding or no heads exist yet."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
img = await session.get(ImageRecord, image_id)
|
||||||
|
if img is None or img.siglip_embedding is None:
|
||||||
|
return []
|
||||||
|
settings = await _settings_async(session)
|
||||||
|
heads = await _current_heads(session, settings.embedder_model_version)
|
||||||
|
if heads["W"] is None:
|
||||||
|
return []
|
||||||
|
x = np.asarray(img.siglip_embedding, dtype=np.float32)
|
||||||
|
n = float(np.linalg.norm(x)) or 1.0
|
||||||
|
xn = x / n
|
||||||
|
z = heads["W"] @ xn + heads["b"]
|
||||||
|
probs = 1.0 / (1.0 + np.exp(-z))
|
||||||
|
out = []
|
||||||
|
for i, p in enumerate(probs):
|
||||||
|
cut = threshold_override if threshold_override is not None else heads["thr"][i]
|
||||||
|
if p >= cut:
|
||||||
|
m = heads["meta"][i]
|
||||||
|
out.append({
|
||||||
|
"tag_id": m["tag_id"],
|
||||||
|
"name": m["name"],
|
||||||
|
"category": m["category"],
|
||||||
|
"score": float(p),
|
||||||
|
})
|
||||||
|
out.sort(key=lambda d: d["score"], reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _settings_async(session: AsyncSession) -> MLSettings:
|
||||||
|
return (
|
||||||
|
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Earned auto-apply (sync, ml worker) ---------------------------------
|
||||||
|
# A graduated head can apply its tag to images it scores above the head's
|
||||||
|
# auto_apply_threshold, without a human. Gated by a master switch + a support
|
||||||
|
# floor so a precise-looking but under-supported head can't spray tags.
|
||||||
|
|
||||||
|
_AUTO_APPLY_CHUNK = 5000
|
||||||
|
|
||||||
|
|
||||||
|
class HeadAutoApplyAlreadyRunning(Exception):
|
||||||
|
"""Raised when an auto-apply sweep is already in flight."""
|
||||||
|
|
||||||
|
|
||||||
|
class HeadAutoApplyDisabled(Exception):
|
||||||
|
"""Raised when a real (non-dry-run) sweep is requested but the master
|
||||||
|
switch (head_auto_apply_enabled) is off."""
|
||||||
|
|
||||||
|
|
||||||
|
def start_head_auto_apply_run(session: Session, params: dict[str, Any]) -> int:
|
||||||
|
"""Create a HeadAutoApplyRun + dispatch the ml-queue sweep. dry_run previews
|
||||||
|
(writes nothing); a real sweep needs the master switch on. One run at a time."""
|
||||||
|
dry_run = bool((params or {}).get("dry_run", False))
|
||||||
|
existing = session.execute(
|
||||||
|
select(HeadAutoApplyRun.id).where(HeadAutoApplyRun.status == "running")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise HeadAutoApplyAlreadyRunning(existing)
|
||||||
|
if not dry_run and not _settings(session).head_auto_apply_enabled:
|
||||||
|
raise HeadAutoApplyDisabled()
|
||||||
|
run = HeadAutoApplyRun(
|
||||||
|
dry_run=dry_run, params={"dry_run": dry_run}, status="running",
|
||||||
|
last_progress_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
session.flush()
|
||||||
|
run_id = run.id
|
||||||
|
from ...tasks.ml import apply_head_tags as _task
|
||||||
|
_task.delay(run_id)
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_apply_heads(session: Session, embedding_version: str, min_pos: int):
|
||||||
|
"""Eligible heads to fire: graduated (auto_apply_threshold set), enough
|
||||||
|
support, current embedding. Returns the row list (tag_id/name/weights/...)."""
|
||||||
|
return session.execute(
|
||||||
|
select(
|
||||||
|
TagHead.tag_id, Tag.name, TagHead.weights, TagHead.bias,
|
||||||
|
TagHead.auto_apply_threshold,
|
||||||
|
)
|
||||||
|
.join(Tag, Tag.id == TagHead.tag_id)
|
||||||
|
.where(TagHead.embedding_version == embedding_version)
|
||||||
|
.where(TagHead.auto_apply_threshold.is_not(None))
|
||||||
|
.where(TagHead.n_pos >= min_pos)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
def auto_apply_sweep(
|
||||||
|
session: Session, run: HeadAutoApplyRun, dry_run: bool
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Score every embedded image against the eligible heads and apply (or, for
|
||||||
|
dry_run, just count) each head's tag where score >= its auto_apply_threshold
|
||||||
|
and the tag isn't already applied or rejected on that image. Streams
|
||||||
|
embeddings in chunks; commits per chunk on a real run. Returns
|
||||||
|
{n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}."""
|
||||||
|
import numpy as np
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
|
settings = _settings(session)
|
||||||
|
rows = _auto_apply_heads(
|
||||||
|
session, settings.embedder_model_version,
|
||||||
|
settings.head_auto_apply_min_positives,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return {"n_applied": 0, "concepts": []}
|
||||||
|
|
||||||
|
W = np.vstack([np.asarray(r.weights, dtype=np.float32) for r in rows])
|
||||||
|
b = np.asarray([r.bias for r in rows], dtype=np.float32)
|
||||||
|
thr = np.asarray([r.auto_apply_threshold for r in rows], dtype=np.float32)
|
||||||
|
tag_ids = [r.tag_id for r in rows]
|
||||||
|
names = [r.name for r in rows]
|
||||||
|
|
||||||
|
# Skip images that already carry, or have rejected, each tag.
|
||||||
|
skip = {tid: set() for tid in tag_ids}
|
||||||
|
for tid in tag_ids:
|
||||||
|
for (iid,) in session.execute(
|
||||||
|
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
||||||
|
):
|
||||||
|
skip[tid].add(iid)
|
||||||
|
for (iid,) in session.execute(
|
||||||
|
select(TagSuggestionRejection.image_record_id).where(
|
||||||
|
TagSuggestionRejection.tag_id == tid
|
||||||
|
)
|
||||||
|
):
|
||||||
|
skip[tid].add(iid)
|
||||||
|
|
||||||
|
applied = [0] * len(rows)
|
||||||
|
scanned = 0
|
||||||
|
all_ids = list(session.execute(
|
||||||
|
select(ImageRecord.id).where(ImageRecord.siglip_embedding.is_not(None))
|
||||||
|
).scalars())
|
||||||
|
for start in range(0, len(all_ids), _AUTO_APPLY_CHUNK):
|
||||||
|
chunk = all_ids[start:start + _AUTO_APPLY_CHUNK]
|
||||||
|
emb = _load_embeddings(session, chunk)
|
||||||
|
cids = [i for i in chunk if i in emb]
|
||||||
|
if not cids:
|
||||||
|
continue
|
||||||
|
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||||
|
probs = 1.0 / (1.0 + np.exp(-(Xn @ W.T + b))) # (N, H)
|
||||||
|
scanned += len(cids)
|
||||||
|
for h in range(len(rows)):
|
||||||
|
tid = tag_ids[h]
|
||||||
|
for idx in np.where(probs[:, h] >= thr[h])[0]:
|
||||||
|
iid = cids[int(idx)]
|
||||||
|
if iid in skip[tid]:
|
||||||
|
continue
|
||||||
|
skip[tid].add(iid)
|
||||||
|
applied[h] += 1
|
||||||
|
if not dry_run:
|
||||||
|
session.execute(
|
||||||
|
pg_insert(image_tag)
|
||||||
|
.values(image_record_id=iid, tag_id=tid, source="head_auto")
|
||||||
|
.on_conflict_do_nothing()
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
session.commit()
|
||||||
|
run.last_progress_at = datetime.now(UTC)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
concepts = [
|
||||||
|
{"tag_id": tag_ids[h], "name": names[h], "applied": applied[h],
|
||||||
|
"scanned": scanned, "threshold": float(thr[h])}
|
||||||
|
for h in range(len(rows))
|
||||||
|
]
|
||||||
|
return {"n_applied": sum(applied), "concepts": concepts}
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
"""The suggestion read-path: raw predictions + centroids -> alias-resolved,
|
"""The suggestion read-path: trained HEADS score one image's frozen embedding
|
||||||
threshold-filtered, category-grouped, ranked suggestions for one image.
|
into alias-resolved, category-grouped, ranked suggestions.
|
||||||
|
|
||||||
|
Tagging-v2 (#114): suggestions now come from the per-concept heads that LEARN
|
||||||
|
from the operator's tags (services/ml/heads.py) — the Camie prediction source
|
||||||
|
and the per-tag SigLIP centroid have been REMOVED. A head exists only for an
|
||||||
|
existing concept tag, so every suggestion is a canonical tag (no raw model key,
|
||||||
|
no alias remap, no creates-new). Rejected tags stay in the list FLAGGED (not
|
||||||
|
dropped) so the rail can show + reverse a dismissal.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...models import (
|
from ...models import ImageRecord, TagSuggestionRejection
|
||||||
ImageRecord,
|
|
||||||
MLSettings,
|
|
||||||
Tag,
|
|
||||||
TagSuggestionRejection,
|
|
||||||
)
|
|
||||||
from ...models.tag import image_tag
|
from ...models.tag import image_tag
|
||||||
from .aliases import AliasService
|
from .heads import score_image
|
||||||
from .centroids import CentroidService
|
|
||||||
from .tag_name import normalize as normalize_tag_name
|
|
||||||
from .tagger import SURFACED_CATEGORIES
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -28,8 +27,21 @@ class Suggestion:
|
|||||||
display_name: str
|
display_name: str
|
||||||
category: str
|
category: str
|
||||||
score: float
|
score: float
|
||||||
source: str # 'tagger' | 'centroid' | 'both'
|
source: str # 'head' (Camie 'tagger'/'centroid' sources removed in v2)
|
||||||
creates_new_tag: bool
|
creates_new_tag: bool
|
||||||
|
# raw_name = the booru model vocab key behind this suggestion. It's the key
|
||||||
|
# an alias MUST be stored under (resolution looks up the raw key), so the
|
||||||
|
# modal needs it to author an alias correctly. None for centroid-only hits
|
||||||
|
# (no underlying prediction → nothing to alias).
|
||||||
|
raw_name: str | None = None
|
||||||
|
# via_alias = this suggestion was surfaced because an operator alias remapped
|
||||||
|
# the raw prediction to this canonical tag. Lets the UI mark it + offer undo.
|
||||||
|
via_alias: bool = False
|
||||||
|
# rejected = the operator dismissed this tag for this image (a stored
|
||||||
|
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so
|
||||||
|
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
|
||||||
|
# operator-asked 2026-06-27) instead of silently vanishing or re-suggesting.
|
||||||
|
rejected: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -40,31 +52,24 @@ class SuggestionList:
|
|||||||
class SuggestionService:
|
class SuggestionService:
|
||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.aliases = AliasService(session)
|
|
||||||
self.centroids = CentroidService(session)
|
|
||||||
|
|
||||||
async def _settings(self) -> MLSettings:
|
async def for_image(
|
||||||
return (
|
self, image_id: int, threshold_override: float | None = None,
|
||||||
await self.session.execute(select(MLSettings).where(MLSettings.id == 1))
|
) -> SuggestionList:
|
||||||
).scalar_one()
|
"""Head-scored suggestions for one image, grouped by category and ranked.
|
||||||
|
|
||||||
def _threshold_for(self, s: MLSettings, category: str) -> float:
|
Each trained head scores the image's frozen embedding; a concept surfaces
|
||||||
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
|
when its score clears the head's own suggest threshold. threshold_override
|
||||||
# both fall through to the 1.01 "never surfaces" default like any
|
(used by the typed tag-input dropdown's "show everything" mode) replaces
|
||||||
# unsurfaced category.
|
that per-head cut with a flat floor (0 → every head), so a low-scoring
|
||||||
return {
|
concept can still be typed + picked in canonical formatting.
|
||||||
"character": s.suggestion_threshold_character,
|
|
||||||
"general": s.suggestion_threshold_general,
|
|
||||||
}.get(category, 1.01)
|
|
||||||
|
|
||||||
async def for_image(self, image_id: int) -> SuggestionList:
|
Already-applied tags are dropped; rejected tags stay FLAGGED and sink to
|
||||||
|
the bottom of their category so a dismissal is visible + reversible."""
|
||||||
img = await self.session.get(ImageRecord, image_id)
|
img = await self.session.get(ImageRecord, image_id)
|
||||||
if img is None:
|
if img is None:
|
||||||
return SuggestionList()
|
return SuggestionList()
|
||||||
|
|
||||||
settings = await self._settings()
|
|
||||||
predictions: dict = img.tagger_predictions or {}
|
|
||||||
|
|
||||||
applied = set(
|
applied = set(
|
||||||
(
|
(
|
||||||
await self.session.execute(
|
await self.session.execute(
|
||||||
@@ -84,137 +89,30 @@ class SuggestionService:
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Camie predictions ---
|
hits = await score_image(
|
||||||
# candidates carry (raw_name, display_name, category, confidence).
|
self.session, image_id, threshold_override=threshold_override
|
||||||
# raw_name = the booru-formatted vocab key, kept for alias_map
|
|
||||||
# lookup since alias rows are hand-curated against raw keys.
|
|
||||||
# display_name = normalize_tag_name(raw_name) — what the operator
|
|
||||||
# sees AND what gets written to tag.name on Accept.
|
|
||||||
candidates: list[tuple[str, str, str, float]] = []
|
|
||||||
for name, p in predictions.items():
|
|
||||||
category = p.get("category", "general")
|
|
||||||
if category not in SURFACED_CATEGORIES:
|
|
||||||
continue
|
|
||||||
conf = float(p.get("confidence", 0.0))
|
|
||||||
if conf < self._threshold_for(settings, category):
|
|
||||||
continue
|
|
||||||
display = normalize_tag_name(name)
|
|
||||||
if display is None:
|
|
||||||
# emoticon / pure-punctuation vocab entry — drop entirely
|
|
||||||
continue
|
|
||||||
candidates.append((name, display, category, conf))
|
|
||||||
|
|
||||||
alias_map = await self.aliases.resolve_many(
|
|
||||||
[(raw, c) for raw, _disp, c, _conf in candidates]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
merged: dict[object, Suggestion] = {}
|
|
||||||
|
|
||||||
def _merge(key, sug: Suggestion):
|
|
||||||
existing = merged.get(key)
|
|
||||||
if existing is None:
|
|
||||||
merged[key] = sug
|
|
||||||
elif sug.score > existing.score:
|
|
||||||
merged[key] = Suggestion(
|
|
||||||
canonical_tag_id=existing.canonical_tag_id,
|
|
||||||
display_name=existing.display_name,
|
|
||||||
category=existing.category,
|
|
||||||
score=sug.score,
|
|
||||||
source="both"
|
|
||||||
if existing.source != sug.source
|
|
||||||
else existing.source,
|
|
||||||
creates_new_tag=existing.creates_new_tag,
|
|
||||||
)
|
|
||||||
|
|
||||||
for raw, display, category, conf in candidates:
|
|
||||||
canonical = alias_map.get((raw, category))
|
|
||||||
if canonical is not None:
|
|
||||||
if canonical.id in applied or canonical.id in rejected:
|
|
||||||
continue
|
|
||||||
_merge(
|
|
||||||
canonical.id,
|
|
||||||
Suggestion(
|
|
||||||
canonical_tag_id=canonical.id,
|
|
||||||
display_name=canonical.name,
|
|
||||||
category=category,
|
|
||||||
score=conf,
|
|
||||||
source="tagger",
|
|
||||||
creates_new_tag=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Case-insensitive match on BOTH the raw camie key AND
|
|
||||||
# the normalized form — covers legacy underscore-named
|
|
||||||
# Tag rows accepted before normalization shipped, AND
|
|
||||||
# any tag the operator created with the human form.
|
|
||||||
existing_tag = (
|
|
||||||
await self.session.execute(
|
|
||||||
select(Tag).where(
|
|
||||||
func.lower(Tag.name).in_(
|
|
||||||
[raw.lower(), display.lower()]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
if existing_tag is not None:
|
|
||||||
if (
|
|
||||||
existing_tag.id in applied
|
|
||||||
or existing_tag.id in rejected
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
_merge(
|
|
||||||
existing_tag.id,
|
|
||||||
Suggestion(
|
|
||||||
canonical_tag_id=existing_tag.id,
|
|
||||||
display_name=existing_tag.name,
|
|
||||||
category=category,
|
|
||||||
score=conf,
|
|
||||||
source="tagger",
|
|
||||||
creates_new_tag=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_merge(
|
|
||||||
f"raw:{display}:{category}",
|
|
||||||
Suggestion(
|
|
||||||
canonical_tag_id=None,
|
|
||||||
display_name=display,
|
|
||||||
category=category,
|
|
||||||
score=conf,
|
|
||||||
source="tagger",
|
|
||||||
creates_new_tag=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Centroid augmentation ---
|
|
||||||
hits = await self.centroids.find_similar_tags(image_id, limit=30)
|
|
||||||
for hit in hits:
|
|
||||||
if hit.similarity < settings.centroid_similarity_threshold:
|
|
||||||
continue
|
|
||||||
if hit.tag_id in applied or hit.tag_id in rejected:
|
|
||||||
continue
|
|
||||||
tag = await self.session.get(Tag, hit.tag_id)
|
|
||||||
if tag is None:
|
|
||||||
continue
|
|
||||||
cat = tag.kind.value if hasattr(tag.kind, "value") else str(tag.kind)
|
|
||||||
display_cat = cat if cat in SURFACED_CATEGORIES else "general"
|
|
||||||
_merge(
|
|
||||||
tag.id,
|
|
||||||
Suggestion(
|
|
||||||
canonical_tag_id=tag.id,
|
|
||||||
display_name=tag.name,
|
|
||||||
category=display_cat,
|
|
||||||
score=hit.similarity,
|
|
||||||
source="centroid",
|
|
||||||
creates_new_tag=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = SuggestionList()
|
result = SuggestionList()
|
||||||
for sug in merged.values():
|
for h in hits:
|
||||||
result.by_category.setdefault(sug.category, []).append(sug)
|
tag_id = h["tag_id"]
|
||||||
|
if tag_id in applied:
|
||||||
|
continue
|
||||||
|
result.by_category.setdefault(h["category"], []).append(
|
||||||
|
Suggestion(
|
||||||
|
canonical_tag_id=tag_id,
|
||||||
|
display_name=h["name"],
|
||||||
|
category=h["category"],
|
||||||
|
score=h["score"],
|
||||||
|
source="head",
|
||||||
|
creates_new_tag=False,
|
||||||
|
rejected=tag_id in rejected,
|
||||||
|
)
|
||||||
|
)
|
||||||
for cat in result.by_category:
|
for cat in result.by_category:
|
||||||
result.by_category[cat].sort(key=lambda s: s.score, reverse=True)
|
# Live suggestions first (by score), rejected ones sink to the
|
||||||
|
# bottom of the category — visible for recovery, out of the way.
|
||||||
|
result.by_category[cat].sort(key=lambda s: (s.rejected, -s.score))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def for_selection(
|
async def for_selection(
|
||||||
@@ -241,6 +139,11 @@ class SuggestionService:
|
|||||||
for s in items:
|
for s in items:
|
||||||
if s.canonical_tag_id is None or s.creates_new_tag:
|
if s.canonical_tag_id is None or s.creates_new_tag:
|
||||||
continue
|
continue
|
||||||
|
# for_image keeps rejected tags (flagged) for the rail;
|
||||||
|
# bulk consensus must still ignore them — a tag dismissed on
|
||||||
|
# an image isn't a suggestion for that image.
|
||||||
|
if s.rejected:
|
||||||
|
continue
|
||||||
st = stats.get(s.canonical_tag_id)
|
st = stats.get(s.canonical_tag_id)
|
||||||
if st is None:
|
if st is None:
|
||||||
st = {
|
st = {
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"""Head-vs-centroid tagging eval (#1130, milestone #114 slice 1).
|
||||||
|
|
||||||
|
Proves the "frozen embedding + small trained head (with negatives)" spine on the
|
||||||
|
operator's OWN data, reusing the SigLIP embeddings already stored on
|
||||||
|
image_record. For each concept tag it compares:
|
||||||
|
- CENTROID baseline (the old approach): cosine to the mean of positive vectors.
|
||||||
|
- HEAD (the new approach): logistic regression trained on positives + negatives.
|
||||||
|
and reports cross-validated precision/recall/AP for both, a LEARNING CURVE
|
||||||
|
(accuracy as the number of tagged positives grows), and example image ids to
|
||||||
|
eyeball.
|
||||||
|
|
||||||
|
numpy + scikit-learn are imported LAZILY inside run_eval so the API worker (base
|
||||||
|
image, no ML stack) can still import start_tag_eval_run to enqueue the ml-queue
|
||||||
|
task — the heavy compute only runs on the ml worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ...models import (
|
||||||
|
ImageRecord,
|
||||||
|
Tag,
|
||||||
|
TagEvalRun,
|
||||||
|
TagKind,
|
||||||
|
TagPositiveConfirmation,
|
||||||
|
TagSuggestionRejection,
|
||||||
|
)
|
||||||
|
from ...models.tag import image_tag
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The operator's real concept list (mix of whole-ish + small/local cues). The
|
||||||
|
# admin trigger can override; this is the default eval set.
|
||||||
|
DEFAULT_CONCEPTS = [
|
||||||
|
"glasses", "cat", "dog", "horse", "goblin",
|
||||||
|
"cum", "lactation", "fellatio", "xray", "stomach bulge",
|
||||||
|
]
|
||||||
|
DEFAULT_CURVE_POINTS = [10, 30, 100, 300]
|
||||||
|
DEFAULT_NEG_RATIO = 3 # negatives per positive (rejections + sampled unlabeled)
|
||||||
|
DEFAULT_CV_FOLDS = 5
|
||||||
|
MIN_POSITIVES = 8 # below this, a concept can't be evaluated meaningfully
|
||||||
|
_UNLABELED_POOL = 4000 # cap on sampled unlabeled rows pulled per concept
|
||||||
|
_EXAMPLES_K = 12
|
||||||
|
|
||||||
|
|
||||||
|
def start_tag_eval_run(session: Session, params: dict[str, Any]) -> int:
|
||||||
|
"""Create a TagEvalRun (status='running') and dispatch the ml-queue task.
|
||||||
|
Returns the new run id. Light guard: one running eval at a time."""
|
||||||
|
existing = session.execute(
|
||||||
|
select(TagEvalRun.id).where(TagEvalRun.status == "running")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise EvalAlreadyRunning(existing)
|
||||||
|
norm = _normalize_params(params)
|
||||||
|
run = TagEvalRun(params=norm, status="running", last_progress_at=datetime.now(UTC))
|
||||||
|
session.add(run)
|
||||||
|
session.flush()
|
||||||
|
run_id = run.id
|
||||||
|
# Same enqueue-by-import pattern api/suggestions.py uses for ml tasks; the
|
||||||
|
# commit happens in the API handler so row + dispatch are visible together.
|
||||||
|
from ...tasks.ml import tag_eval_run as _task
|
||||||
|
_task.delay(run_id)
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
class EvalAlreadyRunning(Exception):
|
||||||
|
"""Raised by start_tag_eval_run when an eval is already in flight."""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_params(params: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
params = params or {}
|
||||||
|
concepts = [str(c).strip() for c in (params.get("concepts") or []) if str(c).strip()]
|
||||||
|
try:
|
||||||
|
neg_ratio = max(1, int(params.get("neg_ratio", DEFAULT_NEG_RATIO)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
neg_ratio = DEFAULT_NEG_RATIO
|
||||||
|
try:
|
||||||
|
cv_folds = max(2, int(params.get("cv_folds", DEFAULT_CV_FOLDS)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cv_folds = DEFAULT_CV_FOLDS
|
||||||
|
try:
|
||||||
|
auto_top_n = min(max(int(params.get("auto_top_n", 0) or 0), 0), 200)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
auto_top_n = 0
|
||||||
|
try:
|
||||||
|
precision_target = min(max(float(params.get("precision_target", 0.97)), 0.5), 0.999)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
precision_target = 0.97
|
||||||
|
# No explicit concepts and auto-discovery off → fall back to the hand list.
|
||||||
|
if not concepts and not auto_top_n:
|
||||||
|
concepts = list(DEFAULT_CONCEPTS)
|
||||||
|
curve = params.get("curve_points") or DEFAULT_CURVE_POINTS
|
||||||
|
curve = sorted({int(n) for n in curve if int(n) > 0})
|
||||||
|
return {
|
||||||
|
"concepts": concepts,
|
||||||
|
"neg_ratio": neg_ratio,
|
||||||
|
"cv_folds": cv_folds,
|
||||||
|
"auto_top_n": auto_top_n,
|
||||||
|
"precision_target": round(precision_target, 4),
|
||||||
|
"curve_points": curve,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _top_general_concepts(session: Session, n: int, min_count: int) -> list[str]:
|
||||||
|
"""The n most-tagged general (concept) tags with >= min_count images — a fast
|
||||||
|
server-side way to broaden the eval beyond the hand-picked list (counts all
|
||||||
|
sources; source-aware filtering is a separate concern)."""
|
||||||
|
rows = session.execute(
|
||||||
|
select(Tag.name)
|
||||||
|
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
||||||
|
.where(Tag.kind == TagKind.general)
|
||||||
|
.group_by(Tag.id)
|
||||||
|
.having(func.count(image_tag.c.image_record_id) >= min_count)
|
||||||
|
.order_by(func.count(image_tag.c.image_record_id).desc())
|
||||||
|
.limit(n)
|
||||||
|
).all()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tag_id(session: Session, name: str) -> int | None:
|
||||||
|
"""Case-insensitive tag-name match; if several share a name, take the one
|
||||||
|
applied to the most images (the one the operator actually uses)."""
|
||||||
|
rows = session.execute(
|
||||||
|
select(Tag.id, func.count(image_tag.c.image_record_id))
|
||||||
|
.outerjoin(image_tag, image_tag.c.tag_id == Tag.id)
|
||||||
|
.where(func.lower(Tag.name) == name.lower())
|
||||||
|
.group_by(Tag.id)
|
||||||
|
.order_by(func.count(image_tag.c.image_record_id).desc())
|
||||||
|
).all()
|
||||||
|
return rows[0][0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ids_with_tag(session: Session, tag_id: int) -> list[int]:
|
||||||
|
return [
|
||||||
|
r[0] for r in session.execute(
|
||||||
|
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tag_id)
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _rejected_ids(session: Session, tag_id: int) -> list[int]:
|
||||||
|
return [
|
||||||
|
r[0] for r in session.execute(
|
||||||
|
select(TagSuggestionRejection.image_record_id)
|
||||||
|
.where(TagSuggestionRejection.tag_id == tag_id)
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _confirmed_ids(session: Session, tag_id: int) -> set[int]:
|
||||||
|
"""Positives the operator explicitly affirmed ('keep') — excluded from the
|
||||||
|
doubts list so confirmed-correct images don't resurface every run."""
|
||||||
|
return {
|
||||||
|
r[0] for r in session.execute(
|
||||||
|
select(TagPositiveConfirmation.image_record_id)
|
||||||
|
.where(TagPositiveConfirmation.tag_id == tag_id)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
|
||||||
|
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
|
||||||
|
sparse, so an untagged image is almost always a true negative."""
|
||||||
|
stmt = (
|
||||||
|
select(ImageRecord.id)
|
||||||
|
.where(ImageRecord.siglip_embedding.is_not(None))
|
||||||
|
.order_by(func.random())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if exclude:
|
||||||
|
stmt = stmt.where(ImageRecord.id.not_in(exclude))
|
||||||
|
return [r[0] for r in session.execute(stmt).all()]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_embeddings(session: Session, ids: list[int]) -> dict[int, Any]:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
out: dict[int, Any] = {}
|
||||||
|
if not ids:
|
||||||
|
return out
|
||||||
|
# Chunk the IN list to stay well under psycopg's parameter ceiling.
|
||||||
|
for i in range(0, len(ids), 2000):
|
||||||
|
chunk = ids[i:i + 2000]
|
||||||
|
for rid, emb in session.execute(
|
||||||
|
select(ImageRecord.id, ImageRecord.siglip_embedding)
|
||||||
|
.where(ImageRecord.id.in_(chunk))
|
||||||
|
.where(ImageRecord.siglip_embedding.is_not(None))
|
||||||
|
).all():
|
||||||
|
out[rid] = np.asarray(emb, dtype=np.float32)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def run_eval(session: Session, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Compute the full report. Per-concept failures are captured, not fatal."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
cfg = _normalize_params(params)
|
||||||
|
# Auto-discovery: union the explicit concepts with the top-N most-tagged
|
||||||
|
# general tags (server-side, fast) so the eval can broaden itself.
|
||||||
|
concepts = list(cfg["concepts"])
|
||||||
|
if cfg["auto_top_n"]:
|
||||||
|
seen = {c.lower() for c in concepts}
|
||||||
|
for name in _top_general_concepts(session, cfg["auto_top_n"], MIN_POSITIVES):
|
||||||
|
if name.lower() not in seen:
|
||||||
|
concepts.append(name)
|
||||||
|
seen.add(name.lower())
|
||||||
|
cfg["concepts"] = concepts
|
||||||
|
concepts_out = []
|
||||||
|
for name in cfg["concepts"]:
|
||||||
|
try:
|
||||||
|
concepts_out.append(_eval_concept(session, name, cfg, np))
|
||||||
|
except Exception as exc: # one bad concept shouldn't kill the run
|
||||||
|
log.exception("tag-eval concept %r failed", name)
|
||||||
|
concepts_out.append({"name": name, "skipped": f"error: {exc}"})
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now(UTC).isoformat(),
|
||||||
|
"params": cfg,
|
||||||
|
"concepts": concepts_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_concept(session: Session, name: str, cfg: dict, np) -> dict[str, Any]:
|
||||||
|
tag_id = _resolve_tag_id(session, name)
|
||||||
|
if tag_id is None:
|
||||||
|
return {"name": name, "skipped": "no such tag"}
|
||||||
|
pos_ids = _ids_with_tag(session, tag_id)
|
||||||
|
if len(pos_ids) < MIN_POSITIVES:
|
||||||
|
return {"name": name, "tag_id": tag_id, "n_pos": len(pos_ids),
|
||||||
|
"skipped": f"too few positives (<{MIN_POSITIVES})"}
|
||||||
|
|
||||||
|
neg_ratio = cfg["neg_ratio"]
|
||||||
|
pos_set = set(pos_ids)
|
||||||
|
rejected = [i for i in _rejected_ids(session, tag_id) if i not in pos_set]
|
||||||
|
want_neg = max(len(pos_ids) * neg_ratio, _EXAMPLES_K * 4)
|
||||||
|
sampled = _sample_unlabeled(session, pos_set | set(rejected),
|
||||||
|
min(_UNLABELED_POOL, want_neg))
|
||||||
|
neg_ids = rejected + [i for i in sampled if i not in pos_set]
|
||||||
|
|
||||||
|
emb = _load_embeddings(session, pos_ids + neg_ids)
|
||||||
|
pos = [(i, emb[i]) for i in pos_ids if i in emb]
|
||||||
|
neg = [(i, emb[i]) for i in neg_ids if i in emb]
|
||||||
|
if len(pos) < MIN_POSITIVES or len(neg) < MIN_POSITIVES:
|
||||||
|
return {"name": name, "tag_id": tag_id, "n_pos": len(pos),
|
||||||
|
"n_neg": len(neg), "skipped": "too few embedded examples"}
|
||||||
|
|
||||||
|
ids = np.array([i for i, _ in pos] + [i for i, _ in neg])
|
||||||
|
X = np.vstack([v for _, v in pos] + [v for _, v in neg]).astype(np.float32)
|
||||||
|
y = np.array([1] * len(pos) + [0] * len(neg))
|
||||||
|
Xn = _l2norm(X, np)
|
||||||
|
|
||||||
|
head = _eval_head(Xn, y, cfg["cv_folds"], cfg["precision_target"], np)
|
||||||
|
centroid = _eval_centroid(Xn, y, cfg["cv_folds"], np)
|
||||||
|
curve = _learning_curve(Xn, y, cfg["curve_points"], neg_ratio, np)
|
||||||
|
confirmed = _confirmed_ids(session, tag_id)
|
||||||
|
examples = _examples(session, Xn, y, ids, np, set(rejected), confirmed)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name, "tag_id": tag_id,
|
||||||
|
"n_pos": len(pos), "n_neg": len(neg),
|
||||||
|
"n_rejected": len(rejected),
|
||||||
|
"head": head, "centroid": centroid,
|
||||||
|
"curve": curve, "examples": examples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _l2norm(X, np):
|
||||||
|
n = np.linalg.norm(X, axis=1, keepdims=True)
|
||||||
|
n[n == 0] = 1.0
|
||||||
|
return X / n
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics_from_scores(y, scores, np) -> dict[str, float]:
|
||||||
|
from sklearn.metrics import average_precision_score, precision_recall_curve
|
||||||
|
|
||||||
|
ap = float(average_precision_score(y, scores))
|
||||||
|
prec, rec, thr = precision_recall_curve(y, scores)
|
||||||
|
f1 = (2 * prec * rec) / np.clip(prec + rec, 1e-9, None)
|
||||||
|
best = int(np.argmax(f1))
|
||||||
|
# thr has len = len(prec)-1; map best index safely.
|
||||||
|
t = float(thr[min(best, len(thr) - 1)]) if len(thr) else 0.5
|
||||||
|
return {
|
||||||
|
"ap": round(ap, 4),
|
||||||
|
"precision": round(float(prec[best]), 4),
|
||||||
|
"recall": round(float(rec[best]), 4),
|
||||||
|
"f1": round(float(f1[best]), 4),
|
||||||
|
"threshold": round(t, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_folds(y, folds, np) -> int:
|
||||||
|
minority = int(min(np.bincount(y)))
|
||||||
|
return max(2, min(folds, minority))
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_head(Xn, y, folds, target, np) -> dict[str, float]:
|
||||||
|
from sklearn.linear_model import LogisticRegression
|
||||||
|
from sklearn.model_selection import StratifiedKFold, cross_val_predict
|
||||||
|
|
||||||
|
clf = LogisticRegression(max_iter=1000, class_weight="balanced")
|
||||||
|
cv = StratifiedKFold(n_splits=_safe_folds(y, folds, np), shuffle=True,
|
||||||
|
random_state=0)
|
||||||
|
probs = cross_val_predict(clf, Xn, y, cv=cv, method="predict_proba")[:, 1]
|
||||||
|
m = _metrics_from_scores(y, probs, np)
|
||||||
|
m["auto_apply"] = _auto_apply_point(y, probs, target, np)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_apply_point(y, scores, target, np) -> dict | None:
|
||||||
|
"""The auto-apply operating point: the threshold that yields the MOST recall
|
||||||
|
while holding precision >= target. This answers 'could this concept fire
|
||||||
|
without a human, and how much would it catch?' Returns None if no threshold
|
||||||
|
reaches the precision target (concept not auto-apply-ready)."""
|
||||||
|
from sklearn.metrics import precision_recall_curve
|
||||||
|
|
||||||
|
prec, rec, thr = precision_recall_curve(y, scores)
|
||||||
|
best = None # (threshold, precision, recall) maximizing recall s.t. prec>=target
|
||||||
|
for i in range(len(thr)): # thr[i] corresponds to prec[i], rec[i]
|
||||||
|
if prec[i] >= target and (best is None or rec[i] > best[2]):
|
||||||
|
best = (float(thr[i]), float(prec[i]), float(rec[i]))
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"target": round(float(target), 4),
|
||||||
|
"threshold": round(best[0], 4),
|
||||||
|
"precision": round(best[1], 4),
|
||||||
|
"recall": round(best[2], 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_centroid(Xn, y, folds, np) -> dict[str, float]:
|
||||||
|
"""Cross-validated cosine-to-positive-mean — the OLD method's quality."""
|
||||||
|
from sklearn.model_selection import StratifiedKFold
|
||||||
|
|
||||||
|
cv = StratifiedKFold(n_splits=_safe_folds(y, folds, np), shuffle=True,
|
||||||
|
random_state=0)
|
||||||
|
scores = np.zeros(len(y), dtype=np.float32)
|
||||||
|
for train, test in cv.split(Xn, y):
|
||||||
|
c = Xn[train][y[train] == 1].mean(axis=0)
|
||||||
|
cn = c / (np.linalg.norm(c) or 1.0)
|
||||||
|
scores[test] = Xn[test] @ cn
|
||||||
|
return _metrics_from_scores(y, scores, np)
|
||||||
|
|
||||||
|
|
||||||
|
def _learning_curve(Xn, y, points, neg_ratio, np) -> list[dict[str, float]]:
|
||||||
|
"""Hold out a fixed test split; train the head on a growing number of
|
||||||
|
positives and watch AP/F1 climb — answers 'does tagging more sharpen it?'"""
|
||||||
|
from sklearn.linear_model import LogisticRegression
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
idx = np.arange(len(y))
|
||||||
|
try:
|
||||||
|
tr, te = train_test_split(idx, test_size=0.3, stratify=y, random_state=0)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
tr_pos = tr[y[tr] == 1]
|
||||||
|
tr_neg = tr[y[tr] == 0]
|
||||||
|
out = []
|
||||||
|
for n in points:
|
||||||
|
if n > len(tr_pos):
|
||||||
|
break
|
||||||
|
sp = rng.choice(tr_pos, size=n, replace=False)
|
||||||
|
nn = min(len(tr_neg), n * neg_ratio)
|
||||||
|
sn = rng.choice(tr_neg, size=nn, replace=False)
|
||||||
|
sub = np.concatenate([sp, sn])
|
||||||
|
clf = LogisticRegression(max_iter=1000, class_weight="balanced")
|
||||||
|
clf.fit(Xn[sub], y[sub])
|
||||||
|
prob = clf.predict_proba(Xn[te])[:, 1]
|
||||||
|
m = _metrics_from_scores(y[te], prob, np)
|
||||||
|
out.append({"n_pos": int(n), "ap": m["ap"], "f1": m["f1"]})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _examples(session, Xn, y, ids, np, rejected_set, confirmed_set) -> dict[str, list[dict]]:
|
||||||
|
"""Train on all data, then surface: top-scoring negatives the operator has
|
||||||
|
NOT already rejected (= fresh suggestions) and lowest-scoring POSITIVES the
|
||||||
|
operator has NOT already confirmed (= unreviewed doubts). Excluding rejected
|
||||||
|
ids stops an adjudicated near-miss from resurfacing in 'would suggest';
|
||||||
|
excluding confirmed ids stops a 'kept' correct positive from resurfacing in
|
||||||
|
'head doubts' every run. Resolves thumbnail urls for a self-contained report."""
|
||||||
|
from sklearn.linear_model import LogisticRegression
|
||||||
|
|
||||||
|
clf = LogisticRegression(max_iter=1000, class_weight="balanced")
|
||||||
|
clf.fit(Xn, y)
|
||||||
|
s = clf.predict_proba(Xn)[:, 1]
|
||||||
|
neg_idx = np.where(y == 0)[0]
|
||||||
|
pos_idx = np.where(y == 1)[0]
|
||||||
|
top_neg = []
|
||||||
|
for i in neg_idx[np.argsort(s[neg_idx])[::-1]]: # high score → low
|
||||||
|
rid = int(ids[i])
|
||||||
|
if rid in rejected_set:
|
||||||
|
continue # already told the head 'no' — don't re-suggest it
|
||||||
|
top_neg.append(rid)
|
||||||
|
if len(top_neg) >= _EXAMPLES_K:
|
||||||
|
break
|
||||||
|
low_pos = []
|
||||||
|
for i in pos_idx[np.argsort(s[pos_idx])]: # low score → high
|
||||||
|
rid = int(ids[i])
|
||||||
|
if rid in confirmed_set:
|
||||||
|
continue # already kept/confirmed — don't re-doubt it
|
||||||
|
low_pos.append(rid)
|
||||||
|
if len(low_pos) >= _EXAMPLES_K:
|
||||||
|
break
|
||||||
|
thumbs = _resolve_thumbs(session, top_neg + low_pos)
|
||||||
|
return {
|
||||||
|
"head_would_suggest": [thumbs[i] for i in top_neg if i in thumbs],
|
||||||
|
"head_doubts_positive": [thumbs[i] for i in low_pos if i in thumbs],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_thumbs(session, ids: list[int]) -> dict[int, dict]:
|
||||||
|
from ..gallery_service import thumbnail_url
|
||||||
|
|
||||||
|
out: dict[int, dict] = {}
|
||||||
|
if not ids:
|
||||||
|
return out
|
||||||
|
for rid, tp, sha, mime in session.execute(
|
||||||
|
select(
|
||||||
|
ImageRecord.id, ImageRecord.thumbnail_path,
|
||||||
|
ImageRecord.sha256, ImageRecord.mime,
|
||||||
|
).where(ImageRecord.id.in_(ids))
|
||||||
|
).all():
|
||||||
|
out[rid] = {"id": rid, "thumbnail_url": thumbnail_url(tp, sha, mime)}
|
||||||
|
return out
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Camie-tagger-v2 ONNX wrapper.
|
"""Camie-tagger-v2 ONNX wrapper (CPU).
|
||||||
|
|
||||||
CPU-only, single-image at a time. Loaded lazily inside the ml-worker
|
Single-image at a time. Loaded lazily inside the ml-worker process; NOT
|
||||||
process; NOT thread-safe — the ml queue worker must run --concurrency=1
|
thread-safe — the ml queue worker runs --concurrency=1 per process (scale ML by
|
||||||
(set by the FC-1 entrypoint).
|
running multiple worker replicas, not threads).
|
||||||
|
|
||||||
v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
|
v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
|
||||||
camie-tagger-v2.onnx (789 MB) + camie-tagger-v2-metadata.json (7.77 MB)
|
camie-tagger-v2.onnx (789 MB) + camie-tagger-v2-metadata.json (7.77 MB)
|
||||||
@@ -19,6 +19,11 @@ from pathlib import Path
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageFile
|
from PIL import Image, ImageFile
|
||||||
|
|
||||||
|
# Cap inference threads (see Tagger.load) so each ml-worker replica is a bounded
|
||||||
|
# core consumer on a shared node — keep N_replicas × this within the cores
|
||||||
|
# allotted to ML so replicas don't oversubscribe the box / starve the DB.
|
||||||
|
_INTRA_OP_THREADS = 4
|
||||||
|
|
||||||
# onnxruntime lives in requirements-ml.txt only — it is NOT installed in the
|
# onnxruntime lives in requirements-ml.txt only — it is NOT installed in the
|
||||||
# lean web image or in CI. Imported lazily inside Tagger.load() so this module
|
# lean web image or in CI. Imported lazily inside Tagger.load() so this module
|
||||||
# imports fine without it (the suggestion service imports SURFACED_CATEGORIES
|
# imports fine without it (the suggestion service imports SURFACED_CATEGORIES
|
||||||
@@ -33,8 +38,13 @@ _MODEL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "camie"
|
|||||||
_MODEL_FILE = f"{MODEL_NAME}.onnx"
|
_MODEL_FILE = f"{MODEL_NAME}.onnx"
|
||||||
_METADATA_FILE = f"{MODEL_NAME}-metadata.json"
|
_METADATA_FILE = f"{MODEL_NAME}-metadata.json"
|
||||||
|
|
||||||
# Below this confidence, predictions aren't stored (keeps the JSON compact).
|
# Ingest floor below which predictions aren't stored (keeps the JSON compact).
|
||||||
STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05"))
|
# DEFAULT/fallback only — the live value is DB-backed
|
||||||
|
# (ml_settings.tagger_store_floor) and passed into infer() per call by the ml
|
||||||
|
# task. 0.70: the suggestion path already filters there and the centroid path
|
||||||
|
# covers lower-confidence preferred tags, so the sub-0.70 tail is redundant
|
||||||
|
# (it had bloated image_record's TOAST to ~100 GB; plan-task #764).
|
||||||
|
DEFAULT_STORE_FLOOR = 0.70
|
||||||
|
|
||||||
# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
|
# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
|
||||||
# still stored but the suggestion service filters them out.
|
# still stored but the suggestion service filters them out.
|
||||||
@@ -112,8 +122,15 @@ class Tagger:
|
|||||||
# without onnxruntime (CI / lean web image).
|
# without onnxruntime (CI / lean web image).
|
||||||
import onnxruntime as ort
|
import onnxruntime as ort
|
||||||
|
|
||||||
|
# Cap the intra-op thread pool. ONNX Runtime otherwise sizes it to ALL
|
||||||
|
# host cores, so on a shared node each ml-worker replica would grab every
|
||||||
|
# core and oversubscribe (and starve the co-located DB/web). Bounding it
|
||||||
|
# makes each replica a predictable core consumer — run N replicas where
|
||||||
|
# N × _INTRA_OP_THREADS stays within the cores you allot to ML.
|
||||||
|
opts = ort.SessionOptions()
|
||||||
|
opts.intra_op_num_threads = _INTRA_OP_THREADS
|
||||||
session = ort.InferenceSession(
|
session = ort.InferenceSession(
|
||||||
str(model_path), providers=["CPUExecutionProvider"]
|
str(model_path), sess_options=opts, providers=["CPUExecutionProvider"],
|
||||||
)
|
)
|
||||||
self._input_name = session.get_inputs()[0].name
|
self._input_name = session.get_inputs()[0].name
|
||||||
# Assign sentinels last so a partial load isn't observable.
|
# Assign sentinels last so a partial load isn't observable.
|
||||||
@@ -145,10 +162,13 @@ class Tagger:
|
|||||||
arr = arr.transpose(2, 0, 1) # HWC -> CHW
|
arr = arr.transpose(2, 0, 1) # HWC -> CHW
|
||||||
return arr[np.newaxis, :, :, :] # NCHW
|
return arr[np.newaxis, :, :, :] # NCHW
|
||||||
|
|
||||||
def infer(self, image_path: Path) -> dict[str, TagPrediction]:
|
def infer(
|
||||||
|
self, image_path: Path, *, store_floor: float = DEFAULT_STORE_FLOOR,
|
||||||
|
) -> dict[str, TagPrediction]:
|
||||||
"""Run Camie v2 on one image. Returns {name: TagPrediction} with
|
"""Run Camie v2 on one image. Returns {name: TagPrediction} with
|
||||||
confidence >= STORE_FLOOR (across all categories — the suggestion
|
confidence >= store_floor (across all categories — the suggestion
|
||||||
service does category filtering later).
|
service does category filtering later). store_floor is the DB-backed
|
||||||
|
ml_settings.tagger_store_floor, passed in by the ml task.
|
||||||
|
|
||||||
v2 emits multiple outputs; we use the refined predictions
|
v2 emits multiple outputs; we use the refined predictions
|
||||||
(output[1] per onnx_inference.py). Sigmoid is applied to raw
|
(output[1] per onnx_inference.py). Sigmoid is applied to raw
|
||||||
@@ -167,7 +187,7 @@ class Tagger:
|
|||||||
cats = self._tag_categories
|
cats = self._tag_categories
|
||||||
for idx, score in enumerate(probs):
|
for idx, score in enumerate(probs):
|
||||||
conf = float(score)
|
conf = float(score)
|
||||||
if conf < STORE_FLOOR:
|
if conf < store_floor:
|
||||||
continue
|
continue
|
||||||
if idx >= len(names):
|
if idx >= len(names):
|
||||||
# Output longer than metadata declared — shouldn't happen but
|
# Output longer than metadata declared — shouldn't happen but
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Shared primitives for the native-ingest platform adapters (Patreon,
|
||||||
|
SubscribeStar, …) — the single home for logic the per-platform client/downloader
|
||||||
|
modules would otherwise each copy.
|
||||||
|
|
||||||
|
DRY pass 2026-06-17 (#899): these used to live in `patreon_*` with the
|
||||||
|
SubscribeStar modules importing patreon privates (wrong owner + sibling-coupling).
|
||||||
|
They're platform-agnostic, so they live here and both adapters import them. The
|
||||||
|
per-platform modules keep only what genuinely differs (feed parsing, the media
|
||||||
|
shape, Patreon's Mux/yt-dlp video branch + detail-fetch enrichment).
|
||||||
|
|
||||||
|
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..utils.paths import filehash_from_url, safe_ext
|
||||||
|
from .file_validator import is_validatable, quarantine_file, validate_file
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 429 backoff (plan #703): ride out a transient API rate-limit instead of failing
|
||||||
|
# the whole walk. Honor the server's Retry-After; else exponential, capped.
|
||||||
|
_MAX_429_RETRIES = 3
|
||||||
|
_BACKOFF_BASE_SECONDS = 2.0
|
||||||
|
_BACKOFF_CAP_SECONDS = 30.0
|
||||||
|
|
||||||
|
# Media-download tuning (shared by every platform downloader).
|
||||||
|
_TIMEOUT_SECONDS = 120.0
|
||||||
|
_CHUNK = 1 << 16
|
||||||
|
_MAX_MEDIA_RETRIES = 3
|
||||||
|
_TRANSIENT_TRANSPORT_EXC = (
|
||||||
|
requests.ConnectionError,
|
||||||
|
requests.Timeout,
|
||||||
|
requests.exceptions.ChunkedEncodingError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TITLE_MAX = 40
|
||||||
|
# Windows/gallery-dl path-restrict forbidden set + path separators.
|
||||||
|
_FORBIDDEN = set('<>:"/\\|?*')
|
||||||
|
|
||||||
|
|
||||||
|
# -- shared exception taxonomy --------------------------------------------
|
||||||
|
# Every native client raises one of these (platform subclasses keep an
|
||||||
|
# isinstance-distinct platform name AND the semantic Auth/Drift class), so the
|
||||||
|
# base Ingester._failure_result can map them platform-agnostically.
|
||||||
|
|
||||||
|
class NativeIngestError(Exception):
|
||||||
|
"""Base for a native-ingest client failure. `status_code` carries the HTTP
|
||||||
|
status when the failure was an HTTP response (None for transport/parse);
|
||||||
|
`retry_after` carries the server's 429 Retry-After hint so the cooldown can
|
||||||
|
match it (plan #708 B1)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status_code: int | None = None,
|
||||||
|
retry_after: float | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
|
class NativeAuthError(NativeIngestError):
|
||||||
|
"""Authentication/authorization failure — expired/missing credential or an
|
||||||
|
insufficient tier. The fix is rotating the credential, NOT updating the
|
||||||
|
ingester. Maps to error_type 'auth_error'."""
|
||||||
|
|
||||||
|
|
||||||
|
class NativeDriftError(NativeIngestError):
|
||||||
|
"""A response did not match the shape the ingester depends on (JSON:API field
|
||||||
|
set, or scraped HTML structure). Fail loud so the import step flags 'the
|
||||||
|
platform changed' instead of silently importing nothing. Maps to API_DRIFT."""
|
||||||
|
|
||||||
|
|
||||||
|
# -- HTTP session ----------------------------------------------------------
|
||||||
|
|
||||||
|
def make_session(
|
||||||
|
cookies_path: str | Path | None,
|
||||||
|
*,
|
||||||
|
accept: str = "*/*",
|
||||||
|
extra_headers: dict | None = None,
|
||||||
|
) -> requests.Session:
|
||||||
|
"""Build a requests.Session loaded with the Netscape cookies.txt
|
||||||
|
CredentialService materializes. `accept` sets the Accept header (the JSON:API
|
||||||
|
vs HTML feed differ); `extra_headers` adds platform headers (e.g.
|
||||||
|
X-Requested-With). Missing/unparseable cookies log a warning, never fail."""
|
||||||
|
session = requests.Session()
|
||||||
|
headers = {"User-Agent": _USER_AGENT, "Accept": accept}
|
||||||
|
if extra_headers:
|
||||||
|
headers.update(extra_headers)
|
||||||
|
session.headers.update(headers)
|
||||||
|
if cookies_path and os.path.isfile(str(cookies_path)):
|
||||||
|
try:
|
||||||
|
jar = http.cookiejar.MozillaCookieJar(str(cookies_path))
|
||||||
|
jar.load(ignore_discard=True, ignore_expires=True)
|
||||||
|
session.cookies = jar # type: ignore[assignment]
|
||||||
|
except (OSError, http.cookiejar.LoadError) as exc:
|
||||||
|
log.warning("Could not load cookies from %s: %s", cookies_path, exc)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def retry_after_seconds(
|
||||||
|
resp: requests.Response,
|
||||||
|
attempt: int,
|
||||||
|
*,
|
||||||
|
base: float = _BACKOFF_BASE_SECONDS,
|
||||||
|
cap: float = _BACKOFF_CAP_SECONDS,
|
||||||
|
) -> float:
|
||||||
|
"""Backoff for a 429: the numeric Retry-After header if present, else
|
||||||
|
exponential base·2^(attempt-1), both capped."""
|
||||||
|
header = resp.headers.get("Retry-After")
|
||||||
|
if header:
|
||||||
|
try:
|
||||||
|
return min(float(header), cap)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return min(base * (2 ** max(0, attempt - 1)), cap)
|
||||||
|
|
||||||
|
|
||||||
|
# -- filename / path helpers -----------------------------------------------
|
||||||
|
|
||||||
|
def sanitize_segment(name: str) -> str:
|
||||||
|
"""Make `name` safe for one filesystem path segment: replace separators, the
|
||||||
|
Windows-forbidden set, and control chars with `_`; strip trailing dots/spaces
|
||||||
|
(gallery-dl path-restrict). Never empty (falls back to `_`)."""
|
||||||
|
out = ["_" if (ch in _FORBIDDEN or ord(ch) < 32) else ch for ch in name]
|
||||||
|
cleaned = "".join(out).rstrip(". ")
|
||||||
|
return cleaned or "_"
|
||||||
|
|
||||||
|
|
||||||
|
def basename_from_url(url: str) -> str:
|
||||||
|
"""Derive a sane filename from a URL when the media has no name: path basename
|
||||||
|
with a junk-extension guard (safe_ext), bounded stem; falls back to the URL's
|
||||||
|
content hash, then "file"."""
|
||||||
|
path = urlsplit(url).path
|
||||||
|
base = os.path.basename(path)
|
||||||
|
if base:
|
||||||
|
ext = safe_ext(base)
|
||||||
|
stem = base[: -len(Path(base).suffix)] if Path(base).suffix else base
|
||||||
|
stem = stem[:120] or "file"
|
||||||
|
return f"{stem}{ext}"
|
||||||
|
return filehash_from_url(url) or "file"
|
||||||
|
|
||||||
|
|
||||||
|
def post_dir_name(post: dict) -> str:
|
||||||
|
"""`<YYYY-MM-DD>_<post_id>_<title40>` matching gallery-dl's layout (date prefix
|
||||||
|
omitted when published_at is missing/unparseable; title is empty for platforms
|
||||||
|
with no title field). Accepts both ISO and trailing-`Z` published_at."""
|
||||||
|
post_id = str(post.get("id") or "")
|
||||||
|
attrs = post.get("attributes") or {}
|
||||||
|
title = attrs.get("title")
|
||||||
|
title40 = (title if isinstance(title, str) else "")[:_TITLE_MAX]
|
||||||
|
published = attrs.get("published_at")
|
||||||
|
date_prefix = None
|
||||||
|
if isinstance(published, str) and published:
|
||||||
|
s = published.strip()
|
||||||
|
if s.endswith("Z"):
|
||||||
|
s = s[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
date_prefix = f"{datetime.fromisoformat(s):%Y-%m-%d}"
|
||||||
|
except ValueError:
|
||||||
|
date_prefix = None
|
||||||
|
raw = f"{date_prefix}_{post_id}_{title40}" if date_prefix else f"{post_id}_{title40}"
|
||||||
|
return sanitize_segment(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# -- per-item download outcomes (shared dataclasses) -----------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaOutcome:
|
||||||
|
"""Per-media result of a download_post pass. status ∈ downloaded /
|
||||||
|
skipped_seen / skipped_disk / quarantined / error. `path` is the on-disk file
|
||||||
|
(downloaded / skipped_disk), the quarantine dest (quarantined), or None;
|
||||||
|
`error` is the failure/validation reason (error/quarantined) else None."""
|
||||||
|
|
||||||
|
media: object
|
||||||
|
status: str
|
||||||
|
path: Path | None
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PostRecordOutcome:
|
||||||
|
"""Result of write_post_record — mirrors the MediaOutcome contract so the core
|
||||||
|
reports per-post handling. `path` is the _post.json sidecar (None when the post
|
||||||
|
had no id); the rest is the captured body's shape for the run log."""
|
||||||
|
|
||||||
|
path: Path | None
|
||||||
|
post_type: str | None
|
||||||
|
title: str | None
|
||||||
|
body_chars: int
|
||||||
|
|
||||||
|
|
||||||
|
# -- base downloader (shared fetch/validate plumbing) ----------------------
|
||||||
|
|
||||||
|
class BaseNativeDownloader:
|
||||||
|
"""Shared download plumbing for native-platform downloaders: the streaming
|
||||||
|
GET (transient-retry + Range-resume) and file validation/quarantine. Platform
|
||||||
|
downloaders subclass this and implement `download_post` / `write_post_record`
|
||||||
|
/ the per-media sidecar (and any platform-specific fetch, e.g. Patreon's
|
||||||
|
Mux/yt-dlp video branch). PURE: no DB; the seen-skip is an injected predicate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
images_root: Path,
|
||||||
|
cookies_path: str | None = None,
|
||||||
|
*,
|
||||||
|
platform: str,
|
||||||
|
validate: bool = True,
|
||||||
|
rate_limit: float = 0.0,
|
||||||
|
session: requests.Session | None = None,
|
||||||
|
):
|
||||||
|
self.images_root = Path(images_root)
|
||||||
|
self.cookies_path = str(cookies_path) if cookies_path else None
|
||||||
|
self.platform = platform
|
||||||
|
self._validate = validate
|
||||||
|
self._rate_limit = rate_limit or 0.0
|
||||||
|
self.session = session if session is not None else make_session(cookies_path)
|
||||||
|
|
||||||
|
# -- download seams ----------------------------------------------------
|
||||||
|
|
||||||
|
def _fetch_get(self, url: str, dest: Path) -> Path:
|
||||||
|
"""Stream `url` to a .part then atomic-rename to `dest`."""
|
||||||
|
part = dest.with_name(dest.name + ".part")
|
||||||
|
try:
|
||||||
|
self._fetch_to_file(url, part)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
part.unlink()
|
||||||
|
raise
|
||||||
|
os.replace(part, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
def _fetch_to_file(self, url: str, dest: Path) -> None:
|
||||||
|
"""Stream a URL to `dest`, retrying TRANSIENT failures (transport blips,
|
||||||
|
429 honoring Retry-After, 5xx) with backoff + resume-from-disk (Range);
|
||||||
|
failing fast on permanent 4xx (404/403). Resume: a retry with bytes on
|
||||||
|
disk asks `Range: bytes=<have>-`; 206 → append, 200 → restart clean, 416 →
|
||||||
|
already complete. The caller stages into a `.part` so a non-range server
|
||||||
|
never corrupts the output."""
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
have = dest.stat().st_size if dest.exists() else 0
|
||||||
|
headers = {"Range": f"bytes={have}-"} if have > 0 else None
|
||||||
|
try:
|
||||||
|
resp = self.session.get(
|
||||||
|
url, stream=True, timeout=_TIMEOUT_SECONDS, headers=headers,
|
||||||
|
)
|
||||||
|
if (resp.status_code == 429 or resp.status_code >= 500) \
|
||||||
|
and attempt < _MAX_MEDIA_RETRIES:
|
||||||
|
attempt += 1
|
||||||
|
delay = retry_after_seconds(resp, attempt)
|
||||||
|
log.warning(
|
||||||
|
"%s media transient HTTP %d (%s) — backing off %.1fs "
|
||||||
|
"(retry %d/%d)",
|
||||||
|
self.platform, resp.status_code, url, delay, attempt,
|
||||||
|
_MAX_MEDIA_RETRIES,
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
continue
|
||||||
|
if have > 0 and resp.status_code == 416:
|
||||||
|
return
|
||||||
|
resp.raise_for_status()
|
||||||
|
mode = "ab" if (have > 0 and resp.status_code == 206) else "wb"
|
||||||
|
with open(dest, mode) as fh:
|
||||||
|
for chunk in resp.iter_content(chunk_size=_CHUNK):
|
||||||
|
if chunk:
|
||||||
|
fh.write(chunk)
|
||||||
|
return
|
||||||
|
except _TRANSIENT_TRANSPORT_EXC as exc:
|
||||||
|
if attempt >= _MAX_MEDIA_RETRIES:
|
||||||
|
raise
|
||||||
|
attempt += 1
|
||||||
|
delay = min(2.0 * (2 ** (attempt - 1)), _BACKOFF_CAP_SECONDS)
|
||||||
|
log.warning(
|
||||||
|
"%s media transport error (%s) — backing off %.1fs "
|
||||||
|
"(retry %d/%d): %s",
|
||||||
|
self.platform, url, delay, attempt, _MAX_MEDIA_RETRIES, exc,
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
# -- validation --------------------------------------------------------
|
||||||
|
|
||||||
|
def _validate_path(
|
||||||
|
self, path: Path, artist_slug: str, source_url: str | None = None
|
||||||
|
) -> tuple[str | None, Path | None]:
|
||||||
|
"""Validate a freshly-written file; quarantine if bad (shared
|
||||||
|
file_validator move + provenance sidecar). Returns (reason,
|
||||||
|
quarantine_dest) when quarantined, else (None, None). Logs the quarantine
|
||||||
|
so a corrupt file is visible in the worker logs, not just counted (#899
|
||||||
|
L2)."""
|
||||||
|
if not self._validate or not is_validatable(path):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
result = validate_file(path)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Validator raised on %s: %s", path, exc)
|
||||||
|
return None, None
|
||||||
|
if result.ok:
|
||||||
|
return None, None
|
||||||
|
dest = quarantine_file(
|
||||||
|
self.images_root, path, artist_slug, self.platform,
|
||||||
|
url=source_url, result=result,
|
||||||
|
)
|
||||||
|
reason = result.reason or "validation failed"
|
||||||
|
log.warning(
|
||||||
|
"%s quarantined %s (%s) — %s",
|
||||||
|
self.platform, dest or path, artist_slug, reason,
|
||||||
|
)
|
||||||
|
return reason, (dest or path)
|
||||||
|
|
||||||
|
# -- sidecar (per-media, minimal) --------------------------------------
|
||||||
|
|
||||||
|
def _write_minimal_sidecar(
|
||||||
|
self, post: dict, media_path: Path, *, source_url: str | None = None
|
||||||
|
) -> Path:
|
||||||
|
"""Post-first per-media sidecar (#856): image identity ONLY
|
||||||
|
(category/id/source_url). The post body/links live solely in _post.json."""
|
||||||
|
data: dict = {"category": self.platform, "id": str(post.get("id") or "")}
|
||||||
|
if source_url:
|
||||||
|
data["source_url"] = source_url
|
||||||
|
sidecar_path = media_path.with_suffix(".json")
|
||||||
|
sidecar_path.write_text(json.dumps(data, indent=2))
|
||||||
|
return sidecar_path
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Parse a stated page number/range out of a post's title/description (FC-6.2).
|
||||||
|
|
||||||
|
Artists often state where an installment sits in a series — "pages 9-12",
|
||||||
|
"Page 5", "[3/8]". We use that to order chapters and flag missing-page gaps.
|
||||||
|
This is best-effort: a confident match wins, otherwise we return None and the
|
||||||
|
caller falls back to capture/post-date order. Keep it conservative — a wrong
|
||||||
|
page number is worse than no page number — so matches require an explicit
|
||||||
|
page keyword (page/pg/pp) or a bracketed N/M fraction, never a bare number.
|
||||||
|
|
||||||
|
Supported forms (case-insensitive):
|
||||||
|
range "pages 9-12", "pg 9–12", "pp. 9 - 12" -> (9, 12)
|
||||||
|
fraction "page 3 of 8", "pg 3/8", "[3/8]", "(3/8)" -> (3, 3)
|
||||||
|
single "page 5", "pg 5", "pp 5" -> (5, 5)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Page keyword: page/pages/pg/pgs/pp/pp. (NOT a bare "p" — too many false hits.)
|
||||||
|
_KW = r"(?:pages?|pgs?|pp\.?)"
|
||||||
|
_DASH = r"[-–—]"
|
||||||
|
|
||||||
|
_RANGE = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\s*{_DASH}\s*(\d{{1,4}})", re.I)
|
||||||
|
_OF = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\s*(?:of|/)\s*\d{{1,4}}\b", re.I)
|
||||||
|
_BRACKET = re.compile(r"[\[(]\s*(\d{1,4})\s*/\s*\d{1,4}\s*[\])]")
|
||||||
|
_SINGLE = re.compile(rf"\b{_KW}\s*(\d{{1,4}})\b", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_page_range(text: str | None) -> tuple[int, int] | None:
|
||||||
|
"""Return (start, end) or None. start <= end; a single page yields (n, n)."""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
m = _RANGE.search(text)
|
||||||
|
if m:
|
||||||
|
a, b = int(m.group(1)), int(m.group(2))
|
||||||
|
return (a, b) if a <= b else (b, a)
|
||||||
|
for rx in (_OF, _BRACKET, _SINGLE):
|
||||||
|
m = rx.search(text)
|
||||||
|
if m:
|
||||||
|
n = int(m.group(1))
|
||||||
|
return (n, n)
|
||||||
|
return None
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user