Compare commits

..
28 Commits
Author SHA1 Message Date
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
335 changed files with 5877 additions and 20688 deletions
+24 -69
View File
@@ -173,51 +173,24 @@ jobs:
# same source code as the preceding main-push build but with an
# immutable version tag — they need the XPI too, otherwise the
# versioned image ships without the signed extension.
#
# Tag-push vs main-push race (operator-flagged 2026-05-27 after
# v26.05.27.0 hit it): a release cut fires BOTH workflows almost
# simultaneously. Main-push runs sign-extension (1-5min AMO round
# trip) before publishing the ext-<version> release; tag-push
# skips sign-extension (gated to main) and races straight to
# this download step. Tag-push lost every time. Fix: poll the
# ext-<version> release endpoint with a sleep+retry loop (30s
# for up to 10min total) before giving up. Main-push's signing
# eventually wins and tag-push picks the release up on a later
# iteration.
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -eux
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
# Poll for the ext-<version> release. main-push's sign-extension
# step (AMO round-trip, 1-5min) needs to finish + upload before
# tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail.
for attempt in $(seq 1 20); do
STATUS=$(curl -s -o release.json -w "%{http_code}" \
-H "Authorization: token $TOKEN" \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
if [ "$STATUS" = "200" ]; then
echo "Found ext-$VERSION release on attempt $attempt"
break
fi
if [ "$attempt" = "20" ]; then
echo "ERROR: ext-$VERSION release not available after 10min of polling"
echo "Last HTTP status: $STATUS"
exit 1
fi
echo "Attempt $attempt: ext-$VERSION not yet published (HTTP $STATUS); sleeping 30s"
sleep 30
done
# Extract the .xpi asset's browser_download_url (Forgejo's
# /releases/assets/<id> endpoint returns ASSET METADATA, not
# the binary blob — operator-flagged 2026-05-26: my prior
# code curl'd the metadata endpoint without -f and wrote the
# resulting 404-page-not-found text into fabledcurator-*.xpi,
# which Firefox then rejected as "corrupt").
# browser_download_url is the canonical binary endpoint and
# is also publicly accessible (no token needed) but we pass
# the token anyway for symmetry with private-repo support.
# Look up the ext-<version> release; extract the .xpi asset's
# browser_download_url (Forgejo's /releases/assets/<id> endpoint
# returns ASSET METADATA, not the binary blob — operator-flagged
# 2026-05-26: my prior code curl'd the metadata endpoint without
# -f and wrote the resulting 404-page-not-found text into
# fabledcurator-*.xpi, which Firefox then rejected as "corrupt").
# browser_download_url is the canonical binary endpoint and is
# also publicly accessible (no token needed) but we pass the
# token anyway for symmetry with private-repo support.
curl -sf -H "Authorization: token $TOKEN" \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" \
-o release.json
DOWNLOAD_URL=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['browser_download_url'])")
test -n "$DOWNLOAD_URL"
echo "Downloading XPI from: $DOWNLOAD_URL"
@@ -242,32 +215,20 @@ jobs:
id: tag
run: |
# Three trigger shapes:
# refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD,
# no `.N` per family release-posture rule).
# Publish ONLY the immutable version tag;
# don't touch :latest (the main-push build
# for the merge commit already did that).
# refs/heads/main → push to main: publish :main + :latest
# (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.
# refs/tags/v… → tag-push: publish ONLY the immutable version
# tag (e.g. :v26.05.26.5). Don't touch :latest;
# that already got published by the main-push
# build for the merge commit.
# refs/heads/main → push to main (incl. PR merge commits):
# publish :main + :latest (floating).
# anything else → safety net; shouldn't fire given the `on:`
# config above. Tag :dev to 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)
# config above (dev was dropped). Tag :dev to
# surface the unexpected run in the registry.
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
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
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
fi
@@ -298,19 +259,13 @@ jobs:
id: tag
run: |
# Mirrors build-web's three-shape logic (tag-push / main-push /
# safety-net dev) including the per-commit :c-<short_sha> tag
# on main-push per the family release-posture rule. The -ml
# 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)
# safety-net dev). The -ml image follows the same release cadence
# as the web image.
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
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
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
fi
+153 -48
View File
@@ -1,8 +1,7 @@
name: CI
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
# - backend-lint-and-test: ruff + `pytest -m "not integration"`, no service containers.
# - frontend-build: vitest unit + vite build.
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
@@ -15,20 +14,6 @@ on:
# (single-operator Forgejo repo) so push coverage is complete.
jobs:
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so
# this runs with NO dependency install and surfaces the most common bounce
# class (lint: I001 / UP037 / ASYNC109 / W293 …) in seconds — instead of
# after the backend job's ~30-60s wheel install. ruff is static analysis,
# so no DB/secret env is needed.
lint:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
- name: Ruff lint
run: ruff check backend/ tests/ alembic/
backend-lint-and-test:
runs-on: python-ci
container:
@@ -66,8 +51,9 @@ jobs:
pip install -r requirements.txt pytest pytest-asyncio
fi
# Ruff moved to the dedicated fast `lint` job above (fails in seconds,
# no dep install). This job is now unit tests only.
- name: Ruff lint
run: ruff check backend/ tests/ alembic/
- name: Pytest (unit only — integration runs in the integration job)
run: pytest tests/ -v -m "not integration"
@@ -92,25 +78,28 @@ jobs:
- run: npm run test:unit
- run: npm run build
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
# The shards existed to parallelize ~8.5min of integration tests; once the
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
# runner slots for no wall-clock gain. One job now: spin up once, install
# once, migrate once, run every integration test.
# Integration suite split into THREE parallel shards (2026-05-25, runner
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service
# set and runs alembic + a disjoint subset of integration tests. Shards
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
# stays single-threaded per shard but multiple shards run in parallel
# wall-clock. Approximate split — rebalance once --durations=15 output
# reveals which shard is the long pole.
#
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
# containers by job name. act_runner strips underscores from job names when
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
# stays separator-free (`integration`). The step prints `docker ps -a` first
# so a future naming-convention shift surfaces in the log without a
# guess-and-push cycle.
# Each shard's docker-ps filter uses its own unique job name to scope
# service-container resolution. act_runner appears to strip underscores
# from job names when building container labels — `int_api` yielded
# zero matches on 2026-05-25 — so shards use no-separator names
# (`intapi`, `intimp`, `intcore`) instead. Each step prints
# `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 —
# per ci-requirements.md, FC is the only Python consumer of that image and the
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
integration:
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT
# done — per ci-requirements.md, FC is the only Python consumer of that
# image and the CI-Runner project's "add deps to image when used by >1
# project" rule keeps the install per-job.
intapi:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -141,14 +130,14 @@ jobs:
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Integration suite (resolve service IPs, migrate, test)
- name: API 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=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=intapi" --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")
@@ -165,14 +154,130 @@ jobs:
else
pip install -r requirements.txt pytest pytest-asyncio
fi
# Relax durability on the throwaway CI Postgres so the per-test
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
# surprise can't red the job; fabledcurator is the postgres image's
# bootstrap superuser.
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
alembic upgrade head
pytest tests/ -v -m integration --durations=15
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/settings.local.json
# Transient scheduler lock/state (committed by accident in 3f30327)
.claude/scheduled_tasks.lock
.claude/scheduled_tasks*.json
# Alembic / DB scratch
alembic/versions/__pycache__/
@@ -1,80 +0,0 @@
"""backfill post.post_title from description first-line — 2026-05-27
Revision ID: 0024
Revises: 0023
Create Date: 2026-05-27
SubscribeStar gallery-dl always writes `title: ""` and embeds the leading
sentence inside `content` HTML. FC's sidecar parser was leaving
post_title NULL for every SubscribeStar post since FC-3 shipped. The
parser fix (sidecar._first_line_text fallback) now synthesizes a title
at parse time; this migration applies the same logic retroactively to
existing rows.
Operator-flagged 2026-05-27 after inspecting
/mnt/Data/Patreon/Cheunart/subscribestar/ sidecars.
Idempotent: only touches rows where post_title IS NULL or empty AND
description IS NOT NULL. Re-running the migration is a no-op.
"""
from __future__ import annotations
import re
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
revision: str = "0024"
down_revision: Union[str, None] = "0023"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\s+")
def _first_line_text(body: str, limit: int = 120) -> str | None:
"""Mirror of sidecar._first_line_text. Kept inline so the migration
doesn't carry a runtime import dependency from app code that may
have moved by the time the migration is replayed years from now."""
if not body:
return None
text_ = _TAG_RE.sub(" ", body)
text_ = text_.replace("\xa0", " ")
for line in text_.splitlines():
line = _WS_RE.sub(" ", line).strip()
if line:
if len(line) > limit:
return line[: limit - 1].rstrip() + ""
return line
return None
def upgrade() -> None:
bind = op.get_bind()
rows = bind.execute(
text(
"SELECT id, description FROM post "
"WHERE (post_title IS NULL OR post_title = '') "
"AND description IS NOT NULL AND description <> ''"
)
).fetchall()
updated = 0
for row in rows:
derived = _first_line_text(row.description)
if not derived:
continue
bind.execute(
text("UPDATE post SET post_title = :t WHERE id = :id"),
{"t": derived, "id": row.id},
)
updated += 1
print(f"0024: backfilled post_title on {updated} row(s)")
def downgrade() -> None:
# No safe restore — we can't tell which post_titles were derived vs
# genuinely present. Leave the column alone on rollback.
pass
@@ -1,288 +0,0 @@
"""sidecar-audit followup: correct external_post_id + post_url across all platforms
Revision ID: 0025
Revises: 0024
Create Date: 2026-05-27
Closes the operator-flagged 2026-05-27 sidecar audit findings. Three
data-correctness bugs across non-Patreon platforms had been silently
corrupting Posts since FC-3 shipped; the parser fix (sidecar.py, same
commit) addresses new imports. This migration cleans up existing rows.
Per-platform actions:
subscribestar — gallery-dl wrote the per-attachment id in `id` and
the actual post id in `post_id`. FC's parser picked `id`, so every
multi-image SubscribeStar post was fragmented into N Post rows.
1. For each SubscribeStar Post, read its sidecar (via the related
ImageRecord's on-disk path), pull `post_id`, overwrite
external_post_id and post_url.
2. Merge groups of Posts under one source that now share an
external_post_id (fragments of the same actual post). Same
ImageProvenance pre-delete + repoint dance as alembic 0022.
hentaifoundry — sidecars have NO `url` field; `src` is the image
URL. FC's parser stored post_url=NULL. Read each HF Post's sidecar
for `user` + `index`, derive the canonical /pictures/user/<u>/<i>
permalink. external_post_id (= `index`) was already correct.
discord — gallery-dl wrote the CDN attachment URL in `url`. FC's
parser stored that as post_url. Read each Discord Post's sidecar
for the server/channel/message triple, derive the proper
discord.com/channels/.../<message> permalink. external_post_id (=
`message_id`) was already correct.
pixiv — pure-SQL backfill: replace any `i.pximg.net`-style URL on
Post.post_url with the derived `/artworks/<id>` permalink. Pixiv
external_post_id (= `id`) was already correct; no sidecar IO
needed.
Idempotent: re-running on already-corrected data is a no-op (skips
rows whose derived value matches what's already stored).
Posts whose related ImageRecord paths don't resolve on disk (orphaned
filesystem state) are skipped with a count in the migration output —
those will be picked up by a future deep-scan.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
revision: str = "0025"
down_revision: Union[str, None] = "0024"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Mirror of sidecar._NUMBERING_PREFIX. Kept inline so the migration is
# self-contained (the operator's banked rule:
# reference_postgres_enum_swap_drop_checks.md says migrations shouldn't
# import from runtime app code).
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")
def _find_sidecar(media_path: Path) -> Path | None:
"""gallery-dl writes the sidecar under the unprefixed stem
(`HOLLOW-ICHIGO.json`) while the media file gets a NN_ ordering
prefix (`01_HOLLOW-ICHIGO.png`). Try in order:
1. <stem>.json next to the media
2. <media>.json next to the media (full-name variant)
3. strip the NN_ prefix from the stem, then <stripped>.json
"""
if not media_path:
return None
cand = media_path.with_suffix(".json")
if cand.is_file():
return cand
cand = media_path.parent / f"{media_path.name}.json"
if cand.is_file():
return cand
m = _NUMBERING_PREFIX.match(media_path.stem)
if m:
cand = media_path.parent / f"{m.group(1)}.json"
if cand.is_file():
return cand
return None
def _str_id(v) -> str | None:
"""str() a JSON scalar id; reject bool (JSON booleans are ints in
Python's eyes but they aren't valid sidecar ids)."""
if isinstance(v, bool):
return None
if isinstance(v, (str, int)) and str(v).strip():
return str(v).strip()
return None
def _str_field(v) -> str | None:
if isinstance(v, str) and v.strip():
return v.strip()
return None
def upgrade() -> None:
conn = op.get_bind()
# ── PART 1: Per-platform corrections requiring filesystem IO ─────
# SubscribeStar, HentaiFoundry, Discord all need fields from the
# sidecar to construct the right post_url. We walk each Post's
# related ImageRecord.path to find the sidecar, read it, derive,
# and update.
targets = conn.execute(text("""
SELECT p.id, p.external_post_id, p.post_url, s.platform
FROM post p
JOIN source s ON s.id = p.source_id
WHERE s.platform IN ('subscribestar', 'hentaifoundry', 'discord')
""")).fetchall()
stats: dict[str, dict[str, int]] = {
plat: {"read": 0, "updated": 0, "no_sidecar": 0}
for plat in ("subscribestar", "hentaifoundry", "discord")
}
for post_row in targets:
plat = post_row.platform
path = _first_attachment_path(conn, post_row.id)
if not path:
stats[plat]["no_sidecar"] += 1
continue
sidecar = _find_sidecar(Path(path))
if sidecar is None:
stats[plat]["no_sidecar"] += 1
continue
try:
data = json.loads(sidecar.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
stats[plat]["no_sidecar"] += 1
continue
stats[plat]["read"] += 1
new_epid = post_row.external_post_id
new_url = None
if plat == "subscribestar":
pid = _str_id(data.get("post_id"))
if pid:
new_epid = pid
new_url = f"https://www.subscribestar.com/posts/{pid}"
elif plat == "hentaifoundry":
user = _str_field(data.get("user")) or _str_field(data.get("artist"))
idx = _str_id(data.get("index"))
if user and idx:
new_url = f"https://www.hentai-foundry.com/pictures/user/{user}/{idx}"
elif plat == "discord":
sid = _str_id(data.get("server_id"))
cid = _str_id(data.get("channel_id"))
mid = _str_id(data.get("message_id"))
if sid and cid and mid:
new_url = f"https://discord.com/channels/{sid}/{cid}/{mid}"
# Idempotent: skip if nothing changed.
if new_epid == post_row.external_post_id and new_url == post_row.post_url:
continue
conn.execute(
text("""
UPDATE post
SET external_post_id = :epid, post_url = :url
WHERE id = :id
"""),
{"epid": new_epid, "url": new_url, "id": post_row.id},
)
stats[plat]["updated"] += 1
for plat, s in stats.items():
print(
f"0025: {plat} — read {s['read']} sidecars, "
f"updated {s['updated']} Posts, "
f"{s['no_sidecar']} Posts had no resolvable sidecar"
)
# ── PART 2: Merge SubscribeStar fragments now sharing epid ───────
# After Part 1, each group of Posts under one source with the SAME
# new external_post_id is a fragment-set of the same actual post.
# Merge to one canonical row. Pre-handle the same ImageProvenance
# collision pattern as alembic 0022 (uq_image_provenance_image_post).
fragment_groups = conn.execute(text("""
SELECT p.source_id, p.external_post_id,
ARRAY_AGG(p.id ORDER BY p.id ASC) AS post_ids
FROM post p
JOIN source s ON s.id = p.source_id
WHERE s.platform = 'subscribestar'
AND p.external_post_id IS NOT NULL
GROUP BY p.source_id, p.external_post_id
HAVING COUNT(*) > 1
""")).fetchall()
merged = 0
for grp in fragment_groups:
post_ids = list(grp.post_ids)
keep_id, *drop_ids = post_ids
for drop_id in drop_ids:
# Pre-DELETE colliding ImageProvenance under drop_ that
# already exist under keep (alembic 0022 banked the pattern).
conn.execute(
text("""
DELETE FROM image_provenance
WHERE post_id = :drop_
AND image_record_id IN (
SELECT image_record_id FROM image_provenance
WHERE post_id = :keep
)
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_provenance SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_record SET primary_post_id = :keep
WHERE primary_post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE post_attachment SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("DELETE FROM post WHERE id = :drop_"),
{"drop_": drop_id},
)
merged += 1
print(f"0025: subscribestar — merged {merged} duplicate Post fragments")
# ── PART 3: Pixiv post_url backfill (pure SQL) ───────────────────
# Pixiv's external_post_id is already correct (gallery-dl's `id` is
# the post id). Only post_url needs derivation: replace anything
# under i.pximg.net (the file URL) with the /artworks/<id> permalink.
pixiv_updated = conn.execute(text("""
UPDATE post p
SET post_url = 'https://www.pixiv.net/artworks/' || p.external_post_id
FROM source s
WHERE p.source_id = s.id
AND s.platform = 'pixiv'
AND p.external_post_id IS NOT NULL
AND (p.post_url IS NULL
OR p.post_url LIKE 'https://i.pximg.net/%'
OR p.post_url LIKE 'http://i.pximg.net/%')
""")).rowcount
print(f"0025: pixiv — backfilled post_url on {pixiv_updated} Posts")
def _first_attachment_path(conn, post_id: int) -> str | None:
"""Return any ImageRecord.path attached to this post (via
ImageProvenance). Lowest-id row keeps the migration deterministic
so re-running on the same DB picks the same sidecar."""
row = conn.execute(
text("""
SELECT ir.path
FROM image_provenance ip
JOIN image_record ir ON ir.id = ip.image_record_id
WHERE ip.post_id = :pid
ORDER BY ip.id ASC
LIMIT 1
"""),
{"pid": post_id},
).first()
return row[0] if row else None
def downgrade() -> None:
# Lossy: external_post_id values were overwritten with the correct
# post_id; original per-attachment ids weren't preserved. Post-merge
# also deleted drop rows. No safe restore. To roll back the schema
# invariant, fork from 0024 and re-run sidecar imports.
pass
@@ -1,53 +0,0 @@
"""import_task.recovery_count + refetched — poison-pill circuit breaker
Revision ID: 0026
Revises: 0025
Create Date: 2026-05-28
Backs the import-task resilience work (operator-flagged 2026-05-28):
- recovery_count: how many times recover_interrupted_tasks has
re-queued this row from a stuck 'processing' state. A row that
hard-crashes the worker (OOM / segfault on a corrupt or oversized
input) leaves no terminal flip, so the sweep re-queues it — and
without a cap it would loop forever, re-crashing the worker each
time. After MAX_RECOVERY_ATTEMPTS the sweep marks it 'failed' with a
diagnostic instead.
- refetched: whether a one-shot re-download has already been attempted
for this task's file. Bounds the Layer-2 re-fetch remediation to a
single attempt so source-side corruption doesn't loop.
Both default to 0 / false; additive, no backfill needed.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0026"
down_revision: Union[str, None] = "0025"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"import_task",
sa.Column(
"recovery_count", sa.Integer(), nullable=False,
server_default="0",
),
)
op.add_column(
"import_task",
sa.Column(
"refetched", sa.Boolean(), nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column("import_task", "refetched")
op.drop_column("import_task", "recovery_count")
@@ -1,50 +0,0 @@
"""drop migration_run — one-and-done GS/IR migration tooling removed
Revision ID: 0027
Revises: 0026
Create Date: 2026-05-29
The GS/IR migration tooling (services/migrators, /api/migrate, the
run_migration task, LegacyMigrationCard, and the MigrationRun model) was
removed after the migration cutover completed. This drops its now-orphaned
run-log table. Downgrade recreates the table (mirrors the old model) so the
migration is reversible.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "0027"
down_revision: Union[str, None] = "0026"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table("migration_run")
def downgrade() -> None:
op.create_table(
"migration_run",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("dry_run", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column(
"started_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"counts", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb"),
),
sa.Column("error", sa.Text(), nullable=True),
sa.Column(
"metadata", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb"),
),
)
op.create_index("ix_migration_run_kind", "migration_run", ["kind"])
op.create_index("ix_migration_run_status", "migration_run", ["status"])
@@ -1,190 +0,0 @@
"""collapse-sidecar-synthetic: repoint Posts/ImageProvenance/DownloadEvents
from `sidecar:<platform>:<slug>` synthetic Source anchors onto the real
Source for the same (artist, platform) when one exists, then delete the
synthetic.
Revision ID: 0028
Revises: 0027
Create Date: 2026-05-31
Background: alembic 0022 (2026-05-26) consolidated the old per-post-URL
Source rows into one canonical Source per (artist, platform). When NO
real campaign URL was salvageable among the candidates, it rewrote the
canonical row to url='sidecar:<platform>:<slug>' enabled=false as a
disabled anchor for any Posts already attached.
That was fine while it was the only Source for that artist+platform.
But: the unique constraint on Source is (artist_id, platform, url), not
(artist_id, platform). When the operator later added the real
subscription via the UI / extension / etc., a SECOND row landed —
the real one — with id > the synthetic. Both coexisted.
Two follow-on problems surfaced 2026-05-31:
1. The Subscriptions UI listed both rows. The synthetic was disabled
so the scheduler never polled it, but it looked like a phantom
subscription. (Fixed in same commit by SourceService.list filter.)
2. importer._source_for_sidecar picked Source by `ORDER BY id ASC
LIMIT 1`, so EVERY gallery-dl download since the real Source was
added attached its Post to the SYNTHETIC anchor, not the real
Source. (Fixed in same commit by preferring non-sidecar URLs.)
This migration is the data half of the cleanup: for every (artist,
platform) with both a synthetic AND a real Source, repoint the
synthetic's children (Posts, ImageProvenance, DownloadEvents) onto the
real Source and delete the synthetic. Reuses the same epid/provenance
collision dance from alembic 0022 because the same uniqueness
constraints fire row-by-row during bulk UPDATEs.
Lone synthetic anchors — those where no real Source for the same
(artist, platform) exists (e.g., filesystem-imported artist with no
subscription added) — are LEFT INTACT. They anchor real imported
content; deleting them would CASCADE-delete the Posts the operator
imported. The SourceService.list filter hides them from the UI; the
operator can delete them by hand if they want the underlying imports
gone.
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
revision: str = "0028"
down_revision: Union[str, None] = "0027"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Find (artist_id, platform) groups where BOTH a sidecar synthetic
# and at least one real Source exist.
groups = conn.execute(text("""
SELECT artist_id, platform
FROM source
GROUP BY artist_id, platform
HAVING bool_or(url LIKE 'sidecar:%')
AND bool_or(url NOT LIKE 'sidecar:%')
""")).fetchall()
for artist_id, platform in groups:
rows = conn.execute(
text("""
SELECT id, url FROM source
WHERE artist_id = :a AND platform = :p
ORDER BY id ASC
"""),
{"a": artist_id, "p": platform},
).fetchall()
synthetic_ids = [sid for sid, url in rows if url.startswith("sidecar:")]
real_rows = [(sid, url) for sid, url in rows if not url.startswith("sidecar:")]
if not synthetic_ids or not real_rows:
continue # belt+suspenders; the GROUP BY already filtered
# Canonical real: lowest-id non-sidecar Source.
canonical_id = real_rows[0][0]
# STEP A: PRE-merge Post collisions on (canonical, external_post_id).
# Mirror alembic 0022's pre-merge logic — when synth has Post X
# epid=N and real has Post Y epid=N, the bulk UPDATE below would
# trip uq_post_source_external_id row-by-row. Group all Posts
# under (canonical + synthetics) by epid; for any group >1,
# pick a keep (prefer one already under canonical, else lowest
# id) and merge the rest into it.
all_posts = conn.execute(
text("""
SELECT external_post_id, id, source_id
FROM post
WHERE source_id = :canonical OR source_id = ANY(:synths)
ORDER BY external_post_id, id
"""),
{"canonical": canonical_id, "synths": synthetic_ids},
).fetchall()
by_epid: dict = {}
for epid, post_id, src_id in all_posts:
by_epid.setdefault(epid, []).append((post_id, src_id))
for _epid, posts in by_epid.items():
if len(posts) <= 1:
continue
canonical_side = [p for p in posts if p[1] == canonical_id]
keep_id = canonical_side[0][0] if canonical_side else posts[0][0]
drop_ids = [p[0] for p in posts if p[0] != keep_id]
for drop_id in drop_ids:
# Pre-delete image_provenance rows under drop_ whose
# image_record_id already has provenance under keep —
# avoids tripping uq_image_provenance_image_post (0021)
# row-by-row during the repoint UPDATE.
conn.execute(
text("""
DELETE FROM image_provenance
WHERE post_id = :drop_
AND image_record_id IN (
SELECT image_record_id FROM image_provenance
WHERE post_id = :keep
)
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_provenance SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_record SET primary_post_id = :keep
WHERE primary_post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("DELETE FROM post WHERE id = :drop_"),
{"drop_": drop_id},
)
# STEP B: Bulk reparent the remaining Posts off the synthetics.
conn.execute(
text("""
UPDATE post SET source_id = :canonical
WHERE source_id = ANY(:synths)
"""),
{"canonical": canonical_id, "synths": synthetic_ids},
)
# STEP C: Reparent ImageProvenance.source_id (denormalized FK;
# no UNIQUE on source_id, safe bulk).
conn.execute(
text("""
UPDATE image_provenance SET source_id = :canonical
WHERE source_id = ANY(:synths)
"""),
{"canonical": canonical_id, "synths": synthetic_ids},
)
# STEP D: Reparent any DownloadEvent.source_id. Synthetics are
# enabled=false so the scheduler never created events for them;
# this is belt+suspenders for any rows planted by manual force
# or older code paths.
conn.execute(
text("""
UPDATE download_event SET source_id = :canonical
WHERE source_id = ANY(:synths)
"""),
{"canonical": canonical_id, "synths": synthetic_ids},
)
# STEP E: Drop the now-empty synthetics.
conn.execute(
text("DELETE FROM source WHERE id = ANY(:synths)"),
{"synths": synthetic_ids},
)
def downgrade() -> None:
# Lossy migration — synthetic Sources deleted, Posts repointed and
# potentially merged. No safe downgrade.
pass
@@ -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")
+6
View File
@@ -33,6 +33,12 @@ def create_app() -> Quart:
app = Quart(__name__)
app.secret_key = cfg.secret_key
# FC-5: legacy IR ingest JSON can run to tens of MB (hundreds of
# thousands of image_tag_associations). Werkzeug's default form
# memory cap is 500KB; raise both ceilings so the multipart upload
# for /api/migrate/ir_ingest doesn't 413.
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 * 1024 # 1 GB
app.config["MAX_FORM_MEMORY_SIZE"] = 1024 * 1024 * 1024 # 1 GB
for bp in all_blueprints():
app.register_blueprint(bp)
+2
View File
@@ -26,6 +26,7 @@ def all_blueprints() -> list[Blueprint]:
from .extension import extension_bp
from .gallery import gallery_bp
from .import_admin import import_admin_bp
from .migrate import migrate_bp
from .ml_admin import ml_admin_bp
from .platforms import platforms_bp
from .posts import posts_bp
@@ -53,6 +54,7 @@ def all_blueprints() -> list[Blueprint]:
admin_bp,
cleanup_bp,
import_admin_bp,
migrate_bp,
suggestions_bp,
allowlist_bp,
aliases_bp,
-16
View File
@@ -1,16 +0,0 @@
"""Shared API response helpers."""
from quart import jsonify
def error_response(
error: str, *, status: int = 400, detail: str | None = None, **extra,
):
"""JSON error body + HTTP status. `detail` is included only when given;
`extra` keys are merged into the body. Returns the (response, status)
tuple Quart expects. Imported as `_bad` by the blueprints."""
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
+9 -100
View File
@@ -6,7 +6,6 @@ Five action surfaces:
DELETE /api/admin/tags/<int:tag_id> (Tier B)
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
POST /api/admin/tags/prune-unused (Tier A)
POST /api/admin/tags/purge-legacy (Tier A)
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
Tier-C ops take a dry_run body flag (returns projection inline,
@@ -19,16 +18,21 @@ from __future__ import annotations
import hashlib
from quart import Blueprint, jsonify, request
from sqlalchemy import select, text
from sqlalchemy import select
from ..extensions import get_session
from ..models import Artist
from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete
from ._responses import error_response as _bad
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _bulk_image_confirm_token(image_ids: list[int]) -> str:
"""Stable 8-hex token derived from the sorted id list. Mutates
when the selection changes; stays the same across modal opens of
@@ -93,19 +97,11 @@ async def images_bulk_delete():
)
)
sha8 = _bulk_image_confirm_token(image_ids)
expected = f"delete-images-{sha8}"
if dry_run:
# Hand the canonical Tier-C confirm token back with the
# projection so the frontend doesn't have to recompute SHA-256
# client-side via crypto.subtle (Secure-Context-gated,
# undefined on plain-HTTP origins per the homelab posture).
# Operator-flagged 2026-05-27.
projected["confirm_token"] = expected
return jsonify(projected)
sha8 = _bulk_image_confirm_token(image_ids)
expected = f"delete-images-{sha8}"
if supplied_confirm != expected:
return _bad(
"confirm_mismatch",
@@ -202,90 +198,3 @@ async def tags_prune_unused():
)
)
return jsonify(result)
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
async def tags_purge_legacy():
"""Tier-A: delete legacy IR-migration tags — archive/post/artist
kinds (e.g. `BlenderKnight:Hannah_BJ_Loops`) PLUS general tags with
a legacy name prefix (`source:*`, from IR's source kind that fell
back to general). dry-run preview returns per-kind + per-prefix
counts + a sample so the UI shows exactly what'll go before the
operator confirms with dry_run=false."""
from ..services.cleanup_service import purge_legacy_tags
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: purge_legacy_tags(sync_sess, dry_run=dry_run)
)
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("/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
+11 -16
View File
@@ -31,13 +31,18 @@ from sqlalchemy import select
from ..extensions import get_session
from ..models import LibraryAuditRun
from ..services import cleanup_service
from ._responses import error_response as _bad
cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")
IMAGES_ROOT = Path("/images")
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _min_dim_token(min_w: int, min_h: int) -> str:
# SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both
# sides use SHA-256 truncated to 8 hex chars.
@@ -76,13 +81,6 @@ async def min_dim_preview():
s, min_width=min_w, min_height=min_h,
)
)
# Hand the canonical Tier-C delete token back with the preview so
# the frontend doesn't have to recompute SHA-256 client-side.
# window.crypto.subtle is Secure-Context-gated and undefined on
# plain-HTTP origins (homelab posture); without this the Delete
# button silently swallowed the TypeError and never opened the
# confirm modal. Operator-flagged 2026-05-27.
projection["confirm_token"] = _min_dim_token(min_w, min_h)
return jsonify(projection)
@@ -154,15 +152,12 @@ async def audit_history():
limit = min(int(request.args.get("limit", "20")), 100)
except ValueError:
return _bad("invalid_limit")
# Optional rule filter so a card can reconnect to ITS latest run on mount
# (?rule=transparency&limit=1) — the audit survives navigation; the UI
# rehydrates from this rather than losing the in-flight scan.
rule = request.args.get("rule") or None
async with get_session() as session:
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
if rule is not None:
stmt = stmt.where(LibraryAuditRun.rule == rule)
rows = (await session.execute(stmt.limit(limit))).scalars().all()
rows = (await session.execute(
select(LibraryAuditRun)
.order_by(LibraryAuditRun.id.desc())
.limit(limit)
)).scalars().all()
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
+8 -56
View File
@@ -20,7 +20,6 @@ from ..services.credential_service import (
UnknownPlatformError,
WrongAuthTypeError,
)
from ._responses import error_response as _bad
credentials_bp = Blueprint("credentials", __name__, url_prefix="/api/credentials")
@@ -39,6 +38,14 @@ def _get_crypto() -> CredentialCrypto:
return _crypto
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
async def _ext_key_ok(session) -> bool:
"""If X-Extension-Key is supplied, it must match the stored value.
Missing header → True (browser path; accepted per homelab posture).
@@ -117,58 +124,3 @@ async def delete_credential(platform: str):
except LookupError:
return _bad("not_found", status=404)
return "", 204
@credentials_bp.route("/<platform>/verify", methods=["POST"])
async def verify_credential(platform: str):
"""Test the stored credential against one of the platform's enabled sources,
WITHOUT downloading. Routes through the platform's backend
(download_backends.verify_credential) — native ingester for Patreon, an
authenticated API page; gallery-dl --simulate for the rest. On success
stamps last_verified. Returns {valid: bool|null, reason, last_verified?};
valid=null means "couldn't test" (no credential, no enabled source, or an
inconclusive network/drift result)."""
from ..models import Artist, Source
from ..services.download_backends import verify_source_credential
async with get_session() as session:
if not await _ext_key_ok(session):
return _bad("unauthorized", status=401)
svc = CredentialService(session, _get_crypto())
record = await svc.get(platform)
if record is None:
return jsonify({"valid": None, "reason": "No credential stored for this platform."})
# Pick an enabled source for this platform to point the probe at.
row = (await session.execute(
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.where(Source.platform == platform, Source.enabled.is_(True))
.order_by(Source.id.asc())
)).first()
if row is None:
return jsonify({
"valid": None,
"reason": "No enabled source for this platform to verify against — add a subscription first.",
})
source, artist = row
cookies_path = await svc.get_cookies_path(platform)
auth_token = await svc.get_token(platform)
ok, message = await verify_source_credential(
platform=platform,
url=source.url,
artist_slug=artist.slug,
config_overrides=source.config_overrides or {},
cookies_path=str(cookies_path) if cookies_path else None,
auth_token=auth_token,
images_root=Path("/images"),
)
last_verified = None
if ok:
async with get_session() as session:
ts = await CredentialService(session, _get_crypto()).mark_verified(platform)
last_verified = ts.isoformat() if ts else None
return jsonify({"valid": ok, "reason": message, "last_verified": last_verified})
+1 -97
View File
@@ -5,10 +5,8 @@ status/source/artist. Returns slim records.
Detail view: full DownloadEvent including the metadata JSONB.
"""
from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request
from sqlalchemy import func, select
from sqlalchemy import select
from ..extensions import get_session
from ..models import Artist, DownloadEvent, Source
@@ -97,83 +95,6 @@ async def list_downloads():
return jsonify([_list_record(e, s, a) for e, s, a in rows])
@downloads_bp.route("/stats", methods=["GET"])
async def downloads_stats():
"""Status-grouped count over download_event for the dashboard stat chips.
`?window_hours=` (default 24) bounds by `started_at`. The full set of
statuses is always present in the response (zero for missing) so the
UI doesn't have to fill in defaults.
"""
try:
window_hours = int(request.args.get("window_hours", "24"))
except ValueError:
return jsonify({"error": "invalid_window_hours"}), 400
if window_hours < 1 or window_hours > 24 * 365:
return jsonify({"error": "invalid_window_hours"}), 400
since = datetime.now(UTC) - timedelta(hours=window_hours)
out = {"pending": 0, "running": 0, "ok": 0, "error": 0, "skipped": 0}
async with get_session() as session:
stmt = (
select(DownloadEvent.status, func.count())
.where(DownloadEvent.started_at >= since)
.group_by(DownloadEvent.status)
)
for status, n in (await session.execute(stmt)).all():
if status in out:
out[status] = int(n)
return jsonify(out)
@downloads_bp.route("/activity", methods=["GET"])
async def downloads_activity():
"""Hourly download-event counts over the last `?hours=` (default 24).
Returns a fixed-length, oldest-first bucket array so the UI can render
a sparkline directly. Bucketing is done in Python against UTC to dodge
session-timezone ambiguity in SQL date_trunc.
"""
try:
hours = int(request.args.get("hours", "24"))
except ValueError:
return jsonify({"error": "invalid_hours"}), 400
hours = max(1, min(168, hours))
now = datetime.now(UTC)
end = now.replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=hours - 1)
buckets = [
{"hour": (start + timedelta(hours=i)).isoformat(),
"ok": 0, "error": 0, "other": 0, "total": 0}
for i in range(hours)
]
async with get_session() as session:
rows = (await session.execute(
select(DownloadEvent.started_at, DownloadEvent.status)
.where(DownloadEvent.started_at >= start)
)).all()
for started_at, status in rows:
if started_at is None:
continue
sa = started_at if started_at.tzinfo else started_at.replace(tzinfo=UTC)
idx = int((sa - start).total_seconds() // 3600)
if not (0 <= idx < hours):
continue
b = buckets[idx]
if status == "ok":
b["ok"] += 1
elif status == "error":
b["error"] += 1
else:
b["other"] += 1
b["total"] += 1
return jsonify({"hours": hours, "buckets": buckets})
@downloads_bp.route("/<int:event_id>", methods=["GET"])
async def get_download(event_id: int):
async with get_session() as session:
@@ -187,20 +108,3 @@ async def get_download(event_id: int):
return jsonify({"error": "not_found"}), 404
event, source, artist = row
return jsonify(_detail_record(event, source, artist))
@downloads_bp.route("/recover-stalled", methods=["POST"])
async def recover_stalled():
"""Trigger the recover_stalled_download_events sweep on demand.
The same sweep runs every 5 min via Beat (see celery_app.beat_schedule);
this endpoint exists so the operator can force-clear stuck pending/
running download_events from the Subscriptions → Downloads maintenance
menu without waiting for the next scheduled tick.
"""
# Local import: avoids registering maintenance tasks during blueprint
# import (Celery task discovery races with the API import otherwise).
from ..tasks.maintenance import recover_stalled_download_events
recover_stalled_download_events.delay()
return jsonify({"queued": True}), 202
+6 -19
View File
@@ -20,7 +20,6 @@ from ..services.extension_service import (
UnknownPlatformError,
)
from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension")
@@ -31,6 +30,12 @@ XPI_DIR = Path("/app/frontend/dist/extension")
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
async def _ext_key_required(session) -> bool:
"""Unlike /api/credentials (which accepts the browser path with no
header), quick-add-source writes server state and must be explicitly
@@ -57,24 +62,6 @@ def _sha256(path: Path) -> str:
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"])
async def quick_add_source():
body = await request.get_json(silent=True)
+42 -129
View File
@@ -1,6 +1,4 @@
"""Gallery API: cursor scroll, timeline, jump, image detail, facets."""
from datetime import UTC, datetime, timedelta
"""Gallery API: cursor scroll, timeline, jump, image detail."""
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")
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"])
async def scroll():
cursor = request.args.get("cursor") or None
try:
limit = int(request.args.get("limit", "50"))
filters, sort = _parse_filters()
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:
svc = GalleryService(session)
try:
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:
return jsonify({"error": str(exc)}), 400
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,
"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"])
async def timeline():
try:
filters, _sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session:
svc = GalleryService(session)
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:
return jsonify({"error": str(exc)}), 400
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"])
async def jump():
try:
year = int(request.args["year"])
month = int(request.args["month"])
filters, sort = _parse_filters()
except (KeyError, ValueError):
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:
svc = GalleryService(session)
try:
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:
return jsonify({"error": str(exc)}), 400
+8 -59
View File
@@ -35,26 +35,10 @@ async def trigger_scan():
@import_admin_bp.route("/status", methods=["GET"])
async def status():
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 = (
await session.execute(
select(ImportBatch)
.where(
ImportBatch.status == "running",
select(ImportTask.id)
.where(
ImportTask.batch_id == ImportBatch.id,
ImportTask.status.in_(["pending", "queued", "processing"]),
)
.exists(),
)
.where(ImportBatch.status == "running")
.order_by(ImportBatch.started_at.desc())
.limit(1)
)
@@ -130,53 +114,18 @@ async def retry_failed():
status="queued", error=None,
started_at=None, finished_at=None,
)
.returning(ImportTask.id, ImportTask.task_type)
.returning(ImportTask.id)
)
failed = result.all()
if not failed:
failed_ids = [row[0] for row in result.all()]
if not failed_ids:
return jsonify({"retried": 0})
await session.commit()
from ..tasks.import_file import enqueue_import
for tid, task_type in failed:
enqueue_import(tid, task_type)
from ..tasks.import_file import import_media_file
for tid in failed_ids:
import_media_file.delay(tid)
return jsonify({"retried": len(failed)})
@import_admin_bp.route("/tasks/<int:task_id>/refetch", methods=["POST"])
async def refetch_task(task_id: int):
"""Layer-2 one-shot re-download: delete the (corrupt) file behind a
failed import task and re-run its source's downloader to fetch a
fresh copy. Only works for files that resolve to an enabled,
real-URL subscription Source; filesystem-only imports return
no_source.
Returns one of: refetch_queued (+source_id) / no_source /
already_refetched / not_found / not_failed.
"""
async with get_session() as session:
result = await session.run_sync(_refetch_task_sync, task_id)
if result["status"] == "not_found":
return jsonify(result), 404
if result["status"] == "not_failed":
return jsonify(result), 400
return jsonify(result)
def _refetch_task_sync(session, task_id: int) -> dict:
from pathlib import Path
from ..models import ImportSettings
from ..services.refetch_service import attempt_refetch
task = session.get(ImportTask, task_id)
if task is None:
return {"status": "not_found"}
if task.status != "failed":
return {"status": "not_failed"}
settings = ImportSettings.load_sync(session)
return attempt_refetch(session, task, Path(settings.import_scan_path))
return jsonify({"retried": len(failed_ids)})
@import_admin_bp.route("/clear-stuck", methods=["POST"])
+112
View File
@@ -0,0 +1,112 @@
"""FC-5: /api/migrate — trigger and poll migration runs.
Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an
`export_file` field. All other kinds accept JSON. Backup + rollback
were retired in FC-3h (2026-05-24); use /api/system/backup/* instead.
"""
import json
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from ..extensions import get_session
from ..models import MigrationRun
from ..tasks.migration import run_migration
migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate")
# 'backup' + 'rollback' retired 2026-05-24 (FC-3h); see /api/system/backup/*.
_VALID_KINDS = frozenset({
"gs_ingest", "ir_ingest", "tag_apply",
"ml_queue", "verify", "cleanup",
})
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _run_to_dict(run: MigrationRun) -> dict:
return {
"id": run.id,
"kind": run.kind,
"status": run.status,
"dry_run": run.dry_run,
"started_at": run.started_at.isoformat(),
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"counts": run.counts or {},
"error": run.error,
"metadata": run.metadata_ or {},
}
@migrate_bp.route("/<kind>", methods=["POST"])
async def create_run(kind: str):
if kind not in _VALID_KINDS:
return _bad("unknown_kind", detail=f"kind must be one of {sorted(_VALID_KINDS)}")
# Ingest kinds accept multipart/form-data; everything else takes JSON.
if kind in _INGEST_KINDS:
form = await request.form
files = await request.files
if "export_file" not in files:
return _bad("missing_export_file", detail="multipart export_file required")
export_file = files["export_file"]
try:
raw = export_file.read()
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return _bad("invalid_export_file", detail=str(exc))
dry_run = str(form.get("dry_run", "false")).lower() in ("true", "1", "yes")
params: dict = {"data": data, "dry_run": dry_run}
else:
body = await request.get_json()
if body is None:
body = {}
if not isinstance(body, dict):
return _bad("invalid_body")
dry_run = bool(body.get("dry_run", False))
params = dict(body)
async with get_session() as session:
run = MigrationRun(kind=kind, status="pending", dry_run=dry_run)
session.add(run)
await session.commit()
await session.refresh(run)
run_id = run.id
run_migration.delay(run_id, kind, params)
return jsonify({"run_id": run_id, "status": "pending"}), 202
@migrate_bp.route("/runs/<int:run_id>", methods=["GET"])
async def get_run(run_id: int):
async with get_session() as session:
run = (await session.execute(
select(MigrationRun).where(MigrationRun.id == run_id)
)).scalar_one_or_none()
if run is None:
return _bad("not_found", status=404)
return jsonify(_run_to_dict(run))
@migrate_bp.route("/runs", methods=["GET"])
async def list_runs():
try:
limit = int(request.args.get("limit", "10"))
except ValueError:
return _bad("invalid_limit")
if limit < 1 or limit > 100:
return _bad("invalid_limit")
async with get_session() as session:
rows = (await session.execute(
select(MigrationRun)
.order_by(MigrationRun.id.desc())
.limit(limit)
)).scalars().all()
return jsonify([_run_to_dict(r) for r in rows])
+4
View File
@@ -9,7 +9,9 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
_EDITABLE = (
"suggestion_threshold_artist",
"suggestion_threshold_character",
"suggestion_threshold_copyright",
"suggestion_threshold_general",
"centroid_similarity_threshold",
"min_reference_images",
@@ -26,7 +28,9 @@ async def get_settings():
).scalar_one()
return jsonify(
{
"suggestion_threshold_artist": s.suggestion_threshold_artist,
"suggestion_threshold_character": s.suggestion_threshold_character,
"suggestion_threshold_copyright": s.suggestion_threshold_copyright,
"suggestion_threshold_general": s.suggestion_threshold_general,
"centroid_similarity_threshold": s.centroid_similarity_threshold,
"min_reference_images": s.min_reference_images,
+10 -24
View File
@@ -5,11 +5,18 @@ from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..services.post_feed_service import PostFeedService
from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
@posts_bp.route("", methods=["GET"])
async def list_posts():
args = request.args
@@ -18,8 +25,6 @@ async def list_posts():
artist_id_raw = args.get("artist_id")
platform = args.get("platform") or None
limit_raw = args.get("limit", "24")
direction = args.get("direction", "older")
around_raw = args.get("around")
try:
limit = int(limit_raw)
@@ -28,16 +33,6 @@ async def list_posts():
if limit < 1 or limit > 100:
return _bad("invalid_limit", detail="limit must be between 1 and 100")
if direction not in ("older", "newer"):
return _bad("invalid_direction", detail="direction must be 'older' or 'newer'")
around_id = None
if around_raw is not None:
try:
around_id = int(around_raw)
except ValueError:
return _bad("invalid_around", detail="around must be an integer post id")
artist_id = None
if artist_id_raw is not None:
try:
@@ -52,19 +47,10 @@ async def list_posts():
)
async with get_session() as session:
svc = PostFeedService(session)
if around_id is not None:
result = await svc.around(
post_id=around_id, artist_id=artist_id,
platform=platform, limit=limit,
)
if result is None:
return _bad("not_found", status=404, detail=f"post id={around_id}")
return jsonify(result)
try:
page = await svc.scroll(
page = await PostFeedService(session).scroll(
cursor=cursor, artist_id=artist_id,
platform=platform, limit=limit, direction=direction,
platform=platform, limit=limit,
)
except ValueError as exc:
# Service raises ValueError for malformed cursors only;
+6 -2
View File
@@ -31,7 +31,9 @@ _EDITABLE_FIELDS = (
@settings_bp.route("/settings/import", methods=["GET"])
async def get_import_settings():
async with get_session() as session:
row = await ImportSettings.load(session)
row = (
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
).scalar_one()
return jsonify({
"min_width": row.min_width,
"min_height": row.min_height,
@@ -97,7 +99,9 @@ async def update_import_settings():
return _bad_int("download_failure_warning_threshold", 1, 100)
async with get_session() as session:
row = await ImportSettings.load(session)
row = (
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
).scalar_one()
for field in _EDITABLE_FIELDS:
if field in body:
setattr(row, field, body[field])
+10 -65
View File
@@ -5,7 +5,6 @@ from sqlalchemy import select
from ..extensions import get_session
from ..models import DownloadEvent, Source
from ..services.scheduler_service import active_platform_cooldowns, scheduler_status
from ..services.source_service import (
KNOWN_PLATFORMS,
ArtistNotFoundError,
@@ -15,11 +14,18 @@ from ..services.source_service import (
SourceService,
UnknownPlatformError,
)
from ._responses import error_response as _bad
sources_bp = Blueprint("sources", __name__, url_prefix="/api/sources")
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
@sources_bp.route("", methods=["GET"])
async def list_sources():
artist_id_raw = request.args.get("artist_id")
@@ -29,19 +35,11 @@ async def list_sources():
artist_id = int(artist_id_raw)
except ValueError:
return _bad("invalid_artist_id", detail="artist_id must be an integer")
failing = request.args.get("failing", "").lower() in ("1", "true", "yes")
async with get_session() as session:
records = await SourceService(session).list(artist_id=artist_id, failing=failing)
records = await SourceService(session).list(artist_id=artist_id)
return jsonify([r.to_dict() for r in records])
@sources_bp.route("/schedule-status", methods=["GET"])
async def schedule_status():
"""FC-dashboards: scheduler health for the Subscriptions hub."""
async with get_session() as session:
return jsonify(await scheduler_status(session))
@sources_bp.route("/<int:source_id>", methods=["GET"])
async def get_source(source_id: int):
async with get_session() as session:
@@ -120,52 +118,12 @@ async def delete_source(source_id: int):
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)."""
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'",
)
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>/check", methods=["POST"])
async def check_source(source_id: int):
"""FC-3c: enqueue a download for this source.
Returns 202 with the new DownloadEvent id. If a pending/running
event already exists for this source, returns 409 with that id. If
the source's platform is currently in a rate-limit cooldown, returns
**202 with `{status: "deferred", cooldown_until, platform}`** and
does NOT create an event or dispatch — the bulk retry path uses this
to avoid bowling N sources right back into the rate limit the
cooldown is preventing. Single-click "retry this one source" passes
`?force=true` to override the cooldown (operator-explicit, useful
for rapid auth-fix testing). The in-flight guard always applies.
"""
force = (request.args.get("force") or "").lower() in ("1", "true", "yes")
event already exists for this source, returns 409 with that id."""
async with get_session() as session:
source = (await session.execute(
select(Source).where(Source.id == source_id)
@@ -175,19 +133,6 @@ async def check_source(source_id: int):
if not source.enabled:
return _bad("source_disabled", detail="enable the source first")
# Cooldown gate (unless explicitly overridden). Checked before the
# in-flight guard because a deferred retry doesn't need to create
# or check for an event at all.
if not force:
cooldowns = await active_platform_cooldowns(session)
expires_at = cooldowns.get(source.platform)
if expires_at is not None:
return jsonify({
"status": "deferred",
"platform": source.platform,
"cooldown_until": expires_at.isoformat(),
}), 202
in_flight = (await session.execute(
select(DownloadEvent.id).where(
DownloadEvent.source_id == source_id,
+5 -40
View File
@@ -20,7 +20,6 @@ from sqlalchemy import desc, func, select
from ..config import get_config
from ..extensions import get_session
from ..models import TaskRun
from ..services.scheduler_service import scheduler_status
system_activity_bp = Blueprint(
"system_activity", __name__, url_prefix="/api/system/activity",
@@ -82,22 +81,17 @@ def _read_workers_sync() -> dict:
}
async def _queues_cached() -> dict:
"""Per-queue Redis LLEN, cached 2s. Shared by /queues and /summary."""
now = time.time()
if _QUEUE_CACHE["data"] is None or (now - _QUEUE_CACHE["ts"]) > _QUEUE_CACHE_TTL:
_QUEUE_CACHE["data"] = await asyncio.to_thread(_read_queues_sync)
_QUEUE_CACHE["ts"] = now
return _QUEUE_CACHE["data"]
@system_activity_bp.route("/queues", methods=["GET"])
async def get_queues():
"""Per-queue Redis LLEN. Cached 2s.
Response: {queues: {name: depth_or_null}, fetched_at: iso8601}
"""
return jsonify(await _queues_cached())
now = time.time()
if _QUEUE_CACHE["data"] is None or (now - _QUEUE_CACHE["ts"]) > _QUEUE_CACHE_TTL:
_QUEUE_CACHE["data"] = await asyncio.to_thread(_read_queues_sync)
_QUEUE_CACHE["ts"] = now
return jsonify(_QUEUE_CACHE["data"])
@system_activity_bp.route("/workers", methods=["GET"])
@@ -113,35 +107,6 @@ async def get_workers():
return jsonify(_WORKER_CACHE["data"])
@system_activity_bp.route("/summary", methods=["GET"])
async def get_summary():
"""One-call rollup for the always-on TopNav pipeline indicator:
scheduler health, per-queue pending depths, currently-running count, and
recent (24h) failure count. Cheap — cached queue LLENs + two TaskRun
counts — so it's safe to poll app-wide."""
queues_data = await _queues_cached()
depths = queues_data.get("queues", {})
queued_total = sum(v for v in depths.values() if isinstance(v, int))
since = datetime.now(UTC) - timedelta(hours=24)
async with get_session() as session:
scheduler = await scheduler_status(session)
running = (await session.execute(
select(func.count(TaskRun.id)).where(TaskRun.status == "running")
)).scalar_one()
failing = (await session.execute(
select(func.count(TaskRun.id))
.where(TaskRun.status.in_(["error", "timeout"]))
.where(TaskRun.finished_at >= since)
)).scalar_one()
return jsonify({
"scheduler": scheduler,
"queues": depths,
"queued_total": queued_total,
"running": int(running),
"failing": int(failing),
})
@system_activity_bp.route("/runs", methods=["GET"])
async def list_runs():
"""Paginated task_run history. Query params:
+12 -3
View File
@@ -14,7 +14,6 @@ from sqlalchemy import desc, select
from ..extensions import get_session
from ..models import BackupRun, ImportSettings
from ._responses import error_response as _bad
system_backup_bp = Blueprint(
"system_backup", __name__, url_prefix="/api/system/backup",
@@ -30,6 +29,12 @@ _BACKUP_SETTINGS_FIELDS = (
)
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _row_to_dict(r: BackupRun) -> dict:
return {
"id": r.id,
@@ -227,7 +232,9 @@ async def delete_run(run_id: int):
@system_backup_bp.route("/settings", methods=["GET"])
async def get_settings():
async with get_session() as session:
row = await ImportSettings.load(session)
row = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
return jsonify({
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
@@ -247,7 +254,9 @@ async def patch_settings():
return err
async with get_session() as session:
row = await ImportSettings.load(session)
row = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
for field in _BACKUP_SETTINGS_FIELDS:
if field in body:
setattr(row, field, body[field])
+6 -48
View File
@@ -194,46 +194,15 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
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"])
async def update_tag(tag_id: int):
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
`fandom_id` (a fandom tag id, or null to clear — character tags only).
`merge: true` resolves a collision by merging into the existing tag.
"""
body = await request.get_json() or {}
has_name = "name" in body
has_fandom = "fandom_id" in body
if not has_name and not has_fandom:
return jsonify({"error": "name or fandom_id required"}), 400
do_merge = bool(body.get("merge"))
async def rename_tag(tag_id: int):
body = await request.get_json()
if not body or "name" not in body:
return jsonify({"error": "name required"}), 400
async with get_session() as session:
svc = TagService(session)
try:
tag = None
if has_name:
tag = await svc.rename(tag_id, body["name"])
if has_fandom:
tag = await svc.set_fandom(
tag_id, body["fandom_id"], merge=do_merge
)
tag = await svc.rename(tag_id, body["name"])
except TagMergeConflict as exc:
return jsonify(
{
@@ -250,12 +219,7 @@ async def update_tag(tag_id: int):
return jsonify({"error": str(exc)}), 400
await session.commit()
return jsonify(
{
"id": tag.id,
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
}
{"id": tag.id, "name": tag.name, "kind": tag.kind.value}
)
@@ -281,12 +245,6 @@ async def merge_tag(source_id: int):
from ..tasks.ml import apply_allowlist_tags
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(
{
"target": {
+3 -18
View File
@@ -1,7 +1,5 @@
"""Thumbnail admin API: backfill trigger."""
import asyncio
from quart import Blueprint, jsonify
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"])
async def trigger_backfill():
"""Run the backfill scan synchronously, return the counts. The actual
thumbnail generation work is still off-loaded to the thumbnail Celery
queue via `generate_thumbnail.delay()` per missing row — so this
handler is fast even on a 100k-image library (a scan is just SELECT
id, thumbnail_path + a file.stat() per row, no heavy work).
from ..tasks.thumbnail import backfill_thumbnails
Operator-flagged 2026-06-01: the previous fire-and-forget shape
returned `{celery_task_id}` only, so the admin UI had no idea whether
backfill found 0 or 5000 candidates — \"found nothing\" was
indistinguishable from \"the worker isn't picking up the task.\""""
from ..tasks.thumbnail import _run_backfill_scan
# Sync scan inside an executor so we don't block the event loop.
counts = await asyncio.get_running_loop().run_in_executor(
None, _run_backfill_scan,
)
return jsonify(counts), 200
r = backfill_thumbnails.delay()
return jsonify({"celery_task_id": r.id}), 202
+2 -43
View File
@@ -28,6 +28,7 @@ def make_celery() -> Celery:
"backend.app.tasks.import_file",
"backend.app.tasks.thumbnail",
"backend.app.tasks.maintenance",
"backend.app.tasks.migration",
"backend.app.tasks.ml",
"backend.app.tasks.download",
"backend.app.tasks.backup",
@@ -44,6 +45,7 @@ def make_celery() -> Celery:
"backend.app.tasks.download.*": {"queue": "download"},
"backend.app.tasks.scan.*": {"queue": "scan"},
"backend.app.tasks.maintenance.*": {"queue": "maintenance"},
"backend.app.tasks.migration.*": {"queue": "maintenance"},
"backend.app.tasks.backup.*": {"queue": "maintenance"},
"backend.app.tasks.admin.*": {"queue": "maintenance"},
"backend.app.tasks.library_audit.*": {"queue": "maintenance"},
@@ -85,10 +87,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.cleanup_old_download_events",
"schedule": 86400.0, # daily
},
"recover-stalled-download-events": {
"task": "backend.app.tasks.maintenance.recover_stalled_download_events",
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
},
"recover-stalled-task-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_task_runs",
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
@@ -97,10 +95,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.prune_task_runs",
"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": {
"task": "backend.app.tasks.backup.backup_db_nightly",
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
@@ -109,41 +103,6 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.backup.prune_backups",
"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",
)
+2 -11
View File
@@ -54,14 +54,7 @@ _INT32_MIN = -2_147_483_648
def _queue_for(task) -> str:
"""Reverse the task→queue routing from celery_app.task_routes.
Keep in sync if task_routes is reordered.
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.
"""
Keep in sync if task_routes is reordered."""
name = getattr(task, "name", "") or ""
if name.startswith("backend.app.tasks.import_file."):
return "import"
@@ -75,9 +68,7 @@ def _queue_for(task) -> str:
return "scan"
if name.startswith((
"backend.app.tasks.maintenance.",
"backend.app.tasks.backup.",
"backend.app.tasks.admin.",
"backend.app.tasks.library_audit.",
"backend.app.tasks.migration.",
)):
return "maintenance"
return "default"
+2 -4
View File
@@ -2,7 +2,6 @@
from .app_setting import AppSetting
from .artist import Artist
from .artist_visit import ArtistVisit
from .backup_run import BackupRun
from .base import Base
from .credential import Credential
@@ -13,8 +12,8 @@ from .import_batch import ImportBatch
from .import_settings import ImportSettings
from .import_task import ImportTask
from .library_audit_run import LibraryAuditRun
from .migration_run import MigrationRun
from .ml_settings import MLSettings
from .patreon_seen_media import PatreonSeenMedia
from .post import Post
from .post_attachment import PostAttachment
from .series_page import SeriesPage
@@ -30,11 +29,9 @@ __all__ = [
"Base",
"AppSetting",
"Artist",
"ArtistVisit",
"BackupRun",
"Source",
"Credential",
"PatreonSeenMedia",
"Post",
"PostAttachment",
"SeriesPage",
@@ -49,6 +46,7 @@ __all__ = [
"ImportSettings",
"LibraryAuditRun",
"MLSettings",
"MigrationRun",
"TagAlias",
"TagAllowlist",
"TagReferenceEmbedding",
-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(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
)
# Nullable since alembic 0030 — provenance rows for filesystem-imported
# content with no subscription have NULL source_id. FK ondelete SET
# 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
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
captured_at: Mapped[datetime] = mapped_column(
-11
View File
@@ -74,17 +74,6 @@ class ImageRecord(Base):
created_at: Mapped[datetime] = mapped_column(
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(
DateTime(timezone=True),
nullable=False,
+1 -11
View File
@@ -4,7 +4,7 @@ Enforced as a single row via a CHECK (id = 1) constraint. The application
always SELECTs id=1 and never inserts/deletes after the initial migration.
"""
from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text, select
from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -63,13 +63,3 @@ class ImportSettings(Base):
backup_images_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=3,
)
@classmethod
async def load(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via an async session."""
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
@classmethod
def load_sync(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via a sync session."""
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
+1 -17
View File
@@ -8,16 +8,7 @@ been processing longer than the stuck-task threshold.
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
func,
)
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
@@ -35,13 +26,6 @@ class ImportTask(Base):
task_type: Mapped[str] = mapped_column(String(16), nullable=False) # media|archive
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
# Poison-pill circuit breaker (alembic 0026). recovery_count tracks
# how many times the stuck-task sweep has re-queued this row; after
# the cap it's failed with a diagnostic instead of looping. refetched
# bounds the one-shot re-download remediation to a single attempt.
recovery_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
refetched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
result_image_id: Mapped[int | None] = mapped_column(
ForeignKey("image_record.id", ondelete="SET NULL"), nullable=True
)
+37
View File
@@ -0,0 +1,37 @@
"""MigrationRun — tracks each FC-5 migration invocation (backup/gs/ir/etc).
kind/status are String(32) not Postgres ENUM so adding kinds later
doesn't need a schema migration. The API layer validates values.
"""
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class MigrationRun(Base):
__tablename__ = "migration_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
counts: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default=sa.text("'{}'::jsonb"),
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_: Mapped[dict] = mapped_column(
"metadata", JSONB, nullable=False, default=dict,
server_default=sa.text("'{}'::jsonb"),
)
+8 -6
View File
@@ -15,15 +15,17 @@ class MLSettings(Base):
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
suggestion_threshold_artist: Mapped[float] = mapped_column(
Float, nullable=False, default=0.30
)
suggestion_threshold_character: Mapped[float] = mapped_column(
Float, nullable=False, default=0.70
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(
Float, nullable=False, default=0.70
Float, nullable=False, default=0.95
)
centroid_similarity_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.55
-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
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).
A Post is one creator post; it may contain many images/videos.
"""
from datetime import datetime
@@ -17,25 +14,12 @@ from .base import Base
class Post(Base):
__tablename__ = "post"
__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"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int | None] = mapped_column(
ForeignKey("source.id", ondelete="SET NULL"), nullable=True, 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,
source_id: Mapped[int] = mapped_column(
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
external_post_id: Mapped[str] = mapped_column(String(128), nullable=False)
post_url: Mapped[str | None] = mapped_column(Text, nullable=True)
-14
View File
@@ -26,21 +26,7 @@ class Source(Base):
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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)
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")
@@ -13,10 +13,10 @@ from __future__ import annotations
import base64
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 ..models import Artist, ArtistVisit, ImageRecord, Source
from ..models import Artist, ImageRecord, Source
from .gallery_service import thumbnail_url
_SEP = "|"
@@ -58,27 +58,9 @@ class ArtistDirectoryService:
raise ValueError("limit must be between 1 and 200")
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 = (
select(Artist, count_col, unseen_col)
select(Artist, count_col)
.outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id)
.outerjoin(ArtistVisit, ArtistVisit.artist_id == Artist.id)
.group_by(Artist.id)
)
if q:
@@ -112,7 +94,7 @@ class ArtistDirectoryService:
next_cursor = _encode(last_artist.name, last_artist.id)
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)
cards = [
@@ -122,10 +104,9 @@ class ArtistDirectoryService:
"slug": artist.slug,
"is_subscription": bool(artist.is_subscription),
"image_count": int(image_count),
"unseen_count": int(unseen_count),
"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)
@@ -146,20 +127,17 @@ class ArtistDirectoryService:
ImageRecord.artist_id.label("artist_id"),
ImageRecord.sha256.label("sha256"),
ImageRecord.mime.label("mime"),
ImageRecord.thumbnail_path.label("thumbnail_path"),
rn,
)
.where(ImageRecord.artist_id.in_(artist_ids))
.subquery()
)
stmt = (
select(
sub.c.artist_id, sub.c.sha256, sub.c.mime, sub.c.thumbnail_path,
)
select(sub.c.artist_id, sub.c.sha256, sub.c.mime)
.where(sub.c.rn <= _PREVIEW_COUNT)
.order_by(sub.c.artist_id, sub.c.rn)
)
out: dict[int, list[str]] = {}
for aid, sha, mime, tp in (await self.session.execute(stmt)).all():
out.setdefault(aid, []).append(thumbnail_url(tp, sha, mime))
for aid, sha, mime in (await self.session.execute(stmt)).all():
out.setdefault(aid, []).append(thumbnail_url(sha, mime))
return out
+18 -74
View File
@@ -9,13 +9,11 @@ Dates come from Post.post_date via ImageProvenance.post_id.
from dataclasses import dataclass
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
Artist,
ArtistVisit,
ImageProvenance,
ImageRecord,
Post,
@@ -60,17 +58,13 @@ class ArtistService:
)
).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 = (
await self.session.execute(
select(func.min(Post.post_date), func.max(Post.post_date))
.select_from(Post)
.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()
dmin, dmax = date_row if date_row else (None, None)
@@ -104,14 +98,14 @@ class ArtistService:
)
).all()
# Same Post.artist_id direct filter — counts NULL-source posts too.
month = func.date_trunc("month", Post.post_date).label("m")
activity = (
await self.session.execute(
select(month, func.count(func.distinct(ImageProvenance.image_record_id)))
.select_from(Post)
.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)
.order_by(month)
)
@@ -120,16 +114,12 @@ class ArtistService:
post_count = (
await self.session.execute(
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()
# 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 {
"id": artist.id,
"name": artist.name,
@@ -137,7 +127,6 @@ class ArtistService:
"is_subscription": bool(artist.is_subscription),
"image_count": int(image_count),
"post_count": int(post_count),
"unseen_count_at_visit": unseen_at_visit,
"date_range": {
"min": dmin.isoformat() if dmin 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(
self, slug: str, cursor: str | None, limit: int = 60
) -> ArtistImagesPage | None:
@@ -242,7 +198,7 @@ class ArtistService:
"mime": r.mime,
"width": r.width,
"height": r.height,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
}
for r in rows
],
@@ -250,39 +206,27 @@ class ArtistService:
)
async def find_or_create(self, name: str) -> tuple[Artist, bool]:
"""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.
"""
"""Return (artist, created). Slug-keyed; idempotent under races."""
cleaned = (name or "").strip()
if not cleaned:
raise ValueError("artist name must not be empty")
slug = slugify(cleaned)
select_existing = select(Artist).where(Artist.slug == slug)
existing = (await self.session.execute(select_existing)).scalar_one_or_none()
existing = (await self.session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if existing is not None:
return existing, False
sp = await self.session.begin_nested()
artist = Artist(name=cleaned, slug=slug)
self.session.add(artist)
try:
artist = Artist(name=cleaned, slug=slug)
self.session.add(artist)
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:
await sp.rollback()
existing = (await self.session.execute(select_existing)).scalar_one()
await self.session.rollback()
existing = (await self.session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one()
return existing, False
await self.session.commit()
return artist, True
+17 -172
View File
@@ -6,16 +6,17 @@ HTTP handlers (small ops) and from Celery tasks in
backend.app.tasks.admin (long ops).
This module is the PERMANENT home of artist-cascade + image-unlink
logic. (The legacy migrators/cleanup.py copy was removed with the rest of
the one-and-done GS/IR migration tooling.)
logic. The legacy copy at backend/app/services/migrators/cleanup.py
stays in place until FC-3j; FC-3j will replace its body with thin
re-exports from this module and then delete the wrapper.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import func, or_, select, update
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
@@ -187,14 +188,10 @@ def unlink_image_files(
out["thumbnail"] = True
except OSError:
out["thumbnail"] = False
# Convention thumbs dir — try both extensions thumbnailer writes
# (.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.
# Convention thumbs dir — try all extensions; missing OK.
if image.sha256:
bucket = image.sha256[:3]
for ext in ("jpg", "png"):
for ext in ("jpg", "png", "webp"):
try:
(images_root / "thumbs" / bucket / f"{image.sha256}.{ext}").unlink(
missing_ok=True,
@@ -358,157 +355,18 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
Returns:
dry_run=True: {"count": 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)
sample = [t.name for t in sample_rows]
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()
unused = find_unused_tags(session)
sample = [t.name for t in unused[:50]]
if dry_run:
count = session.execute(
select(func.count())
.select_from(Tag)
.where(Tag.id.not_in(used_via_image_tag))
.where(Tag.id.not_in(used_via_series))
).scalar_one()
return {"count": count, "sample_names": sample}
result = session.execute(
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:
# (1) kinds the tag input never produces — archive/post/artist.
# provenance (post grouping) + archive membership are their own
# systems now, and artists are first-class Artist/Source rows.
# meta/rating were already hard-deleted by alembic 0023.
# (2) name prefixes from IR kinds FC never adopted — `source:*`.
# ImageRepo had a `source` kind; FC's enum doesn't, so ir_ingest
# fell those back to `general` (kind=general, name="source:patreon"
# etc.). They can't be caught by kind, so we match the name prefix.
PURGEABLE_TAG_KINDS = ("archive", "post", "artist")
LEGACY_NAME_PREFIXES = ("source:",)
def _legacy_tag_predicate():
name_clauses = [Tag.name.like(f"{p}%") for p in LEGACY_NAME_PREFIXES]
return or_(Tag.kind.in_(PURGEABLE_TAG_KINDS), *name_clauses)
def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
"""Count (dry_run) or delete legacy IR-migration tags: archive/post/
artist-kind tags PLUS general tags whose name matches a legacy
prefix (source:*).
CASCADE on image_tag / tag_alias / tag_allowlist /
tag_reference_embedding / tag_suggestion_rejection / series_page
clears the related rows on the parent DELETE.
Returns:
{"by_kind": {kind: count, ...}, # kind-matched rows
"by_prefix": {"source:*": count}, # name-prefix-matched rows
"count": total, "sample_names": [first 50],
and on live runs "deleted": total}
"""
predicate = _legacy_tag_predicate()
rows = session.execute(
select(Tag.id, Tag.name, Tag.kind).where(predicate)
).all()
by_kind: dict[str, int] = {}
by_prefix: dict[str, int] = {}
for _id, name, kind in rows:
# Classify by name-prefix first so a source:* row counts once,
# under the prefix bucket, regardless of its (general) kind.
matched_prefix = next(
(p for p in LEGACY_NAME_PREFIXES if name.startswith(p)), None,
return {"count": len(unused), "sample_names": sample}
ids = [t.id for t in unused]
if ids:
session.execute(
Tag.__table__.delete().where(Tag.id.in_(ids))
)
if matched_prefix is not None:
label = f"{matched_prefix}*"
by_prefix[label] = by_prefix.get(label, 0) + 1
else:
key = kind.value if hasattr(kind, "value") else str(kind)
by_kind[key] = by_kind.get(key, 0) + 1
sample = [name for _id, name, _kind in rows[:50]]
total = len(rows)
result = {
"by_kind": by_kind, "by_prefix": by_prefix,
"count": total, "sample_names": sample,
}
if dry_run:
return result
if total:
session.execute(Tag.__table__.delete().where(predicate))
session.commit()
result["deleted"] = total
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
return {"deleted": len(ids), "sample_names": sample}
# ---------------------------------------------------------------------------
@@ -577,9 +435,6 @@ class ConfirmTokenMismatch(Exception):
_VALID_RULES = ("transparency", "single_color")
_AUDIT_GUARD_THRESHOLD_MINUTES = 135 # matches LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES
def start_audit_run(
session: Session, *, rule: str, params: dict[str, Any],
) -> int:
@@ -587,21 +442,11 @@ def start_audit_run(
scan_library_for_rule Celery task. Returns the new audit_id.
Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run
has status='running' AND started recently. Audit 2026-06-02 made
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.
"""
has status='running'. Operator must cancel or wait."""
if rule not in _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(
select(LibraryAuditRun.id)
.where(LibraryAuditRun.status == "running")
.where(LibraryAuditRun.started_at >= cutoff)
select(LibraryAuditRun.id).where(LibraryAuditRun.status == "running")
).scalar_one_or_none()
if existing is not None:
raise AuditAlreadyRunning(existing)
+11 -53
View File
@@ -1,82 +1,40 @@
"""Fernet-based encryption for credential blobs.
The key is a single 32-byte value (urlsafe-base64-encoded; what
Fernet.generate_key produces) stored at /images/secrets/credential_key.b64
(mode 0600, parent dir 0700). The 2026-06-02 audit caught a silent
key-regeneration path: on a partial disaster restore where the DB was
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:
Fernet.generate_key produces) stored at a fixed path inside the
images/data root. Created on first boot if absent; mode 0600. No KDF
needed — the file contents are already maximum-entropy random bytes.
* the caller explicitly passes `bootstrap_ok=True` (tests, scripts), or
* the env var `CURATOR_BOOTSTRAP_NEW_KEY=1` is set (operator opt-in
during first-time setup).
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).
Operator backup procedure must include this file alongside the rest
of /images/ — losing it makes existing encrypted_blob rows
undecryptable (recovery = delete the rows and re-upload).
"""
import logging
import os
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
log = logging.getLogger(__name__)
_BOOTSTRAP_ENV_VAR = "CURATOR_BOOTSTRAP_NEW_KEY"
class InvalidCredentialBlob(Exception):
"""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:
"""Fernet encrypt/decrypt with an on-disk key file.
Instantiate with a path; the file is loaded if present, or created
if absent AND the caller has opted in (bootstrap_ok=True or
CURATOR_BOOTSTRAP_NEW_KEY=1 env var). Production sites:
Instantiate with a path; the file is created on first access and
reused thereafter. Tests pass a tmp_path; production calls with
`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)
if bootstrap_ok is None:
bootstrap_ok = os.environ.get(_BOOTSTRAP_ENV_VAR) == "1"
self._fernet = Fernet(self._load_or_create_key(bootstrap_ok))
self._fernet = Fernet(self._load_or_create_key())
def _load_or_create_key(self, bootstrap_ok: bool) -> bytes:
def _load_or_create_key(self) -> bytes:
if self._key_path.exists():
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.mkdir(parents=True, exist_ok=True)
os.chmod(parent, 0o700)
+1 -28
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import datetime
from pathlib import Path
from sqlalchemy import select
@@ -148,7 +148,6 @@ class CredentialService:
return None
plaintext = self.crypto.decrypt(row.encrypted_blob)
netscape = _to_netscape(plaintext)
netscape = _augment_cookies(platform, netscape)
self.cookies_dir.mkdir(parents=True, exist_ok=True)
out = self.cookies_dir / f"{platform}_cookies.txt"
out.write_text(netscape)
@@ -163,32 +162,6 @@ class CredentialService:
return None
return self.crypto.decrypt(row.encrypted_blob)
async def mark_verified(self, platform: str) -> datetime | None:
"""Stamp last_verified=now after a successful verify. Returns the
timestamp, or None if the credential is gone."""
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
if row is None:
return None
ts = datetime.now(UTC)
row.last_verified = ts
await self.session.commit()
return ts
def _augment_cookies(platform: str, netscape: str) -> str:
"""Delegate to the platform's `augment_cookies` hook if one is
registered (subscribestar, hentaifoundry, etc. — see
`services/platforms/<name>.py`). No-op when the platform doesn't
register a hook (Patreon, DeviantArt). Centralizing the
quirks-per-platform in the platforms package means adding a new
platform's cookie quirks doesn't require touching this file."""
info = PLATFORMS.get(platform)
if info is None or info.augment_cookies is None:
return netscape
return info.augment_cookies(netscape)
def _to_netscape(plaintext: str) -> str:
"""Accept either Netscape-format text (the extension's output) or a
-71
View File
@@ -1,71 +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
from pathlib import Path
# 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"})
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 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,
)
+63 -358
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio
import logging
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -24,26 +25,35 @@ from sqlalchemy.orm import joinedload
from ..models import Artist, DownloadEvent, Source
from .credential_service import CredentialService
from .download_backends import uses_native_ingester
from .gallery_dl import (
BACKFILL_CHUNK_SECONDS,
BACKFILL_SKIP_VALUE,
TICK_SKIP_VALUE,
DownloadResult,
ErrorType,
GalleryDLService,
SourceConfig,
parse_last_cursor,
)
from .gallery_dl import GalleryDLService, SourceConfig
from .importer import Importer
from .patreon_ingester import PatreonIngester
from .patreon_resolver import resolve_campaign_id_for_source
from .platforms import auth_type_for
from .scheduler_service import set_platform_cooldown
from .patreon_resolver import resolve_campaign_id
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:
"""Async orchestrator. The Celery task runs `asyncio.run(svc.download_source(N))`.
@@ -58,18 +68,12 @@ class DownloadService:
gdl: GalleryDLService,
importer: Importer,
cred_service: CredentialService,
sync_session_factory=None,
):
self.async_session = async_session
self.sync_session = sync_session
self.gdl = gdl
self.importer = importer
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:
"""Returns DownloadEvent.id. Idempotent: in-flight events are returned as-is."""
@@ -78,171 +82,50 @@ class DownloadService:
return setup["event_id"]
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 {})
# Backfill mode (plan #693): the source's `_backfill_state == "running"`
# selects a time-boxed deep-walk chunk — skip: True (walk full history)
# + 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
effective_url = _effective_url(
ctx["platform"], ctx["url"], ctx["config_overrides"] or {}
)
dl_result = await self.gdl.download(
url=effective_url,
artist_slug=ctx["artist_slug"],
platform=ctx["platform"],
source_config=source_config,
cookies_path=ctx["cookies_path"],
auth_token=ctx["auth_token"],
)
resolved_campaign_id: str | None = None
if uses_native_ingester(ctx["platform"]):
# Native ingester (plan #697) fully replaces gallery-dl for Patreon
# in phase 2 — zero per-file HEADs, native cursor/resume, loud drift
# detection. Returns a DownloadResult-shaped object so phase 3 is
# untouched. The gallery-dl Patreon path + its campaign-id retry are
# removed at the step-5 cutover.
if in_backfill and bypass_seen:
mode = "recovery"
elif in_backfill:
mode = "backfill"
else:
mode = "tick"
dl_result, resolved_campaign_id = await self._run_patreon_ingester(
ctx, source_config, mode,
)
else:
dl_result = await self.gdl.download(
url=ctx["url"],
artist_slug=ctx["artist_slug"],
platform=ctx["platform"],
source_config=source_config,
cookies_path=ctx["cookies_path"],
auth_token=ctx["auth_token"],
skip_value=skip_value,
)
# A backfill chunk that hit its time-box but made forward progress is
# NORMAL, not a failure — reclassify TIMEOUT → PARTIAL so it reads as
# "ok/progress" (PARTIAL maps to status "ok"), not a red error, and the
# next chunk just resumes from the new cursor (plan #693). A chunk that
# timed out with NO progress stays TIMEOUT and feeds phase 3's
# stall-guard. Leave RATE_LIMITED alone so the platform-cooldown fires.
if in_backfill and dl_result.error_type == ErrorType.TIMEOUT:
new_cursor = parse_last_cursor(dl_result.stdout, dl_result.stderr)
advanced = bool(
(new_cursor and new_cursor != overrides.get("_backfill_cursor"))
or dl_result.files_downloaded > 0
)
if advanced:
dl_result.error_type = ErrorType.PARTIAL
dl_result.error_message = (
f"Backfill chunk: {dl_result.files_downloaded} file(s) — continuing"
if (
ctx["platform"] == "patreon"
and not dl_result.success
and "patreon_campaign_id" not in (ctx["config_overrides"] or {})
and _looks_like_campaign_id_failure(dl_result.stdout, dl_result.stderr)
):
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(
ctx["event_id"], ctx, dl_result, resolved_campaign_id,
)
async def _run_patreon_ingester(
self, ctx: dict, source_config, mode: str,
) -> tuple[DownloadResult, str | None]:
"""Phase-2 Patreon branch: resolve the campaign id, then run the native
ingester in a worker thread (it is sync requests/subprocess).
Returns (DownloadResult, resolved_campaign_id). `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 on the source the same way the old gallery-dl retry did.
A campaign id we cannot resolve is a loud failure (NOT_FOUND) — never a
silent empty success.
"""
overrides = ctx["config_overrides"] or {}
# Shared resolution path (override / id: URL / vanity lookup) — the same
# helper the credential-verify probe uses. resolved_campaign_id is
# non-None only when a vanity lookup ran, so phase 3 caches it.
campaign_id, resolved_campaign_id = await resolve_campaign_id_for_source(
ctx["url"], ctx["cookies_path"], overrides
)
if not campaign_id:
return (
DownloadResult(
success=False,
url=ctx["url"],
artist_slug=ctx["artist_slug"],
platform="patreon",
error_type=ErrorType.NOT_FOUND,
error_message=(
"Could not resolve Patreon campaign id from the source "
"URL (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 self.gdl) 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`
# (gallery_dl._get_default_config), so the rate-limited /api/posts
# endpoint stays politely paced without over-throttling.
rate_limit = self.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=self.gdl.images_root,
cookies_path=ctx["cookies_path"],
session_factory=self.sync_session_factory,
validate=self.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,
),
)
return dl_result, resolved_campaign_id
async def _phase1_setup(self, source_id: int) -> dict[str, Any]:
source = (await self.async_session.execute(
select(Source).options(joinedload(Source.artist)).where(Source.id == source_id)
@@ -272,13 +155,6 @@ class DownloadService:
return {"status": "in_flight", "event_id": existing.id}
if existing and existing.status == "pending":
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()
event_id = existing.id
else:
@@ -289,12 +165,7 @@ class DownloadService:
event_id = ev.id
artist = source.artist
# Drive cookies-vs-token selection from the platform registry's
# 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":
if source.platform in ("discord", "pixiv"):
cookies_path = None
auth_token = await self.cred_service.get_token(source.platform)
else:
@@ -313,7 +184,6 @@ class DownloadService:
"config_overrides": dict(source.config_overrides or {}),
"cookies_path": cookies_path,
"auth_token": auth_token,
"backfill_runs_remaining": source.backfill_runs_remaining or 0,
}
async def _phase3_persist(
@@ -367,40 +237,6 @@ class DownloadService:
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
except OSError:
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
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 (
"duplicate_hash", "duplicate_phash",
):
@@ -409,29 +245,6 @@ class DownloadService:
path.unlink(missing_ok=True) # noqa: ASYNC240
except OSError:
pass
elif result.status == "skipped":
# Soft skip (too_small, too_transparent, invalid_image) —
# the file just didn't qualify, not a download/ingest
# failure. Don't flag the run as error; the file stays
# on disk for operator inspection.
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:
import_summary["errors"] += 1
@@ -445,21 +258,12 @@ class DownloadService:
run_stats["quarantined_count"] = dl_result.files_quarantined
stderr_summary = self.gdl._extract_errors_warnings(dl_result.stderr)
# Plan #544: PARTIAL means the run downloaded ≥1 file but the
# 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"
status = "ok" if (dl_result.success and import_summary["errors"] == 0) else "error"
ev.status = status
ev.finished_at = datetime.now(UTC)
ev.files_count = import_summary["attached"]
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_ = {
"run_stats": run_stats,
"error_type": dl_result.error_type.value if dl_result.error_type else None,
@@ -472,109 +276,18 @@ class DownloadService:
}
await self._update_source_health(
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,
)
await self._apply_backfill_lifecycle(ctx, dl_result)
await self.async_session.commit()
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
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. Patreon checkpoints + resumes via cursor; other
# platforms have no resumable cursor (every chunk re-walks from the
# top), so they advance only by the download archive growing.
new_cursor = (
parse_last_cursor(dl_result.stdout, dl_result.stderr)
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(
self, *, source_id: int, status: str, error_message: str | None,
error_type: str | None = None,
) -> None:
"""FC-3d: update Source.{consecutive_failures, last_error, last_checked_at}.
ok -> failures = 0, error = None, checked_at = now
error -> failures += 1, error = error_message, checked_at = now
skipped -> failures unchanged, error = None, checked_at = now
When error_type == 'rate_limited', also stamps a platform-wide
cooldown via scheduler_service.set_platform_cooldown so the next
scan tick skips every source on this platform until the cooldown
expires. Preventive half of the burst-prevention pair —
consecutive_failures still backs the offending source off across
ticks.
"""
source = (await self.async_session.execute(
select(Source).where(Source.id == source_id)
@@ -583,17 +296,9 @@ class DownloadService:
if status == "ok":
source.consecutive_failures = 0
source.last_error = None
# alembic 0032 — clear the failure-class chip on success.
source.error_type = None
elif status == "error":
source.consecutive_failures = (source.consecutive_failures or 0) + 1
source.last_error = error_message
# 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":
await set_platform_cooldown(self.async_session, source.platform)
elif status == "skipped":
source.last_error = None
source.last_checked_at = now
-70
View File
@@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, Source
from ..utils.slug import slugify
from .source_service import BACKFILL_MAX_CHUNKS
class UnknownPlatformError(Exception):
@@ -87,67 +86,6 @@ class ExtensionService:
"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]:
if not isinstance(url, str) or not url.strip():
raise InvalidUrlError("url is empty")
@@ -205,17 +143,9 @@ class ExtensionService:
return existing, False
sp = await self.session.begin_nested()
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(
artist_id=artist_id, platform=platform,
url=url, enabled=True,
config_overrides={"_backfill_state": "running"},
backfill_runs_remaining=BACKFILL_MAX_CHUNKS,
)
self.session.add(src)
await self.session.flush()
+31 -270
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import asyncio
import json
import logging
import re
import subprocess
import sys
import tempfile
@@ -40,90 +39,19 @@ class ErrorType(StrEnum):
HTTP_ERROR = "http_error"
UNSUPPORTED_URL = "unsupported_url"
VALIDATION_FAILED = "validation_failed"
# Native Patreon ingester only (plan #697): a response parsed as JSON but
# didn't match the JSON:API shape the ingester depends on (missing `data`,
# media lacking `file_name`/`url`, etc.). Distinct from AUTH_ERROR — the fix
# is updating the ingester's field-set/parser, not rotating credentials. The
# contract test guards against silently shipping this.
API_DRIFT = "api_drift"
# Run made real progress (downloaded ≥1 file) but did not finish in the
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
# mapping classifies this as "ok" because the next tick continues.
PARTIAL = "partial"
UNKNOWN_ERROR = "unknown_error"
# Tick mode (routine cron polls): skip ≤20 contiguous already-archived
# items, then exit gallery-dl. Established subscription with zero new
# content exits in ~30s of HEAD requests instead of walking to the bottom
# of the post history (which can be hours for prolific creators). 20 (not
# 5) is operator-set headroom against any edge case where paywalled or
# otherwise-non-downloadable items might interleave with archived ones —
# 20 contiguous HEADs is still negligible.
TICK_SKIP_VALUE = "exit:20"
# 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
@dataclass
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"])
sleep: float | None = None
sleep_request: float | None = None
directory_pattern: str | None = None
filename_pattern: str | None = None
skip_existing: bool = True
save_metadata: bool = True
timeout: int = _DEFAULT_GDL_TIMEOUT_SECONDS
resume_cursor: str | None = None
timeout: int = 3600
@classmethod
def from_dict(cls, data: dict) -> SourceConfig:
@@ -133,8 +61,9 @@ class SourceConfig:
sleep_request=data.get("sleep_request"),
directory_pattern=data.get("directory_pattern"),
filename_pattern=data.get("filename_pattern"),
skip_existing=data.get("skip_existing", True),
save_metadata=data.get("save_metadata", True),
timeout=data.get("timeout", _DEFAULT_GDL_TIMEOUT_SECONDS),
timeout=data.get("timeout", 3600),
)
@@ -172,22 +101,6 @@ def _summarize_validation_failures(failures: list[dict]) -> str:
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
# The native Patreon ingester emits its pagination cursor as `Cursor: <token>`
# — one line per fetched page — into the DownloadResult stdout (mirroring the
# convention gallery-dl used before the #697 cutover, so the backfill lifecycle
# stayed unchanged). The LAST such line is the furthest-progressed page, i.e.
# the resume point. We scan both streams (be defensive) and take the final
# match; download_service checkpoints it as the next chunk's resume_cursor.
# Survives a time-boxed chunk too: the ingester emits the cursor for a page when
# it STARTS it, so an interrupted walk still yields its last cursor.
_CURSOR_RE = re.compile(r"Cursor:\s*(\S+)")
def parse_last_cursor(stdout: str, stderr: str) -> str | None:
matches = _CURSOR_RE.findall(f"{stdout or ''}\n{stderr or ''}")
return matches[-1] if matches else None
class GalleryDLService:
"""Service for executing gallery-dl downloads."""
@@ -221,10 +134,16 @@ class GalleryDLService:
"permission denied", "tier required", "pledge required",
]
# Per-platform defaults for the gallery-dl-backed platforms. Patreon was
# removed at the plan-#697 cutover — it now uses the native ingester
# (services/patreon_ingester.py), not gallery-dl.
# Per-platform defaults. Lifted from GS — same six platforms FC supports.
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": {
"content_types": ["all"],
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
@@ -283,12 +202,7 @@ class GalleryDLService:
"skip": True,
"sleep": self._rate_limit,
"sleep-request": max(0.5, self._rate_limit / 4),
# 2 (not 3) retries — a stuck CDN host shouldn't burn a whole
# backfill chunk on one file. Anduo #40838 spent ~600s on a
# single image (4 extractor HEAD retries × 30s + 4 downloader
# GET retries × 120s); halving retries + the downloader
# timeout below caps a wedged file at ~1-2 min instead.
"retries": 2,
"retries": 3,
"timeout": 30.0,
"verify": True,
"postprocessors": [
@@ -303,17 +217,8 @@ class GalleryDLService:
"downloader": {
"part": True,
"part-directory": str(self._config_dir / "temp"),
# See the extractor retries note above (Anduo #40838): 2
# retries + a 60s read-timeout (was 120) so a stalled
# 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.
"retries": 3,
"timeout": 120.0,
},
"output": {"progress": True},
}
@@ -328,18 +233,7 @@ class GalleryDLService:
platform: str,
source_config: SourceConfig,
artist_slug: str,
skip_value: bool | str = BACKFILL_SKIP_VALUE,
) -> 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
destination = str(self.images_root / artist_slug / platform)
@@ -349,7 +243,7 @@ class GalleryDLService:
config["extractor"]["sleep"] = source_config.sleep
if source_config.sleep_request is not None:
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:
config["extractor"]["postprocessors"] = [
@@ -365,7 +259,14 @@ class GalleryDLService:
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:
platform_section["include"] = "all"
@@ -459,17 +360,7 @@ class GalleryDLService:
if return_code in (1, 4) and (skip_line_count > 0 or has_skip_text) and not has_actual_error:
return ErrorType.NO_NEW_CONTENT, "No new content to download"
# Tier-gated classification used to require `return_code in (1, 4)`,
# which silently fell through to UNKNOWN_ERROR when gallery-dl
# returned a different exit code for mixed-failure runs (e.g.
# paywall warnings + a missing yt-dlp dep flipping the exit bits).
# The artist then surfaced as "needs attention" purely because a
# paywall blocked posts the operator wasn't paying to see —
# operator-flagged 2026-05-31. Now: if no source-level error
# category fired AND tier-gated warnings are present, classify
# as TIER_LIMITED regardless of return code. Same priority order
# as before (auth/rate/access/not_found/network/http still win).
if not has_actual_error:
if return_code in (1, 4) and not has_actual_error:
tier_gated_lines = [
line for line in combined.split("\n")
if "][warning]" in line and "not allowed to view post" in line
@@ -481,22 +372,6 @@ class GalleryDLService:
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})"
def _count_downloaded_files(self, stdout: str) -> int:
@@ -648,7 +523,6 @@ class GalleryDLService:
source_config: SourceConfig | None = None,
cookies_path: str | None = None,
auth_token: str | None = None,
skip_value: bool | str = BACKFILL_SKIP_VALUE,
) -> DownloadResult:
start_time = time.time()
started_at = datetime.now(UTC).isoformat()
@@ -656,9 +530,7 @@ class GalleryDLService:
if source_config is None:
source_config = SourceConfig()
config = self._build_config_for_source(
platform, source_config, artist_slug, skip_value=skip_value,
)
config = self._build_config_for_source(platform, source_config, artist_slug)
if cookies_path:
config["extractor"]["cookies"] = cookies_path
@@ -760,57 +632,13 @@ class GalleryDLService:
started_at=started_at, completed_at=completed_at,
)
except subprocess.TimeoutExpired as e:
except subprocess.TimeoutExpired:
duration = time.time() - start_time
# subprocess.run(text=True) makes these str if non-None, but the
# caller may have raised TimeoutExpired manually with None or
# bytes (tests do); coerce both cases to str.
partial_stdout = e.stdout or ""
partial_stderr = e.stderr or ""
if isinstance(partial_stdout, bytes):
partial_stdout = partial_stdout.decode("utf-8", "replace")
if isinstance(partial_stderr, bytes):
partial_stderr = partial_stderr.decode("utf-8", "replace")
files_so_far = self._count_downloaded_files(partial_stdout)
written_so_far = [str(p) for p in self._written_paths(partial_stdout)]
stderr_lines = partial_stderr.strip().splitlines()
tail_hint = stderr_lines[-1] if stderr_lines else "no stderr output"
# If the partial output already shows a rate-limit pattern, the
# timeout was almost certainly gallery-dl spinning on retries —
# promote to RATE_LIMITED so _update_source_health stamps the
# platform cooldown (same code path as a clean-exit rate limit).
# Otherwise stay TIMEOUT and let the captured stdout/stderr +
# files_so_far tell the operator whether it was "lots of
# content" vs "stuck retrying" vs "hung silent".
combined = (partial_stdout + "\n" + partial_stderr).lower()
if any(p in combined for p in self.RATE_LIMIT_PATTERNS):
error_type = ErrorType.RATE_LIMITED
error_message = (
f"Rate-limited and never completed within "
f"{source_config.timeout}s ({files_so_far} files written)"
)
else:
error_type = ErrorType.TIMEOUT
error_message = (
f"Download timed out after {source_config.timeout}s — "
f"{files_so_far} file(s) written; last stderr: {tail_hint}"
)
log.error(
"Download timeout for %s/%s after %.1fs (%d files written, "
"last stderr: %s)",
artist_slug, platform, duration, files_so_far, tail_hint,
)
log.error("Download timeout for %s/%s after %.1fs", artist_slug, platform, duration)
return DownloadResult(
success=False, url=url, artist_slug=artist_slug, platform=platform,
files_downloaded=files_so_far,
written_paths=written_so_far,
stdout=partial_stdout, stderr=partial_stderr,
return_code=-1, # killed by timeout, no real exit code
error_type=error_type, error_message=error_message,
error_type=ErrorType.TIMEOUT,
error_message=f"Download timed out after {source_config.timeout} seconds",
duration_seconds=duration,
started_at=started_at,
completed_at=datetime.now(UTC).isoformat(),
@@ -830,70 +658,3 @@ class GalleryDLService:
Path(temp_config_path).unlink() # noqa: ASYNC240
except Exception:
pass
async def verify(
self,
url: str,
artist_slug: str,
platform: str,
source_config: SourceConfig | None = None,
cookies_path: str | None = None,
auth_token: str | None = None,
timeout: float = 45.0, # noqa: ASYNC109 — subprocess.run timeout, not a coroutine deadline
) -> tuple[bool, str]:
"""Test that credentials authenticate against `url` WITHOUT
downloading anything. Runs gallery-dl in --simulate mode limited
to the first item; if auth is bad the extractor errors before it
can list, which _categorize_error flags as AUTH_ERROR. Returns
(ok, message). Used by the credential Verify button."""
if source_config is None:
source_config = SourceConfig()
config = self._build_config_for_source(platform, source_config, artist_slug)
if cookies_path:
config["extractor"]["cookies"] = cookies_path
if auth_token and platform == "discord":
config["extractor"].setdefault("discord", {})["token"] = auth_token
if auth_token and platform == "pixiv":
config["extractor"].setdefault("pixiv", {})["refresh-token"] = auth_token
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
) as fh:
json.dump(config, fh, indent=2)
temp_config_path = fh.name
try:
cmd = [
sys.executable, "-m", "gallery_dl",
"--config", temp_config_path,
"--simulate", "--range", "1-1", "--verbose", url,
]
loop = asyncio.get_running_loop()
proc = await loop.run_in_executor(
None,
lambda: subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
),
)
etype, msg = self._categorize_error(proc.returncode, proc.stdout, proc.stderr)
# TIER_LIMITED proves auth worked — gallery-dl reached the
# 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."
if etype == ErrorType.AUTH_ERROR:
return False, msg
# Network / not-found / rate-limit / unknown: inconclusive,
# not a definitive credential failure. Surface the reason.
return False, f"Could not confirm ({etype.value}): {msg}"
except subprocess.TimeoutExpired:
return False, f"Verification timed out after {timeout:.0f}s"
except Exception as exc: # noqa: BLE001
return False, f"Verification error: {exc}"
finally:
try:
Path(temp_config_path).unlink() # noqa: ASYNC240
except Exception:
pass
+86 -369
View File
@@ -18,22 +18,14 @@ import base64
from dataclasses import dataclass
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.orm import aliased
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
from ..models.tag import image_tag
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:
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():
"""The materialized gallery sort key: image_record.effective_date
(alembic 0035) = COALESCE(primary post's post_date, created_at),
maintained at write time by the importer.
"""SQL expression: COALESCE(post.post_date, image_record.created_at).
Canonical sort/group/filter key across the gallery so images attached
to a post surface at their original publish date, not their FC import
date — and, now that it's a single indexed column rather than a
COALESCE across the Post outer join, the cursor scroll is an index
range scan instead of a full re-sort per page.
Used as the canonical sort/group/filter key across the gallery so
images backfilled with primary_post_id (e.g. via tag_apply phase 4)
surface at their original publish date, not their FC import date.
Images without a Post (or with Post.post_date NULL) fall back to
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:
@@ -99,121 +90,21 @@ class TimelineBucket:
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:
"""Return the URL to fetch a thumbnail.
Prefers the stored thumbnail_path verbatim — Quart serves /images/*
1:1 from the volume (frontend.py:20-36), so the URL IS the disk
path. Falls back to deriving from (sha256, mime) only when the
record's thumbnail_path is NULL (thumbnailer hasn't run yet); that
URL will 404 until backfill catches it, same as before the path
was tracked.
Pre-2026-05-30 this was derived only from (sha256, mime), which
disagreed with the actual on-disk extension when the thumbnailer
chose its format from transparency rather than MIME — every PNG
source without alpha (extension was .jpg on disk) and every WebP
source with alpha (extension was .png on disk) silently 404'd
despite the thumbnail file existing.
"""
if thumbnail_path:
return thumbnail_path
# Fallback for records with no thumbnail recorded yet — preserves
# prior behavior (URL exists but 404s until backfill regenerates).
def thumbnail_url(sha256_hex: str, mime: str) -> str:
# Quart serves /images/* via the frontend blueprint (FC-1); thumbnails go
# under /images/thumbs/. The MIME determines the extension.
ext = ".png" if mime in ("image/png", "image/gif") else ".jpg"
bucket = sha256_hex[:3]
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
def _require_single_filter(tag_ids, post_id, artist_id) -> None:
"""post_id is the post-detail view — it can't combine with the
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):
def _require_single_filter(tag_id, post_id, artist_id) -> None:
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
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):
"""Correlated EXISTS clause (NOT a join) so an image with multiple
matching provenance rows is returned exactly once and the
@@ -224,48 +115,14 @@ def _provenance_clause(post_id, artist_id):
ImageProvenance.post_id == post_id,
)
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(
ImageProvenance.image_record_id == ImageRecord.id,
ImageProvenance.post_id == post_inner.id,
post_inner.artist_id == artist_id,
ImageProvenance.source_id == Source.id,
Source.artist_id == artist_id,
)
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]:
"""Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
@@ -290,50 +147,35 @@ class GalleryService:
self,
cursor: str | None,
limit: int = 50,
tag_ids: list[int] | None = None,
tag_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,
) -> GalleryPage:
if limit < 1 or limit > 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()
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt)
stmt = _apply_scope(
stmt, 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,
)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
descending = sort != "oldest"
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
# The cursor is just (last eff, last id); the request's sort
# decides which side of it the next page lies on.
if descending:
stmt = stmt.where(
or_(eff < cur_ts, and_(eff == cur_ts, ImageRecord.id < cur_id))
)
else:
stmt = stmt.where(
or_(eff > cur_ts, and_(eff == cur_ts, ImageRecord.id > cur_id))
stmt = stmt.where(
or_(
eff < cur_ts,
and_(eff == cur_ts, ImageRecord.id < cur_id),
)
)
if descending:
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
else:
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
stmt = stmt.limit(limit + 1)
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1)
rows = (await self.session.execute(stmt)).all()
next_cursor = None
@@ -345,7 +187,22 @@ class GalleryService:
artists = await _artists_for(
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.sha256, record.mime),
artist=artists.get(record.id),
)
for record, posted_at, eff_date in rows
]
return GalleryPage(
images=images,
next_cursor=next_cursor,
@@ -354,15 +211,9 @@ class GalleryService:
async def timeline(
self,
tag_ids: list[int] | None = None,
tag_id: 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,
) -> list[TimelineBucket]:
eff = _effective_date_col()
year_col = func.date_part("year", eff).label("yr")
@@ -371,28 +222,25 @@ class GalleryService:
year_col, month_col, func.count(ImageRecord.id).label("cnt")
)
stmt = _outer_join_primary_post(stmt)
_require_single_filter(tag_ids, post_id, artist_id)
stmt = _apply_scope(
stmt, 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,
)
_require_single_filter(tag_id, post_id, artist_id)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
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())
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]
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,
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:
"""Returns a cursor that, when passed to scroll() with the same sort,
positions at the first image of the given year-month. None if the
bucket is empty.
"""Returns a cursor that, when passed to scroll(), positions at the
first image of the given year-month (by effective_date, not
created_at). None if the bucket is empty.
"""
from sqlalchemy import extract
@@ -402,157 +250,22 @@ class GalleryService:
extract("month", eff) == month,
)
stmt = _outer_join_primary_post(stmt)
_require_single_filter(tag_ids, post_id, artist_id)
stmt = _apply_scope(
stmt, 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,
)
descending = sort != "oldest"
if descending:
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
else:
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
first = (await self.session.execute(stmt.limit(1))).first()
_require_single_filter(tag_id, post_id, artist_id)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
first = (await self.session.execute(stmt)).first()
if first is None:
return None
record, eff_date = first
# Cursor is exclusive; nudge the id one past the boundary row (in the
# scan direction) so the row itself is the first result of scroll().
boundary = record.id + 1 if descending else record.id - 1
return encode_cursor(eff_date, boundary)
async def facets(
self, *, tag_ids: list[int] | None = None,
post_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, 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)
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
# is the first result in the next scroll().
return encode_cursor(eff_date, record.id + 1)
async def get_image_with_tags(self, image_id: int) -> dict | None:
record = await self.session.get(ImageRecord, image_id)
@@ -591,12 +304,9 @@ class GalleryService:
"height": record.height,
"size_bytes": record.size_bytes,
"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(),
"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.sha256, record.mime),
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
"artist": (
{"id": artist.id, "name": artist.name, "slug": artist.slug}
@@ -615,10 +325,17 @@ class GalleryService:
}
async def _neighbors(self, record: ImageRecord) -> dict:
# The boundary image's sort key is materialized on the row now
# (alembic 0035) — read it directly instead of re-deriving COALESCE
# via an extra Post lookup.
boundary_eff = record.effective_date
# Compute the boundary image's effective_date in Python (one query
# below + the SELECT we already have on `record`) and use it for
# the neighbor comparison. Cheaper than re-deriving in SQL via
# 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()
prev_stmt = _outer_join_primary_post(
+118 -223
View File
@@ -30,7 +30,6 @@ from ..models import (
PostAttachment,
Source,
)
from ..utils import safe_probe
from ..utils.paths import derive_subdir, derive_top_level_artist, hash_suffixed_name
from ..utils.phash import compute_phash, find_similar
from ..utils.sidecar import find_sidecar, parse_sidecar
@@ -204,32 +203,6 @@ class Importer:
(phash, width or 0, height or 0, image_id)
)
def _get_or_create(self, stmt, factory):
"""Race-safe find-or-create. Run `stmt` (scalar_one_or_none); if a
row exists, return it. Otherwise open a savepoint and INSERT
``factory()``; on IntegrityError (a concurrent worker inserted the
same row first) roll the savepoint back — NOT the outer transaction,
which would lose the surrounding scan's progress — and re-run `stmt`
(scalar_one) to return the row the other worker created.
Centralizes the pattern shared by _find_or_create_source and
_find_or_create_post. The plain SELECT-then-INSERT version lost
races under the 5-min recovery sweep (operator-flagged
2026-05-26)."""
existing = self.session.execute(stmt).scalar_one_or_none()
if existing is not None:
return existing
sp = self.session.begin_nested()
try:
row = factory()
self.session.add(row)
self.session.flush()
sp.commit()
return row
except IntegrityError:
sp.rollback()
return self.session.execute(stmt).scalar_one()
def _find_or_create_source(
self, *, artist_id: int, platform: str, url: str,
) -> Source:
@@ -248,35 +221,53 @@ class Importer:
and re-select — the concurrent op just created the row we
wanted, so the second select will find it.
"""
stmt = select(Source).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
return self._get_or_create(
stmt,
lambda: Source(artist_id=artist_id, platform=platform, url=url),
)
existing = self.session.execute(
select(Source).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
).scalar_one_or_none()
if existing is not None:
return existing
sp = self.session.begin_nested()
try:
row = Source(artist_id=artist_id, platform=platform, url=url)
self.session.add(row)
self.session.flush()
sp.commit()
return row
except IntegrityError:
sp.rollback()
return self.session.execute(
select(Source).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
).scalar_one()
def _lookup_source_for_sidecar(
self, *, artist_id: int, platform: str,
) -> Source | None:
"""Find the real subscription Source for (artist, platform), or
None if no subscription exists.
def _source_for_sidecar(
self, *, artist_id: int, platform: str, artist_slug: str,
) -> Source:
"""Filesystem-import sidecar Source resolver.
Pre-alembic-0030 this method would CREATE a synthetic
`sidecar:<platform>:<slug>` Source when no real one existed —
because `Post.source_id` was NOT NULL and the importer needed
something to attach Posts to. Alembic 0030 relaxed both
`Post.source_id` and `ImageProvenance.source_id` to nullable, so
synthetic anchors are obsolete; the importer now leaves
source_id as None when no subscription exists for the (artist,
platform). Operator-asked 2026-06-01: synthetic Sources had
leaked into the Subscriptions UI as phantom subscriptions and
the operator wanted the data model to truthfully say "this
content has no live subscription."
Source represents a subscription feed (one per artist+platform — the
gallery-dl URL polled by the FC-3 downloader). The filesystem importer
used to call _find_or_create_source(url=sd.post_url), which created
one Source row per post URL — 100s of junk Sources per artist, all
with enabled=True, polluting the artist detail page and tricking the
subscription checker into trying to poll patreon post URLs as feeds.
Operator-flagged 2026-05-26.
New behaviour: if any Source row exists for (artist_id, platform),
reuse it regardless of its URL — the artist's real subscription Source
(created by the downloader / extension / UI) is the canonical
attachment point for filesystem-imported posts. If none exists, create
ONE synthetic anchor with url='sidecar:<platform>:<artist_slug>' and
enabled=False (so the subscription checker doesn't poll it).
"""
stmt = (
existing = self.session.execute(
select(Source)
.where(
Source.artist_id == artist_id,
@@ -284,39 +275,63 @@ class Importer:
)
.order_by(Source.id.asc())
.limit(1)
)
return self.session.execute(stmt).scalar_one_or_none()
).scalar_one_or_none()
if existing is not None:
return existing
synthetic_url = f"sidecar:{platform}:{artist_slug}"
sp = self.session.begin_nested()
try:
row = Source(
artist_id=artist_id,
platform=platform,
url=synthetic_url,
enabled=False,
)
self.session.add(row)
self.session.flush()
sp.commit()
return row
except IntegrityError:
sp.rollback()
return self.session.execute(
select(Source)
.where(
Source.artist_id == artist_id,
Source.platform == platform,
)
.order_by(Source.id.asc())
.limit(1)
).scalar_one()
def _find_or_create_post(
self, *, source_id: int | None, external_post_id: str,
artist_id: int,
self, *, source_id: int, external_post_id: str,
) -> Post:
"""Race-safe find-or-create on `post`. Keyed by
(source_id, external_post_id) when source_id is set — the
`uq_post_source_external_id` constraint guards. For NULL-source
posts the existence check matches on (artist_id, external_post_id),
which the partial unique index `uq_post_artist_external_id_null_source`
(alembic 0030) guards. Same savepoint + IntegrityError-recovery
pattern as the rest of the helpers."""
if source_id is not None:
stmt = select(Post).where(
"""Race-safe find-or-create on `post` keyed by
(source_id, external_post_id). Mirrors `_find_or_create_source`
— same savepoint + IntegrityError-recovery pattern."""
existing = self.session.execute(
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(
stmt,
lambda: Post(
source_id=source_id,
artist_id=artist_id,
external_post_id=external_post_id,
),
)
).scalar_one_or_none()
if existing is not None:
return existing
sp = self.session.begin_nested()
try:
row = Post(source_id=source_id, external_post_id=external_post_id)
self.session.add(row)
self.session.flush()
sp.commit()
return row
except IntegrityError:
sp.rollback()
return self.session.execute(
select(Post).where(
Post.source_id == source_id,
Post.external_post_id == external_post_id,
)
).scalar_one()
def import_one(self, source: Path) -> ImportResult:
"""Dispatch by kind. Media → normal pipeline. Archive → extract
@@ -356,14 +371,12 @@ class Importer:
return None
sd = parse_sidecar(data)
platform = sd.platform or "unknown"
src = self._lookup_source_for_sidecar(
artist_id=artist.id, platform=platform,
src = self._source_for_sidecar(
artist_id=artist.id, platform=platform, artist_slug=artist.slug,
)
epid = sd.external_post_id or sc.stem
return self._find_or_create_post(
source_id=src.id if src else None,
external_post_id=epid,
artist_id=artist.id,
source_id=src.id, external_post_id=epid,
)
def _capture_attachment(
@@ -374,19 +387,10 @@ class Importer:
artist = self._resolve_artist(source)
post = self._post_for_sidecar(source, artist)
sha = _sha256_of(source)
select_existing = select(PostAttachment).where(PostAttachment.sha256 == sha)
existing = self.session.execute(select_existing).scalar_one_or_none()
if existing is not None:
self.session.commit()
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:
existing = self.session.execute(
select(PostAttachment).where(PostAttachment.sha256 == sha)
).scalar_one_or_none()
if existing is None:
stored = self.attachments.store(source, sha)
self.session.add(PostAttachment(
post_id=post.id if post else None,
@@ -399,67 +403,23 @@ class Importer:
size_bytes=source.stat().st_size,
))
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()
return ImportResult(status="attached")
def _import_archive(
self, source: Path, *,
artist: Artist | None = None,
source_row: Source | None = None,
) -> ImportResult:
# Layer-3 isolation: bomb-size guard + integrity test in a
# spawned child BEFORE extracting in this process. A
# decompression bomb or a native-lib crash on a malformed
# archive is contained to the child; we reject the file cleanly
# instead of OOMing/segfaulting the import worker. extract_archive
# is already fail-soft for plain exceptions, so this only adds
# 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)
if not probe.ok:
if probe.crashed:
return ImportResult(
status="failed",
error=f"archive probe crashed/timed out: {probe.reason}",
)
# Clean rejection (bomb cap exceeded, integrity mismatch):
# still preserve the archive file itself as an attachment so
# nothing silently vanishes, matching extract_archive's
# fail-soft contract.
artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use)
self._capture_attachment(
source, post=post, artist=artist_use, resolved=True,
)
return ImportResult(status="attached")
artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use)
def _import_archive(self, source: Path) -> ImportResult:
artist = self._resolve_artist(source)
post = self._post_for_sidecar(source, artist)
member_ids: list[int] = []
with extract_archive(source) as members:
for _name, member_path in members:
if not is_supported(member_path):
continue # non-media preserved via the stored archive
res = self._import_media(
member_path, source, explicit_source=source_row,
)
res = self._import_media(member_path, source)
if res.status in ("imported", "superseded") and res.image_id:
member_ids.append(res.image_id)
# Preserve the archive itself (links to the same Post/Artist).
self._capture_attachment(
source, post=post, artist=artist_use, resolved=True
source, post=post, artist=artist, resolved=True
)
if member_ids:
return ImportResult(
@@ -469,8 +429,7 @@ class Importer:
return ImportResult(status="attached")
def _import_media(
self, source: Path, attribution_path: Path,
*, explicit_source: Source | None = None,
self, source: Path, attribution_path: Path
) -> ImportResult:
"""The media import pipeline (filters, dedup, copy, provenance).
@@ -487,25 +446,7 @@ class Importer:
# Compute file dimensions (images only) and apply filters.
width = height = None
has_alpha = False
if is_video(source):
# Layer-3 isolation: validate the container via ffprobe (a
# separate process) before the rest of the pipeline touches
# it. A corrupt video that would crash a decoder is rejected
# cleanly here, and we capture width/height for free (the
# importer didn't previously record video dimensions).
probe = safe_probe.probe_video(source)
if not probe.ok:
if probe.crashed:
return ImportResult(
status="failed",
error=f"video probe crashed/timed out: {probe.reason}",
)
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error=probe.reason,
)
width, height = probe.width, probe.height
else:
if not is_video(source):
try:
with Image.open(source) as im:
im.verify()
@@ -617,15 +558,7 @@ class Importer:
artist = self._attach_artist(record, artist_name)
# Sidecar provenance (best-effort; never fails the import).
# explicit_source lets the FC-3c download path bind the new
# 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,
)
self._apply_sidecar(record, attribution_path, artist)
# Thumbnail is queued separately by the calling task; the importer
# does not generate thumbnails inline so the import queue stays moving.
@@ -689,36 +622,16 @@ class Importer:
them through. The sidecar JSON gallery-dl emits next to each
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:
- duplicate_hash / duplicate_phash skip → delete the on-disk file
- superseded → file stays where it is (now canonical)
- 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
"""
if path.suffix.lower() == ".json":
if not is_supported(path):
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error="sidecar json is metadata, not content",
)
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,
error=f"unsupported extension {path.suffix}",
)
# Format / dimension / transparency filters (mirror _import_media).
@@ -790,8 +703,7 @@ class Importer:
if rel == "smaller_exists":
target = self.session.get(ImageRecord, match_id)
self._supersede(
target, path, sha, phash, width, height,
new_path=path, artist=artist, source_row=source,
target, path, sha, phash, width, height, new_path=path
)
return ImportResult(status="superseded", image_id=match_id)
@@ -906,15 +818,14 @@ class Importer:
src = explicit_source
else:
platform = sd.platform or "unknown"
src = self._lookup_source_for_sidecar(
src = self._source_for_sidecar(
artist_id=artist.id, platform=platform,
artist_slug=artist.slug,
)
epid = sd.external_post_id or sc.stem
post = self._find_or_create_post(
source_id=src.id if src else None,
external_post_id=epid,
artist_id=artist.id,
source_id=src.id, external_post_id=epid,
)
if sd.post_url is not None:
post.post_url = sd.post_url
@@ -950,7 +861,7 @@ class Importer:
ImageProvenance(
image_record_id=record.id,
post_id=post.id,
source_id=src.id if src else None,
source_id=src.id,
captured_metadata=sd.raw,
)
)
@@ -960,14 +871,6 @@ class Importer:
sp.rollback()
if record.primary_post_id is None:
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()
def _copy_to_library(
@@ -994,8 +897,6 @@ class Importer:
self, existing: ImageRecord, source: Path, sha: str,
phash: str, width: int | None, height: int | None,
*, new_path: Path | None = None,
artist: Artist | None = None,
source_row: Source | None = None,
) -> None:
"""Replace `existing`'s file with the larger `source`, keeping the
row id (so tags/series/curation stay attached). ML is cleared so
@@ -1047,14 +948,8 @@ class Importer:
# _apply_sidecar resolves artist from the sidecar itself if the
# existing row has none, and is internally guarded against
# 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:
self._apply_sidecar(
existing, source, artist, explicit_source=source_row,
)
self._apply_sidecar(existing, source, None)
except Exception as exc:
# Don't unwind the supersede DB swap if sidecar parsing
# blows up unexpectedly — the file replacement is the
@@ -0,0 +1,10 @@
"""FC-5 migration tooling.
One module per concern (gs/ir/overlap/ml_queue/verify/cleanup).
Each migrator returns a counts dict; the run_migration task wires
that dict into MigrationRun.counts so the UI polling shows progress.
backup + rollback were retired in FC-3h (2026-05-24); first-class
backup lives at backend/app/services/backup_service.py and exposes
its own /api/system/backup/* surface.
"""
+182
View File
@@ -0,0 +1,182 @@
"""Targeted cleanup migrator: delete every image attributed to one Artist.
Built for the IR-migration rescue case where the filesystem scan derived
a bogus 'imagerepo' artist from a mismatched bind-mount layout. Every
image attributed to that artist (40k+ rows) needs to be removed — DB
rows, original files under `/images/<bucket>/...`, and thumbnails under
`/images/thumbs/...` — before the operator remounts and re-scans.
CASCADE handles image_tag, image_provenance, series_page, and
tag_suggestion_rejection child rows; import_task.result_image_id is
SET NULL by FK. We also delete ImportTask rows whose source_path starts
with the (still-existing) IR scan prefix so the next scan isn't fooled
by them.
"""
from __future__ import annotations
import logging
from pathlib import Path
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Artist, ImageRecord, ImportBatch, ImportTask
log = logging.getLogger(__name__)
_BATCH_SIZE = 500
def _zero_counts() -> dict:
return {
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
}
def _thumb_path(images_root: Path, sha256_hex: str) -> tuple[Path, Path]:
"""Return both possible thumbnail paths (.jpg and .png). We try both
because the extension is chosen at generate-time based on the source
image's mode (alpha → .png, otherwise → .jpg)."""
bucket = sha256_hex[:3]
base = images_root / "thumbs" / bucket / sha256_hex
return base.with_suffix(".jpg"), base.with_suffix(".png")
def _delete_file(path: Path) -> bool:
"""Best-effort unlink; True if the file was actually removed."""
try:
path.unlink(missing_ok=True)
return True
except OSError as exc:
log.warning("cleanup: failed to unlink %s: %s", path, exc)
return False
async def cleanup_artist_async(
db: AsyncSession,
*,
slug: str,
images_root: Path | None = None,
dry_run: bool = False,
source_path_prefix: str | None = None,
) -> dict:
"""Delete every image attributed to the Artist with this slug,
along with the artist row itself and any associated import tasks.
Args:
slug: artist.slug to target (e.g. 'imagerepo').
images_root: defaults to /images.
dry_run: skip filesystem + DB writes; still walk rows for counts.
source_path_prefix: if set, ImportTask rows whose source_path
starts with this string are deleted too (use the IR scan
mount prefix, e.g. '/import/imagerepo').
"""
root = images_root if images_root is not None else Path("/images")
artist = (await db.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
raise ValueError(f"no Artist with slug={slug!r}")
artist_id = artist.id
artist_name = artist.name
total_images = (await db.execute(
select(func.count(ImageRecord.id)).where(ImageRecord.artist_id == artist_id)
)).scalar_one()
counts = _zero_counts()
files_deleted = 0
thumbs_deleted = 0
images_deleted = 0
# Batched delete loop. CASCADE handles image_tag, image_provenance,
# series_page, tag_suggestion_rejection. import_task.result_image_id
# is SET NULL by FK.
while True:
rows = (await db.execute(
select(ImageRecord.id, ImageRecord.path, ImageRecord.sha256)
.where(ImageRecord.artist_id == artist_id)
.limit(_BATCH_SIZE)
)).all()
if not rows:
break
ids = [r.id for r in rows]
counts["rows_processed"] += len(ids)
if not dry_run:
for r in rows:
if r.path:
if _delete_file(Path(r.path)):
files_deleted += 1
if r.sha256:
jpg, png = _thumb_path(root, r.sha256)
if _delete_file(jpg):
thumbs_deleted += 1
if _delete_file(png):
thumbs_deleted += 1
await db.execute(
delete(ImageRecord).where(ImageRecord.id.in_(ids))
)
await db.commit()
images_deleted += len(ids)
if dry_run:
# Nothing was actually deleted from the DB; bail after one
# pass so we don't loop forever.
break
import_tasks_deleted = 0
if source_path_prefix and not dry_run:
# Delete ImportTask rows whose source_path is under the bad mount
# prefix. These are mostly orphaned now (result_image_id was set
# NULL by CASCADE) but their presence still blocks the
# idempotency check in scan_directory if the operator remounts
# the same prefix.
like_pattern = source_path_prefix.rstrip("/") + "/%"
result = await db.execute(
delete(ImportTask).where(ImportTask.source_path.like(like_pattern))
)
import_tasks_deleted = result.rowcount or 0
await db.commit()
# Sweep ImportBatch rows that are now empty.
empty_batches_deleted = 0
if not dry_run:
empty_batch_ids = (await db.execute(
select(ImportBatch.id).where(
~select(ImportTask.id)
.where(ImportTask.batch_id == ImportBatch.id)
.exists()
)
)).scalars().all()
if empty_batch_ids:
result = await db.execute(
delete(ImportBatch).where(ImportBatch.id.in_(empty_batch_ids))
)
empty_batches_deleted = result.rowcount or 0
await db.commit()
# Finally, the artist row.
if not dry_run:
await db.execute(delete(Artist).where(Artist.id == artist_id))
await db.commit()
return {
"counts": counts,
"artist": {"id": artist_id, "name": artist_name, "slug": slug},
"summary": {
"images_targeted": total_images,
"images_deleted": images_deleted,
"files_deleted": files_deleted,
"thumbs_deleted": thumbs_deleted,
"import_tasks_deleted": import_tasks_deleted,
"empty_batches_deleted": empty_batches_deleted,
"dry_run": dry_run,
},
}
+126
View File
@@ -0,0 +1,126 @@
"""GallerySubscriber export → FabledCurator ingest.
Reads a parsed gallerysubscriber-export-v1.json dict (no DB connection
to GS). Creates Artist (from subscriptions) + Source (nested under each
subscription) + Credential (re-encrypted with FC's key). Idempotent on
natural keys: Artist.slug, (artist_id, platform, url), Credential.platform.
Credentials arrive plaintext in the export — GS's export script
decrypts using GS's Fernet key in GS's own process. FC re-encrypts
with FC's CredentialCrypto.
"""
from __future__ import annotations
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Artist, Credential, Source
from ...utils.slug import slugify
from ..credential_crypto import CredentialCrypto
def _zero_counts() -> dict:
return {
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
}
async def migrate_async(
db: AsyncSession,
*,
data: dict,
fc_crypto: CredentialCrypto | None = None,
dry_run: bool = False,
) -> dict:
"""Ingest a parsed gallerysubscriber-export-v1.json dict."""
if data.get("source_app") != "gallerysubscriber":
raise ValueError("export source_app must be 'gallerysubscriber'")
if data.get("schema_version") != 1:
raise ValueError(f"unsupported schema_version: {data.get('schema_version')}")
counts = _zero_counts()
# Phase 1: subscriptions → Artist; nested sources within each.
for sub in data.get("subscriptions", []):
counts["rows_processed"] += 1
slug = slugify(sub["name"])
artist = (await db.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
if dry_run:
counts["rows_inserted"] += 1
# Continue to nested sources, but they can't link without an artist row.
continue
notes = json.dumps(sub.get("metadata"), indent=2) if sub.get("metadata") else None
artist = Artist(
name=sub["name"], slug=slug,
is_subscription=True,
auto_check=bool(sub.get("enabled", True)),
notes=notes,
)
db.add(artist)
await db.flush()
counts["rows_inserted"] += 1
else:
counts["rows_skipped"] += 1
# Nested sources under this subscription.
for src in sub.get("sources", []):
counts["rows_processed"] += 1
existing = (await db.execute(
select(Source).where(
Source.artist_id == artist.id,
Source.platform == src["platform"],
Source.url == src["url"],
)
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
db.add(Source(
artist_id=artist.id,
platform=src["platform"],
url=src["url"],
enabled=bool(src.get("enabled", True)),
check_interval_override=src.get("check_interval"),
config_overrides=src.get("metadata") or {},
))
counts["rows_inserted"] += 1
# Phase 2: credentials.
for cred in data.get("credentials", []):
counts["rows_processed"] += 1
existing = (await db.execute(
select(Credential).where(Credential.platform == cred["platform"])
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
if fc_crypto is None:
# Without a crypto helper we can't encrypt — skip rather than
# store plaintext.
counts["rows_skipped"] += 1
counts["conflicts"] += 1
continue
encrypted = fc_crypto.encrypt(cred["plaintext"])
db.add(Credential(
platform=cred["platform"],
credential_type=cred.get("credential_type") or "cookies",
encrypted_blob=encrypted,
expires_at=cred.get("expires_at"),
))
counts["rows_inserted"] += 1
if not dry_run:
await db.commit()
return counts
+146
View File
@@ -0,0 +1,146 @@
"""ImageRepo export → FabledCurator ingest.
Reads a parsed imagerepo-export-v1.json dict (no DB connection to IR).
Creates Tag rows (skipping artist/post kinds, resolving fandom_name to
FK). Writes the per-image-sha256 artist assignments + tag associations
+ series page assignments to /images/_migration_state/ir_tag_manifest.json
so tag_apply.py can join them to ImageRecord rows AFTER the operator
runs FC's filesystem scan.
"""
from __future__ import annotations
import json
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Tag, TagKind
_SKIP_KINDS = frozenset({"artist", "post"})
_MIGRATION_STATE_DIRNAME = "_migration_state"
_IR_MANIFEST_FILENAME = "ir_tag_manifest.json"
def _zero_counts() -> dict:
return {
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
}
def manifest_path(images_root: Path | None = None) -> Path:
root = images_root if images_root is not None else Path("/images")
p = root / _MIGRATION_STATE_DIRNAME
p.mkdir(parents=True, exist_ok=True)
return p / _IR_MANIFEST_FILENAME
async def _resolve_fandom_id(
db: AsyncSession, fandom_name: str | None, dry_run: bool,
) -> int | None:
"""Find-or-create a fandom-kind Tag by name."""
if not fandom_name:
return None
existing = (await db.execute(
select(Tag).where(Tag.name == fandom_name, Tag.kind == "fandom")
)).scalar_one_or_none()
if existing is not None:
return existing.id
if dry_run:
return None
t = Tag(name=fandom_name, kind=TagKind.fandom)
db.add(t)
await db.flush()
return t.id
async def migrate_async(
db: AsyncSession,
*,
data: dict,
images_root: Path | None = None,
dry_run: bool = False,
) -> dict:
"""Ingest a parsed imagerepo-export-v1.json dict.
Creates Tag rows + writes the IR tag manifest file. Tag-to-image
binding happens later in tag_apply.py (after FC's filesystem scan
populates image_record.sha256 → id).
"""
if data.get("source_app") != "imagerepo":
raise ValueError("export source_app must be 'imagerepo'")
if data.get("schema_version") not in (1, 2):
raise ValueError(f"unsupported schema_version: {data.get('schema_version')}")
counts = _zero_counts()
# Phase 1: tags (skip artist + post kinds; resolve fandom_name → fandom_id).
# First pass: create all fandom-kind tags so they're available for FK resolution.
for tag in data.get("tags", []):
kind = tag.get("kind") or "general"
if kind != "fandom":
continue
counts["rows_processed"] += 1
existing = (await db.execute(
select(Tag).where(Tag.name == tag["name"], Tag.kind == "fandom")
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
db.add(Tag(name=tag["name"], kind=TagKind.fandom))
counts["rows_inserted"] += 1
if not dry_run:
await db.flush()
# Second pass: every other kind.
for tag in data.get("tags", []):
kind_str = tag.get("kind") or "general"
if kind_str in _SKIP_KINDS:
counts["rows_skipped"] += 1
continue
if kind_str == "fandom":
continue # handled above
counts["rows_processed"] += 1
try:
kind = TagKind(kind_str)
except ValueError:
kind = TagKind.general
fandom_id = await _resolve_fandom_id(db, tag.get("fandom_name"), dry_run)
existing = (await db.execute(
select(Tag).where(Tag.name == tag["name"], Tag.kind == kind)
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
db.add(Tag(name=tag["name"], kind=kind, fandom_id=fandom_id))
counts["rows_inserted"] += 1
if not dry_run:
await db.commit()
# Phase 2: write the per-image manifest for tag_apply.py to consume later.
# schema_version 2 (added 2026-05-24) carries `image_posts` for
# Post + Source + ImageProvenance restore; schema 1 manifests
# without it stay valid (tag_apply treats the missing field as []).
manifest = {
"schema_version": data.get("schema_version", 1),
"image_artist_assignments": data.get("image_artist_assignments", []),
"image_tag_associations": data.get("image_tag_associations", []),
"series_pages": data.get("series_pages", []),
"image_posts": data.get("image_posts", []),
}
counts["rows_processed"] += len(manifest["image_artist_assignments"])
counts["rows_processed"] += len(manifest["image_tag_associations"])
counts["rows_processed"] += len(manifest["series_pages"])
counts["rows_processed"] += len(manifest["image_posts"])
if not dry_run:
manifest_path(images_root).write_text(json.dumps(manifest, indent=2))
return counts
@@ -0,0 +1,21 @@
"""Queue every migrated image_record with no embedding for ML re-processing."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ImageRecord
async def queue_all_unprocessed_async(db: AsyncSession) -> int:
"""Find every ImageRecord with siglip_embedding IS NULL, fire
tag_and_embed.delay(id) for each. Returns count queued.
"""
from ...tasks.ml import tag_and_embed
rows = (await db.execute(
select(ImageRecord.id).where(ImageRecord.siglip_embedding.is_(None))
)).scalars().all()
for image_id in rows:
tag_and_embed.delay(image_id)
return len(rows)
+368
View File
@@ -0,0 +1,368 @@
"""Apply the IR tag manifest after FC's filesystem scan.
Reads /images/_migration_state/ir_tag_manifest.json and joins each entry
to an ImageRecord row by sha256 (which exists after the operator runs
FC's filesystem scan over the mounted IR images dir).
- image_artist_assignments → ImageRecord.artist_id (find_or_create Artist by slug).
- image_tag_associations → image_tag insert (idempotent).
- series_pages → series_page insert (idempotent on image_id unique).
- image_posts (schema v2) → Source + Post + ImageProvenance restore.
Unmatched sha256s are logged into the result's `unmatched` list so the
Celery task can drop them into MigrationRun.metadata for the operator
to inspect.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import (
Artist,
ImageProvenance,
ImageRecord,
Post,
SeriesPage,
Source,
Tag,
TagKind,
image_tag,
)
from ...utils.slug import slugify
from .ir_ingest import manifest_path
# Per-platform artist-profile URL — used as Source.url when restoring
# IR PostMetadata into FC. Must cover every platform that
# backend/app/services/extension_service.py:_PLATFORM_PATTERNS
# recognizes; an entry missing here silently drops ALL PostMetadata for
# that platform during phase 4 (operator hit this 2026-05-25:
# DeviantArt + Pixiv posts in the IR migration produced empty
# ImageProvenance because they fell through this table).
#
# Pixiv caveat: the real profile URL takes a numeric user_id
# (https://www.pixiv.net/users/12345), but IR's PostMetadata.artist
# stores the display name not the id. We use the slugified name here
# so we preserve the artist→post→image linkage; the resulting Source.url
# won't resolve in a browser and the operator may want to manually fix
# it via Settings → Subscriptions once the migration lands.
_PLATFORM_PROFILE_URL = {
"patreon": "https://www.patreon.com/{slug}",
"subscribestar": "https://www.subscribestar.com/{slug}",
"hentaifoundry": "https://www.hentai-foundry.com/user/{slug}",
"deviantart": "https://www.deviantart.com/{slug}",
"pixiv": "https://www.pixiv.net/users/{slug}",
}
def _profile_url(platform: str, artist_slug: str) -> str | None:
fmt = _PLATFORM_PROFILE_URL.get(platform)
return fmt.format(slug=artist_slug) if fmt else None
async def _find_or_create_source(
db: AsyncSession, *, artist_id: int, platform: str, url: str, dry_run: bool,
) -> int | None:
existing = (await db.execute(
select(Source.id).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
)).scalar_one_or_none()
if existing is not None:
return existing
if dry_run:
return None
s = Source(artist_id=artist_id, platform=platform, url=url, enabled=False)
db.add(s)
await db.flush()
return s.id
async def _find_or_create_post(
db: AsyncSession, *,
source_id: int, external_post_id: str,
title: str | None, description: str | None, post_url: str | None,
post_date_iso: str | None, attachment_count: int, dry_run: bool,
) -> int | None:
existing = (await db.execute(
select(Post.id).where(
Post.source_id == source_id,
Post.external_post_id == external_post_id,
)
)).scalar_one_or_none()
if existing is not None:
return existing
if dry_run:
return None
post_date = None
if post_date_iso:
post_date = datetime.fromisoformat(post_date_iso)
p = Post(
source_id=source_id,
external_post_id=external_post_id,
post_title=title,
description=description,
post_url=post_url,
post_date=post_date,
attachment_count=attachment_count,
raw_metadata={"migrated_from": "imagerepo"},
)
db.add(p)
await db.flush()
return p.id
async def _ensure_provenance(
db: AsyncSession, *,
image_id: int, post_id: int, source_id: int, dry_run: bool,
) -> bool:
"""Returns True if a new ImageProvenance row was inserted.
Also sets ImageRecord.primary_post_id to this post if the image
doesn't already have one — preserves any primary_post_id already
assigned at download time by the importer (don't clobber). This is
the linkage gallery_service.py uses to surface Post.post_date as
the image's effective date for sort/group/jump/neighbor nav.
"""
existing = (await db.execute(
select(ImageProvenance.id).where(
ImageProvenance.image_record_id == image_id,
ImageProvenance.post_id == post_id,
ImageProvenance.source_id == source_id,
)
)).scalar_one_or_none()
# Whether-or-not the provenance row already exists, ensure the
# image's primary_post_id is set so the gallery date-coalesce works.
# Idempotent: only writes when currently NULL.
if not dry_run:
await db.execute(
ImageRecord.__table__.update()
.where(ImageRecord.id == image_id)
.where(ImageRecord.primary_post_id.is_(None))
.values(primary_post_id=post_id)
)
if existing is not None:
return False
if dry_run:
return True
db.add(ImageProvenance(
image_record_id=image_id, post_id=post_id, source_id=source_id,
))
await db.flush()
return True
def _zero_counts() -> dict:
return {
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
}
async def _ensure_artist_id(
db: AsyncSession, artist_name: str, dry_run: bool,
) -> int | None:
if not artist_name or not artist_name.strip():
return None
slug = slugify(artist_name)
existing = (await db.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if existing is not None:
return existing.id
if dry_run:
return None
a = Artist(name=artist_name, slug=slug, is_subscription=False)
db.add(a)
await db.flush()
return a.id
async def _resolve_tag_id(
db: AsyncSession, tag_name: str, tag_kind: str,
) -> int | None:
try:
kind = TagKind(tag_kind)
except ValueError:
kind = TagKind.general
row = (await db.execute(
select(Tag.id).where(Tag.name == tag_name, Tag.kind == kind)
)).scalar_one_or_none()
return row
async def _sha_to_image_id(db: AsyncSession, sha: str) -> int | None:
return (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one_or_none()
async def apply_async(
db: AsyncSession,
*,
images_root: Path | None = None,
dry_run: bool = False,
) -> dict:
"""Apply the manifest. Returns counts + an `unmatched` list of sha256s."""
mf_path = manifest_path(images_root)
if not mf_path.exists():
raise FileNotFoundError(f"no IR tag manifest at {mf_path}")
manifest = json.loads(mf_path.read_text())
counts = _zero_counts()
unmatched: list[dict] = []
# 1. Artist assignments.
for entry in manifest.get("image_artist_assignments", []):
counts["rows_processed"] += 1
img_id = await _sha_to_image_id(db, entry["sha256"])
if img_id is None:
unmatched.append({"kind": "artist", **entry})
counts["rows_skipped"] += 1
continue
aid = await _ensure_artist_id(db, entry["artist_name"], dry_run)
if aid is None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
img = await db.get(ImageRecord, img_id)
if img is not None and img.artist_id != aid:
img.artist_id = aid
counts["rows_inserted"] += 1
else:
counts["rows_skipped"] += 1
# 2. Tag associations.
for entry in manifest.get("image_tag_associations", []):
counts["rows_processed"] += 1
img_id = await _sha_to_image_id(db, entry["sha256"])
if img_id is None:
unmatched.append({"kind": "tag", **entry})
counts["rows_skipped"] += 1
continue
tag_id = await _resolve_tag_id(
db, entry["tag_name"], entry.get("tag_kind") or "general",
)
if tag_id is None:
counts["rows_skipped"] += 1
continue
# Skip if association already exists.
already = (await db.execute(
select(image_tag.c.image_record_id).where(
image_tag.c.image_record_id == img_id,
image_tag.c.tag_id == tag_id,
)
)).first()
if already is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
await db.execute(image_tag.insert().values(
image_record_id=img_id, tag_id=tag_id, source="manual",
))
counts["rows_inserted"] += 1
# 3. Series pages.
for entry in manifest.get("series_pages", []):
counts["rows_processed"] += 1
img_id = await _sha_to_image_id(db, entry["sha256"])
if img_id is None:
unmatched.append({"kind": "series", **entry})
counts["rows_skipped"] += 1
continue
series_tag_id = await _resolve_tag_id(db, entry["series_tag_name"], "series")
if series_tag_id is None:
counts["rows_skipped"] += 1
continue
existing = (await db.execute(
select(SeriesPage).where(SeriesPage.image_id == img_id)
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
db.add(SeriesPage(
series_tag_id=series_tag_id,
image_id=img_id,
page_number=entry["page_number"],
))
counts["rows_inserted"] += 1
# 4. Image posts (schema v2) → Source + Post + ImageProvenance.
# Restores IR PostMetadata as FC's downloader-track provenance,
# so the modal's ProvenancePanel surfaces title/description/
# source URL/publish date the same way it does for live
# gallery-dl downloads.
for entry in manifest.get("image_posts", []):
counts["rows_processed"] += 1
platform = entry.get("platform")
artist_name = entry.get("artist")
if not platform or not artist_name:
counts["rows_skipped"] += 1
continue
aid = await _ensure_artist_id(db, artist_name, dry_run)
if aid is None:
counts["rows_skipped"] += 1
continue
url = _profile_url(platform, slugify(artist_name))
if url is None:
counts["rows_skipped"] += 1
continue
source_id = await _find_or_create_source(
db, artist_id=aid, platform=platform, url=url, dry_run=dry_run,
)
if source_id is None:
counts["rows_skipped"] += 1
continue
post_id = await _find_or_create_post(
db, source_id=source_id,
external_post_id=entry.get("post_id") or "",
title=entry.get("title"),
description=entry.get("description"),
post_url=entry.get("source_url"),
post_date_iso=entry.get("published_at"),
attachment_count=entry.get("attachment_count") or 0,
dry_run=dry_run,
)
if post_id is None:
counts["rows_skipped"] += 1
continue
for sha in entry.get("image_sha256s", []):
img_id = await _sha_to_image_id(db, sha)
if img_id is None:
unmatched.append({
"kind": "post", "sha256": sha,
"post_id": entry.get("post_id"),
})
continue
inserted = await _ensure_provenance(
db, image_id=img_id, post_id=post_id,
source_id=source_id, dry_run=dry_run,
)
if inserted:
counts["rows_inserted"] += 1
else:
counts["rows_skipped"] += 1
if not dry_run:
await db.commit()
return {"counts": counts, "unmatched": unmatched}
+80
View File
@@ -0,0 +1,80 @@
"""Post-migration verification: row counts + sha256 sampling."""
from __future__ import annotations
import hashlib
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Artist, Credential, ImageRecord, Source, Tag
async def verify_async(db: AsyncSession, *, expected: dict | None = None) -> dict:
"""Return per-check status dicts. `expected` is optional row-count
assertions; checks default to status='ok' when no expected provided."""
expected = expected or {}
results: dict[str, dict] = {}
checks = {
"artist_subscriptions": (
select(func.count(Artist.id)).where(Artist.is_subscription.is_(True))
),
"source_count": select(func.count(Source.id)),
"credential_count": select(func.count(Credential.id)),
"tag_count": select(func.count(Tag.id)),
"image_record_imported_or_downloaded": (
select(func.count(ImageRecord.id))
.where(ImageRecord.origin.in_(["imported_filesystem", "downloaded"]))
),
}
for name, stmt in checks.items():
actual = (await db.execute(stmt)).scalar_one()
exp = expected.get(name)
status = "ok" if exp is None or exp == actual else "mismatch"
results[name] = {"status": status, "actual": int(actual), "expected": exp}
return results
async def verify_sha256_sample(
db: AsyncSession, *, sample_size: int = 20,
) -> dict:
"""Sample N image_records; verify file exists + sha256 matches."""
rows = (await db.execute(
select(ImageRecord.id, ImageRecord.path, ImageRecord.sha256)
.order_by(func.random()).limit(sample_size)
)).all()
matched = 0
mismatched = 0
missing = 0
samples: list[dict] = []
for img_id, path, expected_sha in rows:
p = Path(path)
# Sync stdlib filesystem ops are intentional: this verify pass runs
# inside a Celery task under asyncio.run; no other awaitables compete
# for the loop. Same pattern as download_service.py.
if not p.exists(): # noqa: ASYNC240
missing += 1
samples.append({"id": img_id, "path": path, "result": "missing"})
continue
h = hashlib.sha256()
with p.open("rb") as f: # noqa: ASYNC230
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
if h.hexdigest() == expected_sha:
matched += 1
samples.append({"id": img_id, "result": "ok"})
else:
mismatched += 1
samples.append({
"id": img_id, "path": path, "result": "mismatch",
"expected_sha": expected_sha, "actual_sha": h.hexdigest(),
})
return {
"sample_size": len(rows),
"matched": matched,
"mismatched": mismatched,
"missing": missing,
"samples": samples,
}
+4 -20
View File
@@ -19,6 +19,7 @@ from ...models import (
TagReferenceEmbedding,
)
from ...models.tag import image_tag
from .embedder import MODEL_VERSION as SIGLIP_VERSION
ELIGIBLE_KINDS = {
TagKind.character,
@@ -45,21 +46,6 @@ class CentroidService:
)
).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:
"""Recompute one tag's centroid. Returns True if a centroid was
written, False if skipped (ineligible kind or too few members)."""
@@ -83,20 +69,19 @@ class CentroidService:
return False
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
model_version = await self._model_version()
stmt = insert(TagReferenceEmbedding).values(
tag_id=tag_id,
embedding=centroid.tolist(),
reference_count=len(embeddings),
model_version=model_version,
model_version=SIGLIP_VERSION,
)
stmt = stmt.on_conflict_do_update(
index_elements=["tag_id"],
set_={
"embedding": centroid.tolist(),
"reference_count": len(embeddings),
"model_version": model_version,
"model_version": SIGLIP_VERSION,
"updated_at": func.now(),
},
)
@@ -107,7 +92,6 @@ class CentroidService:
"""Tag ids whose centroid is stale: member count != reference_count,
OR no centroid row, OR centroid built on a different SigLIP version.
Only considers eligible-kind tags with embeddings present."""
current_model_version = await self._model_version()
member_counts = (
select(
image_tag.c.tag_id.label("tag_id"),
@@ -132,7 +116,7 @@ class CentroidService:
TagReferenceEmbedding.reference_count
!= member_counts.c.members
)
| (TagReferenceEmbedding.model_version != current_model_version)
| (TagReferenceEmbedding.model_version != SIGLIP_VERSION)
)
)
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 sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import (
@@ -16,7 +16,6 @@ from ...models import (
from ...models.tag import image_tag
from .aliases import AliasService
from .centroids import CentroidService
from .tag_name import normalize as normalize_tag_name
from .tagger import SURFACED_CATEGORIES
@@ -49,11 +48,11 @@ class SuggestionService:
).scalar_one()
def _threshold_for(self, s: MLSettings, category: str) -> float:
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
# both fall through to the 1.01 "never surfaces" default like any
# unsurfaced category.
# 'artist' intentionally absent (FC-2d-vii-c) — falls through to
# the 1.01 "never surfaces" default like any unsurfaced category.
return {
"character": s.suggestion_threshold_character,
"copyright": s.suggestion_threshold_copyright,
"general": s.suggestion_threshold_general,
}.get(category, 1.01)
@@ -85,12 +84,7 @@ class SuggestionService:
)
# --- Camie predictions ---
# candidates carry (raw_name, display_name, category, confidence).
# 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]] = []
candidates: list[tuple[str, str, float]] = []
for name, p in predictions.items():
category = p.get("category", "general")
if category not in SURFACED_CATEGORIES:
@@ -98,14 +92,10 @@ class SuggestionService:
conf = float(p.get("confidence", 0.0))
if conf < self._threshold_for(settings, category):
continue
display = normalize_tag_name(name)
if display is None:
# emoticon / pure-punctuation vocab entry — drop entirely
continue
candidates.append((name, display, category, conf))
candidates.append((name, category, conf))
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] = {}
@@ -126,8 +116,8 @@ class SuggestionService:
creates_new_tag=existing.creates_new_tag,
)
for raw, display, category, conf in candidates:
canonical = alias_map.get((raw, category))
for name, category, conf in candidates:
canonical = alias_map.get((name, category))
if canonical is not None:
if canonical.id in applied or canonical.id in rejected:
continue
@@ -143,17 +133,9 @@ class SuggestionService:
),
)
else:
# Case-insensitive match on BOTH the raw camie key AND
# the normalized form — covers legacy underscore-named
# Tag rows accepted before normalization shipped, AND
# any tag the operator created with the human form.
existing_tag = (
await self.session.execute(
select(Tag).where(
func.lower(Tag.name).in_(
[raw.lower(), display.lower()]
)
)
select(Tag).where(Tag.name == name)
)
).scalars().first()
if existing_tag is not None:
@@ -175,10 +157,10 @@ class SuggestionService:
)
else:
_merge(
f"raw:{display}:{category}",
f"raw:{name}:{category}",
Suggestion(
canonical_tag_id=None,
display_name=display,
display_name=name,
category=category,
score=conf,
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
# still stored but the suggestion service filters them out.
# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
# (image_record.artist_id), never ML-inferred. 'copyright' retired
# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
# this app's franchise/series concept (per TagsView.vue's doc comment).
# Raw predictions for both categories still get stored at STORE_FLOOR but
# don't surface in suggestions.
SURFACED_CATEGORIES = {"character", "general"}
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
# (image_record.artist_id), never ML-inferred. Raw predictions are still
# stored at STORE_FLOOR but artist never surfaces.
SURFACED_CATEGORIES = {"character", "copyright", "general"}
# ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
-588
View File
@@ -1,588 +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
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)
# A bounded, sane extension parse (see importer._safe_ext for the FC gotcha:
# URL-encoded basenames make Path.suffix return base64-ish junk). We only ever
# use this for a fallback filename, never a network call.
_MAX_EXT_LEN = 16
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, …).
"""
def __init__(self, message: str, *, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
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 _safe_ext(name: str) -> str:
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 _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:
raise PatreonAPIError(
f"Patreon posts API returned HTTP {resp.status_code} "
f"(campaign_id={campaign_id})",
status_code=resp.status_code,
)
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, *, require_file_name: bool
) -> 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 = attrs.get("file_name")
if require_file_name and not (isinstance(file_name, str) and file_name):
raise PatreonDriftError(
f"Patreon media resource (post {post_id}, kind={kind}) missing "
f"'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, require_file_name=True
)
)
# 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)
# -- 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
-416
View File
@@ -1,416 +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 shutil
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, validate_file
from .patreon_client import _load_session, _retry_after_seconds
log = logging.getLogger(__name__)
_TITLE_MAX = 40
_TIMEOUT_SECONDS = 120.0
_CHUNK = 1 << 16
# A CDN media GET rarely 429s, but if it does, a couple of backoff retries keep
# one throttled file from becoming a per-item error (plan #703).
_MAX_MEDIA_429_RETRIES = 2
# 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", "error".
`path` is the final on-disk path for "downloaded" (the actual yt-dlp output
for video), or the path that already existed for "skipped_disk"; None for
"skipped_seen" and (usually) "error". `error` carries the failure reason for
"error", 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,
) -> 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.
"""
post_dir = self.images_root / artist_slug / "patreon" / _post_dir_name(post)
outcomes: list[MediaOutcome] = []
for i, media in enumerate(media_items, start=1):
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)
invalid = self._validate_path(out_path, artist_slug)
if invalid is not None:
return MediaOutcome(
media=media, status="error", path=None, error=invalid
)
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.
Retries a transient 429 with backoff (plan #703); any other non-2xx
falls through to raise_for_status → HTTPError, which download_post turns
into a resilient per-item error outcome.
"""
attempt = 0
while True:
resp = self.session.get(url, stream=True, timeout=_TIMEOUT_SECONDS)
if resp.status_code == 429 and attempt < _MAX_MEDIA_429_RETRIES:
attempt += 1
delay = _retry_after_seconds(resp, attempt)
log.warning(
"Patreon media 429 (%s) — backing off %.1fs (retry %d/%d)",
url, delay, attempt, _MAX_MEDIA_429_RETRIES,
)
time.sleep(delay)
continue
break
resp.raise_for_status()
with open(dest, "wb") as fh:
for chunk in resp.iter_content(chunk_size=_CHUNK):
if chunk:
fh.write(chunk)
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.
"""
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)
try:
subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
timeout=_TIMEOUT_SECONDS,
)
except (OSError, subprocess.SubprocessError) as exc:
log.warning("yt-dlp failed for %s: %s", url, exc)
return None
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) -> str | None:
"""Validate a freshly-written file; quarantine + return reason if bad.
Mirrors gallery_dl._validate_and_quarantine: fail-open for unknown
formats, move the corrupt file to _quarantine/<slug>/patreon, and return
the failure reason string (None when ok / not validatable / disabled).
"""
if not self._validate or not is_validatable(path):
return None
try:
result = validate_file(path)
except Exception as exc:
log.warning("Validator raised on %s: %s", path, exc)
return None
if result.ok:
return None
quarantine_root = self.images_root / "_quarantine" / artist_slug / "patreon"
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))
except OSError as exc:
log.error("Failed to quarantine %s: %s. File left in place.", path, exc)
return result.reason or "validation failed"
# -- 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
-414
View File
@@ -1,414 +0,0 @@
"""Native Patreon ingester — phase-2 orchestrator (build step 3).
Ties build steps 1 (`patreon_client`) and 2 (`patreon_downloader` +
`patreon_seen_media`) together into a single sync walk that REPLACES the
gallery-dl subprocess for Patreon. `download_service.download_source` calls
`PatreonIngester.run(...)` from phase 2 (in a thread, via run_in_executor, since
everything here is sync `requests`/`subprocess`) and gets back a
`DownloadResult` — the exact shape gallery-dl returns — so phase 1 (DB setup)
and phase 3 (import → pHash dedup → thumbnails → ML) are untouched and cannot
tell the difference.
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, 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). Triggered by the same #693 backfill
state machine plus the `_backfill_bypass_seen` flag, so the whole
cursor/chunk/complete/stall lifecycle is reused verbatim.
Cursor contract: the ingester emits gallery-dl-style ``Cursor: <token>`` lines
into the returned `stdout`, one per page it walks. That is exactly what
`download_service`'s existing backfill lifecycle reads via
`parse_last_cursor(stdout, stderr)` — so checkpointing, the TIMEOUT→PARTIAL
reclassification, and completion detection all keep working unchanged.
The seen-ledger lives in Postgres (`patreon_seen_media`). The ingester opens a
SHORT-LIVED sync session per page batch (via an injected sessionmaker) — never
held across a network fetch — so the multi-minute walk can't strand a checked-out
connection for the server to reap ([[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
import time
from collections.abc import Callable
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import PatreonSeenMedia
from .gallery_dl import DownloadResult, ErrorType
from .patreon_client import (
MediaItem,
PatreonAPIError,
PatreonAuthError,
PatreonClient,
PatreonDriftError,
)
from .patreon_downloader import PatreonDownloader
from .patreon_resolver import resolve_campaign_id_for_source
log = logging.getLogger(__name__)
# gallery-dl's `exit:20` default ported over: stop a tick after this many
# CONTIGUOUS already-have-it media (seen-ledger or on-disk). Native walks have
# zero per-file HEADs, so the only cost of a higher number is a few extra cheap
# ledger lookups — 20 is operator-set headroom against paywalled/undownloadable
# items interleaving with archived ones.
_TICK_SEEN_THRESHOLD = 20
# 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:
"""Walk a Patreon campaign's posts, download unseen media, return a
`DownloadResult`.
Construct with the per-source `cookies_path` and a sync sessionmaker for the
seen-ledger. `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
self.session_factory = session_factory
# 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.
self.client = (
client
if client is not None
else PatreonClient(cookies_path, request_sleep=request_sleep)
)
self.downloader = (
downloader
if downloader is not None
else PatreonDownloader(
self.images_root, cookies_path, validate=validate, rate_limit=rate_limit
)
)
# -- 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,
) -> DownloadResult:
"""Walk + download for one source, returning a gallery-dl-shaped result.
`mode` is "tick" | "backfill" | "recovery". Recovery bypasses the tier-1
seen-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"
start = time.monotonic()
log_lines: list[str] = []
written: list[str] = []
downloaded = 0
errors = 0
consecutive_seen = 0
emitted_cursor: str | None = None
reached_bottom = False
budget_hit = False
early_out = False
def _result(
*, success: bool, return_code: int,
error_type: ErrorType | None, error_message: str | None,
) -> DownloadResult:
return DownloadResult(
success=success,
url=url,
artist_slug=artist_slug,
platform="patreon",
files_downloaded=downloaded,
files_quarantined=0,
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,
)
try:
for post, included, page_cursor in self.client.iter_posts(
campaign_id, cursor=resume_cursor
):
# Checkpoint: emit the cursor that FETCHED this page once, the
# moment we START it — so a chunk cut mid-page resumes the page,
# not the one after it (matches the gallery-dl cursor semantics
# download_service's lifecycle already depends on).
if page_cursor and page_cursor != emitted_cursor:
log_lines.append(f"Cursor: {page_cursor}")
emitted_cursor = page_cursor
# 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
media = self.client.extract_media(post, included)
if not media:
continue
keys = [_ledger_key(m) for m in media]
seen = (
set()
if bypass_seen
else self._seen_keys(source_id, keys)
)
def _is_seen(m: MediaItem, _seen=seen) -> bool:
return _ledger_key(m) in _seen
outcomes = self.downloader.download_post(
post, media, artist_slug, is_seen=_is_seen
)
to_mark: list[tuple[str, str]] = []
for media_item, outcome in zip(media, outcomes, strict=False):
key = _ledger_key(media_item)
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))
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))
consecutive_seen += 1
elif outcome.status == "skipped_seen":
consecutive_seen += 1
elif outcome.status == "error":
errors += 1
# An error neither advances nor resets the run-of-seen.
if mode == "tick" and consecutive_seen >= seen_threshold:
early_out = True
break
# Mark seen AFTER the network fetch, on its own short session.
if to_mark:
self._mark_seen(source_id, to_mark)
if early_out:
break
else:
reached_bottom = True
except PatreonAPIError as exc:
# Base of PatreonAuthError + PatreonDriftError — catches every
# client-level failure; _failure_result maps it to a typed error.
return self._failure_result(exc, _result)
if errors:
log_lines.append(f"{errors} media item(s) failed")
log_lines.append(
f"Patreon ingest ({mode}): {downloaded} downloaded, "
f"{errors} error(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"Patreon backfill chunk: {downloaded} file(s) — continuing"
),
)
return _result(
success=False, return_code=-1,
error_type=ErrorType.TIMEOUT,
error_message="Patreon 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). NO_NEW_CONTENT would read as "not finished" there and trip
# the stall guard. 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,
)
# -- failure mapping ---------------------------------------------------
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)
return _result(
success=False, return_code=1,
error_type=error_type, error_message=message,
)
# -- seen-ledger (short-lived sessions) --------------------------------
def _seen_keys(self, source_id: int, keys: list[str]) -> set[str]:
"""Which of `keys` are already in the 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(PatreonSeenMedia.filehash).where(
PatreonSeenMedia.source_id == source_id,
PatreonSeenMedia.filehash.in_(keys),
)
).scalars().all()
return set(rows)
def _mark_seen(self, source_id: int, items: list[tuple[str, str]]) -> None:
"""Idempotent upsert of (filehash, post_id) 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(PatreonSeenMedia).values(values)
stmt = stmt.on_conflict_do_nothing(
constraint="uq_patreon_seen_media_source_id"
)
session.execute(stmt)
session.commit()
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:
return None, (
"Couldn't resolve the Patreon campaign id from the source URL — "
"can't verify (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)
-45
View File
@@ -19,22 +19,12 @@ import asyncio
import http.cookiejar
import logging
import os
import re
import requests
log = logging.getLogger(__name__)
_CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns"
# 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.
_ID_URL_RE = re.compile(r"/id:(\d+)")
_VANITY_RE = re.compile(
r"^https?://(?:www\.)?patreon\.com/(?:c/)?(?!id:)([^/?#]+)",
re.IGNORECASE,
)
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
@@ -110,38 +100,3 @@ async def resolve_campaign_id(
Never raises."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _sync_lookup, vanity, 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
vanity lookup against the campaigns API. Returns
`(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None ONLY when
a vanity lookup actually ran, so the caller knows to cache 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
vanity = extract_vanity(url)
if vanity:
resolved = await resolve_campaign_id(vanity, cookies_path)
return resolved, resolved
return None, None
+140
View File
@@ -0,0 +1,140 @@
"""FC-3b platforms registry — the single source of truth for what
FabledCurator supports.
Lifted from GallerySubscriber's
~/Nextcloud/Projects/GallerySubscriber/backend/app/api/platforms.py
and ~/.../extension/lib/platforms.js. Six platforms; auth_type and
URL patterns match GS exactly so the existing browser extension
hits FC unmodified.
"""
from dataclasses import dataclass
from typing import Literal
@dataclass(frozen=True)
class PlatformInfo:
key: str
name: str
description: str
auth_type: Literal["cookies", "token"]
requires_auth: bool
url_pattern: str
url_examples: list[str]
default_config: dict
notes: str | None = None
# Common defaults used across most platforms; embedded per-platform
# below so per-platform overrides remain explicit.
_DEFAULTS = {
"sleep": 3.0,
"sleep_request": 1.5,
"skip_existing": True,
"save_metadata": True,
"timeout": 3600,
}
PLATFORMS: dict[str, PlatformInfo] = {
"patreon": PlatformInfo(
key="patreon",
name="Patreon",
description="Download posts from Patreon creators",
auth_type="cookies",
requires_auth=True,
url_pattern=r"^https?://(www\.)?patreon\.com/",
url_examples=[
"https://www.patreon.com/example_artist",
"https://www.patreon.com/user?u=12345678",
],
default_config={**_DEFAULTS, "content_types": ["images", "attachments"]},
),
"subscribestar": PlatformInfo(
key="subscribestar",
name="SubscribeStar",
description="Download posts from SubscribeStar creators",
auth_type="cookies",
requires_auth=True,
url_pattern=r"^https?://(www\.)?subscribestar\.(com|adult)/",
url_examples=[
"https://subscribestar.adult/example_artist",
"https://www.subscribestar.com/example_artist",
],
default_config={**_DEFAULTS, "content_types": ["all"]},
),
"hentaifoundry": PlatformInfo(
key="hentaifoundry",
name="Hentai Foundry",
description="Download artwork from Hentai Foundry artists",
auth_type="cookies",
requires_auth=False,
url_pattern=r"^https?://(www\.)?hentai-foundry\.com/",
url_examples=[
"https://www.hentai-foundry.com/user/example_artist",
"https://www.hentai-foundry.com/pictures/user/example_artist",
],
default_config={**_DEFAULTS, "content_types": ["pictures"]},
),
"discord": PlatformInfo(
key="discord",
name="Discord",
description="Download attachments from Discord channels",
auth_type="token",
requires_auth=True,
url_pattern=r"^https?://(www\.)?discord\.com/channels/",
url_examples=["https://discord.com/channels/123456789/987654321"],
default_config={**_DEFAULTS, "content_types": ["all"]},
notes="Requires Discord user token (not bot token).",
),
"pixiv": PlatformInfo(
key="pixiv",
name="Pixiv",
description="Download artwork from Pixiv artists",
auth_type="token",
requires_auth=True,
url_pattern=r"^https?://(www\.)?pixiv\.net/",
url_examples=[
"https://www.pixiv.net/users/12345678",
"https://www.pixiv.net/en/users/12345678",
],
default_config={**_DEFAULTS, "content_types": ["all"]},
notes="Requires OAuth refresh token. Run `gallery-dl oauth:pixiv` to obtain one.",
),
"deviantart": PlatformInfo(
key="deviantart",
name="DeviantArt",
description="Download artwork from DeviantArt artists",
auth_type="cookies",
requires_auth=False,
url_pattern=r"^https?://(www\.)?deviantart\.com/",
url_examples=[
"https://www.deviantart.com/example-artist",
"https://www.deviantart.com/example-artist/gallery",
],
default_config={**_DEFAULTS, "content_types": ["gallery"]},
),
}
def known_platform_keys() -> frozenset[str]:
return frozenset(PLATFORMS.keys())
def auth_type_for(platform: str) -> str | None:
info = PLATFORMS.get(platform)
return info.auth_type if info else None
def to_dict(info: PlatformInfo) -> dict:
return {
"key": info.key,
"name": info.name,
"description": info.description,
"auth_type": info.auth_type,
"requires_auth": info.requires_auth,
"url_pattern": info.url_pattern,
"url_examples": info.url_examples,
"default_config": info.default_config,
"notes": info.notes,
}
@@ -1,95 +0,0 @@
"""FC-3b platforms registry — single source of truth for what
FabledCurator supports + where each platform's quirks live.
Adding a new platform: drop a new module `<platform>.py` next to this
one, declare an `INFO = PlatformInfo(...)`, add the import + entry in
PLATFORMS below. Sidecar parsing, cookie materialization, and
`/api/platforms` pick it up automatically.
Lifted from GallerySubscriber's
~/Nextcloud/Projects/GallerySubscriber/backend/app/api/platforms.py
and ~/.../extension/lib/platforms.js. Six platforms; auth_type and
URL patterns match GS exactly so the existing browser extension
hits FC unmodified.
"""
from .base import (
DEFAULT_DESCRIPTION_KEYS,
DEFAULT_EXTERNAL_POST_ID_KEYS,
PlatformInfo,
)
from .deviantart import INFO as _DEVIANTART
from .discord import INFO as _DISCORD
from .hentaifoundry import INFO as _HENTAIFOUNDRY
from .patreon import INFO as _PATREON
from .pixiv import INFO as _PIXIV
from .subscribestar import INFO as _SUBSCRIBESTAR
PLATFORMS: dict[str, PlatformInfo] = {
info.key: info
for info in (
_PATREON,
_SUBSCRIBESTAR,
_HENTAIFOUNDRY,
_DISCORD,
_PIXIV,
_DEVIANTART,
)
}
def known_platform_keys() -> frozenset[str]:
return frozenset(PLATFORMS.keys())
def auth_type_for(platform: str) -> str | None:
info = PLATFORMS.get(platform)
return info.auth_type if info else None
def to_dict(info: PlatformInfo) -> dict:
"""Serialize a PlatformInfo to a JSON-safe dict for /api/platforms.
Behavioral fields (callables, sidecar-chain overrides) are
intentionally omitted — they aren't useful to API consumers.
"""
return {
"key": info.key,
"name": info.name,
"description": info.description,
"auth_type": info.auth_type,
"requires_auth": info.requires_auth,
"url_pattern": info.url_pattern,
"url_examples": info.url_examples,
"default_config": info.default_config,
"notes": info.notes,
}
def external_post_id_keys_for(platform: str | None) -> tuple[str, ...]:
"""Resolve the external_post_id lookup chain for a given platform,
falling back to the module default when the platform isn't
registered or hasn't overridden the chain."""
info = PLATFORMS.get(platform) if platform else None
if info is not None and info.external_post_id_keys is not None:
return info.external_post_id_keys
return DEFAULT_EXTERNAL_POST_ID_KEYS
def description_keys_for(platform: str | None) -> tuple[str, ...]:
"""Resolve the description body lookup chain for a given platform."""
info = PLATFORMS.get(platform) if platform else None
if info is not None and info.description_keys is not None:
return info.description_keys
return DEFAULT_DESCRIPTION_KEYS
__all__ = [
"PLATFORMS",
"PlatformInfo",
"auth_type_for",
"description_keys_for",
"external_post_id_keys_for",
"known_platform_keys",
"to_dict",
]
-107
View File
@@ -1,107 +0,0 @@
"""PlatformInfo dataclass + shared defaults + small helpers.
Per-platform modules import from here, register their PlatformInfo via
INFO, optionally attaching `derive_post_url` and/or `augment_cookies`
callables for behavior that diverges from gallery-dl's mainline shape
(Patreon).
Adding a new platform: drop a new module under `services/platforms/`,
declare an INFO, and add it to the import list in
`services/platforms/__init__.py`. Sidecar parsing, cookie
materialization, and the /api/platforms response pick it up
automatically.
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal
# Sidecar parsing defaults. Per-platform PlatformInfo entries can
# override these by setting `external_post_id_keys=` /
# `description_keys=`. Most don't need to — the defaults already cover
# every platform FC supports.
#
# external_post_id chain: `post_id` MUST come before `id` because
# SubscribeStar gallery-dl puts the per-attachment id in `id` and the
# actual post id in `post_id`; picking `id` first fragments
# multi-image SubscribeStar posts into N Post rows. Patreon/Pixiv have
# no `post_id` so `id` still wins for them; HF uses `index`, Discord
# uses `message_id` — all reached via the remaining chain entries.
# (Banked 2026-05-27 during the sidecar audit.)
DEFAULT_EXTERNAL_POST_ID_KEYS: tuple[str, ...] = (
"post_id", "id", "index", "message_id",
)
# Description body chain: Discord's gallery-dl extractor uses `message`
# (no `content`); appended to the chain so Discord posts surface body
# text.
DEFAULT_DESCRIPTION_KEYS: tuple[str, ...] = (
"content", "description", "caption", "message",
)
@dataclass(frozen=True)
class PlatformInfo:
# --- Identity / metadata ---
key: str
name: str
description: str
auth_type: Literal["cookies", "token"]
requires_auth: bool
url_pattern: str
url_examples: list[str]
default_config: dict
notes: str | None = None
# --- Sidecar parsing overrides ---
# Each is None to mean "use the module default above"; a platform
# only sets one of these when its sidecar shape genuinely differs.
external_post_id_keys: tuple[str, ...] | None = None
description_keys: tuple[str, ...] | None = None
# --- Behavioral hooks ---
# Synthesize a post permalink from sidecar data. Required when
# gallery-dl's `url` field is the file/CDN URL rather than the post
# permalink (subscribestar/pixiv/hf/discord). None = trust the bare
# `url` field (patreon, deviantart).
derive_post_url: Callable[[dict], str | None] | None = None
# Post-process the materialized cookies.txt for gallery-dl. Used by
# platforms whose server gates or extractor quirks need synthetic
# cookies the extension can't capture (subscribestar age cookie, HF
# host-only PHPSESSID duplicate). None = no-op.
augment_cookies: Callable[[str], str] | None = None
def str_id_value(v) -> str | None:
"""Coerce a JSON scalar id into a non-empty string, rejecting bool
(Python's bool is an int subclass so `isinstance(True, int)` is
True; without this guard a sidecar with `"id": true` would produce
external_post_id="True")."""
if isinstance(v, bool):
return None
if isinstance(v, (str, int)) and str(v).strip():
return str(v).strip()
return None
def str_field(v) -> str | None:
"""Same idea as str_id_value but for plain string fields (no int
coercion)."""
if isinstance(v, str) and v.strip():
return v.strip()
return None
# Shared gallery-dl invocation defaults. Embedded in each platform's
# default_config (with platform-specific overrides) so per-platform
# 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 = {
"sleep": 3.0,
"sleep_request": 1.5,
"save_metadata": True,
"timeout": 3600,
}
@@ -1,23 +0,0 @@
"""DeviantArt — no exercised quirks yet.
No operator-owned DeviantArt archive existed at the 2026-05-27 sidecar
audit, so we don't know yet whether DA's gallery-dl sidecars are
well-behaved or have their own quirks. When DA gets exercised for the
first time, add `derive_post_url` / `augment_cookies` here as needed.
"""
from .base import GD_DEFAULTS, PlatformInfo
INFO = PlatformInfo(
key="deviantart",
name="DeviantArt",
description="Download artwork from DeviantArt artists",
auth_type="cookies",
requires_auth=False,
url_pattern=r"^https?://(www\.)?deviantart\.com/",
url_examples=[
"https://www.deviantart.com/example-artist",
"https://www.deviantart.com/example-artist/gallery",
],
default_config={**GD_DEFAULTS, "content_types": ["gallery"]},
)
-38
View File
@@ -1,38 +0,0 @@
"""Discord — one quirk + one already-default.
post_url: gallery-dl's `url` is the CDN attachment URL. The "permalink"
for a Discord message uses the (server, channel, message) triple via
`discord.com/channels/<server>/<channel>/<message>`. Note that
permalinks are only resolvable for users in the same server — public
access doesn't work — but the URL is still useful to the operator
in-app.
Description body is in `message` not `content`. That's already covered
by the default description chain in base.py (DEFAULT_DESCRIPTION_KEYS
ends with `message`). No description_keys override needed.
"""
from .base import GD_DEFAULTS, PlatformInfo, str_id_value
def derive_post_url(data: dict) -> str | None:
sid = str_id_value(data.get("server_id"))
cid = str_id_value(data.get("channel_id"))
mid = str_id_value(data.get("message_id"))
if sid and cid and mid:
return f"https://discord.com/channels/{sid}/{cid}/{mid}"
return None
INFO = PlatformInfo(
key="discord",
name="Discord",
description="Download attachments from Discord channels",
auth_type="token",
requires_auth=True,
url_pattern=r"^https?://(www\.)?discord\.com/channels/",
url_examples=["https://discord.com/channels/123456789/987654321"],
default_config={**GD_DEFAULTS, "content_types": ["all"]},
notes="Requires Discord user token (not bot token).",
derive_post_url=derive_post_url,
)
@@ -1,83 +0,0 @@
"""HentaiFoundry — two quirks colocated.
1. post_url: HF sidecars omit `url` entirely; `src` is the image URL.
Synthesize the permalink from `user` + `index`
(/pictures/user/<user>/<index>).
2. augment_cookies: gallery-dl's HF extractor checks
`self.cookies.get("PHPSESSID", domain="www.hentai-foundry.com")` with
`requests`' EXACT domain matching. The extension's pre-v1.0.5
`cookies.js` aggressively rewrote every captured cookie to the
leading-dot subdomain-wide form (`.hentai-foundry.com`), which fails
the exact lookup even though the cookie IS sent on actual HTTP
requests (RFC 6265 subdomain matching). The extractor falls into
an unauthenticated `?enterAgree=1` HEAD that 401s. Inject host-only
duplicates of PHPSESSID + YII_CSRF_TOKEN so the lookup succeeds.
"""
from .base import GD_DEFAULTS, PlatformInfo, str_field, str_id_value
_HOST_ONLY_NAMES = ("PHPSESSID", "YII_CSRF_TOKEN")
def derive_post_url(data: dict) -> str | None:
user = str_field(data.get("user")) or str_field(data.get("artist"))
idx = str_id_value(data.get("index"))
if user and idx:
return f"https://www.hentai-foundry.com/pictures/user/{user}/{idx}"
return None
def augment_cookies(netscape: str) -> str:
body = netscape.rstrip("\n")
if not body:
return netscape
lines = body.split("\n")
existing_host_only: set[str] = set()
by_name: dict[str, list[str]] = {}
for raw in lines:
if not raw or raw.startswith("#"):
continue
parts = raw.split("\t")
if len(parts) < 7:
continue
domain, _flag, _path, _secure, _exp, name, _value = parts[:7]
if name not in _HOST_ONLY_NAMES:
continue
if domain == "www.hentai-foundry.com":
existing_host_only.add(name)
elif domain in (".hentai-foundry.com", "hentai-foundry.com"):
by_name.setdefault(name, []).append(raw)
appended: list[str] = []
for name in _HOST_ONLY_NAMES:
if name in existing_host_only or name not in by_name:
continue
# Duplicate the first subdomain-wide line as host-only on
# www.hentai-foundry.com. Same value + expiry; flag=FALSE marks
# the entry host-only in netscape format.
parts = by_name[name][0].split("\t")
parts[0] = "www.hentai-foundry.com"
parts[1] = "FALSE"
appended.append("\t".join(parts[:7]))
if not appended:
return netscape
return body + "\n" + "\n".join(appended) + "\n"
INFO = PlatformInfo(
key="hentaifoundry",
name="Hentai Foundry",
description="Download artwork from Hentai Foundry artists",
auth_type="cookies",
requires_auth=False,
url_pattern=r"^https?://(www\.)?hentai-foundry\.com/",
url_examples=[
"https://www.hentai-foundry.com/user/example_artist",
"https://www.hentai-foundry.com/pictures/user/example_artist",
],
default_config={**GD_DEFAULTS, "content_types": ["pictures"]},
derive_post_url=derive_post_url,
augment_cookies=augment_cookies,
)
-23
View File
@@ -1,23 +0,0 @@
"""Patreon — no quirks. The reference platform.
Patreon's gallery-dl sidecars are the well-behaved baseline: `url` is a
real permalink, `id` is the post id, `title` and `content` are
populated. No cookie quirks (session cookies are domain-wide). No
derivation overrides.
"""
from .base import GD_DEFAULTS, PlatformInfo
INFO = PlatformInfo(
key="patreon",
name="Patreon",
description="Download posts from Patreon creators",
auth_type="cookies",
requires_auth=True,
url_pattern=r"^https?://(www\.)?patreon\.com/",
url_examples=[
"https://www.patreon.com/example_artist",
"https://www.patreon.com/user?u=12345678",
],
default_config={**GD_DEFAULTS, "content_types": ["images", "attachments"]},
)
-32
View File
@@ -1,32 +0,0 @@
"""Pixiv — one quirk.
post_url: gallery-dl's `url` is the image URL on `i.pximg.net`. The
post permalink follows /artworks/<id>. external_post_id (= `id`) was
already correct, so no override there.
"""
from .base import GD_DEFAULTS, PlatformInfo, str_id_value
def derive_post_url(data: dict) -> str | None:
pid = str_id_value(data.get("id"))
if pid:
return f"https://www.pixiv.net/artworks/{pid}"
return None
INFO = PlatformInfo(
key="pixiv",
name="Pixiv",
description="Download artwork from Pixiv artists",
auth_type="token",
requires_auth=True,
url_pattern=r"^https?://(www\.)?pixiv\.net/",
url_examples=[
"https://www.pixiv.net/users/12345678",
"https://www.pixiv.net/en/users/12345678",
],
default_config={**GD_DEFAULTS, "content_types": ["all"]},
notes="Requires OAuth refresh token. Run `gallery-dl oauth:pixiv` to obtain one.",
derive_post_url=derive_post_url,
)
@@ -1,62 +0,0 @@
"""SubscribeStar — three quirks colocated.
1. external_post_id: gallery-dl puts the per-attachment id in `id`
(e.g. 711509) and the actual post id in `post_id` (e.g. 360360).
The default chain in base.py already prefers `post_id`; this module
doesn't need to override it but the comment lives here too so a
future reader knows the chain's order was driven by this platform.
2. post_url: gallery-dl's `url` is the file CDN URL
(`/post_uploads?payload=...`). Synthesize the post permalink from
`post_id`.
3. augment_cookies: the server gates artist pages behind a
`_personalization_id` age-confirmation cookie that the user can't
easily refresh — SubscribeStar's frontend JS uses localStorage to
suppress the age popup once dismissed. gallery-dl's own login flow
sidesteps this by setting `18_plus_agreement_generic=true` on
`.subscribestar.adult`; we mirror that for cookies captured via the
extension.
"""
from .base import GD_DEFAULTS, PlatformInfo, str_id_value
def derive_post_url(data: dict) -> str | None:
pid = str_id_value(data.get("post_id"))
if pid:
return f"https://www.subscribestar.com/posts/{pid}"
return None
def augment_cookies(netscape: str) -> str:
if "18_plus_agreement_generic" in netscape:
return netscape
# Far-future expiry — gallery-dl's own login flow sets this with no
# explicit expiry; the server only checks presence/value.
expiry = 4102444800 # 2100-01-01 UTC
line = "\t".join([
".subscribestar.adult", "TRUE", "/", "TRUE",
str(expiry), "18_plus_agreement_generic", "true",
])
body = netscape.rstrip("\n")
if not body:
body = "# Netscape HTTP Cookie File"
return body + "\n" + line + "\n"
INFO = PlatformInfo(
key="subscribestar",
name="SubscribeStar",
description="Download posts from SubscribeStar creators",
auth_type="cookies",
requires_auth=True,
url_pattern=r"^https?://(www\.)?subscribestar\.(com|adult)/",
url_examples=[
"https://subscribestar.adult/example_artist",
"https://www.subscribestar.com/example_artist",
],
default_config={**GD_DEFAULTS, "content_types": ["all"]},
derive_post_url=derive_post_url,
augment_cookies=augment_cookies,
)
+20 -99
View File
@@ -56,67 +56,38 @@ class PostFeedService:
artist_id: int | None = None,
platform: str | None = None,
limit: int = 24,
direction: str = "older",
) -> dict:
"""Paginate the feed from `cursor`. direction='older' walks back in
time (default, infinite-scroll down); direction='newer' walks forward
(scroll up in an anchored view). Items are always returned in feed
(descending) order; `next_cursor` points to the far edge in the
requested direction (null when exhausted)."""
if limit < 1 or limit > 100:
raise ValueError("limit must be between 1 and 100")
if direction not in ("older", "newer"):
raise ValueError("direction must be 'older' or 'newer'")
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 = (
select(Post, Artist, Source)
.join(Artist, Post.artist_id == Artist.id)
.outerjoin(Source, Post.source_id == Source.id)
.join(Source, Post.source_id == Source.id)
.join(Artist, Source.artist_id == Artist.id)
)
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:
stmt = stmt.where(Source.platform == platform)
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
if direction == "older":
stmt = stmt.where(or_(
stmt = stmt.where(
or_(
sort_key < cur_ts,
and_(sort_key == cur_ts, Post.id < cur_id),
))
else:
stmt = stmt.where(or_(
sort_key > cur_ts,
and_(sort_key == cur_ts, Post.id > cur_id),
))
)
)
if direction == "older":
stmt = stmt.order_by(sort_key.desc(), Post.id.desc())
else:
stmt = stmt.order_by(sort_key.asc(), Post.id.asc())
stmt = stmt.limit(limit + 1)
stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1)
rows = (await self.session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
if direction == "newer":
# Fetched ascending (closest-newer first); flip to feed order.
rows = list(reversed(rows))
next_cursor: str | None = None
if has_more and rows:
# Far edge in the travel direction: oldest row going older,
# newest row going newer (rows is descending for display).
edge_post = rows[-1][0] if direction == "older" else rows[0][0]
edge_key = edge_post.post_date or edge_post.downloaded_at
next_cursor = encode_cursor(edge_key, edge_post.id)
if len(rows) > limit:
last_post, _, _ = rows[limit - 1]
last_key = last_post.post_date or last_post.downloaded_at
next_cursor = encode_cursor(last_key, last_post.id)
rows = rows[:limit]
post_ids = [p.id for p, _, _ in rows]
thumbs_map = await self._thumbnails_for(post_ids)
@@ -128,54 +99,11 @@ class PostFeedService:
]
return {"items": items, "next_cursor": next_cursor}
async def around(
self,
*,
post_id: int,
artist_id: int | None = None,
platform: str | None = None,
limit: int = 12,
) -> dict | None:
"""A window centered on `post_id`: up to `limit` newer posts + the
post + up to `limit` older posts, in feed (descending) order, with a
cursor for each end. Returns None if the post doesn't exist."""
anchor = (await self.session.execute(
select(Post, Artist, Source)
.join(Artist, Post.artist_id == Artist.id)
.outerjoin(Source, Post.source_id == Source.id)
.where(Post.id == post_id)
)).one_or_none()
if anchor is None:
return None
anchor_post, anchor_artist, anchor_source = anchor
anchor_key = anchor_post.post_date or anchor_post.downloaded_at
anchor_cursor = encode_cursor(anchor_key, anchor_post.id)
older = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="older",
)
newer = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="newer",
)
thumbs_map = await self._thumbnails_for([anchor_post.id])
atts_map = await self._attachments_for([anchor_post.id])
anchor_item = self._to_dict(
anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map,
)
return {
"items": newer["items"] + [anchor_item] + older["items"],
"cursor_older": older["next_cursor"],
"cursor_newer": newer["next_cursor"],
"anchor_id": anchor_post.id,
}
async def get_post(self, post_id: int) -> dict | None:
row = (await self.session.execute(
select(Post, Artist, Source)
.join(Artist, Post.artist_id == Artist.id)
.outerjoin(Source, Post.source_id == Source.id)
.join(Source, Post.source_id == Source.id)
.join(Artist, Source.artist_id == Artist.id)
.where(Post.id == post_id)
)).one_or_none()
if row is None:
@@ -213,7 +141,6 @@ class PostFeedService:
ImageRecord.primary_post_id,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.thumbnail_path,
func.row_number().over(
partition_by=ImageRecord.primary_post_id,
order_by=ImageRecord.id.asc(),
@@ -227,18 +154,18 @@ class PostFeedService:
)
stmt = select(
ranked.c.id, ranked.c.primary_post_id,
ranked.c.sha256, ranked.c.mime, ranked.c.thumbnail_path, ranked.c.total,
ranked.c.sha256, ranked.c.mime, ranked.c.total,
)
if limit is not None:
stmt = stmt.where(ranked.c.rn <= limit)
rows = (await self.session.execute(stmt)).all()
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
for img_id, pid, sha, mime, tp, total in rows:
for img_id, pid, sha, mime, total in rows:
entry = out.setdefault(pid, {"thumbs": [], "more": 0})
entry["thumbs"].append({
"image_id": img_id,
"thumbnail_url": thumbnail_url(tp, sha, mime),
"thumbnail_url": thumbnail_url(sha, mime),
"mime": mime,
})
# `total` is constant per partition; overflow = total - THUMBNAIL_LIMIT.
@@ -266,7 +193,7 @@ class PostFeedService:
return out
def _to_dict(
self, post: Post, artist: Artist, source: Source | None,
self, post: Post, artist: Artist, source: Source,
thumbs_map: dict, atts_map: dict,
) -> dict:
plain_full = html_to_plain(post.description) if post.description else None
@@ -275,9 +202,6 @@ class PostFeedService:
else:
description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT)
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 {
"id": post.id,
"external_post_id": post.external_post_id,
@@ -288,10 +212,7 @@ class PostFeedService:
"description_plain": description_plain,
"description_truncated": truncated,
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
"source": (
{"id": source.id, "platform": source.platform}
if source is not None else None
),
"source": {"id": source.id, "platform": source.platform},
"thumbnails": thumbs_entry["thumbs"],
"thumbnails_more": thumbs_entry["more"],
"attachments": atts_map.get(post.id, []),
+6 -12
View File
@@ -70,15 +70,11 @@ class ProvenanceService:
rec = await self.session.get(ImageRecord, image_id)
if rec is 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 = (
select(ImageProvenance, Post, Source, Artist)
.join(Post, Post.id == ImageProvenance.post_id)
.join(Artist, Artist.id == Post.artist_id)
.outerjoin(Source, Source.id == ImageProvenance.source_id)
.join(Source, Source.id == ImageProvenance.source_id)
.join(Artist, Artist.id == Source.artist_id)
.where(ImageProvenance.image_record_id == image_id)
.order_by(ImageProvenance.captured_at.asc(),
ImageProvenance.id.asc())
@@ -94,7 +90,7 @@ class ProvenanceService:
"captured_at": ip.captured_at.isoformat()
if ip.captured_at else None,
"post": _post_dict(post),
"source": _source_dict(src) if src is not None else None,
"source": _source_dict(src),
"artist": _artist_dict(art),
}
for ip, post, src, art in rows
@@ -103,12 +99,10 @@ class ProvenanceService:
}
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 = (
select(Post, Source, Artist)
.join(Artist, Artist.id == Post.artist_id)
.outerjoin(Source, Source.id == Post.source_id)
.join(Source, Source.id == Post.source_id)
.join(Artist, Artist.id == Source.artist_id)
.where(Post.id == post_id)
)
row = (await self.session.execute(stmt)).first()
@@ -117,7 +111,7 @@ class ProvenanceService:
post, src, art = row
return {
"post": _post_dict(post),
"source": _source_dict(src) if src is not None else None,
"source": _source_dict(src),
"artist": _artist_dict(art),
"attachments": await self._attachments_for_posts([post.id]),
}
-106
View File
@@ -1,106 +0,0 @@
"""Layer-2 one-shot re-download remediation for corrupt imported files.
When an import fails on a file that came from a known, pollable
subscription Source, deleting the bad copy and re-running the source's
downloader can fetch a fresh, unblemished copy. This only helps when:
- the corruption is in transit / on disk (not at the source), AND
- the file resolves to an ENABLED Source with a real feed URL
(a `sidecar:<platform>:<slug>` synthetic anchor is not pollable),
AND
- we haven't already re-fetched this task once (bounded by
ImportTask.refetched so source-side corruption can't loop).
Filesystem-only imports with no resolvable Source return 'no_source'
the operator's only remediation there is to replace the file on disk.
Operator-requested 2026-05-28 (Layer 2).
"""
import json
import logging
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import Artist, ImportTask, Source
from ..utils.paths import derive_top_level_artist
from ..utils.sidecar import find_sidecar, parse_sidecar
from ..utils.slug import slugify
log = logging.getLogger(__name__)
def resolve_refetch_source(
session: Session, source_path: str, import_root: Path,
) -> Source | None:
"""Find an enabled, real-URL Source for the file's (artist, platform),
or None when nothing re-pollable resolves."""
path = Path(source_path)
sc = find_sidecar(path)
if sc is None:
return None
try:
data = json.loads(sc.read_text("utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
sd = parse_sidecar(data)
if not sd.platform:
return None
artist_name = derive_top_level_artist(path, import_root)
if not artist_name:
return None
artist = session.execute(
select(Artist).where(Artist.slug == slugify(artist_name))
).scalar_one_or_none()
if artist is None:
return None
src = session.execute(
select(Source)
.where(
Source.artist_id == artist.id,
Source.platform == sd.platform,
Source.enabled.is_(True),
)
.order_by(Source.id.asc())
).scalars().first()
if src is None:
return None
if (src.url or "").startswith("sidecar:"):
return None # synthetic anchor — not a pollable feed
return src
def attempt_refetch(
session: Session, task: ImportTask, import_root: Path,
) -> dict:
"""Delete the corrupt file, mark the task refetched, and trigger ONE
source re-check. Idempotent/bounded: a task already refetched (or
with no resolvable Source) is a no-op. Commits."""
if task.refetched:
return {"status": "already_refetched"}
src = resolve_refetch_source(session, task.source_path, import_root)
if src is None:
return {"status": "no_source"}
# Remove the bad copy so gallery-dl's archive-skip re-fetches it on
# the source re-check instead of skipping the still-present corrupt
# file.
try:
Path(task.source_path).unlink(missing_ok=True)
except OSError as exc:
log.warning("refetch unlink failed for %s: %s", task.source_path, exc)
task.refetched = True
session.add(task)
session.commit()
# Lazy import to avoid a tasks→services→tasks import cycle at module
# load. download_source.delay() is sync-safe in any context.
from ..tasks.download import download_source
download_source.delay(src.id)
return {"status": "refetch_queued", "source_id": src.id}
+5 -153
View File
@@ -9,33 +9,15 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models import AppSetting, Artist, ImportSettings, Source
from ..models import Artist, ImportSettings, Source
MIN_INTERVAL_SECONDS = 60
MAX_INTERVAL_SECONDS = 86400
MAX_BACKOFF_EXPONENT = 6
# AppSetting key stamped every time the Beat tick fires (see scan.py). The
# tick runs every 60s; the UI flags the scheduler as stalled if the last
# stamp is older than a few minutes.
SCHEDULER_LAST_TICK_KEY = "scheduler_last_tick_at"
# AppSetting key prefix for per-platform rate-limit cooldowns. When a
# download surfaces ErrorType.RATE_LIMITED, every other source on the same
# platform is deferred for PLATFORM_RATE_LIMIT_COOLDOWN_SECONDS so the next
# scan tick doesn't fire a burst of due same-platform sources back into the
# same limit. Per-source consecutive_failures backoff still applies on top
# of this — but this is PREVENTIVE (kills the same-tick burst from N due
# sources hammering the platform at once), while consecutive_failures is
# REACTIVE (slows the offender down over many cycles). Operator-confirmed
# 2026-05-30.
PLATFORM_COOLDOWN_KEY_PREFIX = "platform_cooldown:"
PLATFORM_RATE_LIMIT_COOLDOWN_SECONDS = 900 # 15 min
def compute_effective_interval(
source: Source, artist: Artist, settings: ImportSettings,
@@ -58,76 +40,10 @@ def compute_effective_interval(
return max(MIN_INTERVAL_SECONDS, min(MAX_INTERVAL_SECONDS, raw))
async def set_platform_cooldown(
session: AsyncSession, platform: str,
seconds: int = PLATFORM_RATE_LIMIT_COOLDOWN_SECONDS,
) -> None:
"""Stamp a cooldown expiry on the given platform so select_due_sources
skips every source on that platform until it expires.
Called when a download surfaces ErrorType.RATE_LIMITED so the other
sources on the same platform don't all retry into the same rate limit.
Caller is responsible for committing the session.
Uses INSERT...ON CONFLICT DO UPDATE so two concurrent workers hitting
the same platform's rate limit don't race: a SELECT-then-INSERT pattern
would let the loser's whole transaction (including the source-health
update + event finalize) roll back on a unique-violation, stranding
that event. Atomic upsert avoids that.
"""
now = datetime.now(UTC)
expires_at = (now + timedelta(seconds=seconds)).isoformat()
key = f"{PLATFORM_COOLDOWN_KEY_PREFIX}{platform}"
stmt = pg_insert(AppSetting.__table__).values(
key=key, value=expires_at, updated_at=now,
).on_conflict_do_update(
index_elements=["key"],
set_={"value": expires_at, "updated_at": now},
)
await session.execute(stmt)
async def active_platform_cooldowns(session: AsyncSession) -> dict[str, datetime]:
"""Return {platform: expires_at} for platforms whose cooldown is still
in the future. Expired rows are ignored (a future maintenance sweep can
delete them; they don't affect routing decisions on their own).
Exposed beyond scheduler_service so the manual check endpoint
(`/api/sources/<id>/check`) can defer bulk retries that would bowl
into the same rate limit the cooldown is preventing.
"""
rows = (await session.execute(
select(AppSetting.key, AppSetting.value)
.where(AppSetting.key.startswith(PLATFORM_COOLDOWN_KEY_PREFIX))
)).all()
if not rows:
return {}
now = datetime.now(UTC)
active: dict[str, datetime] = {}
for key, value in rows:
try:
expires_at = datetime.fromisoformat(value)
except (ValueError, TypeError):
continue
if expires_at > now:
active[key[len(PLATFORM_COOLDOWN_KEY_PREFIX):]] = expires_at
return active
async def select_due_sources(session: AsyncSession) -> list[Source]:
"""Sources where (enabled, artist.auto_check) and now >= last_checked_at + effective_interval.
Never-checked sources (last_checked_at IS NULL) are always due. Sources
whose platform is currently in a rate-limit cooldown are excluded — the
cooldown is the preventive half of the burst-prevention pair (per-source
consecutive_failures backoff handles the offending source itself).
Ordering: last_checked_at ASC NULLS FIRST, then id. Never-checked
sources go first, then the longest-since-checked, so the most overdue
sources hit Celery's FIFO download queue first. Anti-starvation: if
queue throughput ever falls below the tick rate, a freshly-rerun source
can't keep cutting in line ahead of one that hasn't been checked at all.
Operator-confirmed 2026-05-30.
Never-checked sources (last_checked_at IS NULL) are always due.
"""
rows = (await session.execute(
select(Source)
@@ -135,17 +51,15 @@ async def select_due_sources(session: AsyncSession) -> list[Source]:
.join(Artist, Source.artist_id == Artist.id)
.where(Source.enabled.is_(True))
.where(Artist.auto_check.is_(True))
.order_by(Source.last_checked_at.asc().nulls_first(), Source.id)
)).scalars().all()
cooldowns = await active_platform_cooldowns(session)
settings = await ImportSettings.load(session)
settings = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
now = datetime.now(UTC)
due: list[Source] = []
for s in rows:
if s.platform in cooldowns:
continue
interval = compute_effective_interval(s, s.artist, settings)
if s.last_checked_at is None:
due.append(s)
@@ -164,65 +78,3 @@ def compute_next_check_at(
return None
interval = compute_effective_interval(source, artist, settings)
return source.last_checked_at + timedelta(seconds=interval)
async def record_tick(session: AsyncSession) -> None:
"""Stamp the current time on the SCHEDULER_LAST_TICK_KEY AppSetting.
Called once per Beat tick so the UI can prove the scheduler is alive.
Commits its own write so the stamp survives even if the rest of the
tick errors out.
"""
now_iso = datetime.now(UTC).isoformat()
row = (await session.execute(
select(AppSetting).where(AppSetting.key == SCHEDULER_LAST_TICK_KEY)
)).scalar_one_or_none()
if row is None:
session.add(AppSetting(key=SCHEDULER_LAST_TICK_KEY, value=now_iso))
else:
row.value = now_iso
await session.commit()
async def scheduler_status(session: AsyncSession) -> dict:
"""Summarise scheduler health for the dashboard.
Returns last_tick_at (when Beat last fired), next_due_at (earliest
upcoming scheduled check across enabled auto-check sources), due_now
(how many are due right now), and auto_sources (total under schedule).
"""
last_tick_at = (await session.execute(
select(AppSetting.value).where(AppSetting.key == SCHEDULER_LAST_TICK_KEY)
)).scalar_one_or_none()
rows = (await session.execute(
select(Source)
.options(selectinload(Source.artist))
.join(Artist, Source.artist_id == Artist.id)
.where(Source.enabled.is_(True))
.where(Artist.auto_check.is_(True))
)).scalars().all()
settings = await ImportSettings.load(session)
now = datetime.now(UTC)
due_now = 0
next_due_at: datetime | None = None
for s in rows:
if s.last_checked_at is None:
due_now += 1
continue
nca = compute_next_check_at(s, s.artist, settings)
if nca is None or nca <= now:
due_now += 1
elif next_due_at is None or nca < next_due_at:
next_due_at = nca
cooldowns = await active_platform_cooldowns(session)
return {
"last_tick_at": last_tick_at,
"next_due_at": next_due_at.isoformat() if next_due_at else None,
"due_now": due_now,
"auto_sources": len(rows),
"platform_cooldowns": {p: dt.isoformat() for p, dt in cooldowns.items()},
}
+1 -2
View File
@@ -63,7 +63,6 @@ class SeriesService:
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.path,
ImageRecord.thumbnail_path,
)
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
.where(SeriesPage.series_tag_id == series_tag_id)
@@ -76,7 +75,7 @@ class SeriesService:
{
"image_id": r.image_id,
"page_number": r.page_number,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
}
for r in rows
+1 -1
View File
@@ -34,7 +34,7 @@ class ShowcaseService:
"mime": r.mime,
"width": r.width,
"height": r.height,
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
}
for r in rows
]
+9 -125
View File
@@ -59,17 +59,9 @@ class SourceRecord:
config_overrides: dict | None
last_checked_at: str | None
last_error: str | None
error_type: str | None
check_interval_override: int | None
consecutive_failures: int
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
def to_dict(self) -> dict:
return {
@@ -83,14 +75,9 @@ class SourceRecord:
"config_overrides": self.config_overrides,
"last_checked_at": self.last_checked_at,
"last_error": self.last_error,
"error_type": self.error_type,
"check_interval_override": self.check_interval_override,
"consecutive_failures": self.consecutive_failures,
"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,
}
@@ -98,14 +85,6 @@ class SourceRecord:
_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:
def __init__(self, session: AsyncSession):
@@ -141,13 +120,14 @@ class SourceService:
return config
async def _load_settings(self) -> ImportSettings:
return await ImportSettings.load(self.session)
return (await self.session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
def _build_record(
self, source: Source, artist: Artist, settings: ImportSettings,
) -> SourceRecord:
nxt = compute_next_check_at(source, artist, settings)
co = source.config_overrides or {}
return SourceRecord(
id=source.id,
artist_id=source.artist_id,
@@ -159,14 +139,9 @@ class SourceService:
config_overrides=source.config_overrides,
last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None,
last_error=source.last_error,
error_type=source.error_type,
check_interval_override=source.check_interval_override,
consecutive_failures=source.consecutive_failures or 0,
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")),
)
async def _row_to_record(self, source: Source) -> SourceRecord:
@@ -176,27 +151,14 @@ class SourceService:
settings = await self._load_settings()
return self._build_record(source, artist, settings)
async def list(
self, artist_id: int | None = None, failing: bool = False,
include_synthetic: bool = False,
) -> list[SourceRecord]:
stmt = select(Source, Artist).join(Artist, Artist.id == Source.artist_id)
async def list(self, artist_id: int | None = None) -> list[SourceRecord]:
stmt = (
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.order_by(Artist.name.asc(), Source.id.asc())
)
if artist_id is not None:
stmt = stmt.where(Source.artist_id == artist_id)
if not include_synthetic:
# Pre-alembic-0030 sidecar synthetic anchors
# have url='sidecar:<platform>:<slug>' and exist only to give
# imported Posts a NOT-NULL Source FK. They aren't pollable
# feeds; the Subscriptions UI used to render them as phantom
# subscriptions. Hide by default.
stmt = stmt.where(~Source.url.like("sidecar:%"))
if failing:
# Worst-first so the rollup card surfaces the loudest failures.
stmt = stmt.where(Source.consecutive_failures > 0).order_by(
Source.consecutive_failures.desc(), Artist.name.asc(),
)
else:
stmt = stmt.order_by(Artist.name.asc(), Source.id.asc())
rows = (await self.session.execute(stmt)).all()
settings = await self._load_settings()
return [self._build_record(s, a, settings) for s, a in rows]
@@ -228,22 +190,10 @@ class SourceService:
select(func.count(Source.id)).where(Source.artist_id == artist_id)
)).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(
artist_id=artist_id, platform=platform, url=url,
enabled=enabled, config_overrides=config_overrides,
check_interval_override=check_interval_override,
backfill_runs_remaining=backfill_runs,
)
self.session.add(source)
try:
@@ -303,72 +253,6 @@ class SourceService:
await self.session.commit()
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"):
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"):
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"):
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:
source = (await self.session.execute(
select(Source).where(Source.id == source_id)
@@ -115,17 +115,12 @@ class TagDirectoryService:
.subquery()
)
stmt = (
select(
sub.c.tag_id,
ImageRecord.sha256,
ImageRecord.mime,
ImageRecord.thumbnail_path,
)
select(sub.c.tag_id, ImageRecord.sha256, ImageRecord.mime)
.join(ImageRecord, ImageRecord.id == sub.c.image_record_id)
.where(sub.c.rn <= 3)
.order_by(sub.c.tag_id, sub.c.rn)
)
out: dict[int, list[str]] = {}
for tag_id, sha, mime, tp in (await self.session.execute(stmt)).all():
out.setdefault(tag_id, []).append(thumbnail_url(tp, sha, mime))
for tag_id, sha, mime in (await self.session.execute(stmt)).all():
out.setdefault(tag_id, []).append(thumbnail_url(sha, mime))
return out
+7 -87
View File
@@ -5,7 +5,6 @@ from dataclasses import dataclass
from sqlalchemy import and_, case, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Tag, TagKind, image_tag
@@ -87,12 +86,9 @@ class TagService:
f"fandom_id {fandom_id} does not reference a fandom tag"
)
# Audit 2026-06-02: race-safe upsert via savepoint +
# IntegrityError recovery. The partial uniqueness index on
# (name, kind, COALESCE(fandom_id, -1)) catches concurrent
# inserts; without the savepoint the outer transaction would
# poison and the calling request crashes. Mirrors
# importer._get_or_create.
# Upsert via INSERT ... ON CONFLICT DO NOTHING. We can't use the
# uniqueness index name directly (it's a partial coalesce-based
# expression), so we re-select after insert.
stmt = (
select(Tag)
.where(Tag.name == name)
@@ -105,16 +101,10 @@ class TagService:
if existing:
return existing
sp = await self.session.begin_nested()
try:
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
self.session.add(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()
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
self.session.add(new_tag)
await self.session.flush()
return new_tag
async def autocomplete(
self,
@@ -280,69 +270,6 @@ class TagService:
await self.session.flush()
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:
"""Transactionally repoint every FK from source→target, optionally
keep source's name as a tagger alias, delete source. Atomic: any
@@ -361,14 +288,7 @@ class TagService:
raise TagValidationError(
"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)
source_name = source.name
source_kind = source.kind
-30
View File
@@ -1,30 +0,0 @@
"""Per-invocation async session factory for Celery task modules.
Async engine connections are bound to the event loop. Each Celery task
runs its async body under a fresh ``asyncio.run()`` loop, so it needs its
own engine created (and disposed) within that loop — a process-wide async
engine would reuse loop-bound connections across tasks and raise "attached
to a different loop". So unlike the process-wide sync engine in
``_sync_engine.py``, this returns a fresh engine per call; the caller
disposes it (``await engine.dispose()``) when its loop ends.
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from ..config import get_config
def async_session_factory():
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine."""
cfg = get_config()
# NullPool: this engine lives for ONE task (created + disposed per
# 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
-7
View File
@@ -34,10 +34,3 @@ def sync_session_factory():
)
_SESSIONMAKER = sessionmaker(_ENGINE, expire_on_commit=False)
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
+8 -7
View File
@@ -240,16 +240,15 @@ def prune_backups() -> dict:
Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}.
Tagged rows (tag IS NOT NULL) are never pruned.
Status='running' / 'restoring' rows are never pruned — the
recover_stalled_backup_runs sweep flips truly-stuck ones to
'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.)
Status='running' / 'restoring' rows are never pruned (recovery
sweep from FC-3i handles those via task_run).
"""
SessionLocal = _sync_session_factory()
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
with SessionLocal() as session:
s = ImportSettings.load_sync(session)
s = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
for kind, keep in (
("db", s.backup_db_keep_last_n),
("images", s.backup_images_keep_last_n),
@@ -287,7 +286,9 @@ def backup_db_nightly() -> dict:
either {'skipped': '<reason>'} or {'dispatched': '<task_id>'}."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
s = ImportSettings.load_sync(session)
s = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
nightly_enabled = s.backup_db_nightly_enabled
configured_hour = s.backup_db_nightly_hour_utc
if not nightly_enabled:
+17 -100
View File
@@ -1,98 +1,31 @@
"""download_source Celery task — runs DownloadService for one source."""
import asyncio
import logging
from datetime import UTC, datetime
from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError
from sqlalchemy.orm import Session as SyncSession
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from ..celery_app import celery
from ..models import DownloadEvent, ImportSettings, Source
from ..config import get_config
from ..models import ImportSettings
from ..services.credential_crypto import CredentialCrypto
from ..services.credential_service import CredentialService
from ..services.download_service import DownloadService
from ..services.gallery_dl import GalleryDLService
from ..services.importer import Importer
from ..services.thumbnailer import Thumbnailer
from ._async_session import async_session_factory
from .import_file import _sync_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")
_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
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 gallery-dl subprocess returned — the run exceeded its "
"budget and its stdout/stderr were lost with the worker thread. "
"If this recurs, the source is too large for one run; the backfill "
"budget was decremented so the next tick walks less."
)
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)
def _async_session_factory():
cfg = get_config()
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
@celery.task(
@@ -104,18 +37,20 @@ def _finalize_soft_limited(session: SyncSession, source_id: int) -> None:
retry_backoff_max=120,
retry_jitter=True,
max_retries=3,
soft_time_limit=DOWNLOAD_SOFT_TIME_LIMIT,
time_limit=DOWNLOAD_HARD_TIME_LIMIT,
soft_time_limit=900,
time_limit=1200,
)
def download_source(self, source_id: int) -> int:
"""Returns the DownloadEvent.id."""
async def _run():
async_factory, async_engine = async_session_factory()
async_factory, async_engine = _async_session_factory()
SyncFactory = _sync_session_factory()
try:
with SyncFactory() as sync_session:
settings = ImportSettings.load_sync(sync_session)
settings = sync_session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
rate_limit = settings.download_rate_limit_seconds
validate_files = settings.download_validate_files
@@ -129,7 +64,9 @@ def download_source(self, source_id: int) -> int:
async with async_factory() as async_session:
cred_service = CredentialService(async_session, crypto)
with SyncFactory() as sync_session:
sync_settings = ImportSettings.load_sync(sync_session)
sync_settings = sync_session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=sync_session,
images_root=IMAGES_ROOT,
@@ -143,29 +80,9 @@ def download_source(self, source_id: int) -> int:
gdl=gdl,
importer=importer,
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)
finally:
await async_engine.dispose()
try:
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
return asyncio.run(_run())
+29 -76
View File
@@ -64,13 +64,30 @@ def _mark_failed(session, task, error_msg: str) -> None:
pass
def _run_import_task(import_task_id: int) -> dict:
"""Shared body for import_media_file + import_archive_file. The two
tasks differ ONLY in their Celery time limits (a single media file
is sub-second; an archive runs the full per-member pipeline inline
for every member and can take many minutes). Both flip the row to
'processing', dispatch to `_do_import`, and honor the
flip-to-terminal resilience contract.
@celery.task(
name="backend.app.tasks.import_file.import_media_file",
bind=True,
autoretry_for=(OperationalError, DBAPIError, OSError),
retry_backoff=5,
retry_backoff_max=60,
retry_jitter=True,
max_retries=3,
soft_time_limit=300,
time_limit=360,
)
def import_media_file(self, import_task_id: int) -> dict:
"""Returns a dict so the eager-mode tests can assert without DB.
Decorator notes:
- autoretry_for: transient DB / filesystem errors retry with
exponential backoff (5s base, jitter, max 3 attempts). On final
give-up the task raises and acks_late=True (set globally on the
Celery app) does NOT redeliver — the recovery sweep catches the
row instead.
- soft_time_limit (300s) raises SoftTimeLimitExceeded in this
process so the task can mark its row failed before being killed.
- time_limit (360s) is the hard cap; SIGKILL if the soft signal
was swallowed.
"""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
@@ -86,88 +103,24 @@ def _run_import_task(import_task_id: int) -> dict:
try:
return _do_import(session, task, import_task_id)
except SoftTimeLimitExceeded:
_mark_failed(session, task, "soft_time_limit exceeded")
_mark_failed(session, task, "soft_time_limit exceeded (>300s)")
raise
except (OperationalError, DBAPIError, OSError):
# Retryable per the decorator; do NOT mark failed (let
# autoretry have a clean go at it). If autoretry exhausts,
# the row stays 'processing' and the maintenance sweep
# flips it.
# flips it within 5 min.
raise
except Exception as exc: # noqa: BLE001 — pipeline crash, mark + re-raise
_mark_failed(session, task, f"{type(exc).__name__}: {exc}")
raise
@celery.task(
name="backend.app.tasks.import_file.import_media_file",
bind=True,
autoretry_for=(OperationalError, DBAPIError, OSError),
retry_backoff=5,
retry_backoff_max=60,
retry_jitter=True,
max_retries=3,
soft_time_limit=300,
time_limit=360,
)
def import_media_file(self, import_task_id: int) -> dict:
"""Import ONE media file (or non-media → PostAttachment). Sub-second
for the common case; the tight 5-min soft limit keeps a genuinely
stuck single-file import detectable fast.
Decorator notes:
- autoretry_for: transient DB / filesystem errors retry with
exponential backoff (5s base, jitter, max 3 attempts). On final
give-up the task raises and acks_late=True (set globally on the
Celery app) does NOT redeliver — the recovery sweep catches the
row instead.
- soft_time_limit (300s) raises SoftTimeLimitExceeded in-process
so the task can mark its row failed before being killed.
- time_limit (360s) is the hard SIGKILL cap.
"""
return _run_import_task(import_task_id)
@celery.task(
name="backend.app.tasks.import_file.import_archive_file",
bind=True,
autoretry_for=(OperationalError, DBAPIError, OSError),
retry_backoff=5,
retry_backoff_max=60,
retry_jitter=True,
max_retries=3,
# Archives run the full per-member pipeline (sha256 + pHash + dedup
# query + copy + provenance) for EVERY media member inline, under a
# single task budget. A multi-hundred-member archive blows the
# 5-min media limit. soft=30min / hard=35min sizes for a large
# archive. Operator-flagged 2026-05-28 (target 1645019 hit the old
# shared 300s soft limit). The recovery sweep gives this task its
# own 40-min threshold via maintenance.TASK_STUCK_THRESHOLD_MINUTES
# so it isn't preempted while legitimately grinding through members.
soft_time_limit=1800,
time_limit=2100,
)
def import_archive_file(self, import_task_id: int) -> dict:
"""Import an archive: extract + run the per-member media pipeline for
every member inline, then preserve the archive as a PostAttachment.
Same body as import_media_file (dispatch is by file kind inside
Importer.import_one); split out purely for the larger time budget."""
return _run_import_task(import_task_id)
def enqueue_import(task_id: int, task_type: str) -> None:
"""Route an ImportTask to the right Celery task by its task_type.
Single source of truth for the media-vs-archive dispatch so the
scan, retry, and recovery-requeue paths stay in sync."""
if task_type == "archive":
import_archive_file.delay(task_id)
else:
import_media_file.delay(task_id)
def _do_import(session, task, import_task_id: int) -> dict:
"""Actual work, called from inside the resilience wrapper."""
settings = ImportSettings.load_sync(session)
settings = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
import_root = Path(settings.import_scan_path)
batch = session.get(ImportBatch, task.batch_id)
deep = bool(batch and batch.scan_mode == "deep")
+36 -508
View File
@@ -1,76 +1,22 @@
"""Periodic maintenance: recover stuck import tasks, garbage-collect old finished tasks."""
import logging
import os
import subprocess
from datetime import UTC, datetime, timedelta
from pathlib import Path
from PIL import Image
from sqlalchemy import Integer, and_, cast, delete, func, or_, select, update
from sqlalchemy import delete, select, update
from ..celery_app import celery
from ..models import (
BackupRun,
DownloadEvent,
ImageRecord,
ImportBatch,
ImportSettings,
ImportTask,
LibraryAuditRun,
Source,
TaskRun,
)
from ..models import DownloadEvent, ImageRecord, ImportSettings, ImportTask, TaskRun
from ..utils.phash import compute_phash
from ._sync_engine import get_sync_engine
from ._sync_engine import sync_session_factory as _sync_session_factory
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
# Archive ImportTasks run the per-member pipeline inline for every
# member (import_archive_file: soft=30min/hard=35min). The ImportTask
# 'processing' recovery sweep must give them a longer threshold or it
# re-queues a legitimately-running archive mid-import (double-process).
# 40 min = 5-min buffer past the archive task's hard kill.
# Operator-flagged 2026-05-28 (target 1645019, a big archive).
ARCHIVE_STUCK_THRESHOLD_MINUTES = 40
# Poison-pill cap. After being recovered (re-queued from a stuck
# 'processing' state) MAX_RECOVERY_ATTEMPTS-1 times, the next sweep
# marks the row 'failed' instead of looping. 3 = two recoveries then
# give up. A row reaches this only if it leaves NO terminal flip each
# run — i.e. it hard-crashes the worker (OOM/segfault/SIGKILL), the
# signature of a corrupt or oversized input. Caught exceptions already
# flip to terminal 'failed' and never enter this loop.
MAX_RECOVERY_ATTEMPTS = 3
ORPHAN_PENDING_THRESHOLD_MINUTES = 30
# DownloadEvent (pending|running) recovery threshold. download_source has
# time_limit=1500s (25 min, DOWNLOAD_HARD_TIME_LIMIT); 30 min is 5 min past
# that, so a legitimately-running task is hard-killed before the sweep ever
# touches it — the sweep only catches events whose worker died without
# 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
OLD_TASK_DAYS = 7
PHASH_PAGE = 500
VERIFY_PAGE = 200
@@ -78,78 +24,17 @@ FFPROBE_TIMEOUT_SECONDS = 10
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
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; db backup at 12 min hard limit fits trivially.
BACKUP_STALL_THRESHOLD_MINUTES = 7 * 60
# 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).
# Tasks/queues that legitimately run longer than the default 5-min
# threshold need their own larger value, else the sweep marks in-flight
# work 'error' before it finishes. Each value MUST be ≥ the relevant
# task.time_limit + a small buffer. task_name overrides take precedence
# over queue overrides.
#
# ml queue: tag_and_embed video branch (≈20 GPU ops); time_limit=1200.
# import_archive_file: shares the 'import' queue with the fast
# single-file import_media_file, so it needs a task-name override
# (the import queue itself stays at the 5-min default for single
# files); time_limit=2100.
QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
"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] = {
"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,
}
@celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks")
def recover_interrupted_tasks() -> int:
"""Recover stuck ImportTask rows. Two distinct stuck states:
1. 'processing' too long — worker crash mid-import. Re-queue via
enqueue_import (routing media vs archive) and let the import
retry. Threshold is task-type-aware: media files are sub-second
and capped at the 5-min soft limit, so STUCK_THRESHOLD_MINUTES
(5) means a confirmed crash; archives run the per-member
pipeline inline (import_archive_file, 35-min hard limit) so they
get ARCHIVE_STUCK_THRESHOLD_MINUTES (40) to avoid re-queueing a
still-running archive. (Media was tightened from 30 min to 5
2026-05-24 after a 2224-row zombie pile; archive split out
2026-05-28.)
1. 'processing' > 5 min — worker crash mid-import. Re-queue via
.delay() and let the import retry. Was 30 min historically;
tightened 2026-05-24 after operator hit a 2224-row zombie pile.
import_media_file is sub-second for the vast majority of files and
capped at the per-task soft_time_limit (5 min), so anything still
'processing' after that window is a confirmed crash.
2. 'pending' or 'queued' > 30 min — enqueue-phase crash. scan_directory
creates rows with status='pending' (commit), then in a second pass
@@ -166,8 +51,7 @@ def recover_interrupted_tasks() -> int:
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
media_cutoff = now - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
archive_cutoff = now - timedelta(minutes=ARCHIVE_STUCK_THRESHOLD_MINUTES)
processing_cutoff = now - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
orphan_cutoff = now - timedelta(minutes=ORPHAN_PENDING_THRESHOLD_MINUTES)
with SessionLocal() as session:
# Both sweeps used to be SELECT ids → UPDATE WHERE id IN (...) which
@@ -175,67 +59,20 @@ def recover_interrupted_tasks() -> int:
# tens of thousands of rows (operator hit it 2026-05-26 after the
# /import deep scan piled up orphans). Folding the SELECT into the
# UPDATE eliminates the IN-list entirely. RETURNING gives us back
# exactly the (id, task_type) pairs that flipped so the requeue
# can route media vs archive correctly.
#
# Media + archive get separate cutoffs: a single media file is
# sub-second so 5 min means crash; an archive runs the per-member
# pipeline inline and can legitimately take up to its 35-min hard
# limit, so it gets ARCHIVE_STUCK_THRESHOLD_MINUTES (40) to avoid
# re-queueing a still-running archive.
stuck_predicate = and_(
ImportTask.status == "processing",
or_(
and_(ImportTask.task_type != "archive",
ImportTask.started_at < media_cutoff),
and_(ImportTask.task_type == "archive",
ImportTask.started_at < archive_cutoff),
),
)
# POISON-PILL CIRCUIT BREAKER (Layer 1, 2026-05-28). A row that
# leaves no terminal flip (hard worker crash: OOM/segfault/SIGKILL
# on a corrupt or oversized input) gets re-queued by this sweep —
# and would loop forever, re-crashing the worker each pass,
# without a cap. Once a row has already been recovered
# MAX_RECOVERY_ATTEMPTS-1 times, stop re-queueing it and mark it
# 'failed' with a diagnostic so the operator can find + replace
# the offending file. This UPDATE runs FIRST so the rows it
# claims drop out of 'processing' before the re-queue pass.
poison_result = session.execute(
update(ImportTask)
.where(stuck_predicate)
.where(ImportTask.recovery_count >= MAX_RECOVERY_ATTEMPTS - 1)
.values(
status="failed",
finished_at=now,
error=(
f"crashed or stalled the worker {MAX_RECOVERY_ATTEMPTS} "
f"times without completing — likely a corrupt or "
f"oversized input. Not re-queued. Inspect/replace the "
f"file, then retry via /api/import/retry-failed."
),
)
.returning(ImportTask.id)
)
poison_ids = [r[0] for r in poison_result.all()]
# Re-queue the remaining stuck rows (under the cap) and bump
# their recovery_count. RETURNING (id, task_type) so the requeue
# routes media vs archive correctly.
# exactly the ids that flipped so the stuck sweep can still
# .delay() each one.
stuck_result = session.execute(
update(ImportTask)
.where(stuck_predicate)
.where(ImportTask.recovery_count < MAX_RECOVERY_ATTEMPTS - 1)
.where(ImportTask.status == "processing")
.where(ImportTask.started_at < processing_cutoff)
.values(
status="queued",
started_at=None,
recovery_count=ImportTask.recovery_count + 1,
error="recovered from stuck state",
)
.returning(ImportTask.id, ImportTask.task_type)
.returning(ImportTask.id)
)
stuck = stuck_result.all()
stuck_ids = [row[0] for row in stuck_result.all()]
orphan_result = session.execute(
update(ImportTask)
@@ -243,11 +80,6 @@ def recover_interrupted_tasks() -> int:
.where(ImportTask.created_at < orphan_cutoff)
.values(
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=(
"orphan pending/queued swept by recover_interrupted_tasks "
"(scanner likely crashed mid-enqueue); retry via "
@@ -259,34 +91,12 @@ def recover_interrupted_tasks() -> int:
session.commit()
if stuck:
from .import_file import enqueue_import
for tid, task_type in stuck:
enqueue_import(tid, task_type)
if stuck_ids:
from .import_file import import_media_file
for tid in stuck_ids:
import_media_file.delay(tid)
# Layer-2 auto re-download (env-gated, default OFF). For each
# poison-pill row that resolves to a pollable Source, delete the
# bad file and trigger ONE source re-check to fetch a fresh
# copy. Bounded by ImportTask.refetched so source-side
# corruption can't loop. The 'failed' row stays as history; the
# re-downloaded file re-imports as a fresh task on the next scan.
if poison_ids and os.environ.get("FC_AUTO_REFETCH_CORRUPT", "0") == "1":
from ..models import ImportSettings
from ..services.refetch_service import attempt_refetch
import_root = Path(session.execute(
select(ImportSettings.import_scan_path)
.where(ImportSettings.id == 1)
).scalar_one())
for pid in poison_ids:
ptask = session.get(ImportTask, pid)
if ptask is None:
continue
try:
attempt_refetch(session, ptask, import_root)
except Exception as exc: # noqa: BLE001 — best-effort
log.warning("auto-refetch failed for task %s: %s", pid, exc)
return len(stuck) + len(poison_ids) + orphan_count
return len(stuck_ids) + orphan_count
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_tasks")
@@ -311,29 +121,18 @@ def cleanup_old_tasks() -> int:
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_task_runs")
def recover_stalled_task_runs() -> int:
"""Flip task_run rows stuck in 'running' past their queue-specific
threshold to 'error'. FC-3i.
"""Flip task_run rows stuck in 'running' for >STUCK_THRESHOLD_MINUTES
to 'error'. FC-3i.
A row gets stuck when the worker dies without emitting
task_postrun / task_failure (e.g. OOM, container restart between
signals, signal handler raised+logged). The default 5-min threshold
fits short-lived queues (import/thumbnail/download); queues that
legitimately run longer tasks (ml-video, deep scans) get their
own larger threshold via QUEUE_STUCK_THRESHOLD_MINUTES so the
sweep doesn't preempt them.
Runs once per distinct threshold value: each pass updates rows
whose queue maps to that threshold.
signals, signal handler raised+logged). Shares the 5-min threshold
with recover_interrupted_tasks for consistency.
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
override_tasks = set(TASK_STUCK_THRESHOLD_MINUTES.keys())
override_queues = set(QUEUE_STUCK_THRESHOLD_MINUTES.keys())
total = 0
def _flag(minutes, *extra_where):
cutoff = now - timedelta(minutes=minutes)
stmt = (
cutoff = datetime.now(UTC) - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
with SessionLocal() as session:
result = session.execute(
update(TaskRun)
.where(TaskRun.status == "running")
.where(TaskRun.started_at < cutoff)
@@ -341,50 +140,14 @@ def recover_stalled_task_runs() -> int:
status="error",
error_type="RecoverySweep",
error_message=(
f"no completion signal received within {minutes} min"
),
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,
f"no completion signal received within "
f"{STUCK_THRESHOLD_MINUTES} min"
),
finished_at=datetime.now(UTC),
)
)
for w in extra_where:
stmt = stmt.where(w)
return session.execute(stmt).rowcount or 0
with SessionLocal() as session:
# Precedence: task_name override → queue override → default.
# Each pass excludes rows claimed by a higher-precedence pass so
# every row is touched at most once.
# 1. Per-task-name overrides (e.g. import_archive_file, which
# shares the 'import' queue with fast single-file imports).
for task_name, minutes in TASK_STUCK_THRESHOLD_MINUTES.items():
total += _flag(minutes, TaskRun.task_name == task_name)
# 2. Per-queue overrides, excluding the override task-names.
for queue, minutes in QUEUE_STUCK_THRESHOLD_MINUTES.items():
wheres = [TaskRun.queue == queue]
if override_tasks:
wheres.append(TaskRun.task_name.notin_(override_tasks))
total += _flag(minutes, *wheres)
# 3. Default — everything not claimed above.
default_wheres = []
if override_queues:
default_wheres.append(TaskRun.queue.notin_(override_queues))
if override_tasks:
default_wheres.append(TaskRun.task_name.notin_(override_tasks))
total += _flag(STUCK_THRESHOLD_MINUTES, *default_wheres)
session.commit()
return total
return result.rowcount or 0
@celery.task(name="backend.app.tasks.maintenance.prune_task_runs")
@@ -421,12 +184,7 @@ def prune_task_runs() -> dict:
return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted}
@celery.task(
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,
)
@celery.task(name="backend.app.tasks.maintenance.backfill_phash")
def backfill_phash() -> int:
"""Recompute phash for stored images that have none (imported before
FC-2d-i+ii). Keyset-paginated by id (restart-safe), NULL-only fill,
@@ -504,13 +262,7 @@ def _verify_one(path: Path, expected_sha: str, mime: str, sha_fn) -> str:
return "failed_verification"
@celery.task(
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,
)
@celery.task(name="backend.app.tasks.maintenance.verify_integrity")
def verify_integrity() -> int:
"""Verify every ImageRecord file: sha256 recompute + decode/probe
(PIL for images; ffprobe for videos). Writes integrity_status
@@ -547,215 +299,6 @@ def verify_integrity() -> int:
return total
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_download_events")
def recover_stalled_download_events() -> int:
"""Recover DownloadEvent rows stuck pending/running past the worker hard kill.
The scan tick (scheduler_service.select_due_sources →
tasks.scan._tick_due_sources_async) inserts DownloadEvent(status='pending')
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
on the 1500s 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)
and Source.last_checked_at never updates; the operator sees "last check
never" in the Subscriptions health column, permanently.
This sweep flips matching events to 'error', stamps each affected Source's
last_checked_at + last_error and bumps consecutive_failures (once per
source, not per event — backoff is exponential on that count so an N-event
bump would inflate the next interval by 2^N for no reason). The source
becomes re-queueable on the next tick and the health dot goes amber.
Operator-confirmed 2026-05-29 (43-row strand pile in production).
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=DOWNLOAD_STALL_THRESHOLD_MINUTES)
msg = "stranded by recovery sweep (no terminal status after time_limit)"
with SessionLocal() as session:
# UPDATE...RETURNING the source_ids in one round trip — keeps us off
# the psycopg 65535-param ceiling that SELECT-then-UPDATE-WHERE-IN
# would hit on a large strand pile.
result = session.execute(
update(DownloadEvent)
.where(DownloadEvent.status.in_(["pending", "running"]))
.where(DownloadEvent.started_at < cutoff)
.values(status="error", finished_at=now, error=msg)
.returning(DownloadEvent.source_id)
)
returned = result.all()
if not returned:
session.commit()
return 0
events_recovered = len(returned)
source_ids = list({row.source_id for row in returned})
session.execute(
update(Source)
.where(Source.id.in_(source_ids))
.values(
consecutive_failures=Source.consecutive_failures + 1,
last_error=msg,
last_checked_at=now,
)
)
session.commit()
log.info(
"recover_stalled_download_events: recovered %d events across %d sources",
events_recovered, len(source_ids),
)
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)
cutoff = now - timedelta(minutes=BACKUP_STALL_THRESHOLD_MINUTES)
msg = (
f"stranded by recovery sweep (no terminal status after "
f"{BACKUP_STALL_THRESHOLD_MINUTES // 60}h)"
)
with SessionLocal() as session:
result = session.execute(
update(BackupRun)
.where(BackupRun.status.in_(["running", "restoring"]))
.where(BackupRun.started_at < 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.)
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES)
msg = (
f"stranded by recovery sweep (no terminal status after "
f"{LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES} min)"
)
with SessionLocal() as session:
result = session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.status == "running")
.where(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")
def cleanup_old_download_events() -> int:
"""FC-3d: delete terminal DownloadEvent rows older than the configured
@@ -768,7 +311,9 @@ def cleanup_old_download_events() -> int:
"""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
settings = ImportSettings.load_sync(session)
settings = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
retention_days = settings.download_event_retention_days
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
result = session.execute(
@@ -778,20 +323,3 @@ def cleanup_old_download_events() -> int:
)
session.commit()
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}
+175
View File
@@ -0,0 +1,175 @@
"""FC-5 run_migration Celery task.
Dispatches to the right migrator based on `kind`. Updates MigrationRun
row's status/counts/finished_at as it runs. Failures set status='error'
with the error message preserved.
kinds: gs_ingest, ir_ingest, tag_apply, ml_queue, verify, cleanup
(backup + rollback retired 2026-05-24 → see /api/system/backup/*)
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from ..celery_app import celery
from ..config import get_config
from ..models import MigrationRun
from ..services.credential_crypto import CredentialCrypto
from ..services.migrators import cleanup as cleanup_mod
from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")
_KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
def _async_session_factory():
cfg = get_config()
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
async def _update_run(
db: AsyncSession, run_id: int, *,
status: str | None = None, counts: dict | None = None,
error: str | None = None, finished_at: datetime | None = None,
metadata_patch: dict | None = None,
) -> None:
run = (await db.execute(
select(MigrationRun).where(MigrationRun.id == run_id)
)).scalar_one()
if status is not None:
run.status = status
if counts is not None:
run.counts = counts
if error is not None:
run.error = error
if finished_at is not None:
run.finished_at = finished_at
if metadata_patch:
run.metadata_ = {**(run.metadata_ or {}), **metadata_patch}
await db.commit()
async def _run_async(run_id: int, kind: str, params: dict) -> dict:
factory, engine = _async_session_factory()
try:
async with factory() as db:
await _update_run(db, run_id, status="running")
try:
if kind in ("backup", "rollback"):
raise ValueError(
f"kind {kind!r} retired in FC-3h; "
"use /api/system/backup/* instead"
)
elif kind == "gs_ingest":
fc_crypto = CredentialCrypto(_KEY_PATH)
counts = await gs_ingest.migrate_async(
db, data=params["data"],
fc_crypto=fc_crypto,
dry_run=params.get("dry_run", False),
)
await _update_run(
db, run_id, status="ok", counts=counts,
finished_at=datetime.now(UTC),
)
return counts
elif kind == "ir_ingest":
counts = await ir_ingest.migrate_async(
db, data=params["data"],
images_root=IMAGES_ROOT,
dry_run=params.get("dry_run", False),
)
await _update_run(
db, run_id, status="ok", counts=counts,
finished_at=datetime.now(UTC),
)
return counts
elif kind == "tag_apply":
result = await tag_apply.apply_async(
db, images_root=IMAGES_ROOT,
dry_run=params.get("dry_run", False),
)
await _update_run(
db, run_id, status="ok",
counts=result["counts"],
finished_at=datetime.now(UTC),
metadata_patch={"unmatched": result["unmatched"]},
)
return result
elif kind == "ml_queue":
count = await ml_queue.queue_all_unprocessed_async(db)
await _update_run(
db, run_id, status="ok",
counts={"rows_processed": count, "rows_inserted": 0,
"rows_skipped": 0, "files_copied": 0,
"bytes_copied": 0, "conflicts": 0},
finished_at=datetime.now(UTC),
)
return {"queued": count}
elif kind == "verify":
checks = await verify.verify_async(db, expected=params.get("expected"))
sample = await verify.verify_sha256_sample(
db, sample_size=params.get("sample_size", 20),
)
await _update_run(
db, run_id, status="ok",
counts={"rows_processed": sample["sample_size"],
"rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0,
"conflicts": sample["mismatched"] + sample["missing"]},
finished_at=datetime.now(UTC),
metadata_patch={"checks": checks, "sample": sample},
)
return {"checks": checks, "sample": sample}
elif kind == "cleanup":
slug = params.get("slug")
if not slug:
raise ValueError("cleanup requires params.slug")
result = await cleanup_mod.cleanup_artist_async(
db, slug=slug, images_root=IMAGES_ROOT,
dry_run=params.get("dry_run", False),
source_path_prefix=params.get("source_path_prefix"),
)
await _update_run(
db, run_id, status="ok",
counts=result["counts"],
finished_at=datetime.now(UTC),
metadata_patch={
"artist": result["artist"],
"summary": result["summary"],
},
)
return result
else:
raise ValueError(f"unknown kind: {kind}")
except Exception as exc:
log.exception("migration kind=%s failed", kind)
await _update_run(
db, run_id, status="error", error=str(exc),
finished_at=datetime.now(UTC),
)
raise
finally:
await engine.dispose()
@celery.task(name="backend.app.tasks.migration.run_migration", bind=True, acks_late=True)
def run_migration(self, run_id: int, kind: str, params: dict) -> dict:
"""FC-5: dispatch a migration kind. Updates MigrationRun row as it goes."""
return asyncio.run(_run_async(run_id, kind, params))

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