Compare commits

..

39 Commits

Author SHA1 Message Date
bvandeusen 856e9104b4 Merge pull request 'Sidecar synthetic anchor cleanup + tier-gated classifier fix' (#39) from dev into main
CI / lint (push) Successful in 5s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m54s
Build images / sign-extension (push) Has been skipped
Build images / build-web (push) Successful in 1m1s
Build images / build-ml (push) Successful in 1m21s
CI / intapi (push) Successful in 7m31s
CI / intcore (push) Successful in 8m0s
2026-06-01 00:16:58 -04:00
bvandeusen 0397642b21 Merge pull request 'Showcase cadence tuning + cooldown-aware bulk retry' (#38) from dev into main 2026-05-30 23:50:36 -04:00
bvandeusen 237575447d Merge pull request 'Thumbnail URL fix + archive daemon fix + batched initial loads' (#37) from dev into main 2026-05-30 22:01:43 -04:00
bvandeusen ed358757dc Merge pull request 'Most-overdue-first scheduling + rich timeout diagnostics' (#36) from dev into main 2026-05-30 14:30:41 -04:00
bvandeusen d181f4afb8 Merge pull request 'Downloads burst-prevention + maintenance-menu fix + gdl timeout' (#35) from dev into main 2026-05-30 11:43:18 -04:00
bvandeusen 2886fa4997 Merge pull request 'Tooltip !important fix — 104cac5 follow-up after Vite CSS reorder' (#34) from dev into main 2026-05-30 00:02:24 -04:00
bvandeusen f256f587ee Merge pull request 'UI batch + I1–I6 service passes + download-event recovery sweep' (#33) from dev into main 2026-05-29 22:46:16 -04:00
bvandeusen 384d8d5e50 Merge pull request 'Dashboard insights + project-wide DRY pass' (#32) from dev into main 2026-05-28 15:38:26 -04:00
bvandeusen 319e8c1d18 Merge pull request 'v26.05.28.0: downloads dashboard + task-resilience overhaul (timeouts, archive split, 3-layer poison-pill defense)' (#31) from dev into main 2026-05-28 00:45:00 -04:00
bvandeusen 9075d8eadd Merge pull request 'v26.05.27.2: subscribestar + HF cookie quirks, platforms package refactor, showcase IR-parity, secure-context audit' (#30) from dev into main 2026-05-27 21:34:02 -04:00
bvandeusen 88e53e5b86 Merge pull request 'v26.05.27.1: subscriptions hub + post-card merge + sidecar audit' (#29) from dev into main 2026-05-27 17:12:48 -04:00
bvandeusen 37e8b796a1 Merge pull request 'v26.05.27.0: PostCard redesign + IR-style tag suffix + drop meta/rating + extension v1.0.4 CSP fix' (#28) from dev into main 2026-05-27 11:31:18 -04:00
bvandeusen 4e82208926 Merge pull request 'v26.05.26.5 — extension CORS unblock + UI gap closes + CI workflow cleanup' (#27) from dev into main 2026-05-26 20:15:07 -04:00
bvandeusen 52fff00353 Merge pull request 'v26.05.26.4 — hotfix: migration 0022 pre-DELETE colliding ImageProvenance before UPDATE' (#26) from dev into main 2026-05-26 18:06:20 -04:00
bvandeusen c14338cbce Merge pull request 'v26.05.26.3 — hotfix: migration 0022 pre-merge across ENTIRE (canonical+others) group' (#25) from dev into main 2026-05-26 17:52:59 -04:00
bvandeusen 8c36dd28b0 Merge pull request 'v26.05.26.2 — hotfix: alembic 0022 Post-collision pre-merge + ci.yml cache continue-on-error' (#24) from dev into main 2026-05-26 16:50:43 -04:00
bvandeusen 88cfb3dd02 Merge pull request 'v26.05.26.1 — thumb backfill, modal redesign, recovery sweep race-safety, artist view redesign, extension fixes' (#23) from dev into main 2026-05-26 16:32:00 -04:00
bvandeusen 5d4f223b71 Merge pull request 'Release v26.05.25.7 — FC-Cleanup tab + UniqueViolation fix + error modal + extension install fix' (#22) from dev into main 2026-05-26 08:26:46 -04:00
bvandeusen 05090c6e85 Merge pull request 'Release v26.05.25.7 — animated-WebP worker fix + FC-Cleanup backend' (#21) from dev into main 2026-05-26 01:48:13 -04:00
bvandeusen 3a577d5ade Merge pull request 'fix(ext-ci): use browser_download_url + curl -f + ZIP magic check (XPI silently corrupt)' (#20) from dev into main 2026-05-26 00:43:02 -04:00
bvandeusen f4fe02e346 Merge pull request 'fix(ext-ci): drop actions/upload-artifact (Forgejo doesn't support v4+ GHES)' (#19) from dev into main 2026-05-25 23:33:40 -04:00
bvandeusen e766197d99 Merge pull request 'fix(ext-ci): jq→python + bump ext to 1.0.3 + rollback-on-upload-failure' (#18) from dev into main 2026-05-25 23:14:51 -04:00
bvandeusen 3872e1dda9 Merge pull request 'fix(ext-ci): web-ext v8 .cjs config workaround' (#17) from dev into main 2026-05-25 22:49:14 -04:00
bvandeusen 9814f3dbaf Merge pull request 'Release v26.05.25.5 — Extension publish refactor, deep-scan IR-parity, archive-import perf, artist Settings tab' (#16) from dev into main 2026-05-25 22:44:59 -04:00
bvandeusen b214460fdb Merge pull request 'Release v26.05.25.4 — importer ext sanitize fix, CI shard split, BrowserExtensionCard on Overview' (#15) from dev into main 2026-05-25 21:11:50 -04:00
bvandeusen ac55d0e8d8 Merge pull request 'fix(ext-ci): match AMO-renamed signed XPI' (#14) from dev into main 2026-05-25 18:22:50 -04:00
bvandeusen 89a89e0ded Merge pull request 'Release v26.05.25.3 — ML embedder SigLIP fix, import-UX, extension publish' (#13) from dev into main 2026-05-25 17:56:50 -04:00
bvandeusen 4e9aac2c05 Merge pull request 'v26.05.25.2: supersede + sidecar enrichment, scan toast feedback, CI uv + pip cache + durations' (#12) from dev into main 2026-05-25 14:30:25 -04:00
bvandeusen 2879ac6f2b Merge pull request 'v26.05.25.1: maintenance sweep + Camie v2 + corrupt-file handling + post-date gallery + clear-stuck escape hatch' (#11) from dev into main 2026-05-25 12:57:46 -04:00
bvandeusen b8dce6c483 Merge pull request 'FC-3h + FC-3k: backup first-class + admin destructive actions' (#10) from dev into main 2026-05-25 01:41:53 -04:00
bvandeusen d1c0b82a22 Merge pull request 'v26.05.24.3: FC-3i System Activity dashboard + migration backup-gate retired + modal Escape' (#9) from dev into main 2026-05-24 21:47:53 -04:00
bvandeusen 5526b8dc78 Merge pull request 'v26.05.24.2: IR Post/Provenance restore + modal artist fallback' (#8) from dev into main 2026-05-24 14:30:06 -04:00
bvandeusen 16eb7075c4 Merge pull request 'v26.05.24.1: FC-3g Firefox extension + worker resilience + UI/migration fixes' (#7) from dev into main 2026-05-24 12:52:31 -04:00
bvandeusen 885dcf64f3 Merge pull request 'v26.05.24.0: TopNav re-fix (flex 1 1 0 side cells)' (#6) from dev into main 2026-05-23 22:49:29 -04:00
bvandeusen f2f6b6d25e Merge pull request 'v26.05.23.3: dogfood UX polish + accurate active-batch stats' (#5) from dev into main 2026-05-23 22:05:59 -04:00
bvandeusen 0822240fde Merge pull request 'v26.05.23.2: serve /images + artist cleanup migrator' (#4) from dev into main 2026-05-23 12:19:16 -04:00
bvandeusen 27f7f3fd01 Merge pull request 'v26.05.23.1: migration durability + dogfood UX' (#3) from dev into main 2026-05-23 11:21:33 -04:00
bvandeusen c5bf564f53 Merge dev: v26.05.23.0 migration follow-ups (#2)
pg_dump + zstd in runtime image, lift Quart body cap to 1 GiB. See PR #2.
2026-05-22 22:37:06 -04:00
bvandeusen 602c7d275d Merge dev: FC-1 → FC-5 v1 build (#1)
First merge of `dev` into `main` for FabledCurator. Brings FC-1 (Foundation) through FC-5 (Migration tooling) onto `main`. See PR #1 body for the full stage rollup.
2026-05-22 14:15:45 -04:00
266 changed files with 1994 additions and 20391 deletions
+12 -30
View File
@@ -242,32 +242,20 @@ jobs:
id: tag id: tag
run: | run: |
# Three trigger shapes: # Three trigger shapes:
# refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD, # refs/tags/v… → tag-push: publish ONLY the immutable version
# no `.N` per family release-posture rule). # tag (e.g. :v26.05.26.5). Don't touch :latest;
# Publish ONLY the immutable version tag; # that already got published by the main-push
# don't touch :latest (the main-push build # build for the merge commit.
# for the merge commit already did that). # refs/heads/main → push to main (incl. PR merge commits):
# refs/heads/main → push to main: publish :main + :latest # publish :main + :latest (floating).
# (floating) AND :c-<short_sha> (immutable
# per-commit rollback substrate, per family
# release-posture rule "Tags are milestones,
# not gates — commit-SHA images are the
# rollback unit"). Rollback to any commit
# becomes `docker pull …:c-<sha>` without a
# release ceremony.
# anything else → safety net; shouldn't fire given the `on:` # anything else → safety net; shouldn't fire given the `on:`
# config above. Tag :dev to surface the # config above (dev was dropped). Tag :dev to
# unexpected run in the registry. # surface the unexpected run in the registry.
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not
# bash — `${var:0:7}` errors with "Bad substitution"; cut works
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
# main-push build failed at this step.
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}" TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT"
else else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
fi fi
@@ -298,19 +286,13 @@ jobs:
id: tag id: tag
run: | run: |
# Mirrors build-web's three-shape logic (tag-push / main-push / # Mirrors build-web's three-shape logic (tag-push / main-push /
# safety-net dev) including the per-commit :c-<short_sha> tag # safety-net dev). The -ml image follows the same release cadence
# on main-push per the family release-posture rule. The -ml # as the web image.
# image follows the same release cadence as the web image.
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not
# bash — `${var:0:7}` errors with "Bad substitution"; cut works
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
# main-push build failed at this step.
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}" TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT"
else else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
fi fi
+149 -30
View File
@@ -92,25 +92,28 @@ jobs:
- run: npm run test:unit - run: npm run test:unit
- run: npm run build - run: npm run build
# Single integration job — collapsed from a 3-way shard split on 2026-06-04. # Integration suite split into THREE parallel shards (2026-05-25, runner
# The shards existed to parallelize ~8.5min of integration tests; once the # capacity bumped 2→6). Each shard gets its own Postgres + Redis service
# throwaway Postgres runs with fsync OFF (the durability step below) the whole # set and runs alembic + a disjoint subset of integration tests. Shards
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead # share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6 # stays single-threaded per shard but multiple shards run in parallel
# runner slots for no wall-clock gain. One job now: spin up once, install # wall-clock. Approximate split — rebalance once --durations=15 output
# once, migrate once, run every integration test. # reveals which shard is the long pole.
# #
# The docker-ps filter scopes to THIS job's own Postgres/Redis service # Each shard's docker-ps filter uses its own unique job name to scope
# containers by job name. act_runner strips underscores from job names when # service-container resolution. act_runner appears to strip underscores
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name # from job names when building container labels — `int_api` yielded
# stays separator-free (`integration`). The step prints `docker ps -a` first # zero matches on 2026-05-25 — so shards use no-separator names
# so a future naming-convention shift surfaces in the log without a # (`intapi`, `intimp`, `intcore`) instead. Each step prints
# guess-and-push cycle. # `docker ps -a` first so a future naming-convention shift surfaces in
# the log without another guess-and-push cycle.
# #
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done — # Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT
# per ci-requirements.md, FC is the only Python consumer of that image and the # done — per ci-requirements.md, FC is the only Python consumer of that
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job. # image and the CI-Runner project's "add deps to image when used by >1
integration: # project" rule keeps the install per-job.
intapi:
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -141,14 +144,14 @@ jobs:
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Integration suite (resolve service IPs, migrate, test) - name: API integration shard (resolve service IPs, migrate, test)
run: | run: |
set -eux set -eux
echo "=== container landscape (diagnostic for filter scoping) ===" echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ===" echo "=== end landscape ==="
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1) PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1) RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD" test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD") RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
@@ -165,14 +168,130 @@ jobs:
else else
pip install -r requirements.txt pytest pytest-asyncio pip install -r requirements.txt pytest pytest-asyncio
fi fi
# Relax durability on the throwaway CI Postgres so the per-test
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
# surprise can't red the job; fabledcurator is the postgres image's
# bootstrap superuser.
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
alembic upgrade head alembic upgrade head
pytest tests/ -v -m integration --durations=15 pytest tests/test_api_*.py -v -m integration --durations=15
intimp:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
DB_USER: fabledcurator
DB_PASSWORD: ci_integration
DB_PORT: "5432"
DB_NAME: fabledcurator_test
SECRET_KEY: ci_integration_placeholder
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: fabledcurator
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: fabledcurator_test
options: >-
--health-cmd "pg_isready -U fabledcurator"
--health-interval 10s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: 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'
-3
View File
@@ -61,9 +61,6 @@ 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__/
+1 -22
View File
@@ -2,21 +2,12 @@
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool, text from sqlalchemy import engine_from_config, pool
from alembic import context from alembic import context
from backend.app.config import get_config from backend.app.config import get_config
from backend.app.models import Base from backend.app.models import Base
# Arbitrary fixed 64-bit key for the session/transaction advisory lock that
# serializes concurrent `alembic upgrade head` runs. Every `web` replica runs
# migrations in its entrypoint, so under `docker stack deploy` two replicas can
# boot at once and race the same DDL — duplicate CREATE TABLE, then a crashed
# replica (operator-flagged 2026-06-07: 0040 raced; one backend died with
# AdminShutdown). The first replica to reach the lock migrates; the rest block,
# then find the version table already at head and apply nothing.
_MIGRATION_LOCK_KEY = 0xFCA1E35C
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
@@ -53,18 +44,6 @@ def run_migrations_online() -> None:
compare_type=True, compare_type=True,
) )
with context.begin_transaction(): with context.begin_transaction():
# Serialize concurrent migrators (see _MIGRATION_LOCK_KEY). A
# transaction-scoped advisory lock: the first replica to get here
# holds it for the whole upgrade and is auto-released when this
# transaction ends. A sibling replica blocks on this line, and only
# once the leader commits does it proceed to read the version table
# — now at head — so it runs zero migrations instead of re-applying
# the same DDL. The lock is acquired BEFORE run_migrations() reads
# the current revision, which is what makes the no-op correct.
connection.execute(
text("SELECT pg_advisory_xact_lock(:k)"),
{"k": _MIGRATION_LOCK_KEY},
)
context.run_migrations() context.run_migrations()
@@ -1,71 +0,0 @@
"""drop artist + copyright ml thresholds; lower general default to 0.50
Revision ID: 0029
Revises: 0028
Create Date: 2026-06-01
Operator-flagged 2026-06-01: the view modal's Suggestions panel hides
most general-category predictions because the default threshold is
0.95. Lowering the default to 0.50 (matches character) so general
suggestions surface more aggressively; the value remains tunable in
Settings → ML.
Same change retires two ML suggestion categories whose Tag.kind
surfaces are unused:
- `artist`: retired in FC-2d-vii-c — artist identity is acquisition-
derived (image_record.artist_id), never ML-inferred. The threshold
column was a leftover from before that retirement.
- `copyright`: retired 2026-06-01 — the app uses `fandom` for the
franchise/copyright concept (per TagsView.vue's doc comment); no
Tag rows of kind=copyright exist, and the threshold column never
fed anything user-visible.
Both columns are dropped from ml_settings; the existing row's
suggestion_threshold_general value is bumped from 0.95 to 0.50 iff
it's still at the old default, so deployed installs pick up the new
UX without overriding any operator tuning.
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
revision: str = "0029"
down_revision: Union[str, None] = "0028"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Bump the general threshold for installs still at the old default.
op.execute(text(
"UPDATE ml_settings "
"SET suggestion_threshold_general = 0.50 "
"WHERE id = 1 AND suggestion_threshold_general = 0.95"
))
op.drop_column("ml_settings", "suggestion_threshold_artist")
op.drop_column("ml_settings", "suggestion_threshold_copyright")
def downgrade() -> None:
# Restore the columns with their prior defaults. The bump from
# 0.95 → 0.50 isn't reversible without remembering whether the
# operator had explicitly set 0.95 (unlikely — that was just the
# default) so we leave the current general value as-is.
from sqlalchemy import Column, Float
op.add_column(
"ml_settings",
Column(
"suggestion_threshold_artist",
Float, nullable=False, server_default="0.30",
),
)
op.add_column(
"ml_settings",
Column(
"suggestion_threshold_copyright",
Float, nullable=False, server_default="0.50",
),
)
@@ -1,145 +0,0 @@
"""nullable post.source_id + denormalized post.artist_id; retire sidecar synthetics
Revision ID: 0030
Revises: 0029
Create Date: 2026-06-01
Operator-asked 2026-06-01 after the Dymkens orphan investigation: the
sidecar synthetic Source pattern (`sidecar:<platform>:<slug>` rows
with enabled=false) was technically correct but misled the operator
into thinking they had phantom subscriptions. The synthetics existed
solely to satisfy `Post.source_id NOT NULL` for filesystem-imported
content with no real subscription.
This migration makes the data model honest:
1. **Post gets a denormalized `artist_id` column** so artist filters
work without traversing `Post → Source.artist_id`. Backfilled from
the existing Source linkage, then NOT NULL'd.
2. **`Post.source_id` becomes nullable**, FK ondelete `CASCADE` → `SET
NULL`. Deleting a Source detaches its Posts instead of destroying
imported content (semantically: subscription ends, archive stays).
3. **`ImageProvenance.source_id` becomes nullable** with the same FK
semantic change.
4. **Sidecar synthetic Sources are deleted** — first NULL out the
FKs from Post + ImageProvenance pointing at them (so the implicit
CASCADE doesn't fire), then delete. DownloadEvent FK is unchanged
(still CASCADE'd, NOT NULL'd) — synthetics have `enabled=false`
so no events exist for them.
Uniqueness handling: the existing `uq_post_source_external_id`
(source_id, external_post_id) keeps working for source-bound Posts
(Postgres treats NULL != NULL so NULL-source rows aren't deduped by
it). A second partial unique index covers the NULL-source case on
(artist_id, external_post_id) so filesystem-imported posts still
dedupe within an artist.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy import text
revision: str = "0030"
down_revision: Union[str, None] = "0029"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Step 1: add Post.artist_id, initially nullable for backfill.
# FK naming follows the Base.metadata naming_convention
# (fk_<table>_<column>_<referred_table>) — alembic 0001 set this up.
op.add_column(
"post",
sa.Column("artist_id", sa.Integer, nullable=True),
)
op.create_foreign_key(
"fk_post_artist_id_artist", "post", "artist",
["artist_id"], ["id"], ondelete="CASCADE",
)
# Step 2: backfill from Source.artist_id (every existing Post has a
# Source today, so every row gets populated).
conn.execute(text("""
UPDATE post p
SET artist_id = s.artist_id
FROM source s
WHERE p.source_id = s.id AND p.artist_id IS NULL
"""))
# Sanity: count any remaining NULLs. Should be zero pre-this-migration.
remaining = conn.execute(text(
"SELECT COUNT(*) FROM post WHERE artist_id IS NULL"
)).scalar_one()
if remaining:
raise RuntimeError(
f"alembic 0030: {remaining} post rows have no resolvable "
f"artist_id after backfill. Investigate before continuing."
)
# Step 3: enforce NOT NULL + add index for artist-filter queries.
op.alter_column("post", "artist_id", nullable=False)
op.create_index("ix_post_artist_id", "post", ["artist_id"])
# Step 4: relax post.source_id + flip FK to SET NULL. The original FK
# name from alembic 0001 is `fk_post_source_id_source` per the
# NAMING_CONVENTION in models/base.py.
op.alter_column("post", "source_id", nullable=True)
op.drop_constraint("fk_post_source_id_source", "post", type_="foreignkey")
op.create_foreign_key(
"fk_post_source_id_source", "post", "source",
["source_id"], ["id"], ondelete="SET NULL",
)
# Step 5: relax image_provenance.source_id + flip FK to SET NULL.
op.alter_column("image_provenance", "source_id", nullable=True)
op.drop_constraint(
"fk_image_provenance_source_id_source", "image_provenance",
type_="foreignkey",
)
op.create_foreign_key(
"fk_image_provenance_source_id_source", "image_provenance", "source",
["source_id"], ["id"], ondelete="SET NULL",
)
# Step 6: partial unique index on (artist_id, external_post_id) for
# NULL-source Posts. The existing uq_post_source_external_id keeps
# guarding source-bound rows; NULL-source rows now dedupe within
# an artist.
op.execute(
"CREATE UNIQUE INDEX uq_post_artist_external_id_null_source "
"ON post (artist_id, external_post_id) "
"WHERE source_id IS NULL"
)
# Step 7: retire sidecar synthetic Sources. NULL out the references
# FIRST (the new FK is SET NULL so CASCADE wouldn't fire anyway, but
# being explicit makes the intent clear). Then delete the synthetic
# source rows. Any DownloadEvent rows under synthetics CASCADE-die
# with the source — synthetics have enabled=false so there shouldn't
# be any in practice.
conn.execute(text("""
UPDATE post
SET source_id = NULL
WHERE source_id IN (SELECT id FROM source WHERE url LIKE 'sidecar:%')
"""))
conn.execute(text("""
UPDATE image_provenance
SET source_id = NULL
WHERE source_id IN (SELECT id FROM source WHERE url LIKE 'sidecar:%')
"""))
deleted = conn.execute(text(
"DELETE FROM source WHERE url LIKE 'sidecar:%' RETURNING id"
)).rowcount
print(f"alembic 0030: deleted {deleted} sidecar synthetic source rows")
def downgrade() -> None:
# Lossy migration — the deleted sidecar synthetics can't be
# restored from the orphan post.source_id / image_provenance.source_id
# values, and the partial unique index encodes a constraint that
# NULL-source Posts may now exist. No safe downgrade.
pass
@@ -1,45 +0,0 @@
"""source.backfill_runs_remaining: sticky deep-scan mode
Revision ID: 0031
Revises: 0030
Create Date: 2026-06-01
Tick vs backfill mode for subscription downloads. When
`backfill_runs_remaining > 0`, the next N download runs use
`skip: True` + 30-min timeout (walk full history). When 0, runs use
`skip: "exit:20"` + 14.5-min timeout (catch-up mode, exits early once
20 contiguous archived items are seen).
Operator-flagged 2026-06-01 (Knuxy run #38887): a creator with ~550
archived posts saturates the 870s catch-up timeout even when there is
no new content, because gallery-dl's default `skip: True` keeps walking.
Tick mode short-circuits that; backfill mode is the explicit opt-in for
deep history scans.
Default 0 (all existing subscriptions start in tick mode).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0031"
down_revision: Union[str, None] = "0030"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"source",
sa.Column(
"backfill_runs_remaining",
sa.Integer,
nullable=False,
server_default="0",
),
)
def downgrade() -> None:
op.drop_column("source", "backfill_runs_remaining")
@@ -1,41 +0,0 @@
"""source.error_type: surface ErrorType taxonomy in FailingSourcesCard
Revision ID: 0032
Revises: 0031
Create Date: 2026-06-02
Audit 2026-06-02: the backend computes 13 ErrorType categories (auth_error,
rate_limited, not_found, access_denied, validation_failed, etc.) and
stamps each one on DownloadEvent.metadata, but the Source row only carried
the free-text last_error. Operators couldn't bulk-triage failing sources
("all auth_error → rotate cookies, all rate_limited → just wait") without
opening Logs per row.
This column receives the last error_type from _update_source_health
and gets cleared on a successful run. Nullable + indexed so the failing-
sources rollup can filter/group cheaply.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0032"
down_revision: Union[str, None] = "0031"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"source",
sa.Column("error_type", sa.String(length=32), nullable=True),
)
op.create_index(
"ix_source_error_type", "source", ["error_type"],
)
def downgrade() -> None:
op.drop_index("ix_source_error_type", table_name="source")
op.drop_column("source", "error_type")
@@ -1,48 +0,0 @@
"""suggestion_threshold default 0.50 → 0.70
Revision ID: 0033
Revises: 0032
Create Date: 2026-06-02
Operator-flagged 2026-06-02 — the 0.50 default (set on 2026-06-01) is
too noisy in practice; raise to 0.70 for both suggestion categories.
Only conditionally updates singletons whose current value is still the
2026-06-01 default (0.50). Operators who deliberately tuned their row
to some other value (0.55, 0.65, 0.80, etc. via the Settings UI) keep
their pick — the migration only catches the unchanged-default case.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0033"
down_revision: Union[str, None] = "0032"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_character = 0.70 "
"WHERE id = 1 AND suggestion_threshold_character = 0.50"
)
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_general = 0.70 "
"WHERE id = 1 AND suggestion_threshold_general = 0.50"
)
def downgrade() -> None:
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_character = 0.50 "
"WHERE id = 1 AND suggestion_threshold_character = 0.70"
)
op.execute(
"UPDATE ml_settings "
"SET suggestion_threshold_general = 0.50 "
"WHERE id = 1 AND suggestion_threshold_general = 0.70"
)
-53
View File
@@ -1,53 +0,0 @@
"""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")
@@ -1,70 +0,0 @@
"""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")
@@ -1,41 +0,0 @@
"""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")
@@ -1,53 +0,0 @@
"""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")
@@ -1,58 +0,0 @@
"""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")
@@ -1,40 +0,0 @@
"""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")
-108
View File
@@ -1,108 +0,0 @@
"""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")
@@ -1,98 +0,0 @@
"""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")
+1 -105
View File
@@ -19,7 +19,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, text from sqlalchemy import select
from ..extensions import get_session from ..extensions import get_session
from ..models import Artist from ..models import Artist
@@ -222,107 +222,3 @@ async def tags_purge_legacy():
lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run) lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run)
) )
return jsonify(result) return jsonify(result)
@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 tagger_predictions 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
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: reset_content_tagging(sync_sess, dry_run=dry_run)
)
return jsonify(result)
@admin_bp.route("/tags/normalize", methods=["POST"])
async def tags_normalize():
"""#714: retro-normalize existing tags to the #701 canonical form (Title
Case + collapsed whitespace) and merge case/whitespace-variant duplicates.
dry_run=true (default) returns a projection inline — group/collision/rename
counts + a sample of the changes — so the UI shows exactly what'll happen.
dry_run=false dispatches the long-running maintenance task (the merge FK
repoints can touch many tags); the UI tails the activity dashboard for the
summary. Idempotent; back up first (the merges are irreversible)."""
from ..services.tag_service import normalize_existing_tags
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True))
if dry_run:
async with get_session() as session:
result = await normalize_existing_tags(session, dry_run=True)
return jsonify(result)
from ..tasks.admin import normalize_tags_task
async_result = normalize_tags_task.delay()
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
async def db_stats():
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
so the operator can see when a VACUUM is worth running."""
from ..tasks.maintenance import VACUUM_TABLES
wanted = set(VACUUM_TABLES)
async with get_session() as session:
rows = (await session.execute(text(
"SELECT relname, n_live_tup, n_dead_tup, last_vacuum, "
"last_autovacuum, last_analyze FROM pg_stat_user_tables"
))).all()
def _iso(v):
return v.isoformat() if v is not None else None
out = []
for r in rows:
if r.relname not in wanted:
continue
live = r.n_live_tup or 0
dead = r.n_dead_tup or 0
total = live + dead
out.append({
"table": r.relname,
"live": live,
"dead": dead,
"dead_pct": round(100 * dead / total, 1) if total else 0.0,
"last_vacuum": _iso(r.last_vacuum),
"last_autovacuum": _iso(r.last_autovacuum),
"last_analyze": _iso(r.last_analyze),
})
out.sort(key=lambda t: t["dead"], reverse=True)
return jsonify({"tables": out})
@admin_bp.route("/maintenance/vacuum", methods=["POST"])
async def trigger_vacuum():
"""Operator-triggered VACUUM (ANALYZE) over the high-churn tables — the
same maintenance-queue task the weekly Beat schedule runs."""
from ..tasks.maintenance import vacuum_analyze
vacuum_analyze.delay()
return jsonify({"status": "queued"}), 202
@admin_bp.route("/maintenance/reextract-archives", methods=["POST"])
async def trigger_reextract_archives():
"""Operator-triggered re-extract (#713): PostAttachments that are actually
archives but were filed opaquely (pre magic-byte gate) get extracted and
their members linked to the post. Idempotent; runs on the maintenance queue."""
from ..tasks.admin import reextract_archive_attachments_task
async_result = reextract_archive_attachments_task.delay()
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
+5 -8
View File
@@ -154,15 +154,12 @@ 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:
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc()) rows = (await session.execute(
if rule is not None: select(LibraryAuditRun)
stmt = stmt.where(LibraryAuditRun.rule == rule) .order_by(LibraryAuditRun.id.desc())
rows = (await session.execute(stmt.limit(limit))).scalars().all() .limit(limit)
)).scalars().all()
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]}) return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
+10 -12
View File
@@ -121,15 +121,13 @@ 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 against one of the platform's enabled sources, """Test the stored credential by running gallery-dl --simulate
WITHOUT downloading. Routes through the platform's backend against one of the platform's enabled sources. On success stamps
(download_backends.verify_credential) — native ingester for Patreon, an last_verified. Returns {valid: bool|null, reason, last_verified?}.
authenticated API page; gallery-dl --simulate for the rest. On success valid=null means "couldn't test" (no credential, or no enabled
stamps last_verified. Returns {valid: bool|null, reason, last_verified?}; source to point at)."""
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.download_backends import verify_source_credential from ..services.gallery_dl import GalleryDLService, SourceConfig
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):
@@ -156,14 +154,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)
ok, message = await verify_source_credential( gdl = GalleryDLService(images_root=Path("/images"))
platform=platform, ok, message = await gdl.verify(
url=source.url, url=source.url,
artist_slug=artist.slug, artist_slug=artist.slug,
config_overrides=source.config_overrides or {}, platform=platform,
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
-3
View File
@@ -44,9 +44,6 @@ def _list_record(event: DownloadEvent, source: Source | None, artist: Artist | N
"bytes_downloaded": event.bytes_downloaded, "bytes_downloaded": event.bytes_downloaded,
"error": event.error, "error": event.error,
"summary": _summary_from_metadata(event.metadata_), "summary": _summary_from_metadata(event.metadata_),
# plan #709: mid-walk live counts for a RUNNING native-ingester event
# (None otherwise; phase 3 overwrites metadata with run_stats on finish).
"live": (event.metadata_ or {}).get("live"),
} }
-18
View File
@@ -57,24 +57,6 @@ def _sha256(path: Path) -> str:
return h.hexdigest() return h.hexdigest()
@extension_bp.route("/probe", methods=["GET"])
async def probe_source():
"""Read-only resolution of a creator-page URL: tells the extension
whether this URL is already a Source, is for an Artist that exists
but with a different URL, is brand new, or doesn't match any known
platform pattern. Drives the content-script chip's color/copy
BEFORE the operator clicks, so the button can show 'already added'
without requiring an add-attempt."""
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
result = await ExtensionService(session).probe(url)
return jsonify(result)
@extension_bp.route("/quick-add-source", methods=["POST"]) @extension_bp.route("/quick-add-source", methods=["POST"])
async def quick_add_source(): async def quick_add_source():
body = await request.get_json(silent=True) body = await request.get_json(silent=True)
+42 -129
View File
@@ -1,6 +1,4 @@
"""Gallery API: cursor scroll, timeline, jump, image detail, facets.""" """Gallery API: cursor scroll, timeline, jump, image detail."""
from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
@@ -10,88 +8,47 @@ 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.
`tag_id` accepts a single id or a comma-separated list (AND); `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
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, "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": "invalid filter or limit parameter"}), 400 return jsonify({"error": "limit must be an integer"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
page = await svc.scroll( page = await svc.scroll(
cursor=cursor, limit=limit, sort=sort, **filters, cursor=cursor, limit=limit, tag_id=tag_id,
post_id=post_id, artist_id=artist_id,
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
return jsonify( return jsonify(
{ {
"images": [_image_json(i) for i in page.images], "images": [
{
"id": i.id,
"sha256": i.sha256,
"mime": i.mime,
"width": i.width,
"height": i.height,
"created_at": i.created_at.isoformat(),
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
"effective_date": i.effective_date.isoformat(),
"thumbnail_url": i.thumbnail_url,
"artist": i.artist,
}
for i in page.images
],
"next_cursor": page.next_cursor, "next_cursor": page.next_cursor,
"date_groups": [ "date_groups": [
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups {"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
@@ -100,46 +57,20 @@ async def scroll():
) )
@gallery_bp.route("/similar", methods=["GET"])
async def similar():
"""Visual "more like this": images ranked by cosine distance to the
`similar_to` image's embedding. Composes with the scope filters (AND) but
ignores post_id and sort. Bounded top-N, no cursor."""
try:
similar_to = int(request.args["similar_to"])
limit = int(request.args.get("limit", "100"))
filters, _sort = _parse_filters()
except (KeyError, ValueError):
return jsonify({"error": "similar_to query param required"}), 400
# 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:
svc = GalleryService(session)
try:
images = await svc.similar(image_id=similar_to, limit=limit, **scope)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if images is None:
return jsonify({"error": "not found"}), 404
return jsonify(
{
"images": [_image_json(i) for i in images],
"next_cursor": None,
"date_groups": [],
}
)
@gallery_bp.route("/timeline", methods=["GET"]) @gallery_bp.route("/timeline", methods=["GET"])
async def timeline(): async def timeline():
try: tag_id_raw = request.args.get("tag_id")
filters, _sort = _parse_filters() tag_id = int(tag_id_raw) if tag_id_raw else None
except ValueError: post_id_raw = request.args.get("post_id")
return jsonify({"error": "invalid filter parameter"}), 400 post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
buckets = await svc.timeline(**filters) buckets = await svc.timeline(
tag_id=tag_id, post_id=post_id, artist_id=artist_id
)
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
return jsonify( return jsonify(
@@ -147,43 +78,25 @@ async def timeline():
) )
@gallery_bp.route("/facets", methods=["GET"])
async def facets():
try:
filters, _sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400
async with get_session() as session:
svc = GalleryService(session)
try:
f = await svc.facets(**filters)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
{
"total": f.total,
"platforms": f.platforms,
"untagged": f.untagged,
"no_artist": f.no_artist,
"date_min": f.date_min.isoformat() if f.date_min else None,
"date_max": f.date_max.isoformat() if f.date_max else None,
}
)
@gallery_bp.route("/jump", methods=["GET"]) @gallery_bp.route("/jump", methods=["GET"])
async def jump(): async def jump():
try: try:
year = int(request.args["year"]) year = int(request.args["year"])
month = int(request.args["month"]) month = int(request.args["month"])
filters, sort = _parse_filters()
except (KeyError, ValueError): except (KeyError, ValueError):
return jsonify({"error": "year and month query params required"}), 400 return jsonify({"error": "year and month query params required"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
cursor = await svc.jump_cursor( cursor = await svc.jump_cursor(
year=year, month=month, sort=sort, **filters, year=year, month=month, tag_id=tag_id,
post_id=post_id, artist_id=artist_id,
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
+1 -17
View File
@@ -35,26 +35,10 @@ async def trigger_scan():
@import_admin_bp.route("/status", methods=["GET"]) @import_admin_bp.route("/status", methods=["GET"])
async def status(): async def status():
async with get_session() as session: async with get_session() as session:
# Active batch = running batch that still has outstanding work.
# Plain "most recent running" picks freshly-created scans that
# enqueued zero new files and hides the older batch that's
# actually being processed. Mirrors the EXISTS predicate
# /api/system/stats already uses (api/settings.py:145-160).
# Audit 2026-06-02 — /api/import/status and /api/system/stats
# used to disagree on the active-batch predicate; the UI banner
# said "Scanning…" indefinitely while the stats card said idle.
active = ( active = (
await session.execute( await session.execute(
select(ImportBatch) select(ImportBatch)
.where( .where(ImportBatch.status == "running")
ImportBatch.status == "running",
select(ImportTask.id)
.where(
ImportTask.batch_id == ImportBatch.id,
ImportTask.status.in_(["pending", "queued", "processing"]),
)
.exists(),
)
.order_by(ImportBatch.started_at.desc()) .order_by(ImportBatch.started_at.desc())
.limit(1) .limit(1)
) )
+4
View File
@@ -9,7 +9,9 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
_EDITABLE = ( _EDITABLE = (
"suggestion_threshold_artist",
"suggestion_threshold_character", "suggestion_threshold_character",
"suggestion_threshold_copyright",
"suggestion_threshold_general", "suggestion_threshold_general",
"centroid_similarity_threshold", "centroid_similarity_threshold",
"min_reference_images", "min_reference_images",
@@ -26,7 +28,9 @@ async def get_settings():
).scalar_one() ).scalar_one()
return jsonify( return jsonify(
{ {
"suggestion_threshold_artist": s.suggestion_threshold_artist,
"suggestion_threshold_character": s.suggestion_threshold_character, "suggestion_threshold_character": s.suggestion_threshold_character,
"suggestion_threshold_copyright": s.suggestion_threshold_copyright,
"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,
-17
View File
@@ -25,8 +25,6 @@ _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",
) )
@@ -48,8 +46,6 @@ 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,
}) })
@@ -100,19 +96,6 @@ 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
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:
-131
View File
@@ -85,22 +85,6 @@ async def create_source():
return _bad("empty_url", detail=str(exc)) return _bad("empty_url", detail=str(exc))
except DuplicateSourceError as exc: except DuplicateSourceError as exc:
return _bad("duplicate", status=409, existing_id=exc.existing_id) return _bad("duplicate", status=409, existing_id=exc.existing_id)
# Immediate kickoff: a new enabled source is armed for backfill (#693)
# but would otherwise sit idle until the next scheduler tick (~60s).
# Enqueue the first walk now, skipping only if the platform is in a
# rate-limit cooldown (the scheduler picks it up when that clears).
dispatch_id = None
if record.enabled:
cooldowns = await active_platform_cooldowns(session)
if record.platform not in cooldowns:
session.add(DownloadEvent(source_id=record.id, status="pending"))
await session.commit()
dispatch_id = record.id
if dispatch_id is not None:
from ..tasks.download import download_source
download_source.delay(dispatch_id)
return jsonify(record.to_dict()), 201 return jsonify(record.to_dict()), 201
@@ -136,121 +120,6 @@ async def delete_source(source_id: int):
return "", 204 return "", 204
@sources_bp.route("/<int:source_id>/backfill", methods=["POST"])
async def set_backfill(source_id: int):
"""Plan #693/#697: start/stop a run-until-done backfill, or start a recovery.
Body: `{"action": "start" | "stop" | "recover"}` (default "start"). 'start'
walks the full post history in time-boxed chunks until it reaches the bottom
(then the source shows 'complete'); 'recover' is the same walk but bypasses
the Patreon seen-ledger to re-fetch dropped-and-deleted near-dups under the
current pHash threshold; 'stop' cancels either back to tick mode. Returns the
updated source dict (incl. backfill_state / backfill_chunks /
backfill_bypass_seen)."""
from pathlib import Path
from ..services.credential_service import CredentialService
from ..services.download_backends import (
uses_native_ingester,
verify_source_credential,
)
from .credentials import _get_crypto
payload = await request.get_json(silent=True) or {}
action = payload.get("action", "start")
if action not in ("start", "stop", "recover"):
return _bad(
"invalid_action",
detail="action must be 'start', 'stop', or 'recover'",
)
# 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"):
async with get_session() as session:
rec = await SourceService(session).get(source_id)
if rec is None:
return _bad("not_found", status=404)
native = uses_native_ingester(rec.platform)
if native:
cred = CredentialService(session, _get_crypto())
cookies_path = await cred.get_cookies_path(rec.platform)
auth_token = await cred.get_token(rec.platform)
if native:
ok, message = await verify_source_credential(
platform=rec.platform,
url=rec.url,
artist_slug=rec.artist_slug,
config_overrides=rec.config_overrides or {},
cookies_path=str(cookies_path) if cookies_path else None,
auth_token=auth_token,
images_root=Path("/images"),
)
if ok is False:
return _bad("credential_rejected", detail=message, status=409)
async with get_session() as session:
try:
svc = SourceService(session)
if action == "start":
record = await svc.start_backfill(source_id)
elif action == "recover":
record = await svc.start_recovery(source_id)
else:
record = await svc.stop_backfill(source_id)
except LookupError:
return _bad("not_found", status=404)
return jsonify(record.to_dict())
@sources_bp.route("/<int:source_id>/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.
+1 -1
View File
@@ -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", "maintenance_long", "download", "scan", "maintenance",
) )
# Cache module-level so all requests share the cache between polls. # Cache module-level so all requests share the cache between polls.
+7 -310
View File
@@ -8,14 +8,12 @@ from ..extensions import get_session
from ..models import Tag, TagKind from ..models import Tag, TagKind
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.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_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
@@ -143,11 +141,6 @@ 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:
@@ -201,46 +194,15 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
return "", 204 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>", methods=["PATCH"]) @tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
async def update_tag(tag_id: int): async def rename_tag(tag_id: int):
"""Rename and/or re-fandom a tag. Body may carry `name` and/or body = await request.get_json()
`fandom_id` (a fandom tag id, or null to clear — character tags only). if not body or "name" not in body:
`merge: true` resolves a collision by merging into the existing tag. return jsonify({"error": "name required"}), 400
"""
body = await request.get_json() or {}
has_name = "name" in body
has_fandom = "fandom_id" in body
if not has_name and not has_fandom:
return jsonify({"error": "name or fandom_id required"}), 400
do_merge = bool(body.get("merge"))
async with get_session() as session: async with get_session() as session:
svc = TagService(session) svc = TagService(session)
try: try:
tag = None tag = await svc.rename(tag_id, body["name"])
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(
{ {
@@ -257,12 +219,7 @@ async def update_tag(tag_id: int):
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
await session.commit() await session.commit()
return jsonify( return jsonify(
{ {"id": tag.id, "name": tag.name, "kind": tag.kind.value}
"id": tag.id,
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
}
) )
@@ -288,12 +245,6 @@ async def merge_tag(source_id: int):
from ..tasks.ml import apply_allowlist_tags from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=result.target_id) apply_allowlist_tags.delay(tag_id=result.target_id)
# Tag merge invalidates the target's centroid (the merged-in source
# tag's images now contribute to it). Daily list_drifted catches it
# within 24h, but eager recompute closes the suggestion-quality dip
# in the meantime. Audit 2026-06-02.
from ..tasks.ml import recompute_centroid
recompute_centroid.delay(result.target_id)
return jsonify( return jsonify(
{ {
"target": { "target": {
@@ -369,31 +320,6 @@ def _series_err(exc: SeriesError):
return jsonify({"error": msg}), status return jsonify({"error": msg}), status
def _opt_int(body, key: str):
"""(value, error) — value is None when absent, error is (json, status)."""
if not body or body.get(key) is None:
return None, None
try:
return int(body[key]), None
except (TypeError, ValueError):
return None, (jsonify({"error": f"{key} must be an integer"}), 400)
def _parse_int_list(body, key: str, *, max_ids: int = 500):
"""(list, error) for a required list of ints under `key`."""
if not body or key not in body:
return None, (jsonify({"error": f"{key} required"}), 400)
raw = body[key]
if not isinstance(raw, list) or not raw:
return None, (jsonify({"error": f"{key} must be a non-empty list"}), 400)
if len(raw) > max_ids:
return None, (jsonify({"error": f"too many ids (max {max_ids})"}), 400)
try:
return [int(x) for x in raw], None
except (TypeError, ValueError):
return None, (jsonify({"error": f"{key} must be integers"}), 400)
@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"]) @tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
async def series_pages(tag_id: int): async def series_pages(tag_id: int):
async with get_session() as session: async with get_session() as session:
@@ -410,14 +336,9 @@ async def series_add(tag_id: int):
ids, err = _parse_bulk_ids(body, max_ids=500) ids, err = _parse_bulk_ids(body, max_ids=500)
if err: if err:
return err return err
chapter_id, cerr = _opt_int(body, "chapter_id")
if cerr:
return cerr
async with get_session() as session: async with get_session() as session:
try: try:
n = await SeriesService(session).add_images( n = await SeriesService(session).add_images(tag_id, ids)
tag_id, ids, chapter_id=chapter_id
)
except SeriesError as exc: except SeriesError as exc:
return _series_err(exc) return _series_err(exc)
await session.commit() await session.commit()
@@ -470,227 +391,3 @@ async def series_cover(tag_id: int):
return _series_err(exc) return _series_err(exc)
await session.commit() await session.commit()
return jsonify({"ok": True}) return jsonify({"ok": True})
# ---- chapters (FC-6.1) ----------------------------------------------------
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
async def series_chapter_create(tag_id: int):
body = await request.get_json() or {}
title = body.get("title")
if title is not None and not isinstance(title, str):
return jsonify({"error": "title must be a string"}), 400
is_placeholder = bool(body.get("is_placeholder", False))
start, serr = _opt_int(body, "stated_page_start")
if serr:
return serr
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
async with get_session() as session:
try:
ch = await SeriesService(session).create_chapter(
tag_id,
title=title,
is_placeholder=is_placeholder,
stated_page_start=start,
stated_page_end=end,
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(ch)
@tags_bp.route("/series/<int:tag_id>/chapters/reorder", methods=["POST"])
async def series_chapter_reorder(tag_id: int):
body = await request.get_json()
ids, err = _parse_int_list(body, "chapter_ids")
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).reorder_chapters(tag_id, ids)
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=["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_page_start" in body:
start, serr = _opt_int(body, "stated_page_start")
if serr:
return serr
kwargs.update(set_start=True, stated_page_start=start)
if "stated_page_end" in body:
end, eerr = _opt_int(body, "stated_page_end")
if eerr:
return eerr
kwargs.update(set_end=True, stated_page_end=end)
async with get_session() as session:
try:
await SeriesService(session).update_chapter(
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_chapter(tag_id, chapter_id)
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>/merge", methods=["POST"]
)
async def series_chapter_merge(tag_id: int, chapter_id: int):
body = await request.get_json()
target, terr = _opt_int(body, "target_chapter_id")
if terr:
return terr
if target is None:
return jsonify({"error": "target_chapter_id required"}), 400
async with get_session() as session:
try:
moved = await SeriesService(session).merge_chapter(
tag_id, chapter_id, target
)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify({"moved_count": moved})
@tags_bp.route(
"/series/<int:tag_id>/chapters/<int:chapter_id>/reorder", methods=["POST"]
)
async def series_chapter_reorder_pages(tag_id: int, chapter_id: int):
body = await request.get_json()
ids, err = _parse_bulk_ids(body, max_ids=500)
if err:
return err
async with get_session() as session:
try:
await SeriesService(session).reorder_pages(tag_id, chapter_id, ids)
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_as_chapter(tag_id, post_id)
except SeriesError as exc:
return _series_err(exc)
await session.commit()
return jsonify(out)
# ---- 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})
+3 -18
View File
@@ -1,7 +1,5 @@
"""Thumbnail admin API: backfill trigger.""" """Thumbnail admin API: backfill trigger."""
import asyncio
from quart import Blueprint, jsonify from quart import Blueprint, jsonify
thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails") thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
@@ -9,20 +7,7 @@ thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
@thumbnails_bp.route("/backfill", methods=["POST"]) @thumbnails_bp.route("/backfill", methods=["POST"])
async def trigger_backfill(): async def trigger_backfill():
"""Run the backfill scan synchronously, return the counts. The actual from ..tasks.thumbnail import backfill_thumbnails
thumbnail generation work is still off-loaded to the thumbnail Celery
queue via `generate_thumbnail.delay()` per missing row — so this
handler is fast even on a 100k-image library (a scan is just SELECT
id, thumbnail_path + a file.stat() per row, no heavy work).
Operator-flagged 2026-06-01: the previous fire-and-forget shape r = backfill_thumbnails.delay()
returned `{celery_task_id}` only, so the admin UI had no idea whether return jsonify({"celery_task_id": r.id}), 202
backfill found 0 or 5000 candidates — \"found nothing\" was
indistinguishable from \"the worker isn't picking up the task.\""""
from ..tasks.thumbnail import _run_backfill_scan
# Sync scan inside an executor so we don't block the event loop.
counts = await asyncio.get_running_loop().run_in_executor(
None, _run_backfill_scan,
)
return jsonify(counts), 200
+3 -48
View File
@@ -43,16 +43,10 @@ def make_celery() -> Celery:
"backend.app.tasks.thumbnail.*": {"queue": "thumbnail"}, "backend.app.tasks.thumbnail.*": {"queue": "thumbnail"},
"backend.app.tasks.download.*": {"queue": "download"}, "backend.app.tasks.download.*": {"queue": "download"},
"backend.app.tasks.scan.*": {"queue": "scan"}, "backend.app.tasks.scan.*": {"queue": "scan"},
# `maintenance` is the QUICK lane — recovery sweeps, vacuum, cleanup
# (concurrency-1 on the scheduler). The long one-shots (DB backups,
# library audits, admin maintenance: normalize/re-extract/cascade-
# delete) run on a SEPARATE `maintenance_long` lane + worker so they
# can never starve the quick self-healing sweeps (operator-flagged
# 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours).
"backend.app.tasks.maintenance.*": {"queue": "maintenance"}, "backend.app.tasks.maintenance.*": {"queue": "maintenance"},
"backend.app.tasks.backup.*": {"queue": "maintenance_long"}, "backend.app.tasks.backup.*": {"queue": "maintenance"},
"backend.app.tasks.admin.*": {"queue": "maintenance_long"}, "backend.app.tasks.admin.*": {"queue": "maintenance"},
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"}, "backend.app.tasks.library_audit.*": {"queue": "maintenance"},
}, },
# 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,
@@ -103,10 +97,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.prune_task_runs", "task": "backend.app.tasks.maintenance.prune_task_runs",
"schedule": 86400.0, # daily "schedule": 86400.0, # daily
}, },
"vacuum-analyze": {
"task": "backend.app.tasks.maintenance.vacuum_analyze",
"schedule": 604800.0, # weekly — reclaim dead-tuple bloat + refresh stats
},
"fc3h-backup-db-nightly": { "fc3h-backup-db-nightly": {
"task": "backend.app.tasks.backup.backup_db_nightly", "task": "backend.app.tasks.backup.backup_db_nightly",
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour "schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
@@ -115,41 +105,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.backup.prune_backups", "task": "backend.app.tasks.backup.prune_backups",
"schedule": 86400.0, # daily "schedule": 86400.0, # daily
}, },
# Audit 2026-06-02 — three new per-entity recovery sweeps.
# Each runs every 5 min like the other recover_stalled_*
# sweeps; each is a no-op when nothing is stuck.
"recover-stalled-backup-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_backup_runs",
"schedule": 300.0,
},
"recover-stalled-library-audit-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_library_audit_runs",
"schedule": 300.0,
},
"recover-stalled-import-batches": {
"task": "backend.app.tasks.maintenance.recover_stalled_import_batches",
"schedule": 300.0,
},
# Audit 2026-06-02 — daily retention for two entities
# whose terminal rows otherwise accumulate forever.
"prune-library-audit-runs": {
"task": "backend.app.tasks.maintenance.prune_library_audit_runs",
"schedule": 86400.0,
},
"prune-import-batches": {
"task": "backend.app.tasks.maintenance.prune_import_batches",
"schedule": 86400.0,
},
# Audit 2026-06-02 — backfill_thumbnails's docstring claimed
# "periodic Beat" but the entry was never registered, so the
# library got no self-healing thumbnail repair; only the
# manual admin-UI button fired it. Daily cadence is gentle
# (the task is idempotent and only enqueues regen for rows
# whose stored thumbnails are missing or corrupt).
"backfill-thumbnails-daily": {
"task": "backend.app.tasks.thumbnail.backfill_thumbnails",
"schedule": 86400.0,
},
}, },
timezone="UTC", timezone="UTC",
) )
+2 -14
View File
@@ -54,14 +54,7 @@ _INT32_MIN = -2_147_483_648
def _queue_for(task) -> str: def _queue_for(task) -> str:
"""Reverse the task→queue routing from celery_app.task_routes. """Reverse the task→queue routing from celery_app.task_routes.
Keep in sync if task_routes is reordered. Keep in sync if task_routes is reordered."""
Audit 2026-06-02: backup/admin/library_audit prefixes were
missing here even though task_routes sent all three to
'maintenance'. The TaskRun.queue column then lied for those
rows (claimed 'default') so per-queue dashboard filters and
per-queue threshold overrides silently missed them.
"""
name = getattr(task, "name", "") or "" name = getattr(task, "name", "") or ""
if name.startswith("backend.app.tasks.import_file."): if name.startswith("backend.app.tasks.import_file."):
return "import" return "import"
@@ -73,12 +66,7 @@ def _queue_for(task) -> str:
return "download" return "download"
if name.startswith("backend.app.tasks.scan."): if name.startswith("backend.app.tasks.scan."):
return "scan" return "scan"
if name.startswith(( if name.startswith("backend.app.tasks.maintenance."):
"backend.app.tasks.maintenance.",
"backend.app.tasks.backup.",
"backend.app.tasks.admin.",
"backend.app.tasks.library_audit.",
)):
return "maintenance" return "maintenance"
return "default" return "default"
-10
View File
@@ -2,7 +2,6 @@
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
@@ -14,13 +13,9 @@ 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 .tag import Tag, TagKind, image_tag from .tag import Tag, TagKind, image_tag
from .tag_alias import TagAlias from .tag_alias import TagAlias
@@ -33,17 +28,12 @@ __all__ = [
"Base", "Base",
"AppSetting", "AppSetting",
"Artist", "Artist",
"ArtistVisit",
"BackupRun", "BackupRun",
"Source", "Source",
"Credential", "Credential",
"PatreonFailedMedia",
"PatreonSeenMedia",
"Post", "Post",
"PostAttachment", "PostAttachment",
"SeriesChapter",
"SeriesPage", "SeriesPage",
"SeriesSuggestion",
"ImageRecord", "ImageRecord",
"ImageProvenance", "ImageProvenance",
"Tag", "Tag",
-36
View File
@@ -1,36 +0,0 @@
"""ArtistVisit — per-artist 'last viewed' timestamp.
Powers the "+N new since last visit" badge on the artists directory and
the matching banner on `ArtistView`. One row per artist, single global
operator. When the multi-user model lands, the PK widens to
`(user_id, artist_id)` — currently aspirational only (no User model,
no services/access.py); operator approved skipping `user_id` for now
under rule #22 (breaking changes welcome).
Seed at migration time: every existing artist gets `last_viewed_at = NOW()`
so the badge starts at 0 across the board (no noisy "5000 unseen" on
first deploy). New artists also auto-get a row via
`ArtistService.find_or_create`.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ArtistVisit(Base):
__tablename__ = "artist_visit"
artist_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("artist.id", ondelete="CASCADE"),
primary_key=True,
)
last_viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
+2 -6
View File
@@ -34,12 +34,8 @@ class ImageProvenance(Base):
post_id: Mapped[int] = mapped_column( post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
) )
# Nullable since alembic 0030 — provenance rows for filesystem-imported source_id: Mapped[int] = mapped_column(
# content with no subscription have NULL source_id. FK ondelete SET ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
# NULL so deleting a Source detaches its provenance rows instead of
# destroying the linkage between image and post.
source_id: Mapped[int | None] = mapped_column(
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True
) )
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(
-11
View File
@@ -74,17 +74,6 @@ 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,
-9
View File
@@ -64,15 +64,6 @@ 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,
)
@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."""
-7
View File
@@ -35,10 +35,3 @@ 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,
)
+9 -7
View File
@@ -15,15 +15,17 @@ class MLSettings(Base):
__table_args__ = (CheckConstraint("id = 1", name="singleton"),) __table_args__ = (CheckConstraint("id = 1", name="singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
suggestion_threshold_character: Mapped[float] = mapped_column( suggestion_threshold_artist: Mapped[float] = mapped_column(
Float, nullable=False, default=0.70 Float, nullable=False, default=0.30
)
suggestion_threshold_character: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
)
suggestion_threshold_copyright: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
) )
# Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50
# surfaced too many low-confidence picks; 0.70 keeps the rail
# signal-rich while still surfacing more than the original 0.95
# which hid almost everything. Operator-tunable via Settings → ML.
suggestion_threshold_general: Mapped[float] = mapped_column( suggestion_threshold_general: Mapped[float] = mapped_column(
Float, nullable=False, default=0.70 Float, nullable=False, default=0.95
) )
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
@@ -1,45 +0,0 @@
"""PatreonFailedMedia — per-source dead-letter ledger of Patreon media that
keeps failing to download/validate.
Plan #705 (#7). A media that fails every walk (404'd CDN URL, deleted post,
geo-blocked Mux stream, persistently-corrupt bytes) would otherwise re-error
forever and re-burn backfill chunks. After ``attempts`` reaches the dead-letter
threshold the ingester skips it on routine tick/backfill walks (recovery still
re-attempts it — the operator's "try everything again"). A later clean download
clears the row (the media recovered).
`filehash` is the same per-media key the seen-ledger uses (32-hex CDN MD5, or a
``video:`` / ``post:filename`` synthesized key) — hence String(128). UNIQUE
(source_id, filehash) is the upsert key.
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import DateTime
from .base import Base
class PatreonFailedMedia(Base):
__tablename__ = "patreon_failed_media"
__table_args__ = (
UniqueConstraint(
"source_id", "filehash", name="uq_patreon_failed_media_source_id"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
first_failed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_failed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-38
View File
@@ -1,38 +0,0 @@
"""PatreonSeenMedia — per-source ledger of Patreon media already
downloaded+processed.
Replaces gallery-dl's archive.sqlite3 with our own queryable table so
routine walks can skip media we've already ingested (and a future
"recovery" mode can deliberately bypass the ledger to re-walk).
`filehash` is normally a Patreon CDN MD5 (32 hex chars), but videos —
which have no stable content hash at discovery time — use a sentinel of
the form ``video:<post_id>:<media_id>``, hence String(128) rather than 32.
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import DateTime
from .base import Base
class PatreonSeenMedia(Base):
__tablename__ = "patreon_seen_media"
__table_args__ = (
# Dedup key the downloader upserts against: one ledger row per
# (source, media). A second sighting of the same media is a no-op.
UniqueConstraint("source_id", "filehash", name="uq_patreon_seen_media_source_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
post_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+4 -20
View File
@@ -1,9 +1,6 @@
"""Post — provenance anchor for one creator post (may contain many images). """Post — provenance anchor for content downloaded from a Source.
`source_id` is nullable since alembic 0030 — filesystem-imported posts A Post is one creator post; it may contain many images/videos.
with no live subscription have NULL source_id. `artist_id` is the
denormalized always-present link to the creator (added in 0030 so
artist-filter queries don't depend on the Source detour).
""" """
from datetime import datetime from datetime import datetime
@@ -17,25 +14,12 @@ from .base import Base
class Post(Base): class Post(Base):
__tablename__ = "post" __tablename__ = "post"
__table_args__ = ( __table_args__ = (
# Source-bound dedup. Postgres treats NULL != NULL so rows
# with source_id IS NULL aren't deduped by this constraint;
# the partial unique index `uq_post_artist_external_id_null_source`
# (created in alembic 0030) covers that case via
# (artist_id, external_post_id).
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"), UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int | None] = mapped_column( source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, index=True ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
# Denormalized; always equals source.artist_id when source_id is set
# (the importer is responsible for keeping them consistent on insert).
# Filter queries (artist detail, artist-scoped posts feed) use this
# directly instead of joining through Source.
artist_id: Mapped[int] = mapped_column(
ForeignKey("artist.id", ondelete="CASCADE"),
nullable=False, index=True,
) )
external_post_id: Mapped[str] = mapped_column(String(128), nullable=False) external_post_id: Mapped[str] = mapped_column(String(128), nullable=False)
post_url: Mapped[str | None] = mapped_column(Text, nullable=True) post_url: Mapped[str | None] = mapped_column(Text, nullable=True)
-47
View File
@@ -1,47 +0,0 @@
"""SeriesChapter — an ordered chapter/part within a series.
A series IS a Tag(kind='series'); a chapter groups ordered SeriesPages under it.
Reading order is (chapter.chapter_number, series_page.page_number): chapter_number
sets the order of chapters, page_number orders pages within a chapter.
chapter_number is an ordering key only (not unique) — reorder rewrites 1..N
wholesale, mirroring series_page.page_number, so a reorder can't transiently
collide on a unique index.
A chapter may be a placeholder (is_placeholder=True) — a reserved empty slot for
a section the operator doesn't have yet; it holds no pages and shows as a gap in
the reader. stated_page_start/end carry the page range parsed from the source
post (FC-6.2), used to flag missing-page gaps; both are nullable when unknown.
"""
from datetime import datetime
from sqlalchemy import Boolean, 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
)
chapter_number: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
is_placeholder: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false"
)
stated_page_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
stated_page_end: 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(),
)
+4 -13
View File
@@ -1,12 +1,9 @@
"""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 ordered pages.
grouped into chapters (FC-6). An image belongs to at most one series An image belongs to at most one series (UNIQUE image_id). Cover = the
(UNIQUE image_id) ⇒ at most one chapter. Reading order is lowest page_number. page_number is an ordering key only (not unique) —
(chapter.chapter_number, series_page.page_number): page_number orders pages reorder rewrites 1..N wholesale.
WITHIN a chapter and is an ordering key only (not unique) — reorder rewrites
1..N wholesale. stated_page carries the page number parsed from the source
post (FC-6.2), nullable when unknown.
""" """
from datetime import datetime from datetime import datetime
@@ -24,18 +21,12 @@ class SeriesPage(Base):
series_tag_id: Mapped[int] = mapped_column( series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
) )
chapter_id: Mapped[int] = mapped_column(
ForeignKey("series_chapter.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
image_id: Mapped[int] = mapped_column( image_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"), ForeignKey("image_record.id", ondelete="CASCADE"),
nullable=False, nullable=False,
unique=True, unique=True,
) )
page_number: Mapped[int] = mapped_column(Integer, nullable=False) page_number: Mapped[int] = mapped_column(Integer, nullable=False)
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()
) )
-55
View File
@@ -1,55 +0,0 @@
"""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(),
)
-14
View File
@@ -26,21 +26,7 @@ class Source(Base):
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True) last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# alembic 0032: last ErrorType category (auth_error, rate_limited,
# not_found, ...). Lets FailingSourcesCard surface the taxonomy as
# a colored chip so operators can bulk-triage by error class. Set
# by _update_source_health alongside last_error; cleared on 'ok'.
error_type: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True) check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0) consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# alembic 0031: sticky deep-scan budget. When > 0, the next N download
# runs use gallery-dl's full-walk config (skip: True + 1800s timeout);
# when 0, runs use tick mode (skip: "exit:20" + 870s, exits early once
# 20 contiguous archived items are seen). Auto-decrements per run, with
# an auto-reset to 0 on clean exit + zero downloads (queue drained).
backfill_runs_remaining: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
)
artist = relationship("Artist", back_populates="sources") artist = relationship("Artist", back_populates="sources")
+5 -41
View File
@@ -16,45 +16,9 @@ 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 detect_archive_format(path) is not None return Path(path).suffix.lower() in ARCHIVE_EXTS
@contextmanager @contextmanager
@@ -68,16 +32,16 @@ def extract_archive(path: Path):
members: list[tuple[str, Path]] = [] members: list[tuple[str, Path]] = []
try: try:
try: try:
fmt = detect_archive_format(path) ext = Path(path).suffix.lower()
if fmt == "zip": if ext in (".zip", ".cbz"):
with zipfile.ZipFile(path) as zf: with zipfile.ZipFile(path) as zf:
zf.extractall(base) zf.extractall(base)
elif fmt == "rar": elif ext == ".rar":
import rarfile import rarfile
with rarfile.RarFile(path) as rf: with rarfile.RarFile(path) as rf:
rf.extractall(base) rf.extractall(base)
elif fmt == "7z": elif ext == ".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_, case, exists, func, or_, select from sqlalchemy import and_, exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, ArtistVisit, ImageRecord, Source from ..models import Artist, ImageRecord, Source
from .gallery_service import thumbnail_url from .gallery_service import thumbnail_url
_SEP = "|" _SEP = "|"
@@ -58,27 +58,9 @@ 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, unseen_col) select(Artist, count_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:
@@ -112,7 +94,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 = [
@@ -122,10 +104,9 @@ 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, unseen_count in rows for artist, image_count in rows
] ]
return DirectoryPage(cards=cards, next_cursor=next_cursor) return DirectoryPage(cards=cards, next_cursor=next_cursor)
+17 -73
View File
@@ -9,13 +9,11 @@ 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.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
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,
@@ -60,17 +58,13 @@ class ArtistService:
) )
).scalar_one() ).scalar_one()
# Posts under this artist that have at least one image attached.
# Use Post.artist_id (alembic 0030) for the artist filter; keep
# the ImageProvenance JOIN so date bounds reflect only image-
# bearing posts (matches the original semantic). NULL-source
# posts now surface too.
date_row = ( date_row = (
await self.session.execute( await self.session.execute(
select(func.min(Post.post_date), func.max(Post.post_date)) select(func.min(Post.post_date), func.max(Post.post_date))
.select_from(Post) .select_from(Post)
.join(ImageProvenance, ImageProvenance.post_id == Post.id) .join(ImageProvenance, ImageProvenance.post_id == Post.id)
.where(Post.artist_id == aid) .join(Source, Source.id == ImageProvenance.source_id)
.where(Source.artist_id == aid)
) )
).first() ).first()
dmin, dmax = date_row if date_row else (None, None) dmin, dmax = date_row if date_row else (None, None)
@@ -104,14 +98,14 @@ class ArtistService:
) )
).all() ).all()
# Same Post.artist_id direct filter — counts NULL-source posts too.
month = func.date_trunc("month", Post.post_date).label("m") month = func.date_trunc("month", Post.post_date).label("m")
activity = ( activity = (
await self.session.execute( await self.session.execute(
select(month, func.count(func.distinct(ImageProvenance.image_record_id))) select(month, func.count(func.distinct(ImageProvenance.image_record_id)))
.select_from(Post) .select_from(Post)
.join(ImageProvenance, ImageProvenance.post_id == Post.id) .join(ImageProvenance, ImageProvenance.post_id == Post.id)
.where(and_(Post.artist_id == aid, Post.post_date.isnot(None))) .join(Source, Source.id == ImageProvenance.source_id)
.where(and_(Source.artist_id == aid, Post.post_date.isnot(None)))
.group_by(month) .group_by(month)
.order_by(month) .order_by(month)
) )
@@ -120,16 +114,12 @@ class ArtistService:
post_count = ( post_count = (
await self.session.execute( await self.session.execute(
select(func.count(func.distinct(Post.id))) select(func.count(func.distinct(Post.id)))
.where(Post.artist_id == aid) .select_from(Post)
.join(Source, Source.id == Post.source_id)
.where(Source.artist_id == aid)
) )
).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,
@@ -137,7 +127,6 @@ 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,
@@ -166,39 +155,6 @@ 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:
@@ -250,39 +206,27 @@ 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."""
Audit 2026-06-02: switched from session.rollback() to a
begin_nested savepoint + IntegrityError recovery so a lost
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:
raise ValueError("artist name must not be empty") raise ValueError("artist name must not be empty")
slug = slugify(cleaned) slug = slugify(cleaned)
select_existing = select(Artist).where(Artist.slug == slug) existing = (await self.session.execute(
existing = (await self.session.execute(select_existing)).scalar_one_or_none() select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if existing is not None: if existing is not None:
return existing, False return existing, False
sp = await self.session.begin_nested() artist = Artist(name=cleaned, slug=slug)
self.session.add(artist)
try: try:
artist = Artist(name=cleaned, slug=slug)
self.session.add(artist)
await self.session.flush() await self.session.flush()
# New artist starts "caught up" — seed ArtistVisit so the
# directory's `+N new` badge stays at 0 until real new
# content arrives. Without this, the unseen-count query
# treats NULL last_viewed_at as "never visited" and would
# count every image imported in the same session.
self.session.add(ArtistVisit(artist_id=artist.id))
await self.session.flush()
await sp.commit()
except IntegrityError: except IntegrityError:
await sp.rollback() await self.session.rollback()
existing = (await self.session.execute(select_existing)).scalar_one() existing = (await self.session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one()
return existing, False return existing, False
await self.session.commit() await self.session.commit()
return artist, True return artist, True
+11 -56
View File
@@ -15,10 +15,7 @@ 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
@@ -29,35 +26,6 @@ _BACKUPS_DIRNAME = "_backups"
# blocking syscall ignores that signal. These bound the worst case. # blocking syscall ignores that signal. These bound the worst case.
_DB_SUBPROCESS_TIMEOUT_S = 12 * 60 # 12 min (Celery soft is 10 min) _DB_SUBPROCESS_TIMEOUT_S = 12 * 60 # 12 min (Celery soft is 10 min)
_IMAGES_SUBPROCESS_TIMEOUT_S = 7 * 60 * 60 # 7 hr (Celery soft is 6 hr) _IMAGES_SUBPROCESS_TIMEOUT_S = 7 * 60 * 60 # 7 hr (Celery soft is 6 hr)
# 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:
@@ -116,25 +84,14 @@ def backup_db(
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" sql_path = out_dir / f"fc_db_{ts}.sql"
# Dump to LOCAL disk first, then move the finished file to the (NFS) backups subprocess.run(
# dir. pg_dump's long phase is then a DB-socket wait + local writes — both [
# killable — instead of an NFS write that can hang uninterruptibly. Only the "pg_dump", "--no-owner", "--no-acl",
# final move touches NFS, and it's a bounded single-file step. "-f", str(sql_path), _libpq_url(db_url),
fd, tmp_name = tempfile.mkstemp(prefix="fc_db_", suffix=".sql") ],
os.close(fd) capture_output=True, check=True,
tmp_path = Path(tmp_name) timeout=_DB_SUBPROCESS_TIMEOUT_S,
try: )
_run_bounded(
[
"pg_dump", "--no-owner", "--no-acl",
"-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,
@@ -157,17 +114,15 @@ 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"
# No local-temp here (the archive is hundreds of GB — it can't stage in subprocess.run(
# /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",
], ],
_IMAGES_SUBPROCESS_TIMEOUT_S, capture_output=True, check=True,
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,
+15 -276
View File
@@ -11,9 +11,7 @@ the one-and-done GS/IR migration tooling.)
""" """
from __future__ import annotations from __future__ import annotations
import logging from datetime import UTC, datetime
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -24,8 +22,6 @@ from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
from ..models.series_page import SeriesPage from ..models.series_page import SeriesPage
from ..models.tag import image_tag from ..models.tag import image_tag
log = logging.getLogger(__name__)
def project_artist_cascade(session: Session, *, slug: str) -> dict: def project_artist_cascade(session: Session, *, slug: str) -> dict:
"""Read-only projection of what delete_artist_cascade would touch. """Read-only projection of what delete_artist_cascade would touch.
@@ -191,14 +187,10 @@ def unlink_image_files(
out["thumbnail"] = True out["thumbnail"] = True
except OSError: except OSError:
out["thumbnail"] = False out["thumbnail"] = False
# Convention thumbs dir — try both extensions thumbnailer writes # Convention thumbs dir — try all extensions; missing OK.
# (.jpg for opaque, .png for alpha). `.webp` used to be in this
# tuple but the thumbnailer never writes it (operator-flagged in
# the 2026-06-02 audit) — keep the tuple aligned with what
# actually lands on disk.
if image.sha256: if image.sha256:
bucket = image.sha256[:3] bucket = image.sha256[:3]
for ext in ("jpg", "png"): for ext in ("jpg", "png", "webp"):
try: try:
(images_root / "thumbs" / bucket / f"{image.sha256}.{ext}").unlink( (images_root / "thumbs" / bucket / f"{image.sha256}.{ext}").unlink(
missing_ok=True, missing_ok=True,
@@ -362,35 +354,18 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
Returns: Returns:
dry_run=True: {"count": N, "sample_names": [first 50]} dry_run=True: {"count": N, "sample_names": [first 50]}
dry_run=False: {"deleted": N, "sample_names": [first 50]} dry_run=False: {"deleted": N, "sample_names": [first 50]}
Implementation note: the previous SELECT-ids → DELETE-WHERE-IN
pattern was vulnerable to the psycopg 65535-parameter ceiling on
libraries with tag explosions. The live delete now runs a single
DELETE with the same NOT-IN predicate find_unused_tags uses, so
the row count scales without binding every id as a parameter.
Audit 2026-06-02.
""" """
sample_rows = find_unused_tags(session, limit=50) unused = find_unused_tags(session)
sample = [t.name for t in sample_rows] sample = [t.name for t in unused[:50]]
used_via_image_tag = select(image_tag.c.tag_id).distinct()
used_via_series = select(SeriesPage.series_tag_id).where(
SeriesPage.series_tag_id.is_not(None)
).distinct()
if dry_run: if dry_run:
count = session.execute( return {"count": len(unused), "sample_names": sample}
select(func.count()) ids = [t.id for t in unused]
.select_from(Tag) if ids:
.where(Tag.id.not_in(used_via_image_tag)) session.execute(
.where(Tag.id.not_in(used_via_series)) Tag.__table__.delete().where(Tag.id.in_(ids))
).scalar_one() )
return {"count": count, "sample_names": sample} session.commit()
result = session.execute( return {"deleted": len(ids), "sample_names": sample}
Tag.__table__.delete()
.where(Tag.id.not_in(used_via_image_tag))
.where(Tag.id.not_in(used_via_series))
)
session.commit()
return {"deleted": result.rowcount or 0, "sample_names": sample}
# Legacy tags FC no longer uses, in two shapes: # Legacy tags FC no longer uses, in two shapes:
@@ -459,62 +434,6 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
return result return result
# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes
# these so the operator can re-tag from scratch via auto-suggest. fandom +
# series (and series_page ordering) are deliberately NOT here — they're kept.
RESETTABLE_TAG_KINDS = ("general", "character")
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
"""Count (dry_run) or DELETE every general + character tag so the operator
can re-tag from scratch via the Camie auto-suggest.
PRESERVED: fandom + series tags and their series_page ordering, plus every
image's image_record.tagger_predictions (untouched) so suggestions
repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
tag_reference_embedding / tag_suggestion_rejection clears each deleted
tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting
character tags never touches the fandom rows. Irreversible except via DB
backup restore.
Returns:
{"by_kind": {"general": N, "character": M},
"count": total tags,
"applications": image_tag rows that will be / were removed,
"sample_names": [first 50],
and on live runs "deleted": total}
"""
predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS)
rows = session.execute(
select(Tag.id, Tag.name, Tag.kind).where(predicate)
).all()
by_kind: dict[str, int] = {}
for _id, _name, kind in rows:
key = kind.value if hasattr(kind, "value") else str(kind)
by_kind[key] = by_kind.get(key, 0) + 1
# Headline impact: applications (image_tag rows) that vanish via cascade.
applications = session.execute(
select(func.count())
.select_from(image_tag)
.where(image_tag.c.tag_id.in_(select(Tag.id).where(predicate)))
).scalar_one()
sample = [name for _id, name, _kind in rows[:50]]
total = len(rows)
result = {
"by_kind": by_kind,
"count": total,
"applications": applications,
"sample_names": sample,
}
if dry_run:
return result
if total:
session.execute(Tag.__table__.delete().where(predicate))
session.commit()
result["deleted"] = total
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules. # FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -581,9 +500,6 @@ class ConfirmTokenMismatch(Exception):
_VALID_RULES = ("transparency", "single_color") _VALID_RULES = ("transparency", "single_color")
_AUDIT_GUARD_THRESHOLD_MINUTES = 135 # matches LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES
def start_audit_run( def start_audit_run(
session: Session, *, rule: str, params: dict[str, Any], session: Session, *, rule: str, params: dict[str, Any],
) -> int: ) -> int:
@@ -591,21 +507,11 @@ def start_audit_run(
scan_library_for_rule Celery task. Returns the new audit_id. scan_library_for_rule Celery task. Returns the new audit_id.
Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run
has status='running' AND started recently. Audit 2026-06-02 made has status='running'. Operator must cancel or wait."""
the guard age-aware: a SIGKILL'd run leaves a row in 'running'
that the recovery sweep flips on its next pass (~5 min), but a
fresh start_audit_run between the SIGKILL and the sweep would
previously block forever. Past the threshold, treat the running
row as stale and let the sweep clean it up — the new run still
gets to start.
"""
if rule not in _VALID_RULES: if rule not in _VALID_RULES:
raise ValueError(f"unknown rule {rule!r}; expected one of {_VALID_RULES}") raise ValueError(f"unknown rule {rule!r}; expected one of {_VALID_RULES}")
cutoff = datetime.now(UTC) - timedelta(minutes=_AUDIT_GUARD_THRESHOLD_MINUTES)
existing = session.execute( existing = session.execute(
select(LibraryAuditRun.id) select(LibraryAuditRun.id).where(LibraryAuditRun.status == "running")
.where(LibraryAuditRun.status == "running")
.where(LibraryAuditRun.started_at >= cutoff)
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None: if existing is not None:
raise AuditAlreadyRunning(existing) raise AuditAlreadyRunning(existing)
@@ -669,170 +575,3 @@ def cancel_audit_run(session: Session, *, audit_id: int) -> None:
.where(LibraryAuditRun.status == "running") .where(LibraryAuditRun.status == "running")
.values(status="cancelled", finished_at=datetime.now(UTC)) .values(status="cancelled", finished_at=datetime.now(UTC))
) )
# -- archive-attachment re-extraction (#713 part 2) ------------------------
_ARCHIVE_EXT_FOR_FORMAT = {"zip": ".zip", "rar": ".rar", "7z": ".7z"}
def _reextract_archive_to_post(
importer, archive_path: Path, post, source_row, artist, images_root: Path,
) -> list[int]:
"""Extract one stored archive and link its members to `post`.
The stored attachment has no adjacent sidecar (it lives in the sha-addressed
attachment store). Stage a copy + a reconstructed sidecar UNDER the artist's
library dir (`images_root/<slug>/<platform>/<post>/`) — the importer
re-derives the artist from the path AND copies members relative to it, so the
members land in the real library and resolve to the right artist — then re-run
`attach_in_place`: the archive extracts and `find_or_create_post` re-attaches
the members to the SAME Post (source_id + external_post_id). Removes only the
staged archive + sidecar afterward; the imported member files stay. Returns
the new member image ids.
"""
import json
import shutil
from .archive_extractor import detect_archive_format
fmt = detect_archive_format(archive_path)
ext = _ARCHIVE_EXT_FOR_FORMAT.get(fmt or "", ".zip")
platform = source_row.platform if source_row is not None else "imported"
sidecar = {
"category": source_row.platform if source_row is not None
else (post.raw_metadata or {}).get("category"),
"id": post.external_post_id,
"title": post.post_title or "",
"content": post.description or "",
"published_at": post.post_date.isoformat() if post.post_date else None,
"url": post.post_url,
}
work = images_root / artist.slug / platform / str(post.external_post_id)
work.mkdir(parents=True, exist_ok=True)
staged = work / f"archive{ext}" # clean ext → is_archive + find_sidecar
sidecar_path = staged.with_suffix(".json")
try:
shutil.copy2(archive_path, staged)
sidecar_path.write_text(json.dumps(sidecar))
res = importer.attach_in_place(staged, artist=artist, source=source_row)
return list(res.member_image_ids or [])
finally:
# Drop only the staged archive + sidecar; the extracted member files
# were copied into the library alongside them and must stay.
staged.unlink(missing_ok=True)
sidecar_path.unlink(missing_ok=True)
def reextract_archive_attachments(
session: Session,
*,
images_root: Path,
time_budget_seconds: float | None = None,
after_id: int = 0,
) -> dict:
"""Re-process existing PostAttachments that are ACTUALLY archives but were
filed opaquely before #713 part 1 (extension-only is_archive missed mangled /
extension-less Patreon attachment names). For each: extract the members,
import them, and link them to the attachment's post.
Idempotent — members dedupe by sha256, the archive dedupes by sha — so it's
safe to run repeatedly. Returns a summary dict for task_run.metadata.
Time-boxed + resumable: scans PostAttachments in ascending id order starting
after ``after_id``. When ``time_budget_seconds`` elapses, stops and reports
``partial=True`` + ``resume_after_id`` (the last scanned id) so the task can
re-enqueue itself and continue — a large archive back-catalog can't run the
task into the Celery time limit or hog the maintenance lane. A bare re-run
(after_id=0) would never advance because an already-extracted archive is
still an archive on disk, so the cursor is what guarantees forward progress.
"""
from ..models import ImportSettings, Post, PostAttachment, Source
from ..tasks.ml import tag_and_embed
from ..tasks.thumbnail import generate_thumbnail
from .archive_extractor import is_archive
from .importer import Importer
from .thumbnailer import Thumbnailer
summary = {
"scanned": 0, "archives": 0, "members_imported": 0,
"posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0,
"errors": 0, "partial": False, "resume_after_id": after_id,
}
settings = ImportSettings.load_sync(session)
importer = Importer(
session=session, images_root=images_root, import_root=images_root,
thumbnailer=Thumbnailer(images_root=images_root), settings=settings,
)
attachments = session.execute(
select(PostAttachment)
.where(PostAttachment.id > after_id)
.order_by(PostAttachment.id)
).scalars().all()
enqueue_ids: list[int] = []
start = time.monotonic()
for att in attachments:
summary["scanned"] += 1
summary["resume_after_id"] = att.id
stored = Path(att.path)
try:
if not stored.is_file() or not is_archive(stored):
continue
except OSError:
continue
summary["archives"] += 1
if att.post_id is None:
summary["skipped_no_post"] += 1
continue
post = session.get(Post, att.post_id)
if post is None:
summary["skipped_no_post"] += 1
continue
artist = session.get(Artist, att.artist_id) if att.artist_id else None
if artist is None and post.artist_id:
artist = session.get(Artist, post.artist_id)
if artist is None or not artist.slug:
# The importer re-derives the artist from the staged path, so we need
# a real artist+slug to anchor under. (Shouldn't happen for
# subscription posts; skip rather than orphan the members.)
summary["skipped_no_artist"] += 1
continue
source_row = session.get(Source, post.source_id) if post.source_id else None
try:
ids = _reextract_archive_to_post(
importer, stored, post, source_row, artist, images_root,
)
session.commit()
except Exception as exc: # one bad archive must not strand the rest
session.rollback()
summary["errors"] += 1
log.warning("re-extract failed for attachment %s: %s", att.id, exc)
continue
if ids:
summary["members_imported"] += len(ids)
summary["posts_touched"] += 1
enqueue_ids.extend(ids)
# Time-box the chunk. resume_after_id already points at this attachment,
# so the next run starts strictly after it. Checked after the commit so a
# half-extracted archive never straddles the boundary.
if (
time_budget_seconds is not None
and time.monotonic() - start >= time_budget_seconds
):
summary["partial"] = True
break
else:
# Loop ran to exhaustion — nothing left to resume.
summary["partial"] = False
# Thumbnails + ML for the newly-imported members (best-effort; off the
# critical path — a Redis hiccup must not fail the whole re-extract).
for img_id in enqueue_ids:
try:
generate_thumbnail.delay(img_id)
tag_and_embed.delay(img_id)
except Exception as exc:
log.warning("re-extract enqueue failed for image %s: %s", img_id, exc)
return summary
+11 -53
View File
@@ -1,82 +1,40 @@
"""Fernet-based encryption for credential blobs. """Fernet-based encryption for credential blobs.
The key is a single 32-byte value (urlsafe-base64-encoded; what The key is a single 32-byte value (urlsafe-base64-encoded; what
Fernet.generate_key produces) stored at /images/secrets/credential_key.b64 Fernet.generate_key produces) stored at a fixed path inside the
(mode 0600, parent dir 0700). The 2026-06-02 audit caught a silent images/data root. Created on first boot if absent; mode 0600. No KDF
key-regeneration path: on a partial disaster restore where the DB was needed — the file contents are already maximum-entropy random bytes.
restored but the secrets dir was lost, the old `_load_or_create_key`
would mint a fresh key with no log, producing a working-looking system
where every authenticated download failed AUTH_ERROR until the operator
re-uploaded every credential by hand. Now the constructor refuses to
auto-generate unless either:
* the caller explicitly passes `bootstrap_ok=True` (tests, scripts), or Operator backup procedure must include this file alongside the rest
* the env var `CURATOR_BOOTSTRAP_NEW_KEY=1` is set (operator opt-in of /images/ — losing it makes existing encrypted_blob rows
during first-time setup). undecryptable (recovery = delete the rows and re-upload).
Otherwise it raises `MissingCredentialKey` so the app fails fast at
startup and the operator can restore the key file from backup.
Operator backup procedure must include /images/secrets/ alongside the
rest of /images/ — losing the key file makes existing encrypted_blob
rows undecryptable (recovery = delete the rows and re-upload).
""" """
import logging
import os import os
from pathlib import Path from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
log = logging.getLogger(__name__)
_BOOTSTRAP_ENV_VAR = "CURATOR_BOOTSTRAP_NEW_KEY"
class InvalidCredentialBlob(Exception): class InvalidCredentialBlob(Exception):
"""Raised when decryption fails (wrong key, tampered blob, …).""" """Raised when decryption fails (wrong key, tampered blob, …)."""
class MissingCredentialKey(Exception):
"""The Fernet key file is missing AND the caller hasn't opted in to
generating a new one. Audit 2026-06-02: prevents silent key
regeneration on partial DB-restored / secrets-lost deployments.
Set CURATOR_BOOTSTRAP_NEW_KEY=1 for first-time setup, or restore the
key file from backup."""
class CredentialCrypto: class CredentialCrypto:
"""Fernet encrypt/decrypt with an on-disk key file. """Fernet encrypt/decrypt with an on-disk key file.
Instantiate with a path; the file is loaded if present, or created Instantiate with a path; the file is created on first access and
if absent AND the caller has opted in (bootstrap_ok=True or reused thereafter. Tests pass a tmp_path; production calls with
CURATOR_BOOTSTRAP_NEW_KEY=1 env var). Production sites:
`IMAGES_ROOT / "secrets" / "credential_key.b64"`. `IMAGES_ROOT / "secrets" / "credential_key.b64"`.
""" """
def __init__(self, key_path: Path, *, bootstrap_ok: bool | None = None): def __init__(self, key_path: Path):
self._key_path = Path(key_path) self._key_path = Path(key_path)
if bootstrap_ok is None: self._fernet = Fernet(self._load_or_create_key())
bootstrap_ok = os.environ.get(_BOOTSTRAP_ENV_VAR) == "1"
self._fernet = Fernet(self._load_or_create_key(bootstrap_ok))
def _load_or_create_key(self, bootstrap_ok: bool) -> bytes: def _load_or_create_key(self) -> bytes:
if self._key_path.exists(): if self._key_path.exists():
return self._key_path.read_bytes() return self._key_path.read_bytes()
if not bootstrap_ok:
raise MissingCredentialKey(
f"Fernet key file not found at {self._key_path}. "
f"For first-time setup, set {_BOOTSTRAP_ENV_VAR}=1. "
f"If this is a restored instance, restore the key file "
f"from backup — generating a new one would make every "
f"existing Credential row undecryptable."
)
log.warning(
"Generating NEW Fernet credential key at %s. Any existing "
"encrypted_blob rows in the DB will be undecryptable — "
"re-upload each credential after this completes.",
self._key_path,
)
parent = self._key_path.parent parent = self._key_path.parent
parent.mkdir(parents=True, exist_ok=True) parent.mkdir(parents=True, exist_ok=True)
os.chmod(parent, 0o700) os.chmod(parent, 0o700)
-241
View File
@@ -1,241 +0,0 @@
"""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 .patreon_ingester import PatreonIngester
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
# Platforms whose download + verify go through the native ingester rather than
# gallery-dl. gallery-dl still serves every other platform (subscribestar,
# hentaifoundry, discord, pixiv, deviantart) unchanged.
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon"})
# 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 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 _run_native_ingester(
ctx: dict, source_config, mode: str | None, gdl, sync_session_factory,
) -> tuple[DownloadResult, str | None]:
"""Patreon (today the only native platform): resolve the campaign id, then run
the native ingester in a worker thread (it is sync requests/subprocess).
`resolved_campaign_id` is non-None only when we had to look it up from the
vanity URL this run, so phase 3 caches it the way the old gallery-dl retry
did. A campaign id we cannot resolve is a loud NOT_FOUND — never a silent
empty success.
"""
overrides = ctx["config_overrides"] or {}
campaign_id, resolved_campaign_id = await resolve_campaign_id_for_source(
ctx["url"], ctx["cookies_path"], overrides
)
if not campaign_id:
url = ctx["url"]
vanity = extract_vanity(url)
return (
DownloadResult(
success=False,
url=url,
artist_slug=ctx["artist_slug"],
platform="patreon",
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 = PatreonIngester(
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
from .patreon_client import PatreonAPIError
campaign_id, _ = await resolve_campaign_id_for_source(
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 = PatreonIngester(
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 PatreonAPIError 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
# (resolve campaign id + one authenticated API page). Patreon today.
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,
)
+71 -325
View File
@@ -13,6 +13,7 @@ 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
@@ -24,25 +25,36 @@ 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 GalleryDLService, SourceConfig
from .gallery_dl import (
BACKFILL_CHUNK_SECONDS,
BACKFILL_SKIP_VALUE,
TICK_SKIP_VALUE,
DownloadResult,
ErrorType,
GalleryDLService,
SourceConfig,
extract_errors_warnings,
truncate_log,
)
from .importer import Importer from .importer import Importer
from .platforms import auth_type_for from .patreon_resolver import resolve_campaign_id
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))`.
@@ -57,18 +69,12 @@ 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."""
@@ -77,101 +83,50 @@ 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 {})
# Backfill mode (plan #693): the source's `_backfill_state == "running"` effective_url = _effective_url(
# selects a time-boxed deep-walk chunk — skip: True (walk full history) ctx["platform"], ctx["url"], ctx["config_overrides"] or {}
# + the BACKFILL_CHUNK_SECONDS budget, resuming from the cursor
# checkpoint (plan #689). State is the single source of truth; it stays
# "running" across ticks until the walk reaches the bottom (phase 3
# flips it to "complete"). Otherwise tick mode: exit gallery-dl after
# 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"))
if in_backfill:
skip_value: bool | str = BACKFILL_SKIP_VALUE
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:
skip_value = TICK_SKIP_VALUE
# Phase 2 dispatch is uniform across backends (download_backends.
# 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:
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,
) )
# A backfill chunk that hit its time-box but made forward progress is dl_result = await self.gdl.download(
# NORMAL, not a failure — reclassify TIMEOUT → PARTIAL so it reads as url=effective_url,
# "ok/progress" (PARTIAL maps to status "ok"), not a red error, and the artist_slug=ctx["artist_slug"],
# next chunk just resumes from the new cursor (plan #693). A chunk that platform=ctx["platform"],
# timed out with NO progress stays TIMEOUT and feeds phase 3's source_config=source_config,
# stall-guard. Leave RATE_LIMITED alone so the platform-cooldown fires. cookies_path=ctx["cookies_path"],
if in_backfill and dl_result.error_type == ErrorType.TIMEOUT: auth_token=ctx["auth_token"],
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"],
)
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)
@@ -201,13 +156,6 @@ class DownloadService:
return {"status": "in_flight", "event_id": existing.id} return {"status": "in_flight", "event_id": existing.id}
if existing and existing.status == "pending": if existing and existing.status == "pending":
existing.status = "running" existing.status = "running"
# Reset started_at on the pending→running transition so the
# recovery sweep (DOWNLOAD_STALL_THRESHOLD_MINUTES, 30 min)
# measures from real start, not from enqueue. On heavy-queue
# days a freshly-promoted event whose original started_at
# predated the cutoff would otherwise get swept mid-flight,
# racing phase3's commit. Audit 2026-06-02.
existing.started_at = datetime.now(UTC)
await self.async_session.commit() await self.async_session.commit()
event_id = existing.id event_id = existing.id
else: else:
@@ -218,12 +166,7 @@ class DownloadService:
event_id = ev.id event_id = ev.id
artist = source.artist artist = source.artist
# Drive cookies-vs-token selection from the platform registry's if source.platform in ("discord", "pixiv"):
# auth_type so a new 7th token-platform automatically picks the
# right credential path. The hardcoded tuple here used to drift
# out of sync with credential_service's auth_type_for(). Audit
# 2026-06-02.
if auth_type_for(source.platform) == "token":
cookies_path = None cookies_path = None
auth_token = await self.cred_service.get_token(source.platform) auth_token = await self.cred_service.get_token(source.platform)
else: else:
@@ -242,7 +185,6 @@ class DownloadService:
"config_overrides": dict(source.config_overrides or {}), "config_overrides": dict(source.config_overrides or {}),
"cookies_path": cookies_path, "cookies_path": cookies_path,
"auth_token": auth_token, "auth_token": auth_token,
"backfill_runs_remaining": source.backfill_runs_remaining or 0,
} }
async def _phase3_persist( async def _phase3_persist(
@@ -269,11 +211,6 @@ class DownloadService:
source_row = self.sync_session.get(Source, ctx["source_id"]) source_row = self.sync_session.get(Source, ctx["source_id"])
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()
@@ -301,51 +238,6 @@ class DownloadService:
bytes_downloaded += path.stat().st_size # noqa: ASYNC240 bytes_downloaded += path.stat().st_size # noqa: ASYNC240
except OSError: except OSError:
pass pass
# Enqueue thumbnail + ML for newly-attached images, matching
# the filesystem-import path (tasks/import_file.py:228-239).
# Importer.attach_in_place deliberately skips inline thumb
# generation to keep the import queue moving; the calling
# task is responsible for the enqueue. Operator-flagged
# 2026-06-01: without this, every downloaded image stayed
# at thumbnail_path=NULL until a periodic backfill swept
# it up, surfacing as broken-thumbnail tiles in the gallery
# for hours after a download landed. Lazy import to avoid
# circular-import risk between this service and the
# tasks/* modules that import it.
from ..tasks.ml import tag_and_embed
from ..tasks.thumbnail import generate_thumbnail
ids = list(result.member_image_ids)
if result.image_id is not None and result.image_id not in ids:
ids.append(result.image_id)
for img_id in ids:
generate_thumbnail.delay(img_id)
tag_and_embed.delay(img_id)
elif result.status == "attached":
# Non-media or extracted archive captured as PostAttachment
# (FC-2d-iii). The canonical copy lives in the attachments
# store; the original download path is now redundant —
# mirror duplicate_hash cleanup so we don't keep two copies.
# Operator-flagged 2026-06-02 (Lustria OST zip).
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:
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
except OSError:
pass
try:
path.unlink(missing_ok=True) # noqa: ASYNC240
except OSError:
pass
elif result.status == "skipped" and result.skip_reason and result.skip_reason.value in ( elif result.status == "skipped" and result.skip_reason and result.skip_reason.value in (
"duplicate_hash", "duplicate_phash", "duplicate_hash", "duplicate_phash",
): ):
@@ -354,29 +246,6 @@ class DownloadService:
path.unlink(missing_ok=True) # noqa: ASYNC240 path.unlink(missing_ok=True) # noqa: ASYNC240
except OSError: except OSError:
pass pass
elif result.status == "skipped":
# Soft skip (too_small, too_transparent, invalid_image) —
# the file just didn't qualify, not a download/ingest
# failure. Don't flag the run as error; the file stays
# on disk for operator inspection.
import_summary["skipped"] += 1
elif result.status == "failed":
# Hard failure (today only: archive probe crash/timeout).
# The original archive sits in /images/ as an orphan; the
# filesystem scanner would re-import and re-crash on the
# same file, so delete the source file and surface the
# error in import_summary. Audit 2026-06-02.
import_summary["errors"] += 1
try:
path.unlink(missing_ok=True) # noqa: ASYNC240
except OSError:
pass
elif result.status == "refreshed":
# Currently unreachable from attach_in_place (the download
# path never runs in deep=True mode), but the importer's
# ImportResult contract enumerates it. Treat the same as
# 'attached' — work happened, no error. Audit 2026-06-02.
import_summary["attached"] += 1
else: else:
import_summary["errors"] += 1 import_summary["errors"] += 1
@@ -384,145 +253,38 @@ class DownloadService:
select(DownloadEvent).where(DownloadEvent.id == event_id) select(DownloadEvent).where(DownloadEvent.id == event_id)
)).scalar_one() )).scalar_one()
# plan #704: the native ingester returns structured run_stats; only the run_stats = self.gdl._compute_run_stats(
# gallery-dl path needs the regex-over-stdout reconstruction. dl_result.return_code, dl_result.stdout, dl_result.stderr
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 = extract_errors_warnings(dl_result.stderr) stderr_summary = self.gdl._extract_errors_warnings(dl_result.stderr)
# Plan #544: PARTIAL means the run downloaded ≥1 file but the status = "ok" if (dl_result.success and import_summary["errors"] == 0) else "error"
# subprocess didn't finish in budget (typically wall-clock timeout
# mid-walk). Real work happened; the next tick continues via
# gallery-dl's archive. NOT a failure for status purposes.
if dl_result.success and import_summary["errors"] == 0:
status = "ok"
elif dl_result.error_type == ErrorType.PARTIAL and import_summary["errors"] == 0:
status = "ok"
else:
status = "error"
ev.status = status ev.status = status
ev.finished_at = datetime.now(UTC) ev.finished_at = datetime.now(UTC)
ev.files_count = import_summary["attached"] ev.files_count = import_summary["attached"]
ev.bytes_downloaded = bytes_downloaded ev.bytes_downloaded = bytes_downloaded
ev.error = dl_result.error_message if status == "error" else None ev.error = dl_result.error_message if not dl_result.success else None
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": truncate_log(dl_result.stdout) or None, "stdout": self.gdl._truncate_log(dl_result.stdout) or None,
"stderr": truncate_log(dl_result.stderr) or None, "stderr": self.gdl._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),
) )
await self._apply_backfill_lifecycle(ctx, dl_result)
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)
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, retry_after_seconds: float | None = None, error_type: str | 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}.
@@ -544,27 +306,11 @@ class DownloadService:
if status == "ok": if status == "ok":
source.consecutive_failures = 0 source.consecutive_failures = 0
source.last_error = None source.last_error = None
# alembic 0032 — clear the failure-class chip on success.
source.error_type = None
elif status == "error": elif status == "error":
source.consecutive_failures = (source.consecutive_failures or 0) + 1 source.consecutive_failures = (source.consecutive_failures or 0) + 1
source.last_error = error_message source.last_error = error_message
# alembic 0032 — stamp the failure-class so FailingSourcesCard
# can render a colored chip and operators can bulk-triage
# by error class without opening Logs per row.
source.error_type = error_type
if error_type == "rate_limited": if error_type == "rate_limited":
# plan #708 B1: honor the server's Retry-After when the native await set_platform_cooldown(self.async_session, source.platform)
# 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
-70
View File
@@ -16,7 +16,6 @@ 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 BACKFILL_MAX_CHUNKS
class UnknownPlatformError(Exception): class UnknownPlatformError(Exception):
@@ -87,67 +86,6 @@ class ExtensionService:
"created_artist": created_artist, "created_artist": created_artist,
} }
async def probe(self, url: str) -> dict:
"""Read-only resolution of a creator-page URL against the FC DB.
Returns one of:
- {state: 'unknown_platform'} — URL didn't match any
platform's strict artist-page pattern
- {state: 'new', platform, slug} — would create both
artist and source on quick-add
- {state: 'artist_match', platform, slug, artist}
— artist exists, this
exact URL isn't a Source yet (collapses the sidecar-synthetic
case too — the synthetic anchor counts as an existing artist
row but not as a pollable Source for this URL)
- {state: 'source_match', platform, slug, artist, source}
— exact (artist, platform,
url) Source already exists
Side-effect-free: two SELECTs at most.
"""
try:
platform, raw_slug = self._derive(url)
except (UnknownPlatformError, InvalidUrlError):
return {"state": "unknown_platform"}
slug = slugify(raw_slug)
artist = (await self.session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
return {"state": "new", "platform": platform, "slug": slug}
artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug}
source = (await self.session.execute(
select(Source).where(
Source.artist_id == artist.id,
Source.platform == platform,
Source.url == url,
)
)).scalar_one_or_none()
if source is None:
return {
"state": "artist_match",
"platform": platform,
"slug": slug,
"artist": artist_payload,
}
return {
"state": "source_match",
"platform": platform,
"slug": slug,
"artist": artist_payload,
"source": {
"id": source.id,
"artist_id": source.artist_id,
"platform": source.platform,
"url": source.url,
"enabled": source.enabled,
},
}
def _derive(self, url: str) -> tuple[str, str]: def _derive(self, url: str) -> tuple[str, str]:
if not isinstance(url, str) or not url.strip(): if not isinstance(url, str) or not url.strip():
raise InvalidUrlError("url is empty") raise InvalidUrlError("url is empty")
@@ -205,17 +143,9 @@ class ExtensionService:
return existing, False return existing, False
sp = await self.session.begin_nested() sp = await self.session.begin_nested()
try: try:
# New subscription sources arm run-until-done backfill (plan #693)
# so the first ticks walk the full history (otherwise gallery-dl's
# 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,
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()
-55
View File
@@ -11,15 +11,9 @@ 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"
@@ -114,52 +108,3 @@ 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
+96 -229
View File
@@ -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, quarantine_file, validate_file from .file_validator import is_validatable, validate_file
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -39,90 +39,31 @@ 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
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
# mapping classifies this as "ok" because the next tick continues.
PARTIAL = "partial"
UNKNOWN_ERROR = "unknown_error" UNKNOWN_ERROR = "unknown_error"
# Tick mode (routine cron polls): skip ≤20 contiguous already-archived # 30 seconds shy of download_source's Celery soft_time_limit (900s, see
# items, then exit gallery-dl. Established subscription with zero new # tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before
# content exits in ~30s of HEAD requests instead of walking to the bottom # Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race,
# of the post history (which can be hours for prolific creators). 20 (not # SIGKILLs the worker, in-memory stdout/stderr is lost, and the
# 5) is operator-set headroom against any edge case where paywalled or # DownloadEvent ends up empty-logged with "stranded by recovery sweep"
# otherwise-non-downloadable items might interleave with archived ones — # 18 minutes later (operator-flagged 2026-05-31, Knuxy event #38275).
# 20 contiguous HEADs is still negligible. # The 30s buffer absorbs scheduler jitter / GC pauses without making
TICK_SKIP_VALUE = "exit:20" # legitimately-long-running syncs timeout-friendlier. Per-source bumps
# still live in source.config_overrides for legitimately long syncs.
# Backfill mode (operator-triggered deep scan): walk the full history,
# one TIME-BOXED CHUNK per run (plan #693). config_overrides["_backfill_state"]
# == "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).
#
# The chunk budget is deliberately FAR below download_source's Celery
# soft_time_limit (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py), not
# "just under" it. Hitting this budget is the NORMAL chunk boundary, not a
# failure: subprocess.run raises TimeoutExpired (which captures partial
# stdout/stderr + the last emitted cursor), and download_service reclassifies
# a chunk that made progress as PARTIAL (status "ok"), not an error. The huge
# headroom means a stuck file or a slow chunk can never let Celery's
# SoftTimeLimitExceeded preempt TimeoutExpired (the failure mode behind Knuxy
# #38275 / Anduo #39912/#40411). Earlier we ran one ~1170s run-to-the-wall
# per arming; that died as a timeout error every time on large catalogs.
BACKFILL_SKIP_VALUE = True
BACKFILL_CHUNK_SECONDS = 600
# Sits well below download_source's Celery soft_time_limit
# (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py). subprocess.run MUST
# raise TimeoutExpired before Celery raises SoftTimeLimitExceeded —
# otherwise Celery wins the race, SIGKILLs the worker, in-memory
# stdout/stderr is lost, and the DownloadEvent ends up empty-logged with
# "stranded by recovery sweep" (operator-flagged 2026-05-31, Knuxy event
# #38275; recurred in backfill mode as Anduo #39912). Per-source bumps
# 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
@dataclass @dataclass
class SourceConfig: class SourceConfig:
"""Per-source overrides loaded from Source.config_overrides JSON.
Note: the gallery-dl `skip` value (tick vs backfill, see TICK_SKIP_VALUE /
BACKFILL_SKIP_VALUE) is NOT carried here — it derives from the
Source.backfill_runs_remaining column at the download_service layer
and is passed to _build_config_for_source as `skip_value`. Same for
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
sleep_request: float | None = None sleep_request: float | None = None
directory_pattern: str | None = None directory_pattern: str | None = None
filename_pattern: str | None = None filename_pattern: str | None = None
skip_existing: bool = True
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:
@@ -132,6 +73,7 @@ class SourceConfig:
sleep_request=data.get("sleep_request"), sleep_request=data.get("sleep_request"),
directory_pattern=data.get("directory_pattern"), directory_pattern=data.get("directory_pattern"),
filename_pattern=data.get("filename_pattern"), filename_pattern=data.get("filename_pattern"),
skip_existing=data.get("skip_existing", True),
save_metadata=data.get("save_metadata", True), save_metadata=data.get("save_metadata", True),
timeout=data.get("timeout", _DEFAULT_GDL_TIMEOUT_SECONDS), timeout=data.get("timeout", _DEFAULT_GDL_TIMEOUT_SECONDS),
) )
@@ -155,51 +97,6 @@ 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:
@@ -216,40 +113,6 @@ 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."""
@@ -283,10 +146,16 @@ class GalleryDLService:
"permission denied", "tier required", "pledge required", "permission denied", "tier required", "pledge required",
] ]
# Per-platform defaults for the gallery-dl-backed platforms. Patreon was # Per-platform defaults. Lifted from GS — same six platforms FC supports.
# removed at the plan-#697 cutover — it now uses the native ingester
# (services/patreon_ingester.py), not gallery-dl.
PLATFORM_DEFAULTS = { PLATFORM_DEFAULTS = {
"patreon": {
"content_types": ["images", "image_large", "attachments", "postfile", "content"],
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
"filename": "{num:>02}_{filename}.{extension}",
"videos": True,
"embeds": True,
"cursor": True,
},
"subscribestar": { "subscribestar": {
"content_types": ["all"], "content_types": ["all"],
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"], "directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
@@ -345,12 +214,7 @@ 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),
# 2 (not 3) retries — a stuck CDN host shouldn't burn a whole "retries": 3,
# 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": [
@@ -365,17 +229,8 @@ class GalleryDLService:
"downloader": { "downloader": {
"part": True, "part": True,
"part-directory": str(self._config_dir / "temp"), "part-directory": str(self._config_dir / "temp"),
# See the extractor retries note above (Anduo #40838): 2 "retries": 3,
# retries + a 60s read-timeout (was 120) so a stalled "timeout": 120.0,
# connection fails fast. 60s is a per-read timeout, not a
# transfer cap — large GIFs that keep streaming are unaffected.
"retries": 2,
"timeout": 60.0,
# NOTE: the Patreon/Mux yt-dlp Referer/Origin forwarding lived
# here until the plan-#697 cutover. It was Patreon-specific (and
# would have wrongly tagged the other platforms' yt-dlp fetches);
# Patreon video is now handled by the native ingester's
# downloader (patreon_downloader._VIDEO_HEADERS), so it's gone.
}, },
"output": {"progress": True}, "output": {"progress": True},
} }
@@ -390,18 +245,7 @@ class GalleryDLService:
platform: str, platform: str,
source_config: SourceConfig, source_config: SourceConfig,
artist_slug: str, artist_slug: str,
skip_value: bool | str = BACKFILL_SKIP_VALUE,
) -> dict: ) -> dict:
"""`skip_value` controls gallery-dl's archive-walk behavior:
- True (BACKFILL_SKIP_VALUE): walk full post history, skipping
archived items but continuing past them. Used in backfill mode.
- "exit:20" (TICK_SKIP_VALUE): exit gallery-dl after 20
contiguous archived items. Used in tick (routine catch-up)
mode for fast no-op syncs on creators with deep history.
- False: don't skip — redownload everything (not used in FC).
The caller (download_service) chooses based on
Source.backfill_runs_remaining.
"""
config = json.loads(json.dumps(self._get_default_config())) # deep copy config = json.loads(json.dumps(self._get_default_config())) # deep copy
destination = str(self.images_root / artist_slug / platform) destination = str(self.images_root / artist_slug / platform)
@@ -411,7 +255,7 @@ class GalleryDLService:
config["extractor"]["sleep"] = source_config.sleep config["extractor"]["sleep"] = source_config.sleep
if source_config.sleep_request is not None: if source_config.sleep_request is not None:
config["extractor"]["sleep-request"] = source_config.sleep_request config["extractor"]["sleep-request"] = source_config.sleep_request
config["extractor"]["skip"] = skip_value config["extractor"]["skip"] = source_config.skip_existing
if source_config.save_metadata: if source_config.save_metadata:
config["extractor"]["postprocessors"] = [ config["extractor"]["postprocessors"] = [
@@ -427,7 +271,14 @@ class GalleryDLService:
platform_section = config["extractor"].setdefault(platform, {}) platform_section = config["extractor"].setdefault(platform, {})
if platform == "hentaifoundry": if platform == "patreon":
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"
@@ -543,22 +394,6 @@ class GalleryDLService:
f"Subscription tier does not grant access to {count} post{'s' if count != 1 else ''}", f"Subscription tier does not grant access to {count} post{'s' if count != 1 else ''}",
) )
# Partial-success: the subprocess exited non-zero (typically because
# the wall-clock timeout fired mid-walk), but it had downloaded ≥1
# file by then and no source-level error category fired. The work
# the run DID do is real; gallery-dl's archive will pick up where
# it left off on the next tick. Mapped to status="ok" downstream
# (download_service.py) so this doesn't flag the source as
# "needs attention." Operator-flagged 2026-06-01 after a Knuxy
# patreon run downloaded hundreds of files then ran red on timeout.
files_downloaded = self._count_downloaded_files(stdout)
if not has_actual_error and files_downloaded > 0:
return (
ErrorType.PARTIAL,
f"Downloaded {files_downloaded} file{'s' if files_downloaded != 1 else ''}; "
"run did not complete in budget — next tick will continue",
)
return ErrorType.UNKNOWN_ERROR, f"Unknown error (return code: {return_code})" return ErrorType.UNKNOWN_ERROR, f"Unknown error (return code: {return_code})"
def _count_downloaded_files(self, stdout: str) -> int: def _count_downloaded_files(self, stdout: str) -> int:
@@ -587,6 +422,10 @@ 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
@@ -597,14 +436,34 @@ class GalleryDLService:
continue continue
if result.ok: if result.ok:
continue continue
# Shared move+sidecar (file_validator.quarantine_file) — same impl the try:
# native ingester uses, so the layout + provenance sidecar can't drift. quarantine_root.mkdir(parents=True, exist_ok=True)
dest = quarantine_file( dest = quarantine_root / path.name
self.images_root, path, artist_slug, platform, counter = 1
url=url, result=result, while dest.exists():
) dest = quarantine_root / f"{path.stem}.{counter}{path.suffix}"
if dest is None: counter += 1
continue # move failed → left in place, not counted path.rename(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)
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,
@@ -642,24 +501,41 @@ 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 make_run_stats( return {
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:
# Thin delegator to the module-level helper (kept for existing callers). if not stderr:
return extract_errors_warnings(stderr) return ""
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:
# Thin delegator to the module-level helper (kept for existing callers). if not text:
return truncate_log(text, max_bytes) 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
async def download( async def download(
self, self,
@@ -669,7 +545,6 @@ class GalleryDLService:
source_config: SourceConfig | None = None, source_config: SourceConfig | None = None,
cookies_path: str | None = None, cookies_path: str | None = None,
auth_token: str | None = None, auth_token: str | None = None,
skip_value: bool | str = BACKFILL_SKIP_VALUE,
) -> DownloadResult: ) -> DownloadResult:
start_time = time.time() start_time = time.time()
started_at = datetime.now(UTC).isoformat() started_at = datetime.now(UTC).isoformat()
@@ -677,9 +552,7 @@ class GalleryDLService:
if source_config is None: if source_config is None:
source_config = SourceConfig() source_config = SourceConfig()
config = self._build_config_for_source( config = self._build_config_for_source(platform, source_config, artist_slug)
platform, source_config, artist_slug, skip_value=skip_value,
)
if cookies_path: if cookies_path:
config["extractor"]["cookies"] = cookies_path config["extractor"]["cookies"] = cookies_path
@@ -896,13 +769,7 @@ class GalleryDLService:
), ),
) )
etype, msg = self._categorize_error(proc.returncode, proc.stdout, proc.stderr) etype, msg = self._categorize_error(proc.returncode, proc.stdout, proc.stderr)
# TIER_LIMITED proves auth worked — gallery-dl reached the if proc.returncode == 0 or etype == ErrorType.NO_NEW_CONTENT:
# post, was told it's tier-gated. The download path treats
# this as success (line 712); verify must too, or operators
# rotate working cookies for no reason. Audit 2026-06-02.
if proc.returncode == 0 or etype in (
ErrorType.NO_NEW_CONTENT, ErrorType.TIER_LIMITED,
):
return True, "Credentials valid — the feed authenticated." return True, "Credentials valid — the feed authenticated."
if etype == ErrorType.AUTH_ERROR: if etype == ErrorType.AUTH_ERROR:
return False, msg return False, msg
+82 -347
View File
@@ -18,22 +18,14 @@ import base64
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from sqlalchemy import Select, and_, distinct, exists, func, or_, select from sqlalchemy import Select, and_, exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
from ..models.tag import image_tag from ..models.tag import image_tag
CURSOR_SEPARATOR = "|" 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
# bucket is selectable. Underscore-wrapped so it can't collide with a real
# gallery-dl platform name (patreon/pixiv/...).
UNSOURCED_PLATFORM = "__unsourced__"
def encode_cursor(effective_date: datetime, image_id: int) -> str: def encode_cursor(effective_date: datetime, image_id: int) -> str:
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}" raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
@@ -50,17 +42,16 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
def _effective_date_col(): def _effective_date_col():
"""The materialized gallery sort key: image_record.effective_date """SQL expression: COALESCE(post.post_date, image_record.created_at).
(alembic 0035) = COALESCE(primary post's post_date, created_at),
maintained at write time by the importer.
Canonical sort/group/filter key across the gallery so images attached Used as the canonical sort/group/filter key across the gallery so
to a post surface at their original publish date, not their FC import images backfilled with primary_post_id (e.g. via tag_apply phase 4)
date — and, now that it's a single indexed column rather than a surface at their original publish date, not their FC import date.
COALESCE across the Post outer join, the cursor scroll is an index Images without a Post (or with Post.post_date NULL) fall back to
range scan instead of a full re-sort per page. image_record.created_at and still order coherently against
post-attached ones.
""" """
return ImageRecord.effective_date return func.coalesce(Post.post_date, ImageRecord.created_at)
def _outer_join_primary_post(stmt: Select) -> Select: def _outer_join_primary_post(stmt: Select) -> Select:
@@ -99,16 +90,6 @@ 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.
@@ -135,85 +116,13 @@ 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_ids, post_id, artist_id) -> None: def _require_single_filter(tag_id, post_id, artist_id) -> None:
"""post_id is the post-detail view — it can't combine with the if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
composable filters. tag_ids + artist_id (+ media_type) compose freely
(AND)."""
if post_id is not None and (tag_ids or artist_id is not None):
raise ValueError( raise ValueError(
"post_id cannot be combined with tag or artist filters" "tag_id, post_id, artist_id are mutually exclusive"
) )
def _apply_scope(
stmt, *, tag_ids, post_id, artist_id, media_type,
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_ids: image must carry ALL of them — one correlated EXISTS per tag.
- 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.
"""
for tid in tag_ids or []:
stmt = stmt.where(
exists().where(
image_tag.c.image_record_id == ImageRecord.id,
image_tag.c.tag_id == tid,
)
)
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
@@ -224,48 +133,14 @@ def _provenance_clause(post_id, artist_id):
ImageProvenance.post_id == post_id, ImageProvenance.post_id == post_id,
) )
if artist_id is not None: if artist_id is not None:
# Use Post.artist_id (alembic 0030 denormalized column) instead
# of joining through ImageProvenance.source_id → Source.artist_id.
# The denormalization is the always-present linkage; the source
# path now drops NULL-source provenance rows (filesystem-imported
# content) which would otherwise vanish from artist-filtered
# gallery views.
# ALIAS Post: the gallery query outer-joins Post on
# ImageRecord.primary_post_id (`_outer_join_primary_post`).
# SQLAlchemy would otherwise correlate a bare `Post` reference
# in this EXISTS subquery to that outer Post (which is NULL for
# images with no primary post), and the filter would silently
# match nothing.
post_inner = aliased(Post)
return exists().where( return exists().where(
ImageProvenance.image_record_id == ImageRecord.id, ImageProvenance.image_record_id == ImageRecord.id,
ImageProvenance.post_id == post_inner.id, ImageProvenance.source_id == Source.id,
post_inner.artist_id == artist_id, Source.artist_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."""
@@ -290,50 +165,35 @@ class GalleryService:
self, self,
cursor: str | None, cursor: str | None,
limit: int = 50, limit: int = 50,
tag_ids: list[int] | None = None, tag_id: 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",
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_ids, post_id, artist_id) _require_single_filter(tag_id, post_id, artist_id)
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)
stmt = _apply_scope( if tag_id is not None:
stmt, tag_ids=tag_ids, post_id=post_id, stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
artist_id=artist_id, media_type=media_type, image_tag.c.tag_id == tag_id
platform=platform, untagged=untagged, no_artist=no_artist, )
date_from=date_from, date_to=date_to, prov = _provenance_clause(post_id, artist_id)
) if prov is not None:
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)
# The cursor is just (last eff, last id); the request's sort stmt = stmt.where(
# decides which side of it the next page lies on. or_(
if descending: eff < cur_ts,
stmt = stmt.where( and_(eff == cur_ts, ImageRecord.id < cur_id),
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))
) )
)
if descending: stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1)
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
@@ -345,7 +205,22 @@ 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 = _gallery_images(rows, artists) images = [
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,
@@ -354,15 +229,9 @@ class GalleryService:
async def timeline( async def timeline(
self, self,
tag_ids: list[int] | None = None, tag_id: 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,
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")
@@ -371,28 +240,25 @@ 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_ids, post_id, artist_id) _require_single_filter(tag_id, post_id, artist_id)
stmt = _apply_scope( if tag_id is not None:
stmt, tag_ids=tag_ids, post_id=post_id, stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
artist_id=artist_id, media_type=media_type, image_tag.c.tag_id == tag_id
platform=platform, untagged=untagged, no_artist=no_artist, )
date_from=date_from, date_to=date_to, prov = _provenance_clause(post_id, artist_id)
) if prov is not None:
stmt = stmt.where(prov)
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_ids: list[int] | None = None, self, year: int, month: int, tag_id: 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",
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() with the same sort, """Returns a cursor that, when passed to scroll(), positions at the
positions at the first image of the given year-month. None if the first image of the given year-month (by effective_date, not
bucket is empty. created_at). None if the bucket is empty.
""" """
from sqlalchemy import extract from sqlalchemy import extract
@@ -402,157 +268,22 @@ 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_ids, post_id, artist_id) _require_single_filter(tag_id, post_id, artist_id)
stmt = _apply_scope( if tag_id is not None:
stmt, tag_ids=tag_ids, post_id=post_id, stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
artist_id=artist_id, media_type=media_type, image_tag.c.tag_id == tag_id
platform=platform, untagged=untagged, no_artist=no_artist, )
date_from=date_from, date_to=date_to, prov = _provenance_clause(post_id, artist_id)
) if prov is not None:
descending = sort != "oldest" stmt = stmt.where(prov)
if descending: stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()) first = (await self.session.execute(stmt)).first()
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; nudge the id one past the boundary row (in the # Cursor is exclusive; we encode a cursor with id+1 so the row itself
# scan direction) so the row itself is the first result of scroll(). # is the first result in the next scroll().
boundary = record.id + 1 if descending else record.id - 1 return encode_cursor(eff_date, 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, 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)
common = {
"tag_ids": tag_ids, "post_id": post_id,
"artist_id": artist_id, "media_type": media_type,
}
# 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, 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,
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)
@@ -591,9 +322,6 @@ 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),
@@ -615,10 +343,17 @@ class GalleryService:
} }
async def _neighbors(self, record: ImageRecord) -> dict: async def _neighbors(self, record: ImageRecord) -> dict:
# The boundary image's sort key is materialized on the row now # Compute the boundary image's effective_date in Python (one query
# (alembic 0035) — read it directly instead of re-deriving COALESCE # below + the SELECT we already have on `record`) and use it for
# via an extra Post lookup. # the neighbor comparison. Cheaper than re-deriving in SQL via
boundary_eff = record.effective_date # correlated subquery.
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(
+110 -178
View File
@@ -31,12 +31,7 @@ from ..models import (
Source, Source,
) )
from ..utils import safe_probe from ..utils import safe_probe
from ..utils.paths import ( from ..utils.paths import derive_subdir, derive_top_level_artist, hash_suffixed_name
derive_subdir,
derive_top_level_artist,
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
@@ -87,9 +82,27 @@ 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)). Thin wrapper over the shared `utils.paths.safe_ext` (kept for (varchar(32)).
the Path-typed call sites + the [[path_suffix_sanitize]] memory pointer)."""
return safe_ext(path) gallery-dl produces some filenames with URL-encoded query-string
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:
@@ -199,10 +212,10 @@ class Importer:
which would lose the surrounding scan's progress — and re-run `stmt` which would lose the surrounding scan's progress — and re-run `stmt`
(scalar_one) to return the row the other worker created. (scalar_one) to return the row the other worker created.
Centralizes the pattern shared by _find_or_create_source and Centralizes the pattern shared by _find_or_create_source,
_find_or_create_post. The plain SELECT-then-INSERT version lost _source_for_sidecar, and _find_or_create_post. The plain
races under the 5-min recovery sweep (operator-flagged SELECT-then-INSERT version lost races under the 5-min recovery sweep
2026-05-26).""" (operator-flagged 2026-05-26)."""
existing = self.session.execute(stmt).scalar_one_or_none() existing = self.session.execute(stmt).scalar_one_or_none()
if existing is not None: if existing is not None:
return existing return existing
@@ -245,25 +258,47 @@ class Importer:
lambda: Source(artist_id=artist_id, platform=platform, url=url), lambda: Source(artist_id=artist_id, platform=platform, url=url),
) )
def _lookup_source_for_sidecar( def _source_for_sidecar(
self, *, artist_id: int, platform: str, self, *, artist_id: int, platform: str, artist_slug: str,
) -> Source | None: ) -> Source:
"""Find the real subscription Source for (artist, platform), or """Sidecar-import Source resolver. Used by both filesystem imports
None if no subscription exists. and gallery-dl downloads (both write sidecar JSON, both flow through
_apply_sidecar / _capture_attachment).
Pre-alembic-0030 this method would CREATE a synthetic Source represents a subscription feed (one per artist+platform — the
`sidecar:<platform>:<slug>` Source when no real one existed URL polled by the FC-3 downloader). The filesystem importer used to
because `Post.source_id` was NOT NULL and the importer needed call _find_or_create_source(url=sd.post_url), creating one Source
something to attach Posts to. Alembic 0030 relaxed both row per post URL — 100s of junk Sources per artist, all with
`Post.source_id` and `ImageProvenance.source_id` to nullable, so enabled=True, polluting the artist detail page and tricking the
synthetic anchors are obsolete; the importer now leaves subscription checker into trying to poll patreon post URLs as feeds.
source_id as None when no subscription exists for the (artist, Operator-flagged 2026-05-26; consolidated via alembic 0022.
platform). Operator-asked 2026-06-01: synthetic Sources had
leaked into the Subscriptions UI as phantom subscriptions and Resolution order: prefer a real (non-sidecar) Source over a
the operator wanted the data model to truthfully say "this synthetic anchor. When alembic 0022 ran, it may have rewritten
content has no live subscription." per-post Sources into `sidecar:<platform>:<slug>` synthetic
anchors. If the operator later added the real subscription, both
rows now coexist. A naive `ORDER BY id ASC LIMIT 1` lookup would
pick the older synthetic and silently attach every gallery-dl
download to the wrong Source — operator-flagged 2026-05-31 after
the Subscriptions UI surfaced the phantom anchors. Pick the real
one when one exists; fall back to the synthetic; only create a
new synthetic when nothing exists for (artist, platform).
""" """
stmt = ( real_stmt = (
select(Source)
.where(
Source.artist_id == artist_id,
Source.platform == platform,
~Source.url.like("sidecar:%"),
)
.order_by(Source.id.asc())
.limit(1)
)
real = self.session.execute(real_stmt).scalar_one_or_none()
if real is not None:
return real
any_stmt = (
select(Source) select(Source)
.where( .where(
Source.artist_id == artist_id, Source.artist_id == artist_id,
@@ -272,37 +307,29 @@ class Importer:
.order_by(Source.id.asc()) .order_by(Source.id.asc())
.limit(1) .limit(1)
) )
return self.session.execute(stmt).scalar_one_or_none() return self._get_or_create(
any_stmt,
lambda: Source(
artist_id=artist_id,
platform=platform,
url=f"sidecar:{platform}:{artist_slug}",
enabled=False,
),
)
def _find_or_create_post( def _find_or_create_post(
self, *, source_id: int | None, external_post_id: str, self, *, source_id: int, external_post_id: str,
artist_id: int,
) -> Post: ) -> Post:
"""Race-safe find-or-create on `post`. Keyed by """Race-safe find-or-create on `post` keyed by
(source_id, external_post_id) when source_id is set — the (source_id, external_post_id). Mirrors `_find_or_create_source`
`uq_post_source_external_id` constraint guards. For NULL-source — same savepoint + IntegrityError-recovery pattern."""
posts the existence check matches on (artist_id, external_post_id), stmt = select(Post).where(
which the partial unique index `uq_post_artist_external_id_null_source` Post.source_id == source_id,
(alembic 0030) guards. Same savepoint + IntegrityError-recovery Post.external_post_id == external_post_id,
pattern as the rest of the helpers.""" )
if source_id is not None:
stmt = select(Post).where(
Post.source_id == source_id,
Post.external_post_id == external_post_id,
)
else:
stmt = select(Post).where(
Post.source_id.is_(None),
Post.artist_id == artist_id,
Post.external_post_id == external_post_id,
)
return self._get_or_create( return self._get_or_create(
stmt, stmt,
lambda: Post( lambda: Post(source_id=source_id, external_post_id=external_post_id),
source_id=source_id,
artist_id=artist_id,
external_post_id=external_post_id,
),
) )
def import_one(self, source: Path) -> ImportResult: def import_one(self, source: Path) -> ImportResult:
@@ -343,14 +370,12 @@ class Importer:
return None return None
sd = parse_sidecar(data) sd = parse_sidecar(data)
platform = sd.platform or "unknown" platform = sd.platform or "unknown"
src = self._lookup_source_for_sidecar( src = self._source_for_sidecar(
artist_id=artist.id, platform=platform, artist_id=artist.id, platform=platform, artist_slug=artist.slug,
) )
epid = sd.external_post_id or sc.stem epid = sd.external_post_id or sc.stem
return self._find_or_create_post( return self._find_or_create_post(
source_id=src.id if src else None, source_id=src.id, external_post_id=epid,
external_post_id=epid,
artist_id=artist.id,
) )
def _capture_attachment( def _capture_attachment(
@@ -361,19 +386,10 @@ 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) existing = self.session.execute(
existing = self.session.execute(select_existing).scalar_one_or_none() select(PostAttachment).where(PostAttachment.sha256 == sha)
if existing is not None: ).scalar_one_or_none()
self.session.commit() if existing is None:
return ImportResult(status="attached")
# Savepoint + IntegrityError recovery — PostAttachment.sha256 is
# UNIQUE, so two workers can both pass the SELECT and only the
# second INSERT fails. Without savepoint, the outer transaction
# poisons and the calling task crashes. attachments.store is
# sha-addressed so both workers race to write the same target
# path; shutil.copy2 + rename is idempotent. Audit 2026-06-02.
sp = self.session.begin_nested()
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 if post else None,
@@ -386,19 +402,10 @@ class Importer:
size_bytes=source.stat().st_size, size_bytes=source.stat().st_size,
)) ))
self.session.flush() self.session.flush()
sp.commit()
except IntegrityError:
sp.rollback()
# Lost the race — the other worker's row is canonical.
self.session.execute(select_existing).scalar_one()
self.session.commit() self.session.commit()
return ImportResult(status="attached") return ImportResult(status="attached")
def _import_archive( def _import_archive(self, source: Path) -> ImportResult:
self, source: Path, *,
artist: Artist | None = None,
source_row: Source | None = None,
) -> ImportResult:
# Layer-3 isolation: bomb-size guard + integrity test in a # Layer-3 isolation: bomb-size guard + integrity test in a
# spawned child BEFORE extracting in this process. A # spawned child BEFORE extracting in this process. A
# decompression bomb or a native-lib crash on a malformed # decompression bomb or a native-lib crash on a malformed
@@ -406,14 +413,6 @@ class Importer:
# instead of OOMing/segfaulting the import worker. extract_archive # instead of OOMing/segfaulting the import worker. extract_archive
# is already fail-soft for plain exceptions, so this only adds # is already fail-soft for plain exceptions, so this only adds
# the hard-crash protection. # the hard-crash protection.
#
# Audit 2026-06-02: optional artist/source_row kwargs let the
# download path thread its explicit subscription context
# through instead of having _resolve_artist re-derive from
# path-walk (which works by coincidence today because gallery-dl
# lays files out under /images/<artist_slug>/...). Filesystem
# import still calls bare _import_archive(source) and falls
# back to the path-walk derivation as before.
probe = safe_probe.probe_archive(source) probe = safe_probe.probe_archive(source)
if not probe.ok: if not probe.ok:
if probe.crashed: if probe.crashed:
@@ -425,55 +424,34 @@ class Importer:
# still preserve the archive file itself as an attachment so # still preserve the archive file itself as an attachment so
# nothing silently vanishes, matching extract_archive's # nothing silently vanishes, matching extract_archive's
# fail-soft contract. # fail-soft contract.
artist_use = artist if artist is not None else self._resolve_artist(source) artist = self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use) post = self._post_for_sidecar(source, artist)
self._capture_attachment( self._capture_attachment(source, post=post, artist=artist, 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 = self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use) post = self._post_for_sidecar(source, artist)
member_ids: list[int] = [] member_ids: list[int] = []
member_total = 0
with extract_archive(source) as members: with extract_archive(source) as members:
for _name, member_path in members: for _name, member_path in members:
member_total += 1
if not is_supported(member_path): if not is_supported(member_path):
continue # non-media preserved via the stored archive continue # non-media preserved via the stored archive
res = self._import_media( res = self._import_media(member_path, source)
member_path, source, explicit_source=source_row,
)
if res.status in ("imported", "superseded") and res.image_id: if res.status in ("imported", "superseded") and res.image_id:
member_ids.append(res.image_id) member_ids.append(res.image_id)
# 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, resolved=True
) )
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,
) )
# No images landed — surface WHY so a post showing "no images" beside an return ImportResult(status="attached")
# archive is diagnosable instead of silent. Zero members usually means
# the extractor backend is missing/failed (unar for rar, py7zr for 7z)
# or the file is corrupt; non-zero-but-no-images means it held only
# non-media files.
reason = (
"archive extracted but held no supported image/video members"
if member_total
else "archive yielded no members (unsupported/corrupt, or the "
"extractor backend failed)"
)
log.warning("%s: %s", source.name, reason)
return ImportResult(status="attached", error=reason)
def _import_media( def _import_media(
self, source: Path, attribution_path: Path, self, source: Path, attribution_path: Path
*, explicit_source: Source | None = None,
) -> ImportResult: ) -> ImportResult:
"""The media import pipeline (filters, dedup, copy, provenance). """The media import pipeline (filters, dedup, copy, provenance).
@@ -620,15 +598,7 @@ class Importer:
artist = self._attach_artist(record, 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 self._apply_sidecar(record, attribution_path, artist)
# ImageProvenance row to its subscription Source instead of
# having _apply_sidecar re-derive via _lookup_source_for_sidecar.
# Audit 2026-06-02 — archive members extracted from a
# subscription-downloaded zip previously lost subscription
# linkage if the on-disk layout didn't match assumptions.
self._apply_sidecar(
record, attribution_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
# does not generate thumbnails inline so the import queue stays moving. # does not generate thumbnails inline so the import queue stays moving.
@@ -692,36 +662,16 @@ class Importer:
them through. The sidecar JSON gallery-dl emits next to each them through. The sidecar JSON gallery-dl emits next to each
downloaded file is read by `_apply_sidecar` via `find_sidecar`. downloaded file is read by `_apply_sidecar` via `find_sidecar`.
File-type dispatch parity with `import_one` (FC-2d-iii): zips,
PDFs, audio etc. become PostAttachments; archives are extracted.
Without this dispatch, gallery-dl-downloaded non-media bounced
back as `skipped+invalid_image`, which DownloadService counted
as an ingest error and flipped otherwise-successful runs to
status="error". Operator-flagged 2026-06-02 after a Lustria
patreon run with a 94MB OST zip went red despite 21 successful
image attaches.
Caller's responsibilities after this returns: Caller's responsibilities after this returns:
- duplicate_hash / duplicate_phash skip → delete the on-disk file - duplicate_hash / duplicate_phash skip → delete the on-disk file
- superseded → file stays where it is (now canonical) - superseded → file stays where it is (now canonical)
- imported → file stays where it is - imported → file stays where it is
- attached → the file's been copied into the attachments store;
caller may delete the on-disk original (mirrors duplicate_hash)
- failed → file untouched; caller decides - failed → file untouched; caller decides
""" """
if path.suffix.lower() == ".json": if not is_supported(path):
return ImportResult( return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image, status="skipped", skip_reason=SkipReason.invalid_image,
error="sidecar json is metadata, not content", error=f"unsupported extension {path.suffix}",
)
if is_archive(path):
return self._import_archive(
path, artist=artist, source_row=source,
)
if not is_supported(path):
post = self._post_for_sidecar(path, artist) if artist else None
return self._capture_attachment(
path, post=post, artist=artist, resolved=True,
) )
# Format / dimension / transparency filters (mirror _import_media). # Format / dimension / transparency filters (mirror _import_media).
@@ -793,8 +743,7 @@ class Importer:
if rel == "smaller_exists": if rel == "smaller_exists":
target = self.session.get(ImageRecord, match_id) target = self.session.get(ImageRecord, match_id)
self._supersede( self._supersede(
target, path, sha, phash, width, height, target, path, sha, phash, width, height, new_path=path
new_path=path, artist=artist, source_row=source,
) )
return ImportResult(status="superseded", image_id=match_id) return ImportResult(status="superseded", image_id=match_id)
@@ -909,15 +858,14 @@ class Importer:
src = explicit_source src = explicit_source
else: else:
platform = sd.platform or "unknown" platform = sd.platform or "unknown"
src = self._lookup_source_for_sidecar( src = self._source_for_sidecar(
artist_id=artist.id, platform=platform, artist_id=artist.id, platform=platform,
artist_slug=artist.slug,
) )
epid = sd.external_post_id or sc.stem epid = sd.external_post_id or sc.stem
post = self._find_or_create_post( post = self._find_or_create_post(
source_id=src.id if src else None, source_id=src.id, external_post_id=epid,
external_post_id=epid,
artist_id=artist.id,
) )
if sd.post_url is not None: if sd.post_url is not None:
post.post_url = sd.post_url post.post_url = sd.post_url
@@ -953,7 +901,7 @@ class Importer:
ImageProvenance( ImageProvenance(
image_record_id=record.id, image_record_id=record.id,
post_id=post.id, post_id=post.id,
source_id=src.id if src else None, source_id=src.id,
captured_metadata=sd.raw, captured_metadata=sd.raw,
) )
) )
@@ -963,14 +911,6 @@ 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(
@@ -997,8 +937,6 @@ class Importer:
self, existing: ImageRecord, source: Path, sha: str, self, existing: ImageRecord, source: Path, sha: str,
phash: str, width: int | None, height: int | None, phash: str, width: int | None, height: int | None,
*, new_path: Path | None = None, *, new_path: Path | None = None,
artist: Artist | None = None,
source_row: Source | 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
@@ -1050,14 +988,8 @@ class Importer:
# _apply_sidecar resolves artist from the sidecar itself if the # _apply_sidecar resolves artist from the sidecar itself if the
# existing row has none, and is internally guarded against # existing row has none, and is internally guarded against
# missing-or-malformed sidecars (silent return). # missing-or-malformed sidecars (silent return).
# Audit 2026-06-02: thread artist/source_row from the
# download-path caller (attach_in_place smaller_exists branch)
# so the supersede preserves explicit subscription linkage
# instead of re-deriving via path-walk.
try: try:
self._apply_sidecar( self._apply_sidecar(existing, source, None)
existing, source, artist, explicit_source=source_row,
)
except Exception as exc: except Exception as exc:
# Don't unwind the supersede DB swap if sidecar parsing # Don't unwind the supersede DB swap if sidecar parsing
# blows up unexpectedly — the file replacement is the # blows up unexpectedly — the file replacement is the
-647
View File
@@ -1,647 +0,0 @@
"""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
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
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],
):
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
# -- 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". Recovery bypasses the tier-1
seen-ledger AND the dead-letter ledger (tier-2 disk still skips kept
files). 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"
# Only deep walks checkpoint their cursor mid-flight (plan #705 #6); a
# tick has no resumable backfill state.
checkpoint = mode in ("backfill", "recovery")
ledger_key = self._ledger_key
start = time.monotonic()
last_live = start # plan #709: last live-progress write timestamp
log_lines: list[str] = []
written: list[str] = []
quarantined_paths: list[str] = []
downloaded = 0
errors = 0
quarantined = 0
dead_lettered = 0
skipped_count = 0
posts_processed = 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,
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,
),
)
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
# 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
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,
)
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
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:
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)")
log_lines.append(
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)"
+ (", reached end" if reached_bottom else "")
+ (", time-boxed" if budget_hit else "")
)
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",
)
# 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
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
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 typed failed DownloadResult. The base
gives a safe default; adapters override with their exception taxonomy."""
log.warning("%s ingest failed: %s", self._platform, exc)
return _result(
success=False, return_code=1,
error_type=ErrorType.UNKNOWN_ERROR, error_message=str(exc),
)
# -- 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()
+4 -20
View File
@@ -19,6 +19,7 @@ from ...models import (
TagReferenceEmbedding, TagReferenceEmbedding,
) )
from ...models.tag import image_tag from ...models.tag import image_tag
from .embedder import MODEL_VERSION as SIGLIP_VERSION
ELIGIBLE_KINDS = { ELIGIBLE_KINDS = {
TagKind.character, TagKind.character,
@@ -45,21 +46,6 @@ class CentroidService:
) )
).scalar_one() ).scalar_one()
async def _model_version(self) -> str:
"""Audit 2026-06-02: SigLIP model-version stamp comes from the
DB row, not the env constant. tag_and_embed (tasks/ml.py:110)
already reads from MLSettings.embedder_model_version, so by
sourcing centroid stamps + drift checks from the same row, we
eliminate the silent-drift case the audit flagged. env
SIGLIP_MODEL_VERSION still drives which model embedder.py
loads at runtime; the version stamp is purely the operator-
controlled identifier."""
return (
await self.session.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
)
).scalar_one()
async def recompute_for_tag(self, tag_id: int) -> bool: async def recompute_for_tag(self, tag_id: int) -> bool:
"""Recompute one tag's centroid. Returns True if a centroid was """Recompute one tag's centroid. Returns True if a centroid was
written, False if skipped (ineligible kind or too few members).""" written, False if skipped (ineligible kind or too few members)."""
@@ -83,20 +69,19 @@ class CentroidService:
return False return False
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32) centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
model_version = await self._model_version()
stmt = insert(TagReferenceEmbedding).values( stmt = insert(TagReferenceEmbedding).values(
tag_id=tag_id, tag_id=tag_id,
embedding=centroid.tolist(), embedding=centroid.tolist(),
reference_count=len(embeddings), reference_count=len(embeddings),
model_version=model_version, model_version=SIGLIP_VERSION,
) )
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=["tag_id"], index_elements=["tag_id"],
set_={ set_={
"embedding": centroid.tolist(), "embedding": centroid.tolist(),
"reference_count": len(embeddings), "reference_count": len(embeddings),
"model_version": model_version, "model_version": SIGLIP_VERSION,
"updated_at": func.now(), "updated_at": func.now(),
}, },
) )
@@ -107,7 +92,6 @@ class CentroidService:
"""Tag ids whose centroid is stale: member count != reference_count, """Tag ids whose centroid is stale: member count != reference_count,
OR no centroid row, OR centroid built on a different SigLIP version. OR no centroid row, OR centroid built on a different SigLIP version.
Only considers eligible-kind tags with embeddings present.""" Only considers eligible-kind tags with embeddings present."""
current_model_version = await self._model_version()
member_counts = ( member_counts = (
select( select(
image_tag.c.tag_id.label("tag_id"), image_tag.c.tag_id.label("tag_id"),
@@ -132,7 +116,7 @@ class CentroidService:
TagReferenceEmbedding.reference_count TagReferenceEmbedding.reference_count
!= member_counts.c.members != member_counts.c.members
) )
| (TagReferenceEmbedding.model_version != current_model_version) | (TagReferenceEmbedding.model_version != SIGLIP_VERSION)
) )
) )
return list((await self.session.execute(stmt)).scalars().all()) return list((await self.session.execute(stmt)).scalars().all())
+12 -30
View File
@@ -4,7 +4,7 @@ threshold-filtered, category-grouped, ranked suggestions for one image.
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 (
@@ -16,7 +16,6 @@ from ...models import (
from ...models.tag import image_tag from ...models.tag import image_tag
from .aliases import AliasService from .aliases import AliasService
from .centroids import CentroidService from .centroids import CentroidService
from .tag_name import normalize as normalize_tag_name
from .tagger import SURFACED_CATEGORIES from .tagger import SURFACED_CATEGORIES
@@ -49,11 +48,11 @@ class SuggestionService:
).scalar_one() ).scalar_one()
def _threshold_for(self, s: MLSettings, category: str) -> float: def _threshold_for(self, s: MLSettings, category: str) -> float:
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired; # 'artist' intentionally absent (FC-2d-vii-c) — falls through to
# both fall through to the 1.01 "never surfaces" default like any # the 1.01 "never surfaces" default like any unsurfaced category.
# unsurfaced category.
return { return {
"character": s.suggestion_threshold_character, "character": s.suggestion_threshold_character,
"copyright": s.suggestion_threshold_copyright,
"general": s.suggestion_threshold_general, "general": s.suggestion_threshold_general,
}.get(category, 1.01) }.get(category, 1.01)
@@ -85,12 +84,7 @@ class SuggestionService:
) )
# --- Camie predictions --- # --- Camie predictions ---
# candidates carry (raw_name, display_name, category, confidence). candidates: list[tuple[str, str, float]] = []
# 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(): for name, p in predictions.items():
category = p.get("category", "general") category = p.get("category", "general")
if category not in SURFACED_CATEGORIES: if category not in SURFACED_CATEGORIES:
@@ -98,14 +92,10 @@ class SuggestionService:
conf = float(p.get("confidence", 0.0)) conf = float(p.get("confidence", 0.0))
if conf < self._threshold_for(settings, category): if conf < self._threshold_for(settings, category):
continue continue
display = normalize_tag_name(name) candidates.append((name, category, conf))
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( alias_map = await self.aliases.resolve_many(
[(raw, c) for raw, _disp, c, _conf in candidates] [(n, c) for n, c, _ in candidates]
) )
merged: dict[object, Suggestion] = {} merged: dict[object, Suggestion] = {}
@@ -126,8 +116,8 @@ class SuggestionService:
creates_new_tag=existing.creates_new_tag, creates_new_tag=existing.creates_new_tag,
) )
for raw, display, category, conf in candidates: for name, category, conf in candidates:
canonical = alias_map.get((raw, category)) canonical = alias_map.get((name, category))
if canonical is not None: if canonical is not None:
if canonical.id in applied or canonical.id in rejected: if canonical.id in applied or canonical.id in rejected:
continue continue
@@ -143,17 +133,9 @@ class SuggestionService:
), ),
) )
else: 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 = ( existing_tag = (
await self.session.execute( await self.session.execute(
select(Tag).where( select(Tag).where(Tag.name == name)
func.lower(Tag.name).in_(
[raw.lower(), display.lower()]
)
)
) )
).scalars().first() ).scalars().first()
if existing_tag is not None: if existing_tag is not None:
@@ -175,10 +157,10 @@ class SuggestionService:
) )
else: else:
_merge( _merge(
f"raw:{display}:{category}", f"raw:{name}:{category}",
Suggestion( Suggestion(
canonical_tag_id=None, canonical_tag_id=None,
display_name=display, display_name=name,
category=category, category=category,
score=conf, score=conf,
source="tagger", source="tagger",
-62
View File
@@ -1,62 +0,0 @@
"""Camie vocabulary -> human-readable tag-name normalization.
Camie v2's ~57k tag vocabulary is booru-derived and arrives as raw
strings like `uchiha_sasuke_(naruto)`, `#unicus_(idolmaster)`,
`1000-nen_ikiteru_(vocaloid)`, or `:/`. We want the operator to see
"Uchiha Sasuke", "Unicus", "1000-Nen Ikiteru", or to never see the
emoticon at all — and we want the same clean string to be what lands
in `tag.name` when the suggestion is accepted, so Accept matches the
existing-tag convention (`tag_service.find_or_create`).
Rules (operator-approved 2026-06-03):
1. Strip leading junk chars (#, ., +, ;, ~, _, whitespace)
2. Drop trailing `_(disambiguator)` block(s), iteratively
3. Strip wrapping single/double quotes (after disambig removal so
`"foo_em_up"_(series)` -> `"foo_em_up"` -> `foo_em_up`)
4. Replace remaining `_` with space; collapse runs of whitespace
5. Add a space after any `:` (namespace:tag -> namespace: tag)
6. Preserve hyphens (booru hyphens often carry meaning)
7. Title-case each space-separated word (first character only —
apostrophes, digits, hyphens stay)
8. If no letters AND no digits remain, return None (drops emoticons
like `:/` or `^_^`; preserves bare digit tags like `2005`)
9. No surname/givenname swap — no reliable signal in the vocab
"""
import re
_LEADING_JUNK = re.compile(r"^[#.+;~_\s]+")
_TRAILING_DISAMBIG = re.compile(r"_\([^)]*\)\s*$")
_MULTISPACE = re.compile(r"\s+")
_COLON_NOSPACE = re.compile(r":(?=\S)")
_HAS_ALPHANUMERIC = re.compile(r"[A-Za-z0-9]")
def _strip_wrapping_quotes(s: str) -> str:
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
return s[1:-1]
return s
def _title_word(w: str) -> str:
return w[:1].upper() + w[1:] if w else w
def normalize(raw: str) -> str | None:
"""Return the human-readable form of a raw Camie tag, or None if the
string is junk (emoticon, empty after stripping)."""
if not raw:
return None
s = _LEADING_JUNK.sub("", raw)
while True:
new = _TRAILING_DISAMBIG.sub("", s)
if new == s:
break
s = new
s = _strip_wrapping_quotes(s)
s = s.replace("_", " ")
s = _COLON_NOSPACE.sub(": ", s)
s = _MULTISPACE.sub(" ", s).strip()
if not s or not _HAS_ALPHANUMERIC.search(s):
return None
return " ".join(_title_word(w) for w in s.split(" "))
+4 -7
View File
@@ -38,13 +38,10 @@ STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05"))
# 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.
# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived # FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
# (image_record.artist_id), never ML-inferred. 'copyright' retired # (image_record.artist_id), never ML-inferred. Raw predictions are still
# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is # stored at STORE_FLOOR but artist never surfaces.
# this app's franchise/series concept (per TagsView.vue's doc comment). SURFACED_CATEGORIES = {"character", "copyright", "general"}
# Raw predictions for both categories still get stored at STORE_FLOOR but
# don't surface in suggestions.
SURFACED_CATEGORIES = {"character", "general"}
# ImageNet preprocessing constants (per Camie v2 onnx_inference.py). # ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) _IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
@@ -1,41 +0,0 @@
"""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 912", "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
-604
View File
@@ -1,604 +0,0 @@
"""Native Patreon JSON:API client (build step 1 of the native ingester).
Clean-room reimplementation of the Patreon `/api/posts` read path. This is a
plain, synchronous client over `requests` — the orchestrator already wraps
sync importer calls, so nothing here needs to be async (mirrors
patreon_resolver.py, which wraps its sync lookup in run_in_executor at the
call site).
Scope (build step 1): fetch + page + parse only. This module is NOT wired into
download_service yet — that is a later step. The public surface here exists so
the later step can drive it:
- PatreonClient(cookies_path).iter_posts(campaign_id)
→ (post, included_index, page_cursor)
- extract_media(post, included_index) → list[MediaItem]
- parse_cursor_from_url(url) → cursor
Drift detection is loud on purpose: Patreon ships JSON:API and the shapes we
depend on (top-level `data`, media resources carrying `file_name`/`url`) are
the contract. If a response comes back as an HTML login page or a media
resource is missing the fields we resolve against, we raise PatreonDriftError
rather than silently yielding empty media — so the later import step surfaces
"Patreon changed something" instead of "creator has no posts".
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
"""
from __future__ import annotations
import http.cookiejar
import logging
import os
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from html import unescape
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
import requests
from ..utils.paths import safe_ext
log = logging.getLogger(__name__)
_POSTS_URL = "https://www.patreon.com/api/posts"
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
_TIMEOUT_SECONDS = 30.0
# 429 backoff (plan #703): ride out a transient API rate-limit instead of
# failing the whole walk (which would stamp RATE_LIMITED → platform-wide
# cooldown → every Patreon source dark). Honor the server's `Retry-After`;
# otherwise exponential base·2^(n-1), capped. Only after the retries are
# exhausted does the 429 propagate as terminal RATE_LIMITED.
_MAX_429_RETRIES = 3
_BACKOFF_BASE_SECONDS = 2.0
_BACKOFF_CAP_SECONDS = 30.0
def _retry_after_seconds(
resp: requests.Response,
attempt: int,
*,
base: float = _BACKOFF_BASE_SECONDS,
cap: float = _BACKOFF_CAP_SECONDS,
) -> float:
"""Backoff delay for a 429: the `Retry-After` seconds header if present and
numeric, else exponential `base·2^(attempt-1)`, both capped. (HTTP-date form
of Retry-After is rare here and falls through to exponential.)"""
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)
# JSON:API request contract (observed from real traffic — see module plan).
_INCLUDE = (
"campaign,access_rules,attachments,attachments_media,audio,images,media,"
"native_video_insights,user,user_defined_tags,ti_checks"
)
_FIELDS_POST = (
"content,post_file,image,post_type,published_at,title,url,patreon_url,"
"current_user_can_view"
)
_FIELDS_MEDIA = "id,image_urls,download_url,metadata,file_name"
_FIELDS_CAMPAIGN = "name,url"
# A CDN download URL embeds a 32-char hex (MD5) path segment; that segment is
# Patreon's stable per-file identity and is what we dedup + ledger against.
# Same role gallery-dl's _filehash plays. Match the FIRST 32-hex run anywhere
# in the URL (path or query); real Patreon CDN URLs carry exactly one.
_FILEHASH_RE = re.compile(r"([0-9a-fA-F]{32})")
# Inline post `content` is HTML; images are emitted as <img ... src="...">.
# Pull every src; downstream dedup collapses any that duplicate a gallery item
# by filehash. Tolerant of attribute ordering and single/double quotes.
_CONTENT_IMG_RE = re.compile(r"<img\b[^>]*?\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE)
class PatreonAPIError(Exception):
"""Base for native Patreon client failures.
`status_code` carries the HTTP status when the failure was an HTTP response
(None for transport-level / parse failures), so the ingester can map it to a
DownloadResult.error_type (429 → rate_limited, 404 → not_found, …).
`retry_after` carries the server's `Retry-After` seconds on a terminal 429, so
the platform cooldown can match the server's hint instead of a flat default
(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 PatreonAuthError(PatreonAPIError):
"""Authentication / authorization failure — missing or expired session
cookies, an insufficient pledge tier, or an HTML login/challenge page served
where JSON was expected. DISTINCT from drift: the fix is rotating the
credential, not updating the ingester. Maps to error_type 'auth_error'.
"""
class PatreonDriftError(PatreonAPIError):
"""A JSON response did not match the JSON:API shape we depend on.
Raised for: a missing top-level `data` list, `data` not a list, or a media
resource lacking the `file_name`/`url` fields we resolve against. Fail loud
so the import step flags API drift (ingester needs update) instead of
silently importing nothing. An HTML-login / non-JSON body is auth, not
drift — that raises PatreonAuthError.
"""
@dataclass
class MediaItem:
"""One resolved downloadable item belonging to a post.
Fields:
url — the CDN/download URL to fetch.
filename — media `file_name` when present; otherwise the URL basename
(NEVER a network call). Bounded/sane extension.
kind — one of: "images", "image_large", "attachments", "postfile",
"content". Mirrors gallery-dl's `files` content-type names so
the later step can honor the same per-source content_types.
filehash — the 32-char hex (MD5) segment from the CDN URL, or None if
the URL carries no such segment. Used for in-post dedup and
the cross-run seen-ledger.
post_id — the owning post's id (so a flattened media list stays
traceable to its post).
"""
url: str
filename: str
kind: str
filehash: str | None
post_id: str
def _load_session(cookies_path: str | Path | None) -> requests.Session:
session = requests.Session()
session.headers.update(
{
"User-Agent": _USER_AGENT,
"Accept": "application/vnd.api+json",
}
)
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 Patreon cookies from %s: %s", cookies_path, exc)
return session
def _filehash(url: str) -> str | None:
if not url:
return None
match = _FILEHASH_RE.search(url)
return match.group(1).lower() if match else None
def _basename_from_url(url: str) -> str:
"""Derive a sane filename from a URL when the media has no file_name.
Strips query/fragment, takes the path basename, and drops a junk
extension (the importer._safe_ext gotcha) so we never write base64 noise
as a name. Falls back to the filehash, then to "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
# Keep the stem bounded; URL-encoded stems can be enormous.
stem = stem[:120] or "file"
return f"{stem}{ext}"
fh = _filehash(url)
return fh or "file"
def parse_cursor_from_url(url: str | None) -> str | None:
"""Extract the `page[cursor]` query param from a links.next URL."""
if not url:
return None
query = urlsplit(url).query
values = parse_qs(query).get("page[cursor]")
if values and values[0]:
return values[0]
return None
class PatreonClient:
"""Synchronous Patreon JSON:API read client.
Construct with a path to a Netscape cookies.txt (the same file
CredentialService.get_cookies_path materializes). Cookies are loaded into a
requests.Session; no secure-context APIs are used.
"""
def __init__(
self,
cookies_path: str | Path | None,
*,
request_sleep: float = 0.0,
max_retries: int = _MAX_429_RETRIES,
):
self.cookies_path = str(cookies_path) if cookies_path else None
self._session = _load_session(cookies_path)
# Politeness: seconds to sleep before each /api/posts page fetch (paces
# the rate-limited API endpoint). 0 = no pacing. plan #703.
self._request_sleep = request_sleep or 0.0
self._max_retries = max_retries
# -- request -----------------------------------------------------------
def _params(self, campaign_id: str, cursor: str | None) -> dict[str, str]:
params = {
"include": _INCLUDE,
"fields[post]": _FIELDS_POST,
"fields[media]": _FIELDS_MEDIA,
"fields[campaign]": _FIELDS_CAMPAIGN,
"filter[campaign_id]": campaign_id,
"filter[contains_exclusive_posts]": "true",
"filter[is_draft]": "false",
"sort": "-published_at",
"json-api-version": "1.0",
}
if cursor:
params["page[cursor]"] = cursor
return params
def _fetch(self, campaign_id: str, cursor: str | None) -> dict:
if self._request_sleep > 0:
time.sleep(self._request_sleep) # pace the API endpoint
attempt = 0
while True:
try:
resp = self._session.get(
_POSTS_URL,
params=self._params(campaign_id, cursor),
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise PatreonAPIError(
f"Patreon posts request failed (campaign_id={campaign_id}): {exc}"
) from exc
# Transient rate-limit: back off and retry rather than failing the
# whole walk. Only a PERSISTENT 429 (retries exhausted) falls
# through to the terminal RATE_LIMITED raise below.
if resp.status_code == 429 and attempt < self._max_retries:
attempt += 1
delay = _retry_after_seconds(resp, attempt)
log.warning(
"Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)",
campaign_id, delay, attempt, self._max_retries,
)
time.sleep(delay)
continue
break
if resp.status_code in (401, 403):
# Auth rejected — expired/missing cookies or an insufficient tier.
# Actionable as "rotate credentials", so it's auth, not drift/http.
raise PatreonAuthError(
f"Patreon posts API returned HTTP {resp.status_code} — auth "
f"rejected (cookies expired or tier insufficient; "
f"campaign_id={campaign_id})",
status_code=resp.status_code,
)
if resp.status_code != 200:
# A persistent 429 (retries exhausted) is terminal RATE_LIMITED — carry
# the server's raw Retry-After seconds so the cooldown matches its hint
# (plan #708 B1). Header is uncapped here; the cooldown clamps it.
retry_after = None
if resp.status_code == 429:
hdr = resp.headers.get("Retry-After")
if hdr:
try:
retry_after = float(hdr)
except (TypeError, ValueError):
retry_after = None
raise PatreonAPIError(
f"Patreon posts API returned HTTP {resp.status_code} "
f"(campaign_id={campaign_id})",
status_code=resp.status_code,
retry_after=retry_after,
)
try:
payload = resp.json()
except ValueError as exc:
# A non-JSON body here is almost always the HTML login/challenge
# page served when cookies are missing/expired — that is an AUTH
# failure (rotate cookies), not API drift (update the ingester) and
# not a transient network error.
raise PatreonAuthError(
"Patreon posts API returned a non-JSON response (likely an "
f"HTML login/challenge page — session expired; "
f"campaign_id={campaign_id}): {exc}"
) from exc
return payload
# -- parsing -----------------------------------------------------------
@staticmethod
def _transform(response: dict) -> dict:
"""Flatten the JSON:API `included` array for relationship resolution.
Returns a dict keyed by `(type, id)` → that resource's `attributes`
(so a post's relationships can be resolved in O(1)). Missing/oddly
shaped `included` entries are skipped rather than fatal — drift
detection for the top-level shape lives in _validate_response.
"""
index: dict[tuple[str, str], dict] = {}
for inc in response.get("included") or []:
if not isinstance(inc, dict):
continue
rtype = inc.get("type")
rid = inc.get("id")
if rtype is None or rid is None:
continue
index[(str(rtype), str(rid))] = inc.get("attributes") or {}
return index
@staticmethod
def _validate_response(response: dict) -> None:
if not isinstance(response, dict):
raise PatreonDriftError("Patreon response was not a JSON object")
if "data" not in response:
raise PatreonDriftError("Patreon response missing top-level 'data' key")
data = response.get("data")
if not isinstance(data, list):
raise PatreonDriftError("Patreon response 'data' was not a list")
def _related_ids(self, post: dict, rel_name: str) -> list[str]:
rels = post.get("relationships") or {}
rel = rels.get(rel_name) or {}
data = rel.get("data")
if isinstance(data, dict): # to-one relationship
data = [data]
if not isinstance(data, list):
return []
ids: list[str] = []
for ref in data:
if isinstance(ref, dict) and ref.get("id") is not None:
ids.append(str(ref["id"]))
return ids
@staticmethod
def _media_url(attrs: dict) -> str | None:
"""Pick the best fetchable URL for a media resource.
Prefer the full-size `download_url`; fall back to the largest
`image_urls` size. gallery-dl prefers download_url too, only dipping
into image_urls when a smaller configured size is requested — FC
always wants the original, so download_url first.
"""
download_url = attrs.get("download_url")
if isinstance(download_url, str) and download_url:
return download_url
image_urls = attrs.get("image_urls")
if isinstance(image_urls, dict):
for key in ("original", "full", "large", "default"):
candidate = image_urls.get(key)
if isinstance(candidate, str) and candidate:
return candidate
# Otherwise take any non-empty string value.
for candidate in image_urls.values():
if isinstance(candidate, str) and candidate:
return candidate
return None
def _media_item(self, attrs: dict, kind: str, post_id: str) -> MediaItem:
url = self._media_url(attrs)
if not url:
raise PatreonDriftError(
f"Patreon media (post {post_id}, kind={kind}) had no resolvable URL "
f"(no download_url / image_urls)"
)
# file_name is OPTIONAL: Patreon legitimately serves some gallery images
# without it (operator-flagged 2026-06-07, BlenderKnight post 73665615),
# and the URL basename is a fine fallback — the same thing gallery-dl
# uses. A genuine schema change shows up as no URL (above) or a media id
# absent from `included` (caller), not a missing name.
file_name = attrs.get("file_name")
filename = file_name if isinstance(file_name, str) and file_name else _basename_from_url(url)
return MediaItem(
url=url,
filename=filename,
kind=kind,
filehash=_filehash(url),
post_id=post_id,
)
def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]:
"""Resolve all downloadable media for one post.
Walks the kinds in the same order gallery-dl does — images,
image_large (post cover), attachments, postfile, content (inline
<img>) — and dedups within the post by filehash (first wins). The
image_large cover commonly duplicates a gallery image; deduping by
filehash collapses them to the gallery item (encountered first).
"""
post_id = str(post.get("id") or "")
attrs = post.get("attributes") or {}
items: list[MediaItem] = []
def _resolve_rel(rel_name: str, kind: str) -> None:
for mid in self._related_ids(post, rel_name):
media_attrs = included_index.get(("media", mid))
if media_attrs is None:
# Referenced but not in `included`: a media id with no
# resource is drift (we asked for include=media).
raise PatreonDriftError(
f"Patreon post {post_id} references media {mid} "
f"({rel_name}) not present in 'included'"
)
items.append(self._media_item(media_attrs, kind, post_id))
# 1. gallery images
_resolve_rel("images", "images")
# 2. image_large — the post-level cover (`image.large_url`). Not a
# media relationship; it lives on the post attributes.
image = attrs.get("image")
if isinstance(image, dict):
large_url = image.get("large_url") or image.get("url")
if isinstance(large_url, str) and large_url:
items.append(
MediaItem(
url=large_url,
filename=_basename_from_url(large_url),
kind="image_large",
filehash=_filehash(large_url),
post_id=post_id,
)
)
# 3. attachments
_resolve_rel("attachments_media", "attachments")
# 4. postfile — the post's primary attached file (`post_file`).
post_file = attrs.get("post_file")
if isinstance(post_file, dict):
pf_url = post_file.get("url") or post_file.get("download_url")
if isinstance(pf_url, str) and pf_url:
pf_name = post_file.get("name")
filename = (
pf_name
if isinstance(pf_name, str) and pf_name
else _basename_from_url(pf_url)
)
items.append(
MediaItem(
url=pf_url,
filename=filename,
kind="postfile",
filehash=_filehash(pf_url),
post_id=post_id,
)
)
# 5. content — inline <img> in the post HTML body.
content = attrs.get("content")
if isinstance(content, str) and content:
for raw_src in _CONTENT_IMG_RE.findall(content):
src = unescape(raw_src)
if not src:
continue
items.append(
MediaItem(
url=src,
filename=_basename_from_url(src),
kind="content",
filehash=_filehash(src),
post_id=post_id,
)
)
return _dedup_by_filehash(items)
@staticmethod
def post_meta(post: dict) -> dict:
"""Title + published date for a post — for the preview sample (plan #708
B4). Part of the client contract `ingest_core.Ingester.preview` calls."""
attrs = post.get("attributes") or {}
title = attrs.get("title")
published = attrs.get("published_at")
return {
"title": title if isinstance(title, str) else None,
"date": published if isinstance(published, str) else None,
}
# -- iteration ---------------------------------------------------------
def iter_posts(
self, campaign_id: str, cursor: str | None = None
) -> Iterator[tuple[dict, dict, str | None]]:
"""Yield (post, included_index, page_cursor) for every post in the feed.
Pages newest→oldest via `links.next`, validating each response for
drift before yielding. The triple gives a caller everything it needs to
resolve and checkpoint without re-fetching:
- post — the raw post resource.
- included_index — the page's flattened `included` (the same object
for every post on a page), to pass straight to
extract_media(post, included_index).
- page_cursor — the cursor that FETCHED this post's page (None for
the first page). The caller checkpoints THIS value,
matching the existing backfill cursor logic where
the saved cursor re-fetches the page being
processed (so a chunk cut mid-page resumes the page,
not the one after it).
"""
current_cursor = cursor
while True:
response = self._fetch(campaign_id, current_cursor)
self._validate_response(response)
page_cursor = current_cursor
included_index = self._transform(response)
for post in response.get("data") or []:
if isinstance(post, dict):
yield post, included_index, page_cursor
next_url = (response.get("links") or {}).get("next")
next_cursor = parse_cursor_from_url(next_url)
if not next_cursor:
return
current_cursor = next_cursor
# -- verify ------------------------------------------------------------
def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]:
"""Cheap auth probe: fetch the first `/api/posts` page and report whether
the credential authenticated, WITHOUT downloading anything.
Returns `(ok, message)` matching the credential-verify contract:
- True — authenticated (the feed returned a valid JSON:API page).
- False — the credential was rejected (PatreonAuthError: 401/403, or an
HTML login page → cookies expired / tier insufficient).
- None — inconclusive: API drift (our parser is stale, not a cred
problem) or a transient network/HTTP error.
"""
try:
response = self._fetch(campaign_id, None)
self._validate_response(response)
except PatreonAuthError as exc:
return False, f"Patreon rejected the credential — {exc}"
except PatreonDriftError as exc:
return None, f"Couldn't verify — Patreon's API shape changed: {exc}"
except PatreonAPIError as exc:
return None, f"Couldn't verify (network/HTTP issue): {exc}"
return True, "Credentials valid — the Patreon feed authenticated."
def _dedup_by_filehash(items: list[MediaItem]) -> list[MediaItem]:
"""Drop later items sharing a filehash with an earlier one (first wins).
Items with no filehash (None) are never deduped against each other — we
can't prove they're the same file, so keep them all.
"""
seen: set[str] = set()
out: list[MediaItem] = []
for item in items:
if item.filehash is not None:
if item.filehash in seen:
continue
seen.add(item.filehash)
out.append(item)
return out
-510
View File
@@ -1,510 +0,0 @@
"""Native Patreon media downloader (build step 2b of the native ingester).
Given a Patreon post and its already-resolved `MediaItem`s (from
patreon_client.extract_media), download the media to the EXACT on-disk layout
gallery-dl produces, write a sidecar JSON the existing importer consumes, and
report per-media outcomes.
This module is PURE: no DB. The cross-run seen-ledger and the iter_posts
orchestration are a LATER step. The tier-1 (seen) skip is an INJECTED predicate
(`is_seen`), so this module needs no DB and is unit-testable without network.
On-disk layout (matches gallery-dl):
<images_root>/<artist_slug>/patreon/<DIR>/<NN>_<filename>
where <DIR> = "<YYYY-MM-DD>_<post_id>_<title40>" (date prefix omitted when the
post's published_at is unparseable), <NN> is the 1-based index of the item in
the post zero-padded to 2, and <filename> is MediaItem.filename. The sidecar
is written next to the media as <NN>_<stem>.json (media_path.with_suffix).
Video: a MediaItem whose URL is a Mux/HLS stream (host stream.mux.com or path
endswith .m3u8) is fetched with yt-dlp (subprocess), passing the same
Referer/Origin headers gallery-dl forwards for Mux playback (see gallery_dl.py
_get_default_config). yt-dlp may remux to a container of its own choosing, so we
accept the actual output extension and record the real path.
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import subprocess
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from urllib.parse import urlsplit
import requests
from .file_validator import is_validatable, quarantine_file, validate_file
from .patreon_client import (
_BACKOFF_CAP_SECONDS,
_load_session,
_retry_after_seconds,
)
log = logging.getLogger(__name__)
_TITLE_MAX = 40
_TIMEOUT_SECONDS = 120.0
_CHUNK = 1 << 16
# Retry a media GET that hits a TRANSIENT failure within the same pass (plan
# #705 #8): a transport blip (connection reset / timeout / truncated stream), a
# 429, or a 5xx. PERMANENT failures (404 gone, 403 forbidden) fail fast straight
# to the error/dead-letter path — no point re-fetching them. Keeps a momentary
# network hiccup from becoming a per-item error that waits for the next walk.
_MAX_MEDIA_RETRIES = 3
# requests transport errors worth retrying (vs. an HTTPError, which is a real
# server response and is classified by status code).
_TRANSIENT_TRANSPORT_EXC = (
requests.ConnectionError,
requests.Timeout,
requests.exceptions.ChunkedEncodingError,
)
# Referer/Origin yt-dlp must send for Mux-hosted Patreon video. Mux's JWT
# playback policy checks Referer/Origin on every request, so yt-dlp must send
# Patreon's, not its own default. (gallery-dl forwarded the same headers before
# the #697 cutover removed its Patreon path; this is now the only place they
# live.)
_VIDEO_HEADERS = {
"Referer": "https://www.patreon.com/",
"Origin": "https://www.patreon.com",
}
# Characters Windows/gallery-dl path-restrict forbids, plus path separators.
_FORBIDDEN = set('<>:"/\\|?*')
def _sanitize(name: str) -> str:
"""Make `name` safe for a single filesystem path segment.
Replaces path separators, the Windows-forbidden set <>:"/\\|?* and control
characters with `_`, then strips trailing dots/spaces (gallery-dl
path-restrict behavior). Never returns empty (falls back to "_").
"""
out = []
for ch in name:
if ch in _FORBIDDEN or ord(ch) < 32:
out.append("_")
else:
out.append(ch)
cleaned = "".join(out).rstrip(". ")
return cleaned or "_"
def _is_video_url(url: str) -> bool:
parts = urlsplit(url)
if parts.hostname and parts.hostname.lower() == "stream.mux.com":
return True
return parts.path.lower().endswith(".m3u8")
def _post_dir_name(post: dict) -> str:
"""Build the post directory name matching gallery-dl's layout."""
post_id = str(post.get("id") or "")
attrs = post.get("attributes") or {}
title = attrs.get("title")
title = title if isinstance(title, str) else ""
title40 = title[:_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:
dt = datetime.fromisoformat(s)
except ValueError:
dt = None
if dt is not None:
date_prefix = f"{dt:%Y-%m-%d}"
if date_prefix:
raw = f"{date_prefix}_{post_id}_{title40}"
else:
raw = f"{post_id}_{title40}"
return _sanitize(raw)
@dataclass
class MediaOutcome:
"""Per-media result of a download_post pass.
status is one of: "downloaded", "skipped_seen", "skipped_disk",
"quarantined", "error". `path` is the final on-disk path for "downloaded"
(the actual yt-dlp output for video), the path that already existed for
"skipped_disk", or the _quarantine destination for "quarantined"; None for
"skipped_seen" and (usually) "error". `error` carries the failure/validation
reason for "error"/"quarantined", else None.
"""
media: object # MediaItem (avoid importing the name for a bare annotation)
status: str
path: Path | None
error: str | None
class PatreonDownloader:
"""Download resolved Patreon media to gallery-dl's on-disk layout.
PURE: no DB. The HTTP session and the yt-dlp invocation are injectable seams
so tests run without network or a real subprocess:
- pass `session=` to stub `session.get`, or monkeypatch `_fetch_to_file`.
- monkeypatch `_run_ytdlp` to avoid spawning yt-dlp.
"""
def __init__(
self,
images_root: Path,
cookies_path: str | None = None,
*,
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._validate = validate
# Politeness: seconds to sleep before each actual media download (paces
# the CDN; honors ImportSettings.download_rate_limit_seconds, the same
# value gallery-dl used as its between-downloads `sleep`). 0 = no pacing.
# Applied only to real downloads, not to seen/disk skips. plan #703.
self._rate_limit = rate_limit or 0.0
# Build a cookie-loaded session the same way patreon_client does, so the
# CDN GETs carry the creator's auth.
self.session = session if session is not None else _load_session(cookies_path)
# -- public ------------------------------------------------------------
def download_post(
self,
post: dict,
media_items: list,
artist_slug: str,
*,
is_seen: Callable[[object], bool] = lambda m: False,
should_stop: Callable[[], bool] = lambda: False,
) -> list[MediaOutcome]:
"""Download every media item of one post; return per-item outcomes.
Builds the post directory, iterates media (1-based NN), applies the
two-tier skip (injected is_seen, then disk), downloads (plain GET or
yt-dlp for video), writes the sidecar for each freshly-downloaded item,
validates, and returns outcomes. Resilient: one media's failure yields
an "error" outcome for that item; the rest proceed.
`should_stop()` is polled BEFORE each media item: a media-dense post can
otherwise run a backfill chunk far past its time-box (the engine only
re-checks the budget between posts), so we honour the deadline mid-post
and return the items done so far — the rest re-fetch next chunk (they
were never marked seen). Bounds chunk overrun to one media download.
"""
post_dir = self.images_root / artist_slug / "patreon" / _post_dir_name(post)
outcomes: list[MediaOutcome] = []
for i, media in enumerate(media_items, start=1):
if should_stop():
break
try:
outcomes.append(
self._download_one(post, media, post_dir, artist_slug, i, is_seen)
)
except Exception as exc: # resilient: isolate one item's failure
log.warning(
"Patreon media failed (post %s, item %d): %s",
post.get("id"), i, exc,
)
outcomes.append(
MediaOutcome(media=media, status="error", path=None, error=str(exc))
)
return outcomes
# -- per-item ----------------------------------------------------------
def _download_one(
self,
post: dict,
media,
post_dir: Path,
artist_slug: str,
index: int,
is_seen: Callable[[object], bool],
) -> MediaOutcome:
# tier-1: seen ledger (injected; no DB here).
if is_seen(media):
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
nn = f"{index:02d}"
final_name = _sanitize(f"{nn}_{media.filename}")
media_path = post_dir / final_name
# tier-2: already on disk.
if media_path.exists():
return MediaOutcome(
media=media, status="skipped_disk", path=media_path, error=None
)
# Video may land at a different extension; honor a pre-existing remux.
if _is_video_url(media.url):
existing = self._existing_video_output(media_path)
if existing is not None:
return MediaOutcome(
media=media, status="skipped_disk", path=existing, error=None
)
post_dir.mkdir(parents=True, exist_ok=True)
# Pace real downloads only (the skips above already returned). plan #703.
if self._rate_limit > 0:
time.sleep(self._rate_limit)
if _is_video_url(media.url):
out_path = self._run_ytdlp(media.url, media_path, _VIDEO_HEADERS)
if out_path is None or not Path(out_path).exists():
return MediaOutcome(
media=media,
status="error",
path=None,
error="yt-dlp produced no output",
)
out_path = Path(out_path)
else:
out_path = self._fetch_get(media.url, media_path)
reason, quarantine_dest = self._validate_path(
out_path, artist_slug, media.url
)
if reason is not None:
# Quarantined (corrupt/invalid) — distinct from a download error
# so the run can report a real files_quarantined count + paths.
return MediaOutcome(
media=media, status="quarantined",
path=quarantine_dest, error=reason,
)
self._write_sidecar(post, out_path)
return MediaOutcome(media=media, status="downloaded", path=out_path, error=None)
# -- download seams ----------------------------------------------------
def _fetch_get(self, url: str, dest: Path) -> Path:
"""Stream `url` to a .part file then atomic-rename to `dest`.
Thin wrapper over `_fetch_to_file` so tests can stub either the whole
GET path (`_fetch_to_file`) or just `session.get`.
"""
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 non-video URL to `dest` via the (stubbable) session, retrying
TRANSIENT failures within the same pass (plan #705 #8) and RESUMING from
the bytes already on disk via a Range request when a retry follows a
mid-download cut (plan #708 B5).
Retried (backoff): transport blips (connection reset / timeout /
truncated stream — incl. mid-download), HTTP 429 (honoring Retry-After),
and 5xx. Failed fast (no retry → HTTPError → per-item error → dead-letter
path): 4xx other than 429 (404 gone, 403 forbidden) — re-fetching a
permanent failure is pointless.
Resume: on a retry, if bytes already landed in `dest`, ask for the rest
with `Range: bytes=<have>-`. A 206 means the server honored it → append; a
200 means it ignored it (served the whole file) → start clean. The caller
(_fetch_get) stages into a `.part`, so a non-range server never corrupts
the output — the worst case is re-downloading from zero, as before.
"""
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(
"Patreon media transient HTTP %d (%s) — backing off "
"%.1fs (retry %d/%d)",
resp.status_code, url, delay, attempt, _MAX_MEDIA_RETRIES,
)
time.sleep(delay)
continue
# A Range that starts at/past EOF (we already have the whole file)
# comes back 416 — the bytes we kept ARE the file.
if have > 0 and resp.status_code == 416:
return
# 2xx → ok; 4xx-non-429 (or an exhausted 429/5xx) → HTTPError
# (permanent for this pass) → not caught below → per-item error.
resp.raise_for_status()
# 206 → server honored the Range; append after the kept bytes.
# Anything else (200) → it served the whole file → start clean.
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 # exhausted → terminal error outcome
attempt += 1
delay = min(2.0 * (2 ** (attempt - 1)), _BACKOFF_CAP_SECONDS)
log.warning(
"Patreon media transport error (%s) — backing off %.1fs "
"(retry %d/%d): %s",
url, delay, attempt, _MAX_MEDIA_RETRIES, exc,
)
time.sleep(delay)
def _run_ytdlp(self, url: str, dest: Path, headers: dict) -> Path | None:
"""Invoke yt-dlp to fetch a Mux/HLS stream to (around) `dest`.
Returns the actual output path (yt-dlp may remux to a different
container, so we resolve the real file afterward). Overridden/
monkeypatched in tests to avoid spawning a real subprocess.
The output template uses `dest` without its extension; yt-dlp appends
the chosen container extension. We pass Referer/Origin (Mux JWT policy)
and the cookies file.
Mirrors the GET path's transient/permanent split (plan #705 #8): a hung
fetch (TimeoutExpired) or a spawn failure (OSError) is TRANSIENT — back
off and retry. A non-zero yt-dlp exit (CalledProcessError) is treated as
PERMANENT for this pass — yt-dlp already does its OWN internal network
retries, so a non-zero exit is effectively a real failure (private/gone/
geo-blocked), like a 4xx on the GET path: fail fast to the per-item error
→ dead-letter path.
"""
dest = Path(dest)
out_template = str(dest.with_suffix("")) + ".%(ext)s"
cmd = ["yt-dlp", "--no-progress", "-o", out_template]
for key, value in headers.items():
cmd += ["--add-header", f"{key}:{value}"]
if self.cookies_path and os.path.isfile(self.cookies_path):
cmd += ["--cookies", self.cookies_path]
cmd.append(url)
attempt = 0
while True:
try:
subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
timeout=_TIMEOUT_SECONDS,
)
break
except subprocess.CalledProcessError as exc:
# Permanent for this pass — fail fast (no retry).
log.warning(
"yt-dlp failed (exit %s) for %s: %s",
exc.returncode, url, (exc.stderr or "").strip() or exc,
)
return None
except (OSError, subprocess.TimeoutExpired) as exc:
# Transient — back off and retry, like a transport blip on a GET.
if attempt >= _MAX_MEDIA_RETRIES:
log.warning(
"yt-dlp transient failure exhausted for %s: %s", url, exc
)
return None
attempt += 1
delay = min(2.0 * (2 ** (attempt - 1)), _BACKOFF_CAP_SECONDS)
log.warning(
"yt-dlp transient failure (%s) — backing off %.1fs "
"(retry %d/%d): %s",
url, delay, attempt, _MAX_MEDIA_RETRIES, exc,
)
time.sleep(delay)
return self._existing_video_output(dest)
def _existing_video_output(self, dest: Path) -> Path | None:
"""Find a yt-dlp output for `dest` regardless of chosen extension.
Returns `dest` itself if present, else any sibling sharing the same
stem (the remuxed container). None if nothing matched.
"""
dest = Path(dest)
if dest.exists():
return dest
stem = dest.with_suffix("").name
parent = dest.parent
if not parent.is_dir():
return None
for cand in sorted(parent.iterdir()):
if cand.is_file() and cand.stem == stem and cand.name != dest.name:
return cand
return None
# -- 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.
Uses the shared `file_validator.quarantine_file` — same move + provenance
sidecar gallery-dl writes (the native path used to skip the sidecar; that
parity gap is closed here). Returns `(reason, quarantine_dest)` when
quarantined (dest is the original path if the move itself failed), else
`(None, None)` (ok / not validatable / disabled). plan #704: the dest is
surfaced so the run reports a real quarantined-paths list.
"""
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, "patreon",
url=source_url, result=result,
)
return (result.reason or "validation failed"), (dest or path)
# -- sidecar -----------------------------------------------------------
def _write_sidecar(self, post: dict, media_path: Path) -> Path:
"""Write the importer-consumed sidecar next to `media_path`.
Patreon uses base-default sidecar keys (parse_sidecar maps
category->platform, id->external_post_id, title->post_title,
content->description, published_at->post_date, url->post_url). Patreon
registers no derive_post_url, so `url` is trusted as the permalink — we
pass the post's attributes.url.
"""
attrs = post.get("attributes") or {}
title = attrs.get("title")
content = attrs.get("content")
published = attrs.get("published_at")
url = attrs.get("url")
data = {
"category": "patreon",
"id": str(post.get("id") or ""),
"title": title if isinstance(title, str) else "",
"content": content if isinstance(content, str) else "",
"published_at": published if isinstance(published, str) else None,
"url": url if isinstance(url, str) else None,
}
sidecar_path = media_path.with_suffix(".json")
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
-197
View File
@@ -1,197 +0,0 @@
"""Native Patreon ingester — the Patreon ADAPTER over the platform-agnostic core.
The orchestration that drives a native subscription walk (page a feed → extract
media → tiered skip → download → mark-seen / record-failures / checkpoint-cursor
→ return a gallery-dl-shaped `DownloadResult`, across tick/backfill/recovery)
now lives in `ingest_core.Ingester` — it's identical for every platform. This
module is the thin Patreon adapter: it wires the Patreon `client`/`downloader`/
ledger models/constraints/key into the core and supplies the Patreon-specific
failure mapping. `download_service.download_source` calls `PatreonIngester.run`
exactly as before; the public surface (this class, `_ledger_key`,
`DEAD_LETTER_THRESHOLD`, `verify_patreon_credential`) is unchanged.
Three modes (selected by `download_service` from `config_overrides` state):
- tick — newest→oldest, skip seen (tier-1 ledger + tier-2 disk), early-out
after N contiguous already-have-it items (the cheap native
equivalent of gallery-dl's `exit:20`, now free of per-file HEADs).
- backfill — full-history walk in a time-boxed chunk, resuming from the
pagination cursor checkpoint; reaches the bottom → "complete".
- recovery — like backfill but BYPASSES the tier-1 seen-ledger AND the
dead-letter ledger, so deliberately-dropped-and-deleted near-dups
get re-fetched and re-evaluated under the current pHash threshold
(tier-2 disk skip still spares files we kept).
The seen/dead-letter ledgers live in Postgres (`patreon_seen_media` /
`patreon_failed_media`); the core opens SHORT-LIVED sync sessions per page batch
— never held across a network fetch ([[db-connection-held-across-subprocess]]).
FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from pathlib import Path
from ..models import PatreonFailedMedia, PatreonSeenMedia
from .gallery_dl import DownloadResult, ErrorType
from .ingest_core import DEAD_LETTER_THRESHOLD, Ingester
from .patreon_client import (
MediaItem,
PatreonAPIError,
PatreonAuthError,
PatreonClient,
PatreonDriftError,
)
from .patreon_downloader import PatreonDownloader
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
__all__ = [
"DEAD_LETTER_THRESHOLD",
"PatreonIngester",
"_ledger_key",
"verify_patreon_credential",
]
log = logging.getLogger(__name__)
# Ledger keys are stored in patreon_seen_media.filehash VARCHAR(128); bound any
# synthesized key so a pathologically long file_name can't overflow the column.
_LEDGER_KEY_MAX = 128
def _ledger_key(media: MediaItem) -> str:
"""Stable per-media identity for the cross-run seen-ledger.
A Patreon CDN URL carries a 32-char MD5 (`media.filehash`) — that is the
natural key. Some media have none: Mux/HLS video (`stream.mux.com`, no
content hash at discovery) and the odd inline-content `<img>` pointing at a
hashless URL. The plan calls the video case the ``video:<post_id>:<media_id>``
sentinel; `MediaItem` carries no media_id, so the post-scoped filename is the
stable proxy. Bounded to the column width.
"""
if media.filehash:
return media.filehash
return f"{media.post_id}:{media.filename}"[:_LEDGER_KEY_MAX]
class PatreonIngester(Ingester):
"""Walk a Patreon campaign's posts, download unseen media, return a
`DownloadResult`. A thin adapter over `ingest_core.Ingester`.
Construct with the per-source `cookies_path` and a sync sessionmaker for the
ledgers. `client` / `downloader` are injectable seams so unit tests run
without network, subprocess, or a real CDN.
"""
def __init__(
self,
images_root: Path,
cookies_path: str | None,
session_factory: Callable[[], object],
*,
validate: bool = True,
rate_limit: float = 0.0,
request_sleep: float = 0.0,
client: PatreonClient | None = None,
downloader: PatreonDownloader | None = None,
):
self.images_root = Path(images_root)
self.cookies_path = str(cookies_path) if cookies_path else None
# Pacing (plan #703): request_sleep paces the API page fetches,
# rate_limit paces the media downloads. Injected client/downloader (in
# tests) already carry their own pacing, so these only apply to the
# default-constructed ones.
resolved_client = (
client
if client is not None
else PatreonClient(cookies_path, request_sleep=request_sleep)
)
resolved_downloader = (
downloader
if downloader is not None
else PatreonDownloader(
self.images_root, cookies_path, validate=validate, rate_limit=rate_limit
)
)
super().__init__(
client=resolved_client,
downloader=resolved_downloader,
session_factory=session_factory,
seen_model=PatreonSeenMedia,
failed_model=PatreonFailedMedia,
seen_constraint="uq_patreon_seen_media_source_id",
failed_constraint="uq_patreon_failed_media_source_id",
ledger_key=_ledger_key,
platform="patreon",
error_base=PatreonAPIError,
)
# -- failure mapping (Patreon exception taxonomy) ----------------------
def _failure_result(self, exc: Exception, _result) -> DownloadResult:
"""Map a client-level exception to a loud, typed failed DownloadResult.
We NEVER return a silent zero-download "success" — the whole point of the
native ingester is to fail RED when Patreon's API shape or our auth
changes. The typed mapping lets FailingSourcesCard render the right chip
and tells the operator what to do:
- PatreonAuthError → AUTH_ERROR (rotate cookies)
- PatreonDriftError → API_DRIFT (ingester field-set/parser needs update)
- HTTP 429 / 404 → RATE_LIMITED / NOT_FOUND
- other HTTP status → HTTP_ERROR; transport failure → NETWORK_ERROR
PatreonAuthError and PatreonDriftError both subclass PatreonAPIError, so
they must be matched before the generic HTTP/transport fallthrough.
"""
message = str(exc)
if isinstance(exc, PatreonAuthError):
error_type = ErrorType.AUTH_ERROR
elif isinstance(exc, PatreonDriftError):
error_type = ErrorType.API_DRIFT
message = f"Patreon API changed — ingester needs update: {message}"
else: # generic PatreonAPIError: HTTP non-2xx (status_code set) or transport
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("Patreon ingest failed (%s): %s", 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
async def verify_patreon_credential(
url: str,
cookies_path: str | None,
overrides: dict | None,
) -> tuple[bool | None, str]:
"""Native Patreon credential probe — the verify counterpart to the ingester's
download path, sharing its campaign-id resolution. Resolves the campaign id
(override / id: URL / vanity) then does ONE authenticated `/api/posts` fetch
via PatreonClient.verify_auth. Returns the uniform `(ok, message)` contract
(True / False / None) so download_backends.verify_credential can treat it
interchangeably with the gallery-dl probe. No download, no DB.
"""
campaign_id, _ = await resolve_campaign_id_for_source(url, cookies_path, overrides)
if not campaign_id:
vanity = extract_vanity(url)
return None, (
f"Couldn't resolve the Patreon campaign id — can't verify. "
f"source_url={url!r}; vanity={vanity!r} "
"(cookies expired, or the creator moved/renamed?)."
)
client = PatreonClient(cookies_path)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, client.verify_auth, campaign_id)
-180
View File
@@ -19,56 +19,18 @@ import asyncio
import http.cookiejar import http.cookiejar
import logging import logging
import os import os
import re
import requests import requests
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns" _CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns"
_POSTS_API = "https://www.patreon.com/api/posts"
# A source URL of the form `.../id:<digits>` already carries the campaign id
# (no lookup needed). The vanity regex deliberately EXCLUDES the id: form so the
# two paths don't overlap.
#
# Patreon serves the creator vanity under several path prefixes — bare
# (`patreon.com/Atole`), `c/` (`patreon.com/c/Atole`), and `cw/`
# (`patreon.com/cw/Atole`, its current "creator workspace" URL). The optional
# prefix group must list `cw/` BEFORE `c/` so the longer prefix wins — otherwise
# `cw/Atole` matches the bare branch and yields vanity="cw" (operator-flagged
# 2026-06-07: every `/cw/` source failed resolution on vanity="cw").
_ID_URL_RE = re.compile(r"/id:(\d+)")
# A single-post permalink — patreon.com/posts/<slug>-<post_id> (or bare
# /posts/<post_id>). The trailing digits are the post id; the creator's
# campaign is resolved from the post itself (operator-flagged 2026-06-07: a
# /posts/ source resolved vanity="posts"). `posts/` is excluded from the vanity
# regex so it never masquerades as a creator slug.
_POST_URL_RE = re.compile(r"/posts/(?:[^/?#]*-)?(\d+)(?:[/?#]|$)")
_VANITY_PREFIXES = ("cw/", "c/")
_VANITY_RE = re.compile(
r"^https?://(?:www\.)?patreon\.com/(?:cw/|c/)?(?!(?:id:|posts/))([^/?#]+)",
re.IGNORECASE,
)
_USER_AGENT = ( _USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
) )
_TIMEOUT_SECONDS = 10.0 _TIMEOUT_SECONDS = 10.0
# Fallback resolution: Patreon's `/api/campaigns?filter[vanity]=` lookup has
# proven unreliable (returns empty `data` for creators that clearly exist —
# operator-flagged 2026-06-06). gallery-dl never used that endpoint; it scrapes
# the campaign id out of the creator page's bootstrap JSON. We do the same as a
# fallback: fetch the creator page HTML and pull the first campaign id out of
# any of these embeddings (ordered most- to least-specific).
_PAGE_CAMPAIGN_ID_PATTERNS = (
re.compile(r'"id":\s*"(\d+)",\s*"type":\s*"campaign"'),
re.compile(r'"campaign":\s*\{\s*"data":\s*\{\s*"id":\s*"(\d+)"'),
re.compile(r"/api/campaigns/(\d+)"),
re.compile(r'"campaign_id":\s*"?(\d+)'),
)
def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None: def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None:
if not cookies_path or not os.path.isfile(cookies_path): if not cookies_path or not os.path.isfile(cookies_path):
@@ -83,15 +45,6 @@ def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJa
def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None: def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
"""Resolve a vanity to a campaign id: try the campaigns API first (cheap,
structured), then fall back to scraping the creator page (robust against the
API's empty-data failures). Returns None only when both miss."""
return _lookup_via_api(vanity, cookies_path) or _lookup_via_page(
vanity, cookies_path
)
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
jar = _load_cookie_jar(cookies_path) jar = _load_cookie_jar(cookies_path)
headers = { headers = {
"User-Agent": _USER_AGENT, "User-Agent": _USER_AGENT,
@@ -139,87 +92,6 @@ def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
return campaign_id return campaign_id
def _scrape_campaign_id(html: str) -> str | None:
"""First campaign id found in creator-page HTML via the known embeddings."""
if not isinstance(html, str):
return None
for pat in _PAGE_CAMPAIGN_ID_PATTERNS:
m = pat.search(html)
if m:
return m.group(1)
return None
def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None:
"""Fallback: GET the creator page and scrape the campaign id from the page
bootstrap (gallery-dl's method). Tries both the bare and `/c/` vanity paths
Patreon redirects between. Never raises."""
jar = _load_cookie_jar(cookies_path)
headers = {"User-Agent": _USER_AGENT, "Accept": "text/html"}
# Try the bare vanity and every known creator-path prefix Patreon
# redirects between (c/, cw/) — the one the source used isn't known here
# (extract_vanity already stripped it).
page_urls = [f"https://www.patreon.com/{vanity}"]
page_urls += [f"https://www.patreon.com/{p}{vanity}" for p in _VANITY_PREFIXES]
for page_url in page_urls:
try:
resp = requests.get(
page_url,
headers=headers,
cookies=jar,
timeout=_TIMEOUT_SECONDS,
allow_redirects=True,
)
except requests.RequestException as exc:
log.warning("Patreon creator-page fetch failed for %s: %s", page_url, exc)
continue
if resp.status_code != 200:
continue
campaign_id = _scrape_campaign_id(resp.text)
if campaign_id:
log.info(
"Resolved Patreon vanity=%s → campaign_id=%s via creator page",
vanity, campaign_id,
)
return campaign_id
return None
def _lookup_campaign_from_post(post_id: str, cookies_path: str | None) -> str | None:
"""Resolve the owning campaign id from a single post id via the Patreon
post API (`/api/posts/<id>?include=campaign`). A `/posts/` source URL points
at one post, but a subscription walks the whole creator — so we follow the
post to its campaign. Never raises."""
jar = _load_cookie_jar(cookies_path)
headers = {"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"}
params = {"include": "campaign", "fields[campaign]": "name"}
url = f"{_POSTS_API}/{post_id}"
try:
resp = requests.get(
url, params=params, headers=headers, cookies=jar,
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
log.warning("Patreon post lookup failed for post=%s: %s", post_id, exc)
return None
if resp.status_code != 200:
log.warning("Patreon post API returned HTTP %d for post=%s", resp.status_code, post_id)
return None
try:
payload = resp.json()
except ValueError:
return None
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, dict):
return None
campaign = ((data.get("relationships") or {}).get("campaign") or {}).get("data") or {}
campaign_id = campaign.get("id")
if isinstance(campaign_id, str) and campaign_id:
log.info("Resolved Patreon post=%s → campaign_id=%s", post_id, campaign_id)
return campaign_id
return None
async def resolve_campaign_id( async def resolve_campaign_id(
vanity: str, vanity: str,
cookies_path: str | None, cookies_path: str | None,
@@ -228,55 +100,3 @@ async def resolve_campaign_id(
Never raises.""" Never raises."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _sync_lookup, vanity, cookies_path) return await loop.run_in_executor(None, _sync_lookup, vanity, cookies_path)
async def resolve_campaign_from_post(
post_id: str, cookies_path: str | None,
) -> str | None:
"""Async wrapper around _lookup_campaign_from_post. Never raises."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, _lookup_campaign_from_post, post_id, cookies_path
)
def extract_vanity(url: str) -> str | None:
"""The vanity slug from a Patreon creator URL, or None for an `id:` URL."""
m = _VANITY_RE.match(url or "")
return m.group(1) if m else None
async def resolve_campaign_id_for_source(
url: str,
cookies_path: str | None,
overrides: dict | None,
) -> tuple[str | None, str | None]:
"""Resolve a Patreon source to its campaign id — the single resolution path
shared by the download ingester and the credential-verify probe.
Order: cached `patreon_campaign_id` override → an `id:<digits>` URL → a
`/posts/<id>` permalink (resolve the owning campaign from the post) → a
vanity lookup against the campaigns API. Returns
`(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None whenever
a lookup actually ran, so the caller caches it on the source (the
override/id: paths needed no lookup). `(None, None)` when unresolvable.
Never raises.
"""
overrides = overrides or {}
cached = overrides.get("patreon_campaign_id")
if cached:
return cached, None
id_match = _ID_URL_RE.search(url or "")
if id_match:
return id_match.group(1), None
# A single-post URL → follow the post to its creator's campaign so the
# source subscribes to the whole feed (a subscription isn't one post).
post_match = _POST_URL_RE.search(url or "")
if post_match:
resolved = await resolve_campaign_from_post(post_match.group(1), cookies_path)
return resolved, resolved
vanity = extract_vanity(url)
if vanity:
resolved = await resolve_campaign_id(vanity, cookies_path)
return resolved, resolved
return None, None
-59
View File
@@ -1,59 +0,0 @@
"""Per-platform download concurrency cap.
Some platforms (Patreon) are API-rate-sensitive enough that two *simultaneous*
walks can trip the server's rate limit even with each source pacing its own
requests. The platform-cooldown handles the AFTERMATH of a 429; this is the
preventive half — it serializes downloads PER PLATFORM to one at a time.
Different platforms still run concurrently up to the worker's concurrency; only
a second walk on the SAME serialized platform waits. The lock lives in Redis
(the Celery broker) with a TTL, so a SIGKILL'd worker can't wedge a platform —
the lock auto-expires shortly after the download hard time limit.
"""
from __future__ import annotations
import logging
import redis
from ..config import get_config
log = logging.getLogger(__name__)
# Platforms walked one-at-a-time. gallery-dl platforms are intentionally NOT
# here: each runs as a self-pacing subprocess and they're lower-volume. Add a
# platform here to cap it to a single concurrent walk.
SERIALIZED_PLATFORMS = frozenset({"patreon"})
_LOCK_PREFIX = "fc:download_lock:"
_client: redis.Redis | None = None
def _redis() -> redis.Redis:
# One client per worker process (Celery prefork forks before tasks run, so
# each process lazily builds its own). redis-py pools connections.
global _client
if _client is None:
_client = redis.from_url(get_config().celery_broker_url)
return _client
def platform_lock(platform: str, *, ttl_seconds: int):
"""A non-blocking Redis lock for `platform`, or None when the platform is
not serialized. Caller does `.acquire(blocking=False)` / `.release()`.
Returns None (rather than raising) on any Redis error so a broker hiccup
degrades to the prior behaviour (uncapped) instead of stalling downloads.
"""
if platform not in SERIALIZED_PLATFORMS:
return None
try:
return _redis().lock(
f"{_LOCK_PREFIX}{platform}",
timeout=ttl_seconds,
blocking=False,
)
except redis.RedisError as exc: # pragma: no cover - broker outage
log.warning("platform_lock unavailable for %s: %s", platform, exc)
return None
+1 -3
View File
@@ -96,12 +96,10 @@ def str_field(v) -> str | None:
# Shared gallery-dl invocation defaults. Embedded in each platform's # Shared gallery-dl invocation defaults. Embedded in each platform's
# default_config (with platform-specific overrides) so per-platform # default_config (with platform-specific overrides) so per-platform
# choices stay explicit. # choices stay explicit.
# Note: the gallery-dl `skip` value (tick "exit:20" vs backfill True) is
# NOT here — it's derived from Source.backfill_runs_remaining at download
# time. See plan #544 / gallery_dl.TICK_SKIP_VALUE,BACKFILL_SKIP_VALUE.
GD_DEFAULTS = { GD_DEFAULTS = {
"sleep": 3.0, "sleep": 3.0,
"sleep_request": 1.5, "sleep_request": 1.5,
"skip_existing": True,
"save_metadata": True, "save_metadata": True,
"timeout": 3600, "timeout": 3600,
} }
+9 -21
View File
@@ -69,19 +69,13 @@ class PostFeedService:
raise ValueError("direction must be 'older' or 'newer'") raise ValueError("direction must be 'older' or 'newer'")
sort_key = _sort_key() sort_key = _sort_key()
# Artist via the denormalized Post.artist_id (alembic 0030);
# Source via LEFT JOIN since post.source_id can now be NULL for
# filesystem-imported posts with no live subscription. A
# platform= filter implicitly excludes NULL-source posts (they
# have no platform); an artist_id= filter still surfaces them
# because Post.artist_id is always set.
stmt = ( stmt = (
select(Post, Artist, Source) select(Post, Artist, Source)
.join(Artist, Post.artist_id == Artist.id) .join(Source, Post.source_id == Source.id)
.outerjoin(Source, Post.source_id == Source.id) .join(Artist, Source.artist_id == Artist.id)
) )
if artist_id is not None: if artist_id is not None:
stmt = stmt.where(Post.artist_id == artist_id) stmt = stmt.where(Source.artist_id == artist_id)
if platform is not None: if platform is not None:
stmt = stmt.where(Source.platform == platform) stmt = stmt.where(Source.platform == platform)
if cursor: if cursor:
@@ -141,8 +135,8 @@ class PostFeedService:
cursor for each end. Returns None if the post doesn't exist.""" cursor for each end. Returns None if the post doesn't exist."""
anchor = (await self.session.execute( anchor = (await self.session.execute(
select(Post, Artist, Source) select(Post, Artist, Source)
.join(Artist, Post.artist_id == Artist.id) .join(Source, Post.source_id == Source.id)
.outerjoin(Source, Post.source_id == Source.id) .join(Artist, Source.artist_id == Artist.id)
.where(Post.id == post_id) .where(Post.id == post_id)
)).one_or_none() )).one_or_none()
if anchor is None: if anchor is None:
@@ -174,8 +168,8 @@ class PostFeedService:
async def get_post(self, post_id: int) -> dict | None: async def get_post(self, post_id: int) -> dict | None:
row = (await self.session.execute( row = (await self.session.execute(
select(Post, Artist, Source) select(Post, Artist, Source)
.join(Artist, Post.artist_id == Artist.id) .join(Source, Post.source_id == Source.id)
.outerjoin(Source, Post.source_id == Source.id) .join(Artist, Source.artist_id == Artist.id)
.where(Post.id == post_id) .where(Post.id == post_id)
)).one_or_none() )).one_or_none()
if row is None: if row is None:
@@ -266,7 +260,7 @@ class PostFeedService:
return out return out
def _to_dict( def _to_dict(
self, post: Post, artist: Artist, source: Source | None, self, post: Post, artist: Artist, source: Source,
thumbs_map: dict, atts_map: dict, thumbs_map: dict, atts_map: dict,
) -> dict: ) -> dict:
plain_full = html_to_plain(post.description) if post.description else None plain_full = html_to_plain(post.description) if post.description else None
@@ -275,9 +269,6 @@ class PostFeedService:
else: else:
description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT) description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT)
thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0}) thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0})
# `source` is null for filesystem-imported posts with no live
# subscription (alembic 0030). Frontend renders that as a
# "filesystem import" affordance instead of a platform chip.
return { return {
"id": post.id, "id": post.id,
"external_post_id": post.external_post_id, "external_post_id": post.external_post_id,
@@ -288,10 +279,7 @@ class PostFeedService:
"description_plain": description_plain, "description_plain": description_plain,
"description_truncated": truncated, "description_truncated": truncated,
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug}, "artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
"source": ( "source": {"id": source.id, "platform": source.platform},
{"id": source.id, "platform": source.platform}
if source is not None else None
),
"thumbnails": thumbs_entry["thumbs"], "thumbnails": thumbs_entry["thumbs"],
"thumbnails_more": thumbs_entry["more"], "thumbnails_more": thumbs_entry["more"],
"attachments": atts_map.get(post.id, []), "attachments": atts_map.get(post.id, []),
+6 -12
View File
@@ -70,15 +70,11 @@ class ProvenanceService:
rec = await self.session.get(ImageRecord, image_id) rec = await self.session.get(ImageRecord, image_id)
if rec is None: if rec is None:
return None return None
# Artist via Post.artist_id (alembic 0030); Source via LEFT JOIN
# since both Post.source_id and ImageProvenance.source_id can be
# NULL for filesystem-imported content. Frontend renders source=
# null as "filesystem import."
stmt = ( stmt = (
select(ImageProvenance, Post, Source, Artist) select(ImageProvenance, Post, Source, Artist)
.join(Post, Post.id == ImageProvenance.post_id) .join(Post, Post.id == ImageProvenance.post_id)
.join(Artist, Artist.id == Post.artist_id) .join(Source, Source.id == ImageProvenance.source_id)
.outerjoin(Source, Source.id == ImageProvenance.source_id) .join(Artist, Artist.id == Source.artist_id)
.where(ImageProvenance.image_record_id == image_id) .where(ImageProvenance.image_record_id == image_id)
.order_by(ImageProvenance.captured_at.asc(), .order_by(ImageProvenance.captured_at.asc(),
ImageProvenance.id.asc()) ImageProvenance.id.asc())
@@ -94,7 +90,7 @@ class ProvenanceService:
"captured_at": ip.captured_at.isoformat() "captured_at": ip.captured_at.isoformat()
if ip.captured_at else None, if ip.captured_at else None,
"post": _post_dict(post), "post": _post_dict(post),
"source": _source_dict(src) if src is not None else None, "source": _source_dict(src),
"artist": _artist_dict(art), "artist": _artist_dict(art),
} }
for ip, post, src, art in rows for ip, post, src, art in rows
@@ -103,12 +99,10 @@ class ProvenanceService:
} }
async def for_post(self, post_id: int) -> dict | None: async def for_post(self, post_id: int) -> dict | None:
# Same LEFT JOIN to Source — get_post must succeed for a
# NULL-source post.
stmt = ( stmt = (
select(Post, Source, Artist) select(Post, Source, Artist)
.join(Artist, Artist.id == Post.artist_id) .join(Source, Source.id == Post.source_id)
.outerjoin(Source, Source.id == Post.source_id) .join(Artist, Artist.id == Source.artist_id)
.where(Post.id == post_id) .where(Post.id == post_id)
) )
row = (await self.session.execute(stmt)).first() row = (await self.session.execute(stmt)).first()
@@ -117,7 +111,7 @@ class ProvenanceService:
post, src, art = row post, src, art = row
return { return {
"post": _post_dict(post), "post": _post_dict(post),
"source": _source_dict(src) if src is not None else None, "source": _source_dict(src),
"artist": _artist_dict(art), "artist": _artist_dict(art),
"attachments": await self._attachments_for_posts([post.id]), "attachments": await self._attachments_for_posts([post.id]),
} }
+1 -1
View File
@@ -86,7 +86,7 @@ def attempt_refetch(
if src is None: if src is None:
return {"status": "no_source"} return {"status": "no_source"}
# Remove the bad copy so gallery-dl's archive-skip re-fetches it on # Remove the bad copy so gallery-dl (skip_existing) re-fetches it on
# the source re-check instead of skipping the still-present corrupt # the source re-check instead of skipping the still-present corrupt
# file. # file.
try: try:
@@ -1,337 +0,0 @@
"""FC-6.3 — assisted continuation matcher.
Scores a (post, candidate series) pair from several weighted signals; above the
configured threshold it records a *pending* SeriesSuggestion. Confirm-only — the
operator accepts (post becomes a chapter) or dismisses. No single signal gates;
the score is an additive weighted sum, each signal a 0..1 strength.
The learned "title pattern" isn't persisted — it's derived on the fly from the
post titles already in a series, so it sharpens automatically as more posts are
confirmed into the series.
"""
import re
import time
from sqlalchemy import and_, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ImageRecord, Post, Tag, TagKind
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage
from ..models.series_suggestion import SeriesSuggestion
from ..models.tag import image_tag
from .page_number_parser import parse_page_range
from .series_service import SeriesError, SeriesService
# Additive signal weights (sum to 1.0 → max score 1.0). Kept as constants;
# only the on/off + threshold are operator-tunable (sensitivity is the knob
# that matters; per-signal weights are an over-tune for v1).
WEIGHTS = {"title": 0.40, "artist": 0.20, "pages": 0.25, "tags": 0.15}
_DISTINCTIVE_KINDS = (TagKind.character, TagKind.series, TagKind.fandom)
_MAX_CANDIDATES = 50
# Strip installment markers so titles collapse to their stable stem.
_PAGE_TOKEN = re.compile(r"\b(?:pages?|pgs?|pp\.?)\s*\d+(?:\s*[-–—/]\s*\d+)?", re.I)
_BRACKET_NUM = re.compile(r"[\[(]\s*\d+\s*(?:/\s*\d+)?\s*[\])]")
_TRAILING_NUM = re.compile(r"[\s\-_#]*\d+\s*$")
def normalize_title(title: str | None) -> str:
if not title:
return ""
t = _PAGE_TOKEN.sub("", title)
t = _BRACKET_NUM.sub("", t)
t = _TRAILING_NUM.sub("", t)
return " ".join(t.split()).strip().lower()
def _common_prefix(strings: list[str]) -> str:
strings = [s for s in strings if s]
if not strings:
return ""
pre = strings[0]
for s in strings[1:]:
i = 0
while i < len(pre) and i < len(s) and pre[i] == s[i]:
i += 1
pre = pre[:i]
if not pre:
break
return pre.strip()
def title_signal(series_titles: list[str], post_title: str | None) -> float:
"""Overlap of the post title against the series' title stem (the longest
common prefix of its known titles, page/installment markers removed)."""
norm = [normalize_title(t) for t in series_titles]
norm = [t for t in norm if t]
pt = normalize_title(post_title)
if not norm or not pt:
return 0.0
stem = _common_prefix(norm)
if len(stem) < 3:
stem = max(norm, key=len)
i = 0
while i < len(stem) and i < len(pt) and stem[i] == pt[i]:
i += 1
return min(1.0, i / max(len(stem), 4))
def pages_signal(series_max_stated_end: int | None, post_start: int | None) -> float:
"""1.0 when the post's first page continues right after the series' last
stated page; partial for a near-continuation; 0 otherwise."""
if series_max_stated_end is None or post_start is None:
return 0.0
diff = post_start - series_max_stated_end
if diff == 1:
return 1.0
if 2 <= diff <= 3:
return 0.5
return 0.0
def tags_signal(shared_distinctive: int) -> float:
if shared_distinctive <= 0:
return 0.0
return min(1.0, shared_distinctive / 3.0)
def weighted_score(signals: dict) -> float:
return round(sum(WEIGHTS[k] * signals.get(k, 0.0) for k in WEIGHTS), 4)
class SeriesMatchService:
def __init__(self, session: AsyncSession):
self.session = session
async def _post_image_ids(self, post_id: int) -> list[int]:
rows = (
await self.session.execute(
select(ImageRecord.id).where(ImageRecord.primary_post_id == post_id)
)
).scalars().all()
return list(rows)
async def _series_image_ids(self, series_tag_id: int) -> set[int]:
rows = (
await self.session.execute(
select(SeriesPage.image_id).where(
SeriesPage.series_tag_id == series_tag_id
)
)
).scalars().all()
return set(rows)
async def _series_post_titles(self, series_tag_id: int) -> list[str]:
rows = (
await self.session.execute(
select(Post.post_title)
.select_from(SeriesPage)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.join(Post, Post.id == ImageRecord.primary_post_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
Post.post_title.isnot(None),
)
)
.distinct()
)
).scalars().all()
return [t for t in rows if t]
async def _series_max_stated_end(self, series_tag_id: int) -> int | None:
return await self.session.scalar(
select(func.max(SeriesChapter.stated_page_end)).where(
SeriesChapter.series_tag_id == series_tag_id
)
)
async def _distinctive_tags(self, image_ids: list[int] | set[int]) -> set[int]:
ids = list(image_ids)
if not ids:
return set()
rows = (
await self.session.execute(
select(image_tag.c.tag_id)
.join(Tag, Tag.id == image_tag.c.tag_id)
.where(
and_(
image_tag.c.image_record_id.in_(ids),
Tag.kind.in_(_DISTINCTIVE_KINDS),
)
)
.distinct()
)
).scalars().all()
return set(rows)
async def _candidate_series(self, artist_id: int) -> list[int]:
"""Series that already contain a page by this artist — cross-artist
series are rare, so same-artist is the candidate bound."""
rows = (
await self.session.execute(
select(SeriesPage.series_tag_id)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(ImageRecord.artist_id == artist_id)
.distinct()
)
).scalars().all()
return list(rows)
async def _decided_series(self, post_id: int) -> set[int]:
rows = (
await self.session.execute(
select(SeriesSuggestion.series_tag_id).where(
and_(
SeriesSuggestion.post_id == post_id,
SeriesSuggestion.status.in_(["added", "dismissed"]),
)
)
)
).scalars().all()
return set(rows)
async def match_post(self, post_id: int, *, threshold: float) -> int:
"""Score a post against its artist's series; upsert pending suggestions
for those at/above threshold. Returns the number written."""
post = await self.session.get(Post, post_id)
if post is None or post.artist_id is None:
return 0
post_images = await self._post_image_ids(post_id)
if not post_images:
return 0
post_image_set = set(post_images)
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
post_start = rng[0] if rng else None
post_dtags = await self._distinctive_tags(post_images)
decided = await self._decided_series(post_id)
candidates = await self._candidate_series(post.artist_id)
made = 0
for sid in candidates[:_MAX_CANDIDATES]:
if sid in decided:
continue
s_images = await self._series_image_ids(sid)
if post_image_set & s_images:
continue # the post is already (partly) in this series
signals = {
"title": title_signal(
await self._series_post_titles(sid), post.post_title
),
"artist": 1.0, # candidates are same-artist by construction
"pages": pages_signal(
await self._series_max_stated_end(sid), post_start
),
"tags": tags_signal(
len(post_dtags & await self._distinctive_tags(s_images))
),
}
score = weighted_score(signals)
if score < threshold:
continue
await self.session.execute(
pg_insert(SeriesSuggestion)
.values(
post_id=post_id, series_tag_id=sid,
score=score, signals=signals, status="pending",
)
.on_conflict_do_update(
constraint="uq_series_suggestion_post_series",
set_={"score": score, "signals": signals, "status": "pending"},
where=SeriesSuggestion.status == "pending",
)
)
made += 1
return made
# ---- queue ops --------------------------------------------------------
async def list_pending(self) -> list[dict]:
rows = (
await self.session.execute(
select(
SeriesSuggestion.id,
SeriesSuggestion.post_id,
SeriesSuggestion.series_tag_id,
SeriesSuggestion.score,
SeriesSuggestion.signals,
Post.post_title,
Post.external_post_id,
Tag.name.label("series_name"),
)
.join(Post, Post.id == SeriesSuggestion.post_id)
.join(Tag, Tag.id == SeriesSuggestion.series_tag_id)
.where(SeriesSuggestion.status == "pending")
.order_by(SeriesSuggestion.score.desc())
)
).all()
return [
{
"id": r.id,
"post_id": r.post_id,
"series_tag_id": r.series_tag_id,
"series_name": r.series_name,
"post_title": r.post_title or f"Post {r.external_post_id}",
"score": r.score,
"signals": r.signals or {},
}
for r in rows
]
async def accept(self, suggestion_id: int) -> dict:
s = await self.session.get(SeriesSuggestion, suggestion_id)
if s is None:
raise SeriesError(f"suggestion {suggestion_id} not found")
if s.status != "pending":
raise SeriesError(f"suggestion {suggestion_id} is already {s.status}")
out = await SeriesService(self.session).add_post_as_chapter(
s.series_tag_id, s.post_id
)
s.status = "added"
self.session.add(s)
return out
async def dismiss(self, suggestion_id: int) -> None:
s = await self.session.get(SeriesSuggestion, suggestion_id)
if s is None:
raise SeriesError(f"suggestion {suggestion_id} not found")
if s.status == "pending":
s.status = "dismissed"
self.session.add(s)
async def rescan(
self, *, threshold: float, time_budget_seconds: float | None = None,
after_post_id: int = 0,
) -> dict:
"""Score every post (id > after_post_id) against its artist's series,
time-boxed + resumable like the other long maintenance sweeps."""
post_ids = (
await self.session.execute(
select(Post.id)
.where(Post.id > after_post_id)
.order_by(Post.id.asc())
)
).scalars().all()
summary = {
"scanned": 0, "suggested": 0,
"partial": False, "resume_after_id": after_post_id,
}
start = time.monotonic()
for pid in post_ids:
summary["scanned"] += 1
summary["resume_after_id"] = pid
summary["suggested"] += await self.match_post(pid, threshold=threshold)
await self.session.commit() # commit per post so progress survives
if (
time_budget_seconds is not None
and time.monotonic() - start >= time_budget_seconds
):
summary["partial"] = True
break
else:
summary["partial"] = False
return summary
+34 -597
View File
@@ -1,8 +1,4 @@
"""Series = a series-kind Tag + ordered chapters, each holding ordered pages. """Series = a series-kind Tag + ordered series_page membership.
Reading order is (series_chapter.chapter_number, series_page.page_number). A
chapter may be a placeholder (no pages) reserving a slot. An image lives in at
most one series ⇒ one chapter (series_page.image_id is UNIQUE).
All mutations are Core/set-based and run in the request transaction. All mutations are Core/set-based and run in the request transaction.
""" """
@@ -11,11 +7,9 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, ImageRecord, Post, Tag, TagKind from ..models import ImageRecord, Tag, TagKind
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage from ..models.series_page import SeriesPage
from .gallery_service import thumbnail_url from .gallery_service import thumbnail_url
from .page_number_parser import parse_page_range
class SeriesError(ValueError): class SeriesError(ValueError):
@@ -27,8 +21,6 @@ class SeriesService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
# ---- guards / helpers -------------------------------------------------
async def _require_series(self, series_tag_id: int) -> Tag: async def _require_series(self, series_tag_id: int) -> Tag:
tag = await self.session.get(Tag, series_tag_id) tag = await self.session.get(Tag, series_tag_id)
if tag is None: if tag is None:
@@ -40,7 +32,7 @@ class SeriesService:
@staticmethod @staticmethod
def _clean_ids(ids: list[int]) -> list[int]: def _clean_ids(ids: list[int]) -> list[int]:
if not ids: if not ids:
raise SeriesError("ids must be a non-empty list") raise SeriesError("image_ids must be a non-empty list")
if len(ids) > 500: if len(ids) > 500:
raise SeriesError("selection too large (max 500)") raise SeriesError("selection too large (max 500)")
seen: set[int] = set() seen: set[int] = set()
@@ -51,172 +43,51 @@ class SeriesService:
out.append(int(x)) out.append(int(x))
return out return out
async def _require_chapter(self, series_tag_id: int, chapter_id: int): async def _member_order(self, series_tag_id: int) -> list[int]:
row = (
await self.session.execute(
select(SeriesChapter).where(
and_(
SeriesChapter.id == chapter_id,
SeriesChapter.series_tag_id == series_tag_id,
)
)
)
).scalar_one_or_none()
if row is None:
raise SeriesError(
f"chapter {chapter_id} is not in series {series_tag_id}"
)
return row
async def _ensure_default_chapter(self, series_tag_id: int) -> int:
"""Lowest-numbered chapter of the series, creating a first chapter if
the series has none (a fresh series, or the legacy single-chapter path)."""
existing = await self.session.scalar(
select(SeriesChapter.id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
.limit(1)
)
if existing is not None:
return existing
res = await self.session.execute(
pg_insert(SeriesChapter)
.values(series_tag_id=series_tag_id, chapter_number=1)
.returning(SeriesChapter.id)
)
return res.scalar_one()
async def _chapter_page_order(self, chapter_id: int) -> list[int]:
rows = ( rows = (
await self.session.execute( await self.session.execute(
select(SeriesPage.image_id) select(SeriesPage.image_id)
.where(SeriesPage.chapter_id == chapter_id) .where(SeriesPage.series_tag_id == series_tag_id)
.order_by(SeriesPage.page_number.asc()) .order_by(SeriesPage.page_number.asc())
) )
).scalars().all() ).scalars().all()
return list(rows) return list(rows)
async def _chapter_order(self, series_tag_id: int) -> list[int]:
rows = (
await self.session.execute(
select(SeriesChapter.id)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
)
).scalars().all()
return list(rows)
# ---- read -------------------------------------------------------------
@staticmethod
def _gaps(chapters: list[dict]) -> list[dict]:
"""Missing-page gaps between consecutive chapters with stated ranges."""
out: list[dict] = []
prev = None
for ch in chapters:
start = ch["stated_page_start"]
if (
prev is not None
and prev["stated_page_end"] is not None
and start is not None
and start > prev["stated_page_end"] + 1
):
out.append(
{
"after_chapter_id": prev["id"],
"start": prev["stated_page_end"] + 1,
"end": start - 1,
}
)
prev = ch
return out
async def list_pages(self, series_tag_id: int) -> dict: async def list_pages(self, series_tag_id: int) -> dict:
tag = await self._require_series(series_tag_id) tag = await self._require_series(series_tag_id)
rows = ( rows = (
await self.session.execute( await self.session.execute(
select( select(
SeriesChapter.id.label("chapter_id"),
SeriesChapter.chapter_number,
SeriesChapter.title,
SeriesChapter.is_placeholder,
SeriesChapter.stated_page_start,
SeriesChapter.stated_page_end,
SeriesPage.image_id, SeriesPage.image_id,
SeriesPage.page_number, SeriesPage.page_number,
SeriesPage.stated_page,
ImageRecord.sha256, ImageRecord.sha256,
ImageRecord.mime, ImageRecord.mime,
ImageRecord.path, ImageRecord.path,
ImageRecord.thumbnail_path, ImageRecord.thumbnail_path,
) )
.select_from(SeriesChapter) .join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.outerjoin(SeriesPage, SeriesPage.chapter_id == SeriesChapter.id) .where(SeriesPage.series_tag_id == series_tag_id)
.outerjoin(ImageRecord, ImageRecord.id == SeriesPage.image_id) .order_by(SeriesPage.page_number.asc())
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(
SeriesChapter.chapter_number.asc(),
SeriesPage.page_number.asc(),
)
) )
).all() ).all()
chapters: list[dict] = []
flat: list[dict] = []
by_id: dict[int, dict] = {}
for r in rows:
ch = by_id.get(r.chapter_id)
if ch is None:
ch = {
"id": r.chapter_id,
"chapter_number": r.chapter_number,
"title": r.title,
"is_placeholder": r.is_placeholder,
"stated_page_start": r.stated_page_start,
"stated_page_end": r.stated_page_end,
"pages": [],
}
by_id[r.chapter_id] = ch
chapters.append(ch)
if r.image_id is None:
continue # placeholder / empty chapter
page = {
"image_id": r.image_id,
"chapter_id": r.chapter_id,
"page_number": r.page_number,
"stated_page": r.stated_page,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
}
ch["pages"].append(page)
flat.append(page)
return { return {
"series": {"id": tag.id, "name": tag.name}, "series": {"id": tag.id, "name": tag.name},
"chapters": chapters, "pages": [
"pages": flat, # back-compat: flat reading order across chapters {
"gaps": self._gaps(chapters), "image_id": r.image_id,
"page_number": r.page_number,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
}
for r in rows
],
} }
# ---- pages ------------------------------------------------------------
async def add_images( async def add_images(
self, self, series_tag_id: int, image_ids: list[int]
series_tag_id: int,
image_ids: list[int],
chapter_id: int | None = None,
stated_pages: dict[int, int] | None = None,
) -> int: ) -> int:
"""Append images as pages of a chapter (the series' default chapter when
chapter_id is None). Images already in THIS series are left untouched;
images in another series are moved here (image_id is UNIQUE)."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
ids = self._clean_ids(image_ids) ids = self._clean_ids(image_ids)
if chapter_id is None:
chapter_id = await self._ensure_default_chapter(series_tag_id)
else:
await self._require_chapter(series_tag_id, chapter_id)
existing = dict( existing = dict(
( (
await self.session.execute( await self.session.execute(
@@ -225,30 +96,29 @@ class SeriesService:
) )
).all() ).all()
) )
# Already in THIS series → leave untouched (no churn).
to_add = [i for i in ids if existing.get(i) != series_tag_id] to_add = [i for i in ids if existing.get(i) != series_tag_id]
if not to_add: if not to_add:
return 0 return 0
# Move: drop any prior membership for these images (UNIQUE image_id). # Remove any prior membership for the to_add images (the "move").
await self.session.execute( await self.session.execute(
SeriesPage.__table__.delete().where(SeriesPage.image_id.in_(to_add)) SeriesPage.__table__.delete().where(
SeriesPage.image_id.in_(to_add)
)
) )
max_pn = ( max_pn = (
await self.session.scalar( await self.session.scalar(
select(func.coalesce(func.max(SeriesPage.page_number), 0)).where( select(func.coalesce(func.max(SeriesPage.page_number), 0))
SeriesPage.chapter_id == chapter_id .where(SeriesPage.series_tag_id == series_tag_id)
)
) )
) or 0 ) or 0
sp = stated_pages or {}
await self.session.execute( await self.session.execute(
pg_insert(SeriesPage).values( pg_insert(SeriesPage).values(
[ [
{ {
"series_tag_id": series_tag_id, "series_tag_id": series_tag_id,
"chapter_id": chapter_id,
"image_id": iid, "image_id": iid,
"page_number": max_pn + offset, "page_number": max_pn + offset,
"stated_page": sp.get(iid),
} }
for offset, iid in enumerate(to_add, start=1) for offset, iid in enumerate(to_add, start=1)
] ]
@@ -271,465 +141,32 @@ class SeriesService:
) )
return res.rowcount or 0 return res.rowcount or 0
async def reorder_pages( async def reorder(
self, series_tag_id: int, chapter_id: int, ordered_image_ids: list[int] self, series_tag_id: int, ordered_image_ids: list[int]
) -> None: ) -> None:
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id)
ordered = self._clean_ids(ordered_image_ids) ordered = self._clean_ids(ordered_image_ids)
current = set(await self._chapter_page_order(chapter_id)) current = set(await self._member_order(series_tag_id))
if set(ordered) != current or len(ordered) != len(current): if set(ordered) != current or len(ordered) != len(current):
raise SeriesError( raise SeriesError(
"ordered image_ids must exactly match the chapter's pages" "ordered image_ids must exactly match series membership"
) )
for idx, iid in enumerate(ordered, start=1): for idx, iid in enumerate(ordered, start=1):
await self.session.execute( await self.session.execute(
update(SeriesPage) update(SeriesPage)
.where( .where(
and_( and_(
SeriesPage.chapter_id == chapter_id, SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == iid, SeriesPage.image_id == iid,
) )
) )
.values(page_number=idx) .values(page_number=idx)
) )
async def reorder(
self, series_tag_id: int, ordered_image_ids: list[int]
) -> None:
"""Legacy series-wide reorder — reorders the default chapter. Valid only
for single-chapter series (the only shape the pre-chapter UI produced);
multi-chapter series must use reorder_pages with an explicit chapter."""
await self._require_series(series_tag_id)
chapters = await self._chapter_order(series_tag_id)
if len(chapters) > 1:
raise SeriesError(
"series has multiple chapters; reorder a specific chapter"
)
chapter_id = (
chapters[0]
if chapters
else await self._ensure_default_chapter(series_tag_id)
)
await self.reorder_pages(series_tag_id, chapter_id, ordered_image_ids)
# ---- chapters ---------------------------------------------------------
async def create_chapter(
self,
series_tag_id: int,
*,
title: str | None = None,
is_placeholder: bool = False,
stated_page_start: int | None = None,
stated_page_end: int | None = None,
) -> dict:
await self._require_series(series_tag_id)
max_cn = (
await self.session.scalar(
select(
func.coalesce(func.max(SeriesChapter.chapter_number), 0)
).where(SeriesChapter.series_tag_id == series_tag_id)
)
) or 0
res = await self.session.execute(
pg_insert(SeriesChapter)
.values(
series_tag_id=series_tag_id,
chapter_number=max_cn + 1,
title=title,
is_placeholder=is_placeholder,
stated_page_start=stated_page_start,
stated_page_end=stated_page_end,
)
.returning(SeriesChapter.id, SeriesChapter.chapter_number)
)
row = res.one()
return {"id": row.id, "chapter_number": row.chapter_number}
async def update_chapter(
self,
series_tag_id: int,
chapter_id: int,
*,
title: str | None = None,
stated_page_start: int | None = None,
stated_page_end: int | None = None,
set_title: bool = False,
set_start: bool = False,
set_end: bool = False,
) -> None:
"""Partial chapter edit. The set_* flags say which fields to write (so
None can be written explicitly, e.g. clearing a stated page)."""
await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id)
values: dict = {}
if set_title:
values["title"] = title
if set_start:
values["stated_page_start"] = stated_page_start
if set_end:
values["stated_page_end"] = stated_page_end
if not values:
return
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == chapter_id)
.values(**values)
)
async def _renumber_chapters(self, series_tag_id: int) -> None:
for idx, cid in enumerate(
await self._chapter_order(series_tag_id), start=1
):
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == cid)
.values(chapter_number=idx)
)
async def reorder_chapters(
self, series_tag_id: int, ordered_chapter_ids: list[int]
) -> None:
await self._require_series(series_tag_id)
ordered = self._clean_ids(ordered_chapter_ids)
current = set(await self._chapter_order(series_tag_id))
if set(ordered) != current or len(ordered) != len(current):
raise SeriesError(
"ordered chapter_ids must exactly match the series' chapters"
)
for idx, cid in enumerate(ordered, start=1):
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.id == cid)
.values(chapter_number=idx)
)
async def delete_chapter(self, series_tag_id: int, chapter_id: int) -> None:
await self._require_series(series_tag_id)
await self._require_chapter(series_tag_id, chapter_id)
# Pages cascade-delete with the chapter (FK ondelete=CASCADE).
await self.session.execute(
SeriesChapter.__table__.delete().where(
SeriesChapter.id == chapter_id
)
)
await self._renumber_chapters(series_tag_id)
async def merge_chapter(
self, series_tag_id: int, source_chapter_id: int, target_chapter_id: int
) -> int:
"""Move source chapter's pages onto the end of the target chapter, then
delete the now-empty source chapter. Returns the number of pages moved."""
await self._require_series(series_tag_id)
if source_chapter_id == target_chapter_id:
raise SeriesError("cannot merge a chapter into itself")
await self._require_chapter(series_tag_id, source_chapter_id)
await self._require_chapter(series_tag_id, target_chapter_id)
moving = await self._chapter_page_order(source_chapter_id)
max_pn = (
await self.session.scalar(
select(func.coalesce(func.max(SeriesPage.page_number), 0)).where(
SeriesPage.chapter_id == target_chapter_id
)
)
) or 0
for offset, iid in enumerate(moving, start=1):
await self.session.execute(
update(SeriesPage)
.where(
and_(
SeriesPage.chapter_id == source_chapter_id,
SeriesPage.image_id == iid,
)
)
.values(chapter_id=target_chapter_id, page_number=max_pn + offset)
)
await self.session.execute(
SeriesChapter.__table__.delete().where(
SeriesChapter.id == source_chapter_id
)
)
await self._renumber_chapters(series_tag_id)
return len(moving)
async def set_cover(self, series_tag_id: int, image_id: int) -> None: async def set_cover(self, series_tag_id: int, image_id: int) -> None:
"""Cover = the (chapter_number=1, page_number=1) image. Bring the image's
chapter to the front and the image to the front of its chapter."""
await self._require_series(series_tag_id) await self._require_series(series_tag_id)
row = ( order = await self._member_order(series_tag_id)
await self.session.execute( if image_id not in order:
select(SeriesPage.chapter_id)
.where(
and_(
SeriesPage.series_tag_id == series_tag_id,
SeriesPage.image_id == image_id,
)
)
)
).scalar_one_or_none()
if row is None:
raise SeriesError(f"image {image_id} is not in this series") raise SeriesError(f"image {image_id} is not in this series")
chapter_id = row new_order = [image_id] + [i for i in order if i != image_id]
chapters = await self._chapter_order(series_tag_id) await self.reorder(series_tag_id, new_order)
if chapters and chapters[0] != chapter_id:
new_chapters = [chapter_id] + [c for c in chapters if c != chapter_id]
await self.reorder_chapters(series_tag_id, new_chapters)
pages = await self._chapter_page_order(chapter_id)
if pages and pages[0] != image_id:
new_pages = [image_id] + [p for p in pages if p != image_id]
await self.reorder_pages(series_tag_id, chapter_id, new_pages)
# ---- post → series (FC-6.2) ------------------------------------------
async def _post_images_ordered(self, post_id: int) -> list[int]:
"""A post's images in capture order (ImageRecord.primary_post_id), the
same ordering the posts feed renders thumbnails in."""
rows = (
await self.session.execute(
select(ImageRecord.id)
.where(ImageRecord.primary_post_id == post_id)
.order_by(ImageRecord.id.asc())
)
).scalars().all()
return list(rows)
@staticmethod
def _stated_map(image_ids: list[int], start: int | None) -> dict | None:
"""Per-image stated_page = start, start+1, ... when the post stated a
starting page; None when it didn't (fall back to capture order)."""
if start is None:
return None
return {iid: start + i for i, iid in enumerate(image_ids)}
async def promote_post_to_series(self, post_id: int) -> dict:
"""Case 1: a self-contained multi-image post becomes its own series.
Creates a series tag named after the post, one chapter, the post's
images as its pages (stated pages parsed from the post when present)."""
from .tag_service import TagService
post = await self.session.get(Post, post_id)
if post is None:
raise SeriesError(f"post {post_id} not found")
image_ids = await self._post_images_ordered(post_id)
if not image_ids:
raise SeriesError("post has no images to make a series from")
name = (post.post_title or f"Series from post {post_id}").strip()[:200]
tag = await TagService(self.session).find_or_create(name, TagKind.series)
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
start, end = rng if rng else (None, None)
ch = await self.create_chapter(
tag.id, stated_page_start=start, stated_page_end=end,
)
added = await self.add_images(
tag.id, image_ids, chapter_id=ch["id"],
stated_pages=self._stated_map(image_ids, start),
)
return {
"series_tag_id": tag.id, "name": tag.name,
"chapter_id": ch["id"], "added": added,
}
async def add_post_as_chapter(
self, series_tag_id: int, post_id: int
) -> dict:
"""Case 2: append a post as the next chapter of an existing series. The
chapter is titled after the post and slotted by parsed page number when
present (else appended at the end)."""
await self._require_series(series_tag_id)
post = await self.session.get(Post, post_id)
if post is None:
raise SeriesError(f"post {post_id} not found")
image_ids = await self._post_images_ordered(post_id)
if not image_ids:
raise SeriesError("post has no images to add")
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
start, end = rng if rng else (None, None)
ch = await self.create_chapter(
series_tag_id, title=post.post_title or None,
stated_page_start=start, stated_page_end=end,
)
added = await self.add_images(
series_tag_id, image_ids, chapter_id=ch["id"],
stated_pages=self._stated_map(image_ids, start),
)
if start is not None:
await self._place_chapter_by_stated(series_tag_id, ch["id"], start)
return {
"series_tag_id": series_tag_id, "chapter_id": ch["id"],
"added": added,
}
async def _place_chapter_by_stated(
self, series_tag_id: int, chapter_id: int, start: int
) -> None:
"""Move `chapter_id` to sit before the first chapter whose stated start
is higher — so a post stating pages 5-8 lands ahead of the 9-12 chapter."""
rows = (
await self.session.execute(
select(SeriesChapter.id, SeriesChapter.stated_page_start)
.where(SeriesChapter.series_tag_id == series_tag_id)
.order_by(SeriesChapter.chapter_number.asc())
)
).all()
current = [r.id for r in rows]
others = [r for r in rows if r.id != chapter_id]
insert_at = len(others)
for idx, r in enumerate(others):
if r.stated_page_start is not None and r.stated_page_start > start:
insert_at = idx
break
new_order = [r.id for r in others]
new_order.insert(insert_at, chapter_id)
if new_order != current:
await self.reorder_chapters(series_tag_id, new_order)
# ---- browse list (FC-6.2) --------------------------------------------
async def list_series(
self, *, sort: str = "recent", artist_id: int | None = None
) -> list[dict]:
"""Series cards for the browse view: cover thumb, name, artist, chapter
+ page counts, a gap flag, and last-updated. sort ∈ recent|name|size."""
# Page counts + most-recent activity per series.
page_rows = (
await self.session.execute(
select(
SeriesPage.series_tag_id,
func.count().label("pages"),
).group_by(SeriesPage.series_tag_id)
)
).all()
page_count = {r.series_tag_id: r.pages for r in page_rows}
ch_rows = (
await self.session.execute(
select(
SeriesChapter.series_tag_id,
SeriesChapter.id,
SeriesChapter.chapter_number,
SeriesChapter.stated_page_start,
SeriesChapter.stated_page_end,
SeriesChapter.updated_at,
)
.order_by(
SeriesChapter.series_tag_id,
SeriesChapter.chapter_number.asc(),
)
)
).all()
chapters_by_series: dict[int, list] = {}
updated_by_series: dict[int, object] = {}
for r in ch_rows:
chapters_by_series.setdefault(r.series_tag_id, []).append(r)
prev = updated_by_series.get(r.series_tag_id)
if prev is None or (r.updated_at and r.updated_at > prev):
updated_by_series[r.series_tag_id] = r.updated_at
# Cover = the (chapter_number, page_number)-minimum page per series.
cover_q = (
select(
SeriesPage.series_tag_id,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.thumbnail_path,
ImageRecord.artist_id,
func.row_number()
.over(
partition_by=SeriesPage.series_tag_id,
order_by=(
SeriesChapter.chapter_number.asc(),
SeriesPage.page_number.asc(),
),
)
.label("rn"),
)
.join(SeriesChapter, SeriesChapter.id == SeriesPage.chapter_id)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.subquery()
)
cover_rows = (
await self.session.execute(
select(
cover_q.c.series_tag_id,
cover_q.c.sha256,
cover_q.c.mime,
cover_q.c.thumbnail_path,
cover_q.c.artist_id,
).where(cover_q.c.rn == 1)
)
).all()
cover_by_series = {r.series_tag_id: r for r in cover_rows}
# Artist names for the covers.
artist_ids = {
r.artist_id for r in cover_rows if r.artist_id is not None
}
artist_by_id: dict[int, object] = {}
if artist_ids:
arows = (
await self.session.execute(
select(Artist.id, Artist.name, Artist.slug)
.where(Artist.id.in_(artist_ids))
)
).all()
artist_by_id = {a.id: a for a in arows}
tags = (
await self.session.execute(
select(Tag.id, Tag.name).where(Tag.kind == TagKind.series)
)
).all()
out: list[dict] = []
for t in tags:
cover = cover_by_series.get(t.id)
if artist_id is not None and (
cover is None or cover.artist_id != artist_id
):
continue
chs = chapters_by_series.get(t.id, [])
gap = bool(
self._gaps(
[
{
"id": c.id,
"stated_page_start": c.stated_page_start,
"stated_page_end": c.stated_page_end,
}
for c in chs
]
)
)
artist = artist_by_id.get(cover.artist_id) if cover else None
out.append(
{
"id": t.id,
"name": t.name,
"cover_thumbnail_url": (
thumbnail_url(
cover.thumbnail_path, cover.sha256, cover.mime
)
if cover
else None
),
"artist_name": artist.name if artist else None,
"artist_slug": artist.slug if artist else None,
"chapter_count": len(chs),
"page_count": page_count.get(t.id, 0),
"has_gap": gap,
"updated_at": (
updated_by_series.get(t.id).isoformat()
if updated_by_series.get(t.id)
else None
),
}
)
if sort == "name":
out.sort(key=lambda s: s["name"].lower())
elif sort == "size":
out.sort(key=lambda s: s["page_count"], reverse=True)
else: # recent
out.sort(key=lambda s: s["updated_at"] or "", reverse=True)
return out
+3 -12
View File
@@ -20,20 +20,11 @@ class ShowcaseService:
async def random_sample(self, limit: int = 60) -> list[dict]: async def random_sample(self, limit: int = 60) -> list[dict]:
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")
# Over-sample then random-order (#699): SYSTEM_ROWS reads CONTIGUOUS rows
# from each sampled page, so sequentially-imported near-duplicates
# (multi-image posts, variant sets) come back adjacent and cluster in the
# showcase ("three near-identical in a row"). Sampling a multiple of
# `limit` spans more pages, and ORDER BY random() before taking `limit`
# breaks the physical adjacency — far better spread, still cheap
# (random() over a few hundred rows, not the whole table).
oversample = min(limit * 5, 1000)
stmt = select(ImageRecord).from_statement( stmt = select(ImageRecord).from_statement(
text( text(
"SELECT * FROM (" "SELECT * FROM image_record "
" SELECT * FROM image_record TABLESAMPLE SYSTEM_ROWS(:o)" "TABLESAMPLE SYSTEM_ROWS(:n)"
") sub ORDER BY random() LIMIT :n" ).bindparams(n=limit)
).bindparams(o=oversample, n=limit)
) )
rows = (await self.session.execute(stmt)).scalars().all() rows = (await self.session.execute(stmt)).scalars().all()
return [ return [
+1 -113
View File
@@ -59,20 +59,9 @@ class SourceRecord:
config_overrides: dict | None config_overrides: dict | None
last_checked_at: str | None last_checked_at: str | None
last_error: str | None last_error: str | None
error_type: str | None
check_interval_override: int | None check_interval_override: int | None
consecutive_failures: int consecutive_failures: int
next_check_at: str | None next_check_at: str | None
backfill_runs_remaining: int
# plan #693: derived from config_overrides for the UI badge.
backfill_state: str | None # "running" | "complete" | "stalled" | None (idle)
backfill_chunks: int
# plan #697: a running deep-walk that bypasses the Patreon seen-ledger
# (recovery) vs. a normal backfill. Lets the badge label it "Recovering".
backfill_bypass_seen: bool
# plan #704: cumulative posts processed across the walk's chunks — live
# progress for the badge.
backfill_posts: int
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -86,15 +75,9 @@ class SourceRecord:
"config_overrides": self.config_overrides, "config_overrides": self.config_overrides,
"last_checked_at": self.last_checked_at, "last_checked_at": self.last_checked_at,
"last_error": self.last_error, "last_error": self.last_error,
"error_type": self.error_type,
"check_interval_override": self.check_interval_override, "check_interval_override": self.check_interval_override,
"consecutive_failures": self.consecutive_failures, "consecutive_failures": self.consecutive_failures,
"next_check_at": self.next_check_at, "next_check_at": self.next_check_at,
"backfill_runs_remaining": self.backfill_runs_remaining,
"backfill_state": self.backfill_state,
"backfill_chunks": self.backfill_chunks,
"backfill_bypass_seen": self.backfill_bypass_seen,
"backfill_posts": self.backfill_posts,
} }
@@ -102,14 +85,6 @@ class SourceRecord:
_EDITABLE = {"enabled", "url", "config_overrides", "check_interval_override", "platform"} _EDITABLE = {"enabled", "url", "config_overrides", "check_interval_override", "platform"}
# Plan #693: backfill safety cap. "Start backfill" (and a newly created
# enabled source) arms a run-until-done walk; this caps how many time-boxed
# chunks it may spend before pausing as "stalled", so a pathological walk that
# never reaches the bottom can't run forever. Generous on purpose — at
# BACKFILL_CHUNK_SECONDS (600s) per chunk this is ~33h of cumulative walk, far
# beyond any real catalog; the cursor stall-guard is the real terminator.
BACKFILL_MAX_CHUNKS = 200
class SourceService: class SourceService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
@@ -151,7 +126,6 @@ class SourceService:
self, source: Source, artist: Artist, settings: ImportSettings, self, source: Source, artist: Artist, settings: ImportSettings,
) -> SourceRecord: ) -> SourceRecord:
nxt = compute_next_check_at(source, artist, settings) nxt = compute_next_check_at(source, artist, settings)
co = source.config_overrides or {}
return SourceRecord( return SourceRecord(
id=source.id, id=source.id,
artist_id=source.artist_id, artist_id=source.artist_id,
@@ -163,15 +137,9 @@ class SourceService:
config_overrides=source.config_overrides, config_overrides=source.config_overrides,
last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None, last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None,
last_error=source.last_error, last_error=source.last_error,
error_type=source.error_type,
check_interval_override=source.check_interval_override, check_interval_override=source.check_interval_override,
consecutive_failures=source.consecutive_failures or 0, consecutive_failures=source.consecutive_failures or 0,
next_check_at=nxt.isoformat() if nxt else None, next_check_at=nxt.isoformat() if nxt else None,
backfill_runs_remaining=source.backfill_runs_remaining or 0,
backfill_state=co.get("_backfill_state"),
backfill_chunks=int(co.get("_backfill_chunks", 0)),
backfill_bypass_seen=bool(co.get("_backfill_bypass_seen")),
backfill_posts=int(co.get("_backfill_posts", 0)),
) )
async def _row_to_record(self, source: Source) -> SourceRecord: async def _row_to_record(self, source: Source) -> SourceRecord:
@@ -189,7 +157,7 @@ class SourceService:
if artist_id is not None: if artist_id is not None:
stmt = stmt.where(Source.artist_id == artist_id) stmt = stmt.where(Source.artist_id == artist_id)
if not include_synthetic: if not include_synthetic:
# Pre-alembic-0030 sidecar synthetic anchors # Filesystem-import sidecar anchors (importer._source_for_sidecar)
# have url='sidecar:<platform>:<slug>' and exist only to give # have url='sidecar:<platform>:<slug>' and exist only to give
# imported Posts a NOT-NULL Source FK. They aren't pollable # imported Posts a NOT-NULL Source FK. They aren't pollable
# feeds; the Subscriptions UI used to render them as phantom # feeds; the Subscriptions UI used to render them as phantom
@@ -233,22 +201,10 @@ class SourceService:
select(func.count(Source.id)).where(Source.artist_id == artist_id) select(func.count(Source.id)).where(Source.artist_id == artist_id)
)).scalar_one() )).scalar_one()
# Plan #693: a freshly added subscription has no archive yet, so it
# should walk its full post history once. Arm run-until-done backfill
# (state="running" + the chunk cap); the time-boxed chunks march to the
# bottom across ticks, then flip to "complete" and tick mode takes over.
# Disabled sources (incl. sidecar synthetics, url='sidecar:...') are
# never polled, so leave them idle.
if enabled:
config_overrides = {**(config_overrides or {}), "_backfill_state": "running"}
backfill_runs = BACKFILL_MAX_CHUNKS
else:
backfill_runs = 0
source = Source( source = Source(
artist_id=artist_id, platform=platform, url=url, artist_id=artist_id, platform=platform, url=url,
enabled=enabled, config_overrides=config_overrides, enabled=enabled, config_overrides=config_overrides,
check_interval_override=check_interval_override, check_interval_override=check_interval_override,
backfill_runs_remaining=backfill_runs,
) )
self.session.add(source) self.session.add(source)
try: try:
@@ -308,74 +264,6 @@ class SourceService:
await self.session.commit() await self.session.commit()
return await self._row_to_record(source) return await self._row_to_record(source)
async def start_backfill(self, source_id: int) -> SourceRecord:
"""Plan #693: arm a run-until-done backfill. Sets state="running" and
the chunk cap; download runs then walk the full post history in
time-boxed chunks (skip:True + BACKFILL_CHUNK_SECONDS), resuming from
the cursor each chunk, until gallery-dl reaches the bottom (→ state
"complete") or the cap/stall-guard pauses it (→ "stalled"). Clears any
prior cursor/chunk/stall state so a re-start walks fresh from the top."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
co = dict(source.config_overrides or {})
co["_backfill_state"] = "running"
for k in ("_backfill_cursor", "_backfill_cursor_stalls", "_backfill_chunks",
"_backfill_posts"):
co.pop(k, None)
source.config_overrides = co
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
await self.session.commit()
return await self._row_to_record(source)
async def start_recovery(self, source_id: int) -> SourceRecord:
"""Plan #697: arm a RECOVERY walk — a backfill that bypasses the Patreon
seen-ledger so deliberately-dropped-and-deleted near-dups get re-fetched
and re-evaluated under the CURRENT pHash threshold (tier-2 disk still
spares files we kept). Reuses the entire #693 backfill state machine
(time-boxed chunks, cursor checkpoint, complete/stall lifecycle) plus the
`_backfill_bypass_seen` flag that flips download mode to recovery. Clears
any prior cursor/chunk/stall state so it walks fresh from the top. The
flag is cleared on completion (download_service) and on stop.
Recovery is Patreon-only (the seen-ledger is Patreon's); for other
platforms the flag is inert (download_service ignores it) and the walk
runs as a plain backfill. The UI gates the action to Patreon sources."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
co = dict(source.config_overrides or {})
co["_backfill_state"] = "running"
co["_backfill_bypass_seen"] = True
for k in ("_backfill_cursor", "_backfill_cursor_stalls", "_backfill_chunks",
"_backfill_posts"):
co.pop(k, None)
source.config_overrides = co
source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS
await self.session.commit()
return await self._row_to_record(source)
async def stop_backfill(self, source_id: int) -> SourceRecord:
"""Plan #693: cancel an in-progress backfill — back to idle/tick mode.
Clears the running state + cursor/chunk/stall bookkeeping."""
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
)).scalar_one_or_none()
if source is None:
raise LookupError(f"source id={source_id} not found")
co = dict(source.config_overrides or {})
for k in ("_backfill_state", "_backfill_cursor", "_backfill_cursor_stalls",
"_backfill_chunks", "_backfill_bypass_seen", "_backfill_posts"):
co.pop(k, None)
source.config_overrides = co
source.backfill_runs_remaining = 0
await self.session.commit()
return await self._row_to_record(source)
async def delete(self, source_id: int) -> None: async def delete(self, source_id: int) -> None:
source = (await self.session.execute( source = (await self.session.execute(
select(Source).where(Source.id == source_id) select(Source).where(Source.id == source_id)
+9 -333
View File
@@ -1,38 +1,16 @@
"""Tag CRUD + autocomplete + image-tag association.""" """Tag CRUD + autocomplete + image-tag association."""
import logging
import time
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import and_, case, exists, func, select, text, update from sqlalchemy import and_, case, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Tag, TagKind, image_tag from ..models import Tag, TagKind, image_tag
from ..models.tag_allowlist import TagAllowlist from ..models.tag_allowlist import TagAllowlist
from ..models.tag_reference_embedding import TagReferenceEmbedding from ..models.tag_reference_embedding import TagReferenceEmbedding
log = logging.getLogger(__name__)
def normalize_tag_name(name: str) -> str:
"""Canonical tag form (#701): collapse whitespace + capitalize the first
letter of each word, PRESERVING the rest of the word.
Per-word capitalize (NOT str.title(), which mangles apostrophes:
don't → Don'T). The word tail is left untouched so acronyms survive
(DC stays DC, NSFW stays NSFW) — operator-revised 2026-06-06: protecting
acronyms matters more than folding ALL-CAPS input. This MATCHES
ml/tag_name._title_word, so a tag suggested by the Camie tagger keeps the
exact casing the suggestion UI showed when it round-trips through the
create endpoint on Accept.
"""
return " ".join(
w[:1].upper() + w[1:] for w in (name or "").split()
)
class TagValidationError(ValueError): class TagValidationError(ValueError):
"""Raised when tag construction breaks the kind/fandom rules.""" """Raised when tag construction breaks the kind/fandom rules."""
@@ -92,10 +70,6 @@ class TagService:
- if fandom_id is set, the referenced tag must exist and have - if fandom_id is set, the referenced tag must exist and have
kind == TagKind.fandom kind == TagKind.fandom
""" """
# NOTE: case is NOT normalized here — find_or_create is the shared path
# the ML tagger / allowlist also use, and Title-Casing the booru
# vocabulary breaks allowlist matching. Display-casing (Title Case) is
# applied at the user-entry layer (api/tags create_tag) only (#701).
name = name.strip() name = name.strip()
if not name: if not name:
raise TagValidationError("Tag name cannot be empty") raise TagValidationError("Tag name cannot be empty")
@@ -112,38 +86,25 @@ class TagService:
f"fandom_id {fandom_id} does not reference a fandom tag" f"fandom_id {fandom_id} does not reference a fandom tag"
) )
# Audit 2026-06-02: race-safe upsert via savepoint + # Upsert via INSERT ... ON CONFLICT DO NOTHING. We can't use the
# IntegrityError recovery. The partial uniqueness index on # uniqueness index name directly (it's a partial coalesce-based
# (name, kind, COALESCE(fandom_id, -1)) catches concurrent # expression), so we re-select after insert.
# inserts; without the savepoint the outer transaction would
# poison and the calling request crashes. Mirrors
# importer._get_or_create.
# Case-insensitive match (#701): a normalized (Title Case) input must
# find an existing differently-cased tag instead of forking a duplicate.
stmt = ( stmt = (
select(Tag) select(Tag)
.where(func.lower(Tag.name) == name.lower()) .where(Tag.name == name)
.where(Tag.kind == kind) .where(Tag.kind == kind)
.where( .where(
Tag.fandom_id.is_(None) if fandom_id is None else Tag.fandom_id == fandom_id Tag.fandom_id.is_(None) if fandom_id is None else Tag.fandom_id == fandom_id
) )
.order_by(Tag.id)
.limit(1)
) )
existing = (await self.session.execute(stmt)).scalar_one_or_none() existing = (await self.session.execute(stmt)).scalar_one_or_none()
if existing: if existing:
return existing return existing
sp = await self.session.begin_nested() new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
try: self.session.add(new_tag)
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id) await self.session.flush()
self.session.add(new_tag) return new_tag
await self.session.flush()
await sp.commit()
return new_tag
except IntegrityError:
await sp.rollback()
return (await self.session.execute(stmt)).scalar_one()
async def autocomplete( async def autocomplete(
self, self,
@@ -278,11 +239,9 @@ class TagService:
if tag is None: if tag is None:
raise TagValidationError(f"Tag {tag_id} not found") raise TagValidationError(f"Tag {tag_id} not found")
# Case-insensitive clash (#701) — renaming onto a differently-cased tag
# is still a merge, not a silent fork.
clash_stmt = ( clash_stmt = (
select(Tag) select(Tag)
.where(func.lower(Tag.name) == new_name.lower()) .where(Tag.name == new_name)
.where(Tag.kind == tag.kind) .where(Tag.kind == tag.kind)
.where( .where(
Tag.fandom_id.is_(None) Tag.fandom_id.is_(None)
@@ -290,8 +249,6 @@ class TagService:
else Tag.fandom_id == tag.fandom_id else Tag.fandom_id == tag.fandom_id
) )
.where(Tag.id != tag_id) .where(Tag.id != tag_id)
.order_by(Tag.id)
.limit(1)
) )
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none() clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
if clash is not None: if clash is not None:
@@ -313,69 +270,6 @@ class TagService:
await self.session.flush() await self.session.flush()
return tag return tag
async def set_fandom(
self, tag_id: int, fandom_id: int | None, *, merge: bool = False
) -> Tag:
"""Set / change / clear a character tag's fandom.
Raises TagValidationError unless the tag is a character and fandom_id
(when given) references a fandom tag. If the change would collide with
an existing character of the same name in the TARGET fandom, raises
TagMergeConflict (the API turns that into a 409 merge hint) — unless
merge=True, in which case this tag is merged INTO that existing
character (a deliberate cross-fandom merge) and the surviving target
is returned. Passing fandom_id=None clears the fandom.
"""
tag = await self.session.get(Tag, tag_id)
if tag is None:
raise TagValidationError(f"Tag {tag_id} not found")
if tag.kind != TagKind.character:
raise TagValidationError("Only character tags can have a fandom")
if fandom_id is not None:
fandom = await self.session.get(Tag, fandom_id)
if fandom is None or fandom.kind != TagKind.fandom:
raise TagValidationError(
f"fandom_id {fandom_id} does not reference a fandom tag"
)
if fandom_id == tag.fandom_id:
return tag
# Collision: another character with the same name already lives in the
# target fandom. Mirrors rename's (name, kind, fandom_id) uniqueness.
clash_stmt = (
select(Tag)
.where(Tag.name == tag.name)
.where(Tag.kind == TagKind.character)
.where(
Tag.fandom_id.is_(None)
if fandom_id is None
else Tag.fandom_id == fandom_id
)
.where(Tag.id != tag_id)
)
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
if clash is not None:
if not merge:
source_image_count = await self.session.scalar(
select(func.count())
.select_from(image_tag)
.where(image_tag.c.tag_id == tag_id)
)
will_alias = await self._keep_as_alias(tag_id)
raise TagMergeConflict(
f"A character named {tag.name!r} already exists in that fandom",
target_id=clash.id,
target_name=clash.name,
source_image_count=int(source_image_count or 0),
will_alias=will_alias,
)
await self._do_merge(tag, clash)
return clash
tag.fandom_id = fandom_id
await self.session.flush()
return tag
async def merge(self, source_id: int, target_id: int) -> MergeResult: async def merge(self, source_id: int, target_id: int) -> MergeResult:
"""Transactionally repoint every FK from source→target, optionally """Transactionally repoint every FK from source→target, optionally
keep source's name as a tagger alias, delete source. Atomic: any keep source's name as a tagger alias, delete source. Atomic: any
@@ -394,14 +288,7 @@ class TagService:
raise TagValidationError( raise TagValidationError(
"Tags must be the same kind and fandom to merge" "Tags must be the same kind and fandom to merge"
) )
return await self._do_merge(source, target)
async def _do_merge(self, source: Tag, target: Tag) -> MergeResult:
"""Repoint every FK source→target, optionally keep source's name as a
tagger alias, delete source. NO kind/fandom validation — callers that
need it (public merge()) validate first; set_fandom's collision
resolution calls this directly for a deliberate CROSS-fandom merge."""
source_id, target_id = source.id, target.id
keep_as_alias = await self._keep_as_alias(source_id) keep_as_alias = await self._keep_as_alias(source_id)
source_name = source.name source_name = source.name
source_kind = source.kind source_kind = source.kind
@@ -515,18 +402,8 @@ class TagService:
) )
async def _repoint_series_pages(self, src: int, tgt: int) -> None: async def _repoint_series_pages(self, src: int, tgt: int) -> None:
from ..models.series_chapter import SeriesChapter
from ..models.series_page import SeriesPage from ..models.series_page import SeriesPage
# Move the chapters first so the pages' chapter_id FK stays valid: a
# chapter left pointing at src would cascade-delete (with its pages) when
# src is removed. chapter_number may now collide across the merged set —
# acceptable (it's an ordering key, not unique).
await self.session.execute(
update(SeriesChapter)
.where(SeriesChapter.series_tag_id == src)
.values(series_tag_id=tgt)
)
# image_id is UNIQUE across series_page and src != tgt, so an # image_id is UNIQUE across series_page and src != tgt, so an
# image in src's series cannot already be in tgt's — no collision. # image in src's series cannot already be in tgt's — no collision.
await self.session.execute( await self.session.execute(
@@ -544,21 +421,6 @@ class TagService:
update(Tag).where(Tag.fandom_id == src).values(fandom_id=tgt) update(Tag).where(Tag.fandom_id == src).values(fandom_id=tgt)
) )
async def _image_assoc_counts(self, tag_ids: list[int]) -> dict[int, int]:
"""image_tag row counts keyed by tag_id, for the survivor heuristic
(the best-connected tag in a collision group survives → fewest
FK repoints). Tags with zero associations are absent from the map."""
if not tag_ids:
return {}
rows = (
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()
return {tid: int(n) for tid, n in rows}
async def _create_protective_aliases( async def _create_protective_aliases(
self, src_name: str, src_kind: TagKind, tgt: int self, src_name: str, src_kind: TagKind, tgt: int
) -> bool: ) -> bool:
@@ -606,189 +468,3 @@ class TagService:
if res.rowcount: if res.rowcount:
created = True created = True
return created return created
# ---------------------------------------------------------------------------
# #714: retro-normalize existing tags to the #701 canonical (Title Case +
# collapsed whitespace) and merge case/whitespace-variant duplicates.
# ---------------------------------------------------------------------------
_NORMALIZE_SAMPLE_CAP = 50
def _group_existing_tags(
rows,
) -> dict[tuple, list[tuple[int, str]]]:
"""Group (id, name, kind, fandom_id) rows by their post-normalization
identity: (kind, COALESCE(fandom_id, -1), canonical_name). Every tag that
would collapse to the same canonical lives in ONE group, so the canonical
form is unique within (kind, fandom) once each group is resolved."""
groups: dict[tuple, list[tuple[int, str]]] = {}
for tag_id, name, kind, fandom_id in rows:
canonical = normalize_tag_name(name)
key = (kind, fandom_id if fandom_id is not None else -1, canonical)
groups.setdefault(key, []).append((tag_id, name))
return groups
def _group_needs_change(canonical: str, members: list[tuple[int, str]]) -> bool:
"""A group is already canonical iff it's a single member whose name equals
the canonical form. Anything else (a collision, or a lone mis-cased tag)
needs work."""
if len(members) > 1:
return True
return members[0][1] != canonical
def _best_connected(tag_ids: list[int], counts: dict[int, int]) -> int:
"""The tag with the most image associations (→ fewest FK repoints when it
survives), tie-broken to the lowest id for determinism. Module-level so the
key closes over its parameter, not a loop variable (ruff B023)."""
return max(tag_ids, key=lambda tid: (counts.get(tid, 0), -tid))
async def normalize_existing_tags(
session: AsyncSession,
*,
dry_run: bool = False,
time_budget_seconds: float | None = None,
) -> dict:
"""Convert the back-catalog to the #701 canonical tag form.
For each (kind, fandom, canonical) group: pick a survivor, merge any
case/whitespace-variant siblings INTO it via the tested merge path
(TagService._do_merge — FK repoints + protective aliases), then rename the
survivor to the canonical form. Idempotent: a group that is already a lone
canonical tag is a no-op, so re-running is safe.
A first run over a fresh back-catalog can touch tens of thousands of tags
(the whole booru-derived vocabulary needs recasing) and won't finish inside
one Celery time limit — it timed out at 40 min (operator-flagged 2026-06-07).
`time_budget_seconds` time-boxes the live run: it stops cleanly at the budget
and reports `partial`/`remaining` so the caller can re-enqueue and continue.
Because it commits per group and is idempotent, the next run just picks up
the groups still needing change.
dry_run=True returns a projection (counts + a sample of the changes) with no
mutations. Live runs commit per group and isolate failures per group so one
bad group can't strand the rest.
Returns (dry_run):
{"groups": N, "collisions": M, "tags_to_merge": K, "tags_to_rename": R,
"total_changes": T, "sample": [{"to", "from": [...], "kind", "merge"}]}
Returns (live):
{"groups_processed", "merged", "renamed", "aliases_created", "errors",
"total_changes", "remaining", "partial", "sample": [...]}
"""
rows = (
await session.execute(
select(Tag.id, Tag.name, Tag.kind, Tag.fandom_id)
)
).all()
groups = _group_existing_tags(rows)
# Deterministic sample/ordering: by kind then canonical name.
touched = sorted(
(
(key, members)
for key, members in groups.items()
if _group_needs_change(key[2], members)
),
key=lambda km: (
km[0][0].value if hasattr(km[0][0], "value") else str(km[0][0]),
km[0][2].lower(),
),
)
sample = [
{
"to": key[2],
"from": [name for _id, name in members],
"kind": key[0].value if hasattr(key[0], "value") else str(key[0]),
"merge": len(members) > 1,
}
for key, members in touched[:_NORMALIZE_SAMPLE_CAP]
]
if dry_run:
collisions = sum(1 for key, m in touched if len(m) > 1)
tags_to_merge = sum(len(m) - 1 for key, m in touched if len(m) > 1)
# A group renames iff the canonical form isn't already one of its
# members' exact names (else that member is picked as survivor → no
# rename, the rest merge into it).
tags_to_rename = sum(
1
for key, m in touched
if key[2] not in {name for _id, name in m}
)
return {
"groups": len(groups),
"collisions": collisions,
"tags_to_merge": tags_to_merge,
"tags_to_rename": tags_to_rename,
"total_changes": len(touched),
"sample": sample,
}
svc = TagService(session)
summary = {
"groups_processed": 0,
"merged": 0,
"renamed": 0,
"aliases_created": 0,
"errors": 0,
"total_changes": len(touched),
"remaining": len(touched),
"partial": False,
"sample": sample,
}
start = time.monotonic()
for done, (key, members) in enumerate(touched):
# Time-box: stop cleanly before the Celery limit kills us mid-group and
# strands the run as a timeout. The caller re-enqueues to finish the
# rest (idempotent — already-canonical groups are skipped next pass).
if (
time_budget_seconds is not None
and time.monotonic() - start >= time_budget_seconds
):
summary["partial"] = True
summary["remaining"] = len(touched) - done
break
canonical = key[2]
names_by_id = dict(members)
# Survivor: prefer a member already named canonically (no rename, no
# self-alias); else the best-connected (fewest FK repoints); else
# lowest id for determinism.
survivor_id = next(
(tid for tid, name in members if name == canonical), None
)
if survivor_id is None:
counts = await svc._image_assoc_counts(list(names_by_id))
survivor_id = _best_connected(list(names_by_id), counts)
loser_ids = [tid for tid in names_by_id if tid != survivor_id]
try:
survivor = await session.get(Tag, survivor_id)
for loser_id in loser_ids:
loser = await session.get(Tag, loser_id)
if loser is None:
continue
result = await svc._do_merge(loser, survivor)
if result.alias_created:
summary["aliases_created"] += 1
if survivor.name != canonical:
survivor.name = canonical
await session.flush()
summary["renamed"] += 1
await session.commit()
summary["groups_processed"] += 1
summary["merged"] += len(loser_ids)
except Exception as exc: # one bad group must not strand the rest
await session.rollback()
summary["errors"] += 1
log.warning(
"tag normalize failed for group %r: %s", canonical, exc
)
else:
# Loop finished without hitting the time budget — nothing left to do.
summary["remaining"] = 0
return summary
+1 -10
View File
@@ -10,7 +10,6 @@ disposes it (``await engine.dispose()``) when its loop ends.
""" """
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from ..config import get_config from ..config import get_config
@@ -18,13 +17,5 @@ from ..config import get_config
def async_session_factory(): def async_session_factory():
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine.""" """Return ``(sessionmaker, engine)`` bound to a fresh async engine."""
cfg = get_config() cfg = get_config()
# NullPool: this engine lives for ONE task (created + disposed per engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
# asyncio.run loop), so intra-task connection pooling buys nothing and
# actively bit us — download_source releases its phase-1 connection
# before a multi-minute gallery-dl subprocess, and a *pooled* idle
# connection would be reaped by the server and handed back dead to
# phase 3 (asyncpg ConnectionDoesNotExistError, Anduo #40014). NullPool
# opens a fresh real connection on each checkout, so phase 3 always
# reconnects clean; pre_ping is then redundant.
engine = create_async_engine(cfg.database_url, future=True, poolclass=NullPool)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
-7
View File
@@ -34,10 +34,3 @@ def sync_session_factory():
) )
_SESSIONMAKER = sessionmaker(_ENGINE, expire_on_commit=False) _SESSIONMAKER = sessionmaker(_ENGINE, expire_on_commit=False)
return _SESSIONMAKER return _SESSIONMAKER
def get_sync_engine():
"""The process-wide sync Engine — for raw work that needs a connection
directly (e.g. AUTOCOMMIT VACUUM, which can't run inside a transaction)."""
sync_session_factory() # ensure _ENGINE is initialized
return _ENGINE
-143
View File
@@ -55,146 +55,3 @@ def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
return cleanup_service.delete_images( return cleanup_service.delete_images(
session, image_ids=image_ids, images_root=IMAGES_ROOT, session, image_ids=image_ids, images_root=IMAGES_ROOT,
) )
# Time-box one chunk well under the soft limit so a large archive back-catalog
# can't run the task into the Celery time limit (or hog the maintenance_long
# lane). The task re-enqueues itself with the resume cursor until the scan is
# exhausted — mirrors normalize_tags_task (operator-asked 2026-06-07: reasonable
# timeout, then re-queue so other work keeps flowing).
_REEXTRACT_CHUNK_SECONDS = 600
@celery.task(
name="backend.app.tasks.admin.reextract_archive_attachments_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def reextract_archive_attachments_task(self, after_id: int = 0) -> dict:
"""Wraps cleanup_service.reextract_archive_attachments (#713 part 2):
re-extract PostAttachments that are actually archives but were filed
opaquely before the magic-byte gate, and link their members to the post.
Time-boxed + self-resuming: scans attachments after ``after_id`` and, on a
chunk cut, re-enqueues from where it stopped so a big backlog finishes across
chunks instead of dying at the soft limit."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
summary = cleanup_service.reextract_archive_attachments(
session, images_root=IMAGES_ROOT,
time_budget_seconds=_REEXTRACT_CHUNK_SECONDS, after_id=after_id,
)
# More attachments past this chunk's cursor — continue in the next.
if summary.get("partial") and summary.get("resume_after_id", 0) > after_id:
log.info(
"reextract chunk done (%d scanned, %d archives, resume after id %s) "
"— re-enqueuing to continue",
summary.get("scanned", 0), summary.get("archives", 0),
summary["resume_after_id"],
)
reextract_archive_attachments_task.delay(summary["resume_after_id"])
return summary
# Time-box one chunk well under the soft limit so a large back-catalog (the
# first run recases the whole booru vocabulary) can't run the task into the
# Celery time limit — it timed out at 40 min, operator-flagged 2026-06-07. The
# task re-enqueues itself until nothing remains (idempotent — already-canonical
# groups are skipped). 600s keeps each chunk short enough that the recovery
# sweep and other maintenance tasks interleave on the concurrency-1 queue.
_NORMALIZE_CHUNK_SECONDS = 600
@celery.task(
name="backend.app.tasks.admin.normalize_tags_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def normalize_tags_task(self) -> dict:
"""Wraps tag_service.normalize_existing_tags (#714): Title-Case the
back-catalog and merge case/whitespace-variant duplicate tags via the
tested async merge path. Time-boxed + self-resuming so a huge first run
finishes across chunks instead of timing out. Runs under its own asyncio
loop + per-task async engine (NullPool), mirroring download_source."""
import asyncio
from ..services.tag_service import normalize_existing_tags
from ._async_session import async_session_factory
async def _run() -> dict:
async_factory, async_engine = async_session_factory()
try:
async with async_factory() as session:
# normalize_existing_tags commits per group internally.
return await normalize_existing_tags(
session, dry_run=False,
time_budget_seconds=_NORMALIZE_CHUNK_SECONDS,
)
finally:
await async_engine.dispose()
summary = asyncio.run(_run())
# More groups to canonicalize than fit this chunk — continue in the next.
if summary.get("partial") and summary.get("remaining", 0) > 0:
log.info(
"normalize_tags_task chunk done (%d processed, %d remaining) — "
"re-enqueuing to continue",
summary.get("groups_processed", 0), summary["remaining"],
)
normalize_tags_task.delay()
return summary
# Time-box one rescan chunk well under the soft limit and re-enqueue from the
# cursor — scoring every post against its artist's series is O(posts) and grows
# with the library (FC-6.3). Mirrors normalize_tags_task.
_SERIES_RESCAN_CHUNK_SECONDS = 600
@celery.task(
name="backend.app.tasks.admin.rescan_series_suggestions_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def rescan_series_suggestions_task(self, after_post_id: int = 0) -> dict:
"""Score posts against their artist's series and write pending suggestions
(FC-6.3). Settings-gated; time-boxed + self-resuming from a post-id cursor.
Per-task async engine (NullPool) under its own asyncio loop, like normalize."""
import asyncio
from ..models import ImportSettings
from ..services.series_match_service import SeriesMatchService
from ._async_session import async_session_factory
async def _run() -> dict:
async_factory, async_engine = async_session_factory()
try:
async with async_factory() as session:
settings = await ImportSettings.load(session)
if not settings.series_suggest_enabled:
return {"skipped": "series suggestions disabled"}
threshold = settings.series_suggest_threshold
return await SeriesMatchService(session).rescan(
threshold=threshold,
time_budget_seconds=_SERIES_RESCAN_CHUNK_SECONDS,
after_post_id=after_post_id,
)
finally:
await async_engine.dispose()
summary = asyncio.run(_run())
if summary.get("partial") and summary.get("resume_after_id", 0) > after_post_id:
log.info(
"rescan_series_suggestions chunk done (%d scanned, %d suggested, "
"resume after %s) — re-enqueuing",
summary.get("scanned", 0), summary.get("suggested", 0),
summary["resume_after_id"],
)
rescan_series_suggestions_task.delay(summary["resume_after_id"])
return summary
+3 -10
View File
@@ -41,11 +41,7 @@ def _mark_failed(session, row: BackupRun, exc: BaseException) -> None:
bind=True, bind=True,
autoretry_for=(OperationalError, DBAPIError), autoretry_for=(OperationalError, DBAPIError),
retry_backoff=10, retry_backoff_max=120, max_retries=2, retry_backoff=10, retry_backoff_max=120, max_retries=2,
# A pg_dump can't be chunked; the 12-min limit timed out once the DB grew soft_time_limit=600, time_limit=720,
# (operator-flagged 2026-06-07). 30/35 min gives real headroom. (A long
# backup still briefly holds the concurrency-1 maintenance lane — the
# structural fix is a dedicated lane for the long one-shots.)
soft_time_limit=1800, time_limit=2100,
) )
def backup_db_task(self, *, tag: str | None = None, def backup_db_task(self, *, tag: str | None = None,
triggered_by: str = "manual") -> dict: triggered_by: str = "manual") -> dict:
@@ -244,11 +240,8 @@ def prune_backups() -> dict:
Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}. Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}.
Tagged rows (tag IS NOT NULL) are never pruned. Tagged rows (tag IS NOT NULL) are never pruned.
Status='running' / 'restoring' rows are never pruned — the Status='running' / 'restoring' rows are never pruned (recovery
recover_stalled_backup_runs sweep flips truly-stuck ones to sweep from FC-3i handles those via task_run).
'error' first. (Earlier docstring claimed the FC-3i TaskRun sweep
handled those, but TaskRun cleanup never touched BackupRun rows.
Audit 2026-06-02 added the dedicated sweep.)
""" """
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0} counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
+5 -168
View File
@@ -1,17 +1,12 @@
"""download_source Celery task — runs DownloadService for one source.""" """download_source Celery task — runs DownloadService for one source."""
import asyncio import asyncio
import logging
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError from sqlalchemy.exc import DBAPIError, OperationalError
from sqlalchemy.orm import Session as SyncSession
from ..celery_app import celery from ..celery_app import celery
from ..models import DownloadEvent, ImportSettings, Source from ..models import ImportSettings
from ..services.credential_crypto import CredentialCrypto from ..services.credential_crypto import CredentialCrypto
from ..services.credential_service import CredentialService from ..services.credential_service import CredentialService
from ..services.download_service import DownloadService from ..services.download_service import DownloadService
@@ -21,99 +16,9 @@ from ..services.thumbnailer import Thumbnailer
from ._async_session import async_session_factory from ._async_session import async_session_factory
from .import_file import _sync_session_factory from .import_file import _sync_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images") IMAGES_ROOT = Path("/images")
_KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64" _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
# Celery time budget for one download_source run. The ceiling that
# governs *clean* teardown is the SOFT limit: it raises a catchable
# SoftTimeLimitExceeded in-process, whereas the HARD limit SIGKILLs the
# worker (no chance to finalize). Both gallery-dl subprocess budgets
# (gallery_dl.py: _DEFAULT_GDL_TIMEOUT_SECONDS=870 tick,
# BACKFILL_CHUNK_SECONDS=600 per backfill chunk, plan #693) MUST sit below the soft limit
# so subprocess.run raises its own TimeoutExpired first — that path
# captures partial stdout/stderr and finalizes the DownloadEvent. soft is
# max-subprocess (1170) + ~180s phase-3 persist headroom; hard is soft +
# 150s SIGKILL backstop. Audit 2026-06-03 (Anduo #39912): the old
# soft=900 sat BELOW the 1170 backfill budget, so SoftTimeLimitExceeded
# preempted TimeoutExpired and the event stranded empty. The recovery
# sweep's DOWNLOAD_STALL_THRESHOLD_MINUTES (30 min) still trails the new
# 25-min hard kill by 5 min, so it stays a true backstop. Invariant
# guarded by test_timeout_ladder_keeps_subprocess_budgets_under_soft_limit.
DOWNLOAD_SOFT_TIME_LIMIT = 1350
DOWNLOAD_HARD_TIME_LIMIT = 1500
# Per-platform serialization (plan: concurrency cap). When a serialized
# platform (Patreon) is already walking, defer this run by re-enqueuing it a
# little later rather than holding a worker slot or bowling into the same rate
# limit. Bounded so a wedged platform eventually runs anyway. The lock TTL sits
# just past the hard kill so a SIGKILL'd worker's lock auto-expires; a backfill
# chunk only holds it ~10 min, so the bounded wait stays well under the 30-min
# DownloadEvent recovery sweep.
_PLATFORM_LOCK_TTL = DOWNLOAD_HARD_TIME_LIMIT + 120
_SERIALIZE_COUNTDOWN = 20
_MAX_SERIALIZE_WAITS = 45 # ~15 min ceiling, then run uncapped as a safety valve
def _peek_platform(source_id: int) -> str | None:
SyncFactory = _sync_session_factory()
with SyncFactory() as session:
return session.execute(
select(Source.platform).where(Source.id == source_id)
).scalar_one_or_none()
def _finalize_soft_limited(session: SyncSession, source_id: int) -> None:
"""Defense in depth for the soft-time-limit kill path.
A SoftTimeLimitExceeded unwinds download_source before phase 3 can
finalize the DownloadEvent, leaving it 'running' until the recovery
sweep stamps a context-free "stranded" error 30 min later — AND
leaving backfill_runs_remaining undecremented so the source re-runs
and re-strands every tick (Anduo #39912, 2026-06-03). Flip the
in-flight event to error with a real reason, mirror phase 3's
source-health write, and decrement any backfill budget so a
chronically-slow source self-heals back to tick mode.
The caller owns the commit. All mutations are gated on actually
finding a running event, so a benign late soft-limit (phase 3 already
committed) is a no-op.
"""
now = datetime.now(UTC)
ev = session.execute(
select(DownloadEvent)
.where(DownloadEvent.source_id == source_id)
.where(DownloadEvent.status == "running")
.order_by(DownloadEvent.id.desc())
.limit(1)
).scalar_one_or_none()
if ev is None:
return
ev.status = "error"
ev.finished_at = now
ev.error = (
f"killed by Celery soft time limit ({DOWNLOAD_SOFT_TIME_LIMIT}s) "
"before the download finished — the run exceeded its time budget. "
"Progress is checkpointed per page, so the next tick resumes near "
"the cut; the backfill budget was decremented so it walks less. If "
"this recurs, a single post is heavy enough to overrun the chunk "
"time-box on its own."
)
ev.metadata_ = {
**(ev.metadata_ or {}),
"error_type": "timeout",
"soft_time_limited": True,
}
src = session.get(Source, source_id)
if src is not None:
src.consecutive_failures = (src.consecutive_failures or 0) + 1
src.last_error = "soft time limit exceeded"
src.error_type = "timeout"
src.last_checked_at = now
if (src.backfill_runs_remaining or 0) > 0:
src.backfill_runs_remaining = max(0, src.backfill_runs_remaining - 1)
@celery.task( @celery.task(
name="backend.app.tasks.download.download_source", name="backend.app.tasks.download.download_source",
@@ -124,51 +29,12 @@ def _finalize_soft_limited(session: SyncSession, source_id: int) -> None:
retry_backoff_max=120, retry_backoff_max=120,
retry_jitter=True, retry_jitter=True,
max_retries=3, max_retries=3,
soft_time_limit=DOWNLOAD_SOFT_TIME_LIMIT, soft_time_limit=900,
time_limit=DOWNLOAD_HARD_TIME_LIMIT, time_limit=1200,
) )
def download_source(self, source_id: int, _serialize_waits: int = 0) -> int: def download_source(self, source_id: int) -> int:
"""Returns the DownloadEvent.id.""" """Returns the DownloadEvent.id."""
# Per-platform concurrency cap: only one Patreon walk runs at a time.
from ..services.platform_lock import platform_lock
platform = _peek_platform(source_id)
lock = (
platform_lock(platform, ttl_seconds=_PLATFORM_LOCK_TTL)
if platform is not None
else None
)
if lock is not None:
try:
got_lock = bool(lock.acquire(blocking=False))
except Exception: # noqa: BLE001 — broker hiccup → degrade to uncapped
log.warning("platform lock acquire failed for %s; running uncapped", platform)
lock = None
got_lock = True
if lock is not None and not got_lock:
if _serialize_waits < _MAX_SERIALIZE_WAITS:
# Another walk on this platform holds the lock — re-enqueue
# ourselves shortly (the pending DownloadEvent stays; no new
# event, no log spam) rather than running concurrently into
# the rate limit.
download_source.apply_async(
(source_id,),
{"_serialize_waits": _serialize_waits + 1},
countdown=_SERIALIZE_COUNTDOWN,
)
log.info(
"download_source(%s) deferred — %s already walking (wait %d/%d)",
source_id, platform, _serialize_waits + 1, _MAX_SERIALIZE_WAITS,
)
return -1
log.warning(
"download_source(%s) running WITHOUT the %s lock — max serialize "
"waits hit, proceeding uncapped",
source_id, platform,
)
lock = None
async def _run(): async def _run():
async_factory, async_engine = async_session_factory() async_factory, async_engine = async_session_factory()
SyncFactory = _sync_session_factory() SyncFactory = _sync_session_factory()
@@ -202,38 +68,9 @@ def download_source(self, source_id: int, _serialize_waits: int = 0) -> int:
gdl=gdl, gdl=gdl,
importer=importer, importer=importer,
cred_service=cred_service, cred_service=cred_service,
# The native Patreon ingester opens its own short-lived
# sync sessions for the seen-ledger (never held across
# the walk). Same factory the importer's sync session
# comes from — a different DB connection per checkout.
sync_session_factory=SyncFactory,
) )
return await svc.download_source(source_id) return await svc.download_source(source_id)
finally: finally:
await async_engine.dispose() await async_engine.dispose()
try: return asyncio.run(_run())
return asyncio.run(_run())
except SoftTimeLimitExceeded:
# phase 3 never ran — salvage the in-flight event so the operator
# sees a real reason instead of the recovery sweep's generic
# "stranded" 30 min later (Anduo #39912). Best-effort: a failure
# here must not mask the timeout. Re-raise so Celery + the
# task_run signal handler still record the kill.
try:
SyncFactory = _sync_session_factory()
with SyncFactory() as session:
_finalize_soft_limited(session, source_id)
session.commit()
except Exception: # noqa: BLE001 — cleanup must not swallow the kill
log.exception("soft-limit finalize failed for source %s", source_id)
raise
finally:
# Release the per-platform lock so the next walk can proceed. The TTL is
# a backstop for a SIGKILL; here we free it the instant the run ends. A
# late release after TTL expiry raises (lock already gone) — ignore it.
if lock is not None:
try:
lock.release()
except Exception: # noqa: BLE001 — TTL may have already freed it
pass
+10 -60
View File
@@ -13,7 +13,6 @@ State machine:
""" """
import logging import logging
import time
import traceback import traceback
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -32,12 +31,6 @@ log = logging.getLogger(__name__)
_BATCH = 500 _BATCH = 500
_PROGRESS_TICK = 100 _PROGRESS_TICK = 100
_MAX_MATCHED = 50_000 _MAX_MATCHED = 50_000
# One chunk's wall-clock budget. Was a single 2h pass that timed out on large
# libraries and held the concurrency-1 maintenance queue the whole time
# (operator-flagged 2026-06-07). Now: scan ~10 min, persist the keyset cursor +
# matches, re-enqueue to continue — so backups/vacuum/normalize chunks can
# interleave. soft/hard limits sit just above so the budget fires first.
_CHUNK_SECONDS = 600
_RULES = { _RULES = {
"transparency": transparency.evaluate, "transparency": transparency.evaluate,
@@ -53,16 +46,13 @@ _RULES = {
retry_backoff_max=60, retry_backoff_max=60,
retry_jitter=True, retry_jitter=True,
max_retries=3, max_retries=3,
soft_time_limit=900, soft_time_limit=7200,
time_limit=1000, time_limit=7500,
) )
def scan_library_for_rule(self, audit_id: int) -> dict: def scan_library_for_rule(self, audit_id: int) -> dict:
"""See module docstring. Time-boxed + self-resuming: one call scans a """See module docstring. Returns a small summary dict for eager-mode
~10-min chunk, persists the resume cursor + matches, and re-enqueues itself
until the library is exhausted. Returns a small summary dict for eager-mode
test assertions (real workers ignore the return value).""" test assertions (real workers ignore the return value)."""
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
start = time.monotonic()
try: try:
with SessionLocal() as session: with SessionLocal() as session:
audit = session.get(LibraryAuditRun, audit_id) audit = session.get(LibraryAuditRun, audit_id)
@@ -73,10 +63,9 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
_mark_error(session, audit_id, f"unknown rule {audit.rule!r}") _mark_error(session, audit_id, f"unknown rule {audit.rule!r}")
return {"audit_id": audit_id, "status": "error"} return {"audit_id": audit_id, "status": "error"}
params = dict(audit.params or {}) params = dict(audit.params or {})
# Resume from the previous chunk's persisted state. matched: list[int] = []
matched: list[int] = list(audit.matched_ids or []) scanned = 0
scanned = audit.scanned_count or 0 last_id = 0
last_id = audit.resume_after_id or 0
while True: while True:
# Cancellation check between batches. # Cancellation check between batches.
current_status = session.execute( current_status = session.execute(
@@ -85,15 +74,6 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
).scalar_one() ).scalar_one()
if current_status == "cancelled": if current_status == "cancelled":
return {"audit_id": audit_id, "status": "cancelled"} return {"audit_id": audit_id, "status": "cancelled"}
# Time-box: persist the cursor + matches and re-enqueue so the
# queue is freed between chunks. The next call resumes here.
if time.monotonic() - start >= _CHUNK_SECONDS:
_persist_chunk(session, audit_id, scanned, matched, last_id)
scan_library_for_rule.delay(audit_id)
return {
"audit_id": audit_id, "status": "running",
"partial": True, "scanned": scanned,
}
rows = session.execute( rows = session.execute(
select(ImageRecord.id, ImageRecord.path) select(ImageRecord.id, ImageRecord.path)
.where(ImageRecord.id > last_id) .where(ImageRecord.id > last_id)
@@ -134,16 +114,10 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
) )
return {"audit_id": audit_id, "status": "error"} return {"audit_id": audit_id, "status": "error"}
if scanned % _PROGRESS_TICK == 0: if scanned % _PROGRESS_TICK == 0:
# Cheap heartbeat: scanned_count + last_progress_at so the
# recovery sweep sees the multi-chunk audit is alive. The
# cursor + matches are persisted at chunk boundaries.
session.execute( session.execute(
update(LibraryAuditRun) update(LibraryAuditRun)
.where(LibraryAuditRun.id == audit_id) .where(LibraryAuditRun.id == audit_id)
.values( .values(scanned_count=scanned)
scanned_count=scanned,
last_progress_at=datetime.now(UTC),
)
) )
session.commit() session.commit()
# Final state. # Final state.
@@ -154,10 +128,8 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
scanned_count=scanned, scanned_count=scanned,
matched_count=len(matched), matched_count=len(matched),
matched_ids=matched, matched_ids=matched,
resume_after_id=last_id,
status="ready", status="ready",
finished_at=datetime.now(UTC), finished_at=datetime.now(UTC),
last_progress_at=datetime.now(UTC),
) )
) )
session.commit() session.commit()
@@ -168,14 +140,9 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
"matched": len(matched), "matched": len(matched),
} }
except SoftTimeLimitExceeded: except SoftTimeLimitExceeded:
# Backstop (the in-chunk budget should fire first): the audit stays with SessionLocal() as session:
# 'running' with its last committed cursor; re-enqueue to continue from _mark_error(session, audit_id, "soft_time_limit exceeded (>7200s)")
# there rather than marking the whole run an error. raise
log.warning(
"audit %s: soft time limit hit — re-enqueuing to resume", audit_id,
)
scan_library_for_rule.delay(audit_id)
return {"audit_id": audit_id, "status": "running", "partial": True}
except (OperationalError, DBAPIError): except (OperationalError, DBAPIError):
# Retryable per the decorator; leave row in 'running' and let # Retryable per the decorator; leave row in 'running' and let
# autoretry try again. Recovery sweep catches if all retries fail. # autoretry try again. Recovery sweep catches if all retries fail.
@@ -187,23 +154,6 @@ def scan_library_for_rule(self, audit_id: int) -> dict:
raise raise
def _persist_chunk(session, audit_id, scanned, matched, last_id) -> None:
"""Persist a chunk boundary: scanned count, matches so far, and the keyset
cursor the next chunk resumes from. Keeps status='running'."""
session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.id == audit_id)
.values(
scanned_count=scanned,
matched_count=len(matched),
matched_ids=list(matched),
resume_after_id=last_id,
last_progress_at=datetime.now(UTC),
)
)
session.commit()
def _mark_error(session, audit_id: int, error_msg: str) -> None: def _mark_error(session, audit_id: int, error_msg: str) -> None:
session.execute( session.execute(
update(LibraryAuditRun) update(LibraryAuditRun)
+7 -279
View File
@@ -7,42 +7,22 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
from sqlalchemy import Integer, and_, cast, delete, func, or_, select, update from sqlalchemy import and_, delete, or_, select, update
from ..celery_app import celery from ..celery_app import celery
from ..models import ( from ..models import (
BackupRun,
DownloadEvent, DownloadEvent,
ImageRecord, ImageRecord,
ImportBatch,
ImportSettings, ImportSettings,
ImportTask, ImportTask,
LibraryAuditRun,
Source, Source,
TaskRun, TaskRun,
) )
from ..utils.phash import compute_phash from ..utils.phash import compute_phash
from ._sync_engine import get_sync_engine
from ._sync_engine import sync_session_factory as _sync_session_factory from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# High-churn tables whose dead-tuple bloat matters: the TABLESAMPLE showcase
# reads physical blocks (bloat slows it directly), and the periodic
# prune/backfill/recovery tasks generate dead tuples faster than autovacuum
# always keeps up with. VACUUM reclaims them; ANALYZE refreshes planner stats.
# Allowlist ONLY — names are interpolated into VACUUM, so they must never come
# from request input.
VACUUM_TABLES = (
"image_record",
"image_provenance",
"post_attachment",
"download_event",
"task_run",
"import_task",
"import_batch",
)
STUCK_THRESHOLD_MINUTES = 5 STUCK_THRESHOLD_MINUTES = 5
# Archive ImportTasks run the per-member pipeline inline for every # Archive ImportTasks run the per-member pipeline inline for every
# member (import_archive_file: soft=30min/hard=35min). The ImportTask # member (import_archive_file: soft=30min/hard=35min). The ImportTask
@@ -63,12 +43,9 @@ MAX_RECOVERY_ATTEMPTS = 3
ORPHAN_PENDING_THRESHOLD_MINUTES = 30 ORPHAN_PENDING_THRESHOLD_MINUTES = 30
# DownloadEvent (pending|running) recovery threshold. download_source has # DownloadEvent (pending|running) recovery threshold. download_source has
# time_limit=1500s (25 min, DOWNLOAD_HARD_TIME_LIMIT); 30 min is 5 min past # time_limit=1200s (20 min); 30 min is 10 min past that, so a legitimately-
# that, so a legitimately-running task is hard-killed before the sweep ever # running task is never killed by the sweep. Operator-confirmed 2026-05-29
# touches it — the sweep only catches events whose worker died without # after 43 sources stranded at "last check never" by the in-flight guard.
# finalizing. Operator-confirmed 2026-05-29 after 43 sources stranded at
# "last check never" by the in-flight guard; budget bumped 2026-06-03 with
# the soft/hard limit raise (Anduo #39912).
DOWNLOAD_STALL_THRESHOLD_MINUTES = 30 DOWNLOAD_STALL_THRESHOLD_MINUTES = 30
OLD_TASK_DAYS = 7 OLD_TASK_DAYS = 7
@@ -78,34 +55,6 @@ FFPROBE_TIMEOUT_SECONDS = 10
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
# Audit 2026-06-02: per-entity recovery sweep thresholds. Each must be
# > the entity's longest legitimate runtime (its task's time_limit + a
# small buffer) so the sweep never flags in-flight work.
#
# Backups: images backup has time_limit=23400s (6.5h). 7h covers it
# with a 30-min buffer.
BACKUP_STALL_THRESHOLD_MINUTES = 7 * 60
# DB backup/restore is seconds-to-minutes (35-min hard limit). It must NOT share
# the images' 7h window — a DB backup wedged on NFS would otherwise sit "running"
# for 7 hours holding the concurrency-1 maintenance_long lane (operator-flagged
# 2026-06-07). 40 min gives a small buffer over the hard limit.
BACKUP_DB_STALL_THRESHOLD_MINUTES = 40
# Library audit: scan_library_for_rule has time_limit=7500s (2h5m).
# 2h15m gives a 10-min buffer.
LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES = 135
# Import batches finalize only after every child ImportTask hits a
# terminal state. The recovery sweep targets the case where every
# task is done but the batch never got its closing UPDATE
# (orchestrator crashed at the wrong instant). 2h is well past any
# realistic single-batch import.
IMPORT_BATCH_STALL_THRESHOLD_MINUTES = 120
# Retention windows (terminal rows older than these get deleted by
# the daily prune sweeps). 30 days = operator-flagged "useful for
# triage for a few weeks, then noise."
LIBRARY_AUDIT_KEEP_DAYS = 30
IMPORT_BATCH_KEEP_DAYS = 30
# Overrides for recover_stalled_task_runs (the TaskRun 'running' sweep). # Overrides for recover_stalled_task_runs (the TaskRun 'running' sweep).
# Tasks/queues that legitimately run longer than the default 5-min # Tasks/queues that legitimately run longer than the default 5-min
# threshold need their own larger value, else the sweep marks in-flight # threshold need their own larger value, else the sweep marks in-flight
@@ -120,24 +69,9 @@ IMPORT_BATCH_KEEP_DAYS = 30
# files); time_limit=2100. # files); time_limit=2100.
QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = { QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
"ml": 25, "ml": 25,
# Audit 2026-06-02 — maintenance/scan queues run tasks that
# legitimately exceed the 5-min default (verify_integrity at 70m
# hard, scan_directory at 70m hard, apply_allowlist_tags /
# recompute_centroids / backfill_phash at 35m hard). 75 min lives
# above the longest of those and the per-task overrides below
# cover the outliers (backups, library audit).
"maintenance": 75,
"scan": 75,
} }
TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = { TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
"backend.app.tasks.import_file.import_archive_file": 40, "backend.app.tasks.import_file.import_archive_file": 40,
# Backup images runs hours, not minutes (6.5h hard limit). The
# task-name override beats the queue's 75-min default so a
# legitimately-running backup isn't flagged.
"backend.app.tasks.backup.backup_images_task": 420,
"backend.app.tasks.backup.restore_images_task": 420,
# Library audit scans the full library — 2h hard limit.
"backend.app.tasks.library_audit.scan_library_for_rule": 130,
} }
@@ -248,11 +182,6 @@ def recover_interrupted_tasks() -> int:
.where(ImportTask.created_at < orphan_cutoff) .where(ImportTask.created_at < orphan_cutoff)
.values( .values(
status="failed", status="failed",
# Without finished_at, cleanup_old_tasks (`WHERE
# finished_at < cutoff`) never reaps these rows —
# orphan-swept rows would become permanent table
# tenants. Audit 2026-06-02.
finished_at=now,
error=( error=(
"orphan pending/queued swept by recover_interrupted_tasks " "orphan pending/queued swept by recover_interrupted_tasks "
"(scanner likely crashed mid-enqueue); retry via " "(scanner likely crashed mid-enqueue); retry via "
@@ -349,14 +278,6 @@ def recover_stalled_task_runs() -> int:
f"no completion signal received within {minutes} min" f"no completion signal received within {minutes} min"
), ),
finished_at=now, finished_at=now,
# Matches celery_signals.finalize's
# int((now - started_at).total_seconds() * 1000)
# — sweep-closed rows now carry duration like
# normally-finalized rows. Audit 2026-06-02.
duration_ms=cast(
func.extract("epoch", now - TaskRun.started_at) * 1000,
Integer,
),
) )
) )
for w in extra_where: for w in extra_where:
@@ -426,12 +347,7 @@ def prune_task_runs() -> dict:
return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted} return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted}
@celery.task( @celery.task(name="backend.app.tasks.maintenance.backfill_phash")
name="backend.app.tasks.maintenance.backfill_phash",
# Audit 2026-06-02 — keyset-paginated phash recompute over the whole
# library; legitimately runs >5 min on large libraries.
soft_time_limit=1800, time_limit=2100,
)
def backfill_phash() -> int: def backfill_phash() -> int:
"""Recompute phash for stored images that have none (imported before """Recompute phash for stored images that have none (imported before
FC-2d-i+ii). Keyset-paginated by id (restart-safe), NULL-only fill, FC-2d-i+ii). Keyset-paginated by id (restart-safe), NULL-only fill,
@@ -509,13 +425,7 @@ def _verify_one(path: Path, expected_sha: str, mime: str, sha_fn) -> str:
return "failed_verification" return "failed_verification"
@celery.task( @celery.task(name="backend.app.tasks.maintenance.verify_integrity")
name="backend.app.tasks.maintenance.verify_integrity",
# Audit 2026-06-02 — full library sha256 + decode probe; on 100k-image
# libraries this runs an hour or more. Match the maintenance queue's
# recovery threshold (75 min) with 30s buffer below.
soft_time_limit=3600, time_limit=4200,
)
def verify_integrity() -> int: def verify_integrity() -> int:
"""Verify every ImageRecord file: sha256 recompute + decode/probe """Verify every ImageRecord file: sha256 recompute + decode/probe
(PIL for images; ffprobe for videos). Writes integrity_status (PIL for images; ffprobe for videos). Writes integrity_status
@@ -560,7 +470,7 @@ def recover_stalled_download_events() -> int:
tasks.scan._tick_due_sources_async) inserts DownloadEvent(status='pending') tasks.scan._tick_due_sources_async) inserts DownloadEvent(status='pending')
and fires download_source.delay(). If that task dies before finalizing the and fires download_source.delay(). If that task dies before finalizing the
event — worker OOM/SIGKILL, lost task, or a gallery-dl that didn't unwind event — worker OOM/SIGKILL, lost task, or a gallery-dl that didn't unwind
on the 1500s hard time_limit — the event stays in-flight forever. The next on the 1200s hard time_limit — the event stays in-flight forever. The next
tick then skips that source because of the in-flight guard (scan.py:168) tick then skips that source because of the in-flight guard (scan.py:168)
and Source.last_checked_at never updates; the operator sees "last check and Source.last_checked_at never updates; the operator sees "last check
never" in the Subscriptions health column, permanently. never" in the Subscriptions health column, permanently.
@@ -611,171 +521,6 @@ def recover_stalled_download_events() -> int:
return events_recovered return events_recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_backup_runs")
def recover_stalled_backup_runs() -> int:
"""Flip BackupRun rows stuck in running/restoring past the hard limit
to error. Audit 2026-06-02.
prune_backups (FC-3h) used to claim the FC-3i task_run sweep handled
these — but that sweep only flips TaskRun rows, not the BackupRun
artifact rows. A SIGKILL'd backup left BackupRun stuck forever
(dashboard showed phantom in-flight backups, keep_last_n offset
arithmetic skewed because zombies sat outside the ok/error window).
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
db_cutoff = now - timedelta(minutes=BACKUP_DB_STALL_THRESHOLD_MINUTES)
slow_cutoff = now - timedelta(minutes=BACKUP_STALL_THRESHOLD_MINUTES)
msg = "stranded by recovery sweep (no terminal status within the stall window)"
with SessionLocal() as session:
result = session.execute(
update(BackupRun)
.where(BackupRun.status.in_(["running", "restoring"]))
# db backups/restores are fast (40-min window); images run hours (7h).
.where(
or_(
and_(BackupRun.kind == "db", BackupRun.started_at < db_cutoff),
and_(BackupRun.kind != "db", BackupRun.started_at < slow_cutoff),
)
)
.values(status="error", finished_at=now, error=msg)
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info("recover_stalled_backup_runs: recovered %d rows", recovered)
return recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_library_audit_runs")
def recover_stalled_library_audit_runs() -> int:
"""Flip LibraryAuditRun rows stuck in running past the hard limit
to error. Audit 2026-06-02.
LibraryAuditRun.status='running' was protected by an exclusive
guard in start_audit_run — a SIGKILL'd run would block all future
audits until manual DB surgery. (The guard is now age-aware, but
this sweep is what makes that work in practice.)
Measures staleness from last_progress_at (alembic 0039), NOT started_at:
a chunked scan stays 'running' across many re-enqueued chunks and can
legitimately run for hours on a big library — only flag one that hasn't
made progress in the threshold window (a dead chunk that never re-enqueued).
Falls back to started_at for pre-0039 / never-ticked rows.
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES)
msg = (
f"stranded by recovery sweep (no progress for "
f"{LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES} min)"
)
with SessionLocal() as session:
result = session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.status == "running")
.where(
func.coalesce(
LibraryAuditRun.last_progress_at,
LibraryAuditRun.started_at,
) < cutoff
)
.values(status="error", finished_at=now, error=msg)
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info(
"recover_stalled_library_audit_runs: recovered %d rows", recovered,
)
return recovered
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_import_batches")
def recover_stalled_import_batches() -> int:
"""Finalize ImportBatch rows stuck in running past the hard limit
when NO outstanding ImportTask remains. Audit 2026-06-02.
A batch row finalizes only after every child task hits a terminal
state. The orphan case: scanner crashed between the last task's
completion and the batch's closing UPDATE. The
`/api/import/status` route then surfaces the batch as 'active'
indefinitely while `/api/system/stats` (which uses the same
EXISTS predicate we apply below) correctly returns null.
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=IMPORT_BATCH_STALL_THRESHOLD_MINUTES)
with SessionLocal() as session:
# Batches still 'running' past the cutoff whose tasks are all
# terminal — there's no outstanding work, so flip the batch
# too. Mirrors the EXISTS predicate the active-batch surfaces use.
result = session.execute(
update(ImportBatch)
.where(ImportBatch.status == "running")
.where(ImportBatch.started_at < cutoff)
.where(
~select(ImportTask.id)
.where(
ImportTask.batch_id == ImportBatch.id,
ImportTask.status.in_(["pending", "queued", "processing"]),
)
.exists()
)
.values(status="complete", finished_at=now)
)
session.commit()
recovered = result.rowcount or 0
if recovered:
log.info(
"recover_stalled_import_batches: finalized %d zombie batches",
recovered,
)
return recovered
@celery.task(name="backend.app.tasks.maintenance.prune_library_audit_runs")
def prune_library_audit_runs() -> int:
"""Daily retention: delete terminal LibraryAuditRun rows older than
LIBRARY_AUDIT_KEEP_DAYS. Never touches 'running'. Audit 2026-06-02.
Audit rows carry matched_ids JSONB blobs that can hold tens of
thousands of ids; without retention these accumulate.
"""
SessionLocal = _sync_session_factory()
cutoff = datetime.now(UTC) - timedelta(days=LIBRARY_AUDIT_KEEP_DAYS)
with SessionLocal() as session:
result = session.execute(
delete(LibraryAuditRun)
.where(LibraryAuditRun.status.in_(["ready", "applied", "cancelled", "error"]))
.where(LibraryAuditRun.finished_at < cutoff)
)
session.commit()
return result.rowcount or 0
@celery.task(name="backend.app.tasks.maintenance.prune_import_batches")
def prune_import_batches() -> int:
"""Daily retention: delete terminal ImportBatch rows older than
IMPORT_BATCH_KEEP_DAYS. Cascade-deletes child ImportTask rows via
the model relationship. Never touches 'running'. Audit 2026-06-02.
"""
SessionLocal = _sync_session_factory()
cutoff = datetime.now(UTC) - timedelta(days=IMPORT_BATCH_KEEP_DAYS)
with SessionLocal() as session:
# ORM-level delete here (not Core delete) so the
# ImportBatch->tasks cascade fires; Core delete would skip it.
old_batches = session.execute(
select(ImportBatch)
.where(ImportBatch.status.in_(["complete", "cancelled"]))
.where(ImportBatch.finished_at < cutoff)
).scalars().all()
for batch in old_batches:
session.delete(batch)
session.commit()
return len(old_batches)
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_download_events") @celery.task(name="backend.app.tasks.maintenance.cleanup_old_download_events")
def cleanup_old_download_events() -> int: def cleanup_old_download_events() -> int:
"""FC-3d: delete terminal DownloadEvent rows older than the configured """FC-3d: delete terminal DownloadEvent rows older than the configured
@@ -798,20 +543,3 @@ def cleanup_old_download_events() -> int:
) )
session.commit() session.commit()
return result.rowcount or 0 return result.rowcount or 0
@celery.task(name="backend.app.tasks.maintenance.vacuum_analyze")
def vacuum_analyze() -> dict:
"""Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to
reclaim dead-tuple bloat and refresh planner statistics. VACUUM cannot run
inside a transaction block, so it runs on an AUTOCOMMIT connection.
Scheduled weekly; also operator-triggerable from Settings → Maintenance.
"""
engine = get_sync_engine()
done = []
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
for table in VACUUM_TABLES:
conn.exec_driver_sql(f"VACUUM (ANALYZE) {table}")
done.append(table)
log.info("vacuum_analyze complete: %s", done)
return {"vacuumed": done}
+2 -15
View File
@@ -212,14 +212,7 @@ def backfill(self) -> int:
return enqueued return enqueued
@celery.task( @celery.task(name="backend.app.tasks.ml.apply_allowlist_tags", bind=True)
name="backend.app.tasks.ml.apply_allowlist_tags",
bind=True,
# Audit 2026-06-02 — the full-sweep mode (neither tag_id nor image_id)
# is O(images × allowlist) and legitimately runs >5 min on large
# libraries. Cap matches the maintenance queue's recovery threshold.
soft_time_limit=1800, time_limit=2100,
)
def apply_allowlist_tags(self, tag_id: int | None = None, def apply_allowlist_tags(self, tag_id: int | None = None,
image_id: int | None = None) -> int: image_id: int | None = None) -> int:
"""Retroactively apply allowlisted tags. """Retroactively apply allowlisted tags.
@@ -348,13 +341,7 @@ def recompute_centroid(self, tag_id: int) -> bool:
return asyncio.run(_run()) return asyncio.run(_run())
@celery.task( @celery.task(name="backend.app.tasks.ml.recompute_centroids", bind=True)
name="backend.app.tasks.ml.recompute_centroids",
bind=True,
# Audit 2026-06-02 — drifted-centroid rebuild over potentially
# hundreds of tags.
soft_time_limit=1800, time_limit=2100,
)
def recompute_centroids(self) -> int: def recompute_centroids(self) -> int:
"""Daily: find drifted centroids, enqueue recompute_centroid for each.""" """Daily: find drifted centroids, enqueue recompute_centroid for each."""
import asyncio import asyncio
+1 -9
View File
@@ -35,15 +35,7 @@ def _iter_import_files(import_root: Path):
yield entry yield entry
@celery.task( @celery.task(name="backend.app.tasks.scan.scan_directory", bind=True)
name="backend.app.tasks.scan.scan_directory",
bind=True,
# Audit 2026-06-02 — large libraries make the scan legitimately long.
# Hard cap at 70 min so the corresponding QUEUE_STUCK_THRESHOLD_MINUTES
# ("scan") of 75 min always wins; soft limit gives the task a clean
# exit window before SIGKILL.
soft_time_limit=3600, time_limit=4200,
)
def scan_directory(self, triggered_by: str = "manual", def scan_directory(self, triggered_by: str = "manual",
mode: str = "quick") -> int: mode: str = "quick") -> int:
"""Walks the import root and creates ImportTasks. `mode` is 'quick' """Walks the import root and creates ImportTasks. `mode` is 'quick'
+47 -85
View File
@@ -20,32 +20,14 @@ IMAGES_ROOT = Path("/images")
THUMB_MAGIC_JPEG = b"\xff\xd8\xff" THUMB_MAGIC_JPEG = b"\xff\xd8\xff"
THUMB_MAGIC_PNG = b"\x89PNG\r\n\x1a\n" THUMB_MAGIC_PNG = b"\x89PNG\r\n\x1a\n"
# Minimum file size for a thumbnail to count as valid. Anything smaller
# is almost certainly truncated/corrupt — a legitimate 400×400 JPEG@85
# bottoms out around 2KB even on a solid-color image; 400×400 PNG starts
# around 1KB. 256 bytes is well below any real thumbnail and well above
# header-only corrupt files (~8-12 bytes). Operator-flagged 2026-06-01:
# header-only corrupt files were silently passing the magic-byte check
# and backfill counted them as "ok" — so broken-image tiles in the UI
# never got regenerated even after running backfill.
MIN_THUMB_BYTES = 256
def _thumb_is_valid(path: Path) -> bool: def _thumb_is_valid(path: Path) -> bool:
"""Return True iff `path` exists, starts with a JPEG or PNG magic """Return True iff `path` exists and starts with a JPEG or PNG magic header.
header, AND is at least MIN_THUMB_BYTES on disk.
The on-disk thumbnail format is set by services/thumbnailer.py — JPEG The on-disk thumbnail format is set by services/thumbnailer.py — JPEG for
for opaque sources, PNG for alpha sources. Anything else (missing opaque sources, PNG for alpha sources. Anything else (missing file, OSError,
file, OSError, truncated below the size floor, wrong magic) is truncated, wrong magic) is invalid.
invalid and gets re-enqueued.
""" """
try:
size = path.stat().st_size
except OSError:
return False
if size < MIN_THUMB_BYTES:
return False
try: try:
with path.open("rb") as f: with path.open("rb") as f:
head = f.read(12) head = f.read(12)
@@ -60,59 +42,6 @@ def _thumb_is_valid(path: Path) -> bool:
return False return False
def _run_backfill_scan() -> dict:
"""Synchronous scan logic shared by the Celery task and the API
endpoint. Returns {enqueued, ok, regenerated, scanned}.
Operator-flagged 2026-06-01: the original task was fire-and-forget,
so the admin UI couldn't show what backfill actually found —
operator saw \"Enqueued.\" with no counts and assumed nothing was
happening. Now the API runs this synchronously and returns the
real numbers; the periodic Celery task wraps it too."""
from sqlalchemy import select, update
SessionLocal = _sync_session_factory()
enqueued = 0
ok = 0
regenerated = 0
scanned = 0
last_id = 0
with SessionLocal() as session:
while True:
rows = session.execute(
select(ImageRecord.id, ImageRecord.thumbnail_path)
.where(ImageRecord.id > last_id)
.order_by(ImageRecord.id.asc())
.limit(500)
).all()
if not rows:
break
scanned += len(rows)
for image_id, thumb_path in rows:
if thumb_path is None:
generate_thumbnail.delay(image_id)
enqueued += 1
elif _thumb_is_valid(Path(thumb_path)):
ok += 1
else:
session.execute(
update(ImageRecord)
.where(ImageRecord.id == image_id)
.values(thumbnail_path=None)
)
generate_thumbnail.delay(image_id)
enqueued += 1
regenerated += 1
session.commit()
last_id = rows[-1][0]
return {
"scanned": scanned,
"enqueued": enqueued,
"ok": ok,
"regenerated": regenerated,
}
@celery.task( @celery.task(
name="backend.app.tasks.thumbnail.generate_thumbnail", name="backend.app.tasks.thumbnail.generate_thumbnail",
bind=True, bind=True,
@@ -155,15 +84,48 @@ def backfill_thumbnails(self) -> dict:
"""Scan ImageRecord and enqueue generate_thumbnail for rows whose """Scan ImageRecord and enqueue generate_thumbnail for rows whose
thumbnail is missing, gone from disk, or has wrong magic bytes. thumbnail is missing, gone from disk, or has wrong magic bytes.
Keyset paginates by id ASC, page size 500. NULLs out thumbnail_path Keyset paginates by id ASC, page size 500. NULLs out thumbnail_path for
for rows that point at a missing or corrupt file before enqueueing — rows that point at a missing or corrupt file before enqueueing — keeps
keeps the DB self-consistent on partial runs and makes re-runs safe. the DB self-consistent on partial runs and makes re-runs safe.
Returns {scanned, enqueued, ok, regenerated} where: Returns {"enqueued": N, "ok": M, "regenerated": K} where:
- scanned = total rows examined - enqueued = total generate_thumbnail.delay() calls
- enqueued = total generate_thumbnail.delay() calls - ok = rows whose existing thumbnail file is valid (skipped)
- ok = rows whose existing thumbnail file is valid (skipped) - regenerated = subset of enqueued that had a non-NULL thumbnail_path
- regenerated = subset of enqueued that had a non-NULL cleared (i.e. missing + corrupt)
thumbnail_path cleared (i.e. missing + corrupt)
""" """
return _run_backfill_scan() from sqlalchemy import select, update
SessionLocal = _sync_session_factory()
enqueued = 0
ok = 0
regenerated = 0
last_id = 0
with SessionLocal() as session:
while True:
rows = session.execute(
select(ImageRecord.id, ImageRecord.thumbnail_path)
.where(ImageRecord.id > last_id)
.order_by(ImageRecord.id.asc())
.limit(500)
).all()
if not rows:
break
for image_id, thumb_path in rows:
if thumb_path is None:
generate_thumbnail.delay(image_id)
enqueued += 1
elif _thumb_is_valid(Path(thumb_path)):
ok += 1
else:
session.execute(
update(ImageRecord)
.where(ImageRecord.id == image_id)
.values(thumbnail_path=None)
)
generate_thumbnail.delay(image_id)
enqueued += 1
regenerated += 1
session.commit()
last_id = rows[-1][0]
return {"enqueued": enqueued, "ok": ok, "regenerated": regenerated}
-20
View File
@@ -2,26 +2,6 @@
from pathlib import Path from pathlib import Path
_MAX_EXT_LEN = 16
def safe_ext(name: str | Path) -> str:
"""Conservatively extract a short, alphanumeric file extension.
gallery-dl and Patreon CDN URLs produce basenames with URL-encoded
query-string artifacts, so `Path.suffix` can return 50+ chars of base64-ish
junk that blows bounded VARCHAR columns (e.g. PostAttachment.ext varchar(32)).
Accept only a suffix ≤16 chars whose post-dot characters are all alphanumeric;
otherwise return "" (no known extension). Operator-flagged 2026-05-25 — ONE
impl for the importer and the native Patreon client.
"""
suffix = Path(name).suffix.lower()
if not suffix or len(suffix) > _MAX_EXT_LEN:
return ""
if not all(c.isalnum() for c in suffix[1:]):
return ""
return suffix
def derive_subdir(source_path: Path, import_root: Path) -> str: def derive_subdir(source_path: Path, import_root: Path) -> str:
"""Returns the relative subdirectory of source_path under import_root. """Returns the relative subdirectory of source_path under import_root.
+9 -13
View File
@@ -149,8 +149,9 @@ def _run_probe(path_str: str) -> tuple[str, str | None]:
call the same code path. call the same code path.
""" """
path = Path(path_str) path = Path(path_str)
ext = path.suffix.lower()
try: try:
total, test_bad = _inspect_archive(path) total, test_bad = _inspect_archive(path, ext)
except Exception as exc: # noqa: BLE001 — clean rejection except Exception as exc: # noqa: BLE001 — clean rejection
return ("error", f"{type(exc).__name__}: {exc}") return ("error", f"{type(exc).__name__}: {exc}")
if total is not None and total > MAX_ARCHIVE_UNCOMPRESSED_BYTES: if total is not None and total > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
@@ -161,29 +162,24 @@ def _run_probe(path_str: str) -> tuple[str, str | None]:
return ("ok", None) return ("ok", None)
def _inspect_archive(path: Path): def _inspect_archive(path: Path, ext: str):
"""Return (total_uncompressed_bytes | None, first_bad_member | None) """Return (total_uncompressed_bytes | None, first_bad_member | None)
for the archive. Format detected by extension OR magic bytes (so a for the archive. Format-specific; raises on a structurally-broken
mis-named archive is still bomb-guarded + integrity-tested, matching the container (caught by the child as a clean rejection)."""
extractor's gate); raises on a structurally-broken container (caught by the if ext in (".zip", ".cbz"):
child as a clean rejection)."""
from ..services.archive_extractor import detect_archive_format
fmt = detect_archive_format(path)
if fmt == "zip":
import zipfile import zipfile
with zipfile.ZipFile(path) as zf: with zipfile.ZipFile(path) as zf:
total = sum(zi.file_size for zi in zf.infolist()) total = sum(zi.file_size for zi in zf.infolist())
return total, zf.testzip() return total, zf.testzip()
if fmt == "rar": if ext == ".rar":
import rarfile import rarfile
with rarfile.RarFile(path) as rf: with rarfile.RarFile(path) as rf:
total = sum(getattr(ri, "file_size", 0) for ri in rf.infolist()) total = sum(getattr(ri, "file_size", 0) for ri in rf.infolist())
rf.testrar() rf.testrar()
return total, None return total, None
if fmt == "7z": if ext == ".7z":
import py7zr import py7zr
with py7zr.SevenZipFile(path, "r") as zf: with py7zr.SevenZipFile(path, "r") as zf:
@@ -191,5 +187,5 @@ def _inspect_archive(path: Path):
total = getattr(info, "uncompressed", None) total = getattr(info, "uncompressed", None)
ok = zf.test() # True / None when all members pass ok = zf.test() # True / None when all members pass
return total, (None if ok in (True, None) else "7z test reported corruption") return total, (None if ok in (True, None) else "7z test reported corruption")
# Not a recognised archive — nothing to test; treat as clean. # Unknown extension — nothing to test; treat as clean.
return None, None return None, None
-9
View File
@@ -35,15 +35,6 @@ services:
volumes: volumes:
- ./backend:/app/backend - ./backend:/app/backend
maintenance-long:
build:
context: .
dockerfile: Dockerfile
environment:
LOG_LEVEL: DEBUG
volumes:
- ./backend:/app/backend
ml-worker: ml-worker:
build: build:
context: . context: .
+3 -32
View File
@@ -27,17 +27,6 @@ services:
POSTGRES_DB: ${DB_NAME:-fabledcurator} POSTGRES_DB: ${DB_NAME:-fabledcurator}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
# Docker's default /dev/shm is 64MB; VACUUM (ANALYZE) + parallel queries
# allocate a POSIX dynamic-shared-memory segment in /dev/shm and fail with
# "could not resize shared memory segment ... No space left on device"
# (operator-flagged 2026-06-07, vacuum_analyze on import_task needed 67MB).
# NOTE: a `shm_size:` key is SILENTLY IGNORED under Docker Swarm
# (`docker stack deploy` — the prod deployment here), so size /dev/shm via
# a tmpfs mount instead — honored by both Swarm and plain Compose.
- type: tmpfs
target: /dev/shm
tmpfs:
size: 536870912 # 512 MB
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fabledcurator}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fabledcurator}"]
interval: 10s interval: 10s
@@ -63,6 +52,7 @@ services:
volumes: volumes:
- ./images:/images - ./images:/images
- ./import:/import - ./import:/import
- ./downloads:/downloads
# FC-5 legacy migration: bind-mount the host's ImageRepo images dir # FC-5 legacy migration: bind-mount the host's ImageRepo images dir
# under /import (FC's existing filesystem scan picks them up). Read-only # under /import (FC's existing filesystem scan picks them up). Read-only
# is sufficient — FC copies into /images during the scan. The worker + # is sufficient — FC copies into /images during the scan. The worker +
@@ -81,11 +71,10 @@ services:
<<: *app_env <<: *app_env
CELERY_QUEUES: default,import,thumbnail,download CELERY_QUEUES: default,import,thumbnail,download
CELERY_CONCURRENCY: "2" CELERY_CONCURRENCY: "2"
# /downloads dropped — nothing in the app references it (operator-flagged
# 2026-06-07: it wasn't mapped in prod and everything worked).
volumes: volumes:
- ./images:/images - ./images:/images
- ./import:/import - ./import:/import
- ./downloads:/downloads
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
redis: { condition: service_healthy } redis: { condition: service_healthy }
@@ -99,25 +88,7 @@ services:
volumes: volumes:
- ./images:/images - ./images:/images
- ./import:/import - ./import:/import
depends_on: - ./downloads:/downloads
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
# Dedicated lane for long one-shot maintenance (DB backups, library audits,
# admin maintenance). Kept off the scheduler's quick `maintenance` lane so a
# 30-min backup or a multi-chunk audit can never starve the 5-min recovery
# sweeps / vacuum (operator-flagged 2026-06-07). One slot — these are heavy.
maintenance-long:
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
command: ["worker"]
environment:
<<: *app_env
CELERY_QUEUES: maintenance_long
CELERY_CONCURRENCY: "1"
# Only /images: backups write to /images/_backups, audits read /images, and
# the admin tasks (re-extract/cascade-delete/normalize) operate on /images.
volumes:
- ./images:/images
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
redis: { condition: service_healthy } redis: { condition: service_healthy }
+2 -41
View File
@@ -194,20 +194,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
if (platform.authType === 'cookies') { if (platform.authType === 'cookies') {
const cookies = await extractCookiesForPlatform(key); const cookies = await extractCookiesForPlatform(key);
if (cookies.length === 0) return { error: 'No cookies found — log in first.' }; if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
// Verify the captured cookies are actually live BEFORE
// uploading. Skips upload on confirmed-stale sessions so we
// don't overwrite FC-side credentials with garbage. Platforms
// without a verify config (verify.ok === null) fall through
// to upload as before.
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
return {
error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`,
};
}
const data = toNetscapeFormat(cookies); const data = toNetscapeFormat(cookies);
await api.uploadCredentials(key, 'cookies', data); await api.uploadCredentials(key, 'cookies', data);
return { success: true, cookieCount: cookies.length, verified: v.ok === true }; return { success: true, cookieCount: cookies.length };
} }
if (key === 'discord') { if (key === 'discord') {
if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
@@ -240,13 +229,8 @@ browser.runtime.onMessage.addListener(async (msg) => {
results[key] = { skipped: true, reason: 'no cookies' }; results[key] = { skipped: true, reason: 'no cookies' };
continue; continue;
} }
const v = await verifyCookiesForPlatform(key);
if (v.ok === false) {
results[key] = { error: `verify failed: ${v.reason}` };
continue;
}
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies)); await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true }; results[key] = { success: true, cookieCount: cookies.length };
} catch (e) { } catch (e) {
results[key] = { error: e.message }; results[key] = { error: e.message };
} }
@@ -275,29 +259,6 @@ browser.runtime.onMessage.addListener(async (msg) => {
return { error: e.message }; return { error: e.message };
} }
case 'PROBE_SOURCE':
try {
return await api.probeSource(msg.url);
} catch (e) {
return { error: e.message };
}
case 'OPEN_ARTIST_PAGE': {
// apiUrl is configured with the /api suffix (see
// options/options.html placeholder); the SPA artist route is
// /artist/:slug, served from the same origin. Strip /api so the
// browser-level URL hits the Vue router, not the JSON API.
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
const slug = encodeURIComponent(msg.slug || '');
if (!base || !slug) return { error: 'apiUrl or slug missing' };
try {
await browser.tabs.create({ url: `${base}/artist/${slug}` });
return { success: true };
} catch (e) {
return { error: e.message };
}
}
default: default:
return { error: `Unknown message type: ${msg.type}` }; return { error: `Unknown message type: ${msg.type}` };
} }
+1 -16
View File
@@ -5,26 +5,11 @@
background: rgb(20, 23, 26); color: rgb(244, 186, 122); background: rgb(20, 23, 26); color: rgb(244, 186, 122);
font: 500 14px/1.2 system-ui, sans-serif; font: 500 14px/1.2 system-ui, sans-serif;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer;
transition: transform 100ms ease, background 150ms ease, color 150ms ease; transition: transform 100ms ease;
} }
.fc-add-source-btn:hover { transform: translateY(-1px); } .fc-add-source-btn:hover { transform: translateY(-1px); }
.fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; } .fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; }
/* state colors map to the FC palette: parchment-on-slate base,
accent-orange for new, sage for already-subscribed, amber-warning for
artist-exists-but-source-missing. All readable on the dark base. */
.fc-add-source-btn--new {
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
}
.fc-add-source-btn--artist-match {
background: rgb(28, 23, 16); color: rgb(255, 200, 120);
border: 1px solid rgb(180, 130, 60);
}
.fc-add-source-btn--source-match {
background: rgb(18, 28, 20); color: rgb(140, 220, 160);
border: 1px solid rgb(80, 160, 100);
}
.fc-toast { .fc-toast {
all: revert; all: revert;
position: fixed; bottom: 84px; right: 24px; z-index: 2147483647; position: fixed; bottom: 84px; right: 24px; z-index: 2147483647;
+14 -96
View File
@@ -2,10 +2,6 @@
if (window.__fc_addsource_injected) return; if (window.__fc_addsource_injected) return;
window.__fc_addsource_injected = true; window.__fc_addsource_injected = true;
// Cached probe result for the current URL so click-handlers know which
// action to dispatch without round-tripping again.
let currentProbe = null;
evaluate(); evaluate();
const reEval = () => evaluate(); const reEval = () => evaluate();
@@ -13,116 +9,38 @@
const origPush = history.pushState; const origPush = history.pushState;
history.pushState = function () { origPush.apply(this, arguments); reEval(); }; history.pushState = function () { origPush.apply(this, arguments); reEval(); };
async function evaluate() { function evaluate() {
const url = window.location.href; const platform = getPlatformFromUrl(window.location.href);
const platform = getPlatformFromUrl(url); const onArtist = platform && isArtistPage(window.location.href, platform);
const onArtist = platform && isArtistPage(url, platform);
const btn = document.getElementById('fc-add-source-btn');
if (!onArtist) {
if (btn) btn.remove();
currentProbe = null;
return;
}
// On artist pages, ask the backend what state the URL is in BEFORE
// injecting the button — so the chip can render the right state on
// first paint instead of flashing the generic "Add" copy and
// updating afterwards.
let probe;
try {
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
} catch (e) {
probe = { error: e?.message || 'probe failed' };
}
currentProbe = probe;
if (probe?.state === 'unknown_platform') {
if (btn) btn.remove();
return;
}
renderButton(probe);
}
function renderButton(probe) {
let btn = document.getElementById('fc-add-source-btn'); let btn = document.getElementById('fc-add-source-btn');
if (!btn) { if (onArtist && !btn) injectButton();
btn = document.createElement('button'); else if (!onArtist && btn) btn.remove();
btn.id = 'fc-add-source-btn'; }
btn.addEventListener('click', onClick);
document.body.appendChild(btn); function injectButton() {
} const btn = document.createElement('button');
// Reset state classes so re-renders (SPA navigation) don't stack. btn.id = 'fc-add-source-btn';
btn.className = 'fc-add-source-btn'; btn.className = 'fc-add-source-btn';
btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`); btn.textContent = '+ Add to FabledCurator';
btn.textContent = labelFor(probe); btn.addEventListener('click', onClick);
btn.disabled = false; document.body.appendChild(btn);
}
function stateModifier(probe) {
if (!probe || probe.error) return 'new';
return ({
source_match: 'source-match',
artist_match: 'artist-match',
new: 'new',
})[probe.state] || 'new';
}
function labelFor(probe) {
if (!probe || probe.error) return '+ Add to FabledCurator';
const platformName = platformDisplayName(probe.platform);
const artistName = probe.artist?.name;
switch (probe.state) {
case 'source_match':
return `✓ In FabledCurator · ${platformName}`;
case 'artist_match':
return `+ Add ${platformName} source to ${artistName || 'artist'}`;
case 'new':
default:
return '+ Add to FabledCurator';
}
}
function platformDisplayName(key) {
return PLATFORMS[key]?.name || key || '';
} }
async function onClick() { async function onClick() {
const btn = document.getElementById('fc-add-source-btn'); const btn = document.getElementById('fc-add-source-btn');
if (!btn) return;
btn.disabled = true; btn.disabled = true;
const original = btn.textContent; const original = btn.textContent;
const probe = currentProbe;
if (probe?.state === 'source_match') {
btn.textContent = 'Opening…';
try {
const r = await browser.runtime.sendMessage({
type: 'OPEN_ARTIST_PAGE',
slug: probe.artist?.slug,
});
if (r?.error) showToast(`Error: ${r.error}`, 'error');
} catch (e) {
showToast(`Error: ${e.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = original;
}
return;
}
btn.textContent = 'Adding…'; btn.textContent = 'Adding…';
try { try {
const r = await browser.runtime.sendMessage({ const r = await browser.runtime.sendMessage({
type: 'ADD_AS_SOURCE', type: 'ADD_AS_SOURCE',
url: window.location.href, url: window.location.href,
}); });
if (r?.error) { if (r.error) {
showToast(`Error: ${r.error}`, 'error'); showToast(`Error: ${r.error}`, 'error');
} else { } else {
const verb = r.created_source ? 'Added' : 'Already a source for'; const verb = r.created_source ? 'Added' : 'Already a source for';
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success'); showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
// Re-probe so the chip flips green without waiting for the next
// navigation.
evaluate();
return;
} }
} catch (e) { } catch (e) {
showToast(`Error: ${e.message}`, 'error'); showToast(`Error: ${e.message}`, 'error');
-6
View File
@@ -83,12 +83,6 @@ class FabledCuratorAPI {
quickAddSource(url) { quickAddSource(url) {
return this.request('POST', '/extension/quick-add-source', { url }); return this.request('POST', '/extension/quick-add-source', { url });
} }
probeSource(url) {
// Read-only existence check. Drives the content-script chip's
// color/copy BEFORE the operator clicks Add.
const qs = new URLSearchParams({ url }).toString();
return this.request('GET', `/extension/probe?${qs}`);
}
// Connection test = the cheapest read with auth. // Connection test = the cheapest read with auth.
testConnection() { testConnection() {
-35
View File
@@ -76,38 +76,3 @@ async function getCookieCount(platformKey) {
return 0; return 0;
} }
} }
/**
* Verify cookies are live by hitting an authenticated endpoint with the
* browser's current cookie jar. Returns:
* { ok: true, status } — verified
* { ok: false, status, reason } — endpoint said we're not logged in
* { ok: null, reason } — no verify config for this platform; caller
* should treat as "verify not available,
* proceed with upload"
*
* Implementation note: extensions with `host_permissions` for the target
* domain get the user's cookies auto-attached to fetch() — same set
* gallery-dl will later use on the backend.
*/
async function verifyCookiesForPlatform(platformKey) {
const platform = PLATFORMS[platformKey];
if (!platform) return { ok: false, reason: `Unknown platform: ${platformKey}` };
if (!platform.verify) return { ok: null, reason: 'verify-not-configured' };
const { url, method, okStatuses } = platform.verify;
let resp;
try {
resp = await fetch(url, { method, credentials: 'include', cache: 'no-store' });
} catch (e) {
return { ok: false, reason: `Verify request failed: ${e.message}` };
}
if (okStatuses.includes(resp.status)) {
return { ok: true, status: resp.status };
}
return {
ok: false,
status: resp.status,
reason: `${url} returned HTTP ${resp.status} — session looks stale or logged out`,
};
}
-21
View File
@@ -13,13 +13,6 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#FF424D', color: '#FF424D',
urlPattern: /^https?:\/\/(www\.)?patreon\.com/, urlPattern: /^https?:\/\/(www\.)?patreon\.com/,
// Patreon's `/api/current_user` returns 200 + the logged-in user
// when authenticated, 401 otherwise. Cheapest definitive check.
verify: {
url: 'https://www.patreon.com/api/current_user',
method: 'GET',
okStatuses: [200],
},
}, },
subscribestar: { subscribestar: {
name: 'SubscribeStar', name: 'SubscribeStar',
@@ -33,9 +26,6 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#FFD700', color: '#FFD700',
urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/, urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/,
// No known stable auth-required endpoint that returns a definitive
// status code; skipping verify so we don't false-positive-fail
// good cookies. Operator can add later if a clean endpoint surfaces.
}, },
hentaifoundry: { hentaifoundry: {
name: 'Hentai Foundry', name: 'Hentai Foundry',
@@ -43,14 +33,6 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#9C27B0', color: '#9C27B0',
urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/, urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/,
// Mirror gallery-dl's _init_site_filters: HEAD on `?enterAgree=1`.
// Logged in → 200, logged out → 401. Catches the exact failure mode
// the backend extractor would hit later.
verify: {
url: 'https://www.hentai-foundry.com/?enterAgree=1',
method: 'HEAD',
okStatuses: [200],
},
}, },
discord: { discord: {
name: 'Discord', name: 'Discord',
@@ -74,9 +56,6 @@ const PLATFORMS = {
authType: 'cookies', authType: 'cookies',
color: '#05CC47', color: '#05CC47',
urlPattern: /^https?:\/\/(www\.)?deviantart\.com/, urlPattern: /^https?:\/\/(www\.)?deviantart\.com/,
// DA's logged-in-only endpoints sit behind their internal _napi
// namespace which shifts; skipping verify until a stable check
// surfaces. Same posture as SubscribeStar.
}, },
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "FabledCurator", "name": "FabledCurator",
"version": "1.0.7", "version": "1.0.5",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": { "browser_specific_settings": {

Some files were not shown because too many files have changed in this diff Show More