Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88cfb3dd02 | |||
| fb41b90110 | |||
| 7d84990f6d | |||
| ca55d92c68 | |||
| cce014be3a | |||
| c07effb593 | |||
| a36f72b383 | |||
| 2e8d7c960c | |||
| 992f38ec20 | |||
| 0bc5767a2b | |||
| 397021dcbd | |||
| b0bfbc585a | |||
| 110c1c0e51 | |||
| 6de84d0d60 | |||
| 4e1f208a9f | |||
| 06913eba8e | |||
| b7f693b15e | |||
| ecd0199799 | |||
| 983da9e5b1 | |||
| a41eddae3f | |||
| 7aa7f5a3d6 | |||
| 5d4f223b71 | |||
| 2505b197ae | |||
| 0d0b236ac3 | |||
| a06ada4c9b | |||
| ebd985990c | |||
| 4da8d1d774 | |||
| 05090c6e85 | |||
| 2d4bfa4375 | |||
| 6ed2021ad6 | |||
| 4f2ceaaf31 | |||
| 8a5b337a53 | |||
| 900d878d27 | |||
| fd80d40a34 | |||
| 929d3fc092 | |||
| c0c9e56fb9 | |||
| 3a577d5ade | |||
| 06a2f60c08 | |||
| 0978fbac66 | |||
| f4fe02e346 | |||
| efb142239d | |||
| e766197d99 | |||
| 5587a76606 | |||
| 3872e1dda9 | |||
| 17e19081a2 | |||
| 9814f3dbaf | |||
| 770bcf3aa6 | |||
| 52d7905c43 | |||
| e6ededbe8e | |||
| c06cbc0abe | |||
| b214460fdb | |||
| ac39509a74 | |||
| 3531f373ee | |||
| 36cc0622cb | |||
| e50f92d900 | |||
| ba8d9b112d | |||
| c451061ca5 | |||
| ac55d0e8d8 | |||
| 47d760550d | |||
| 89a89e0ded | |||
| dc3bce7fc1 | |||
| f657582f30 | |||
| 111b952535 |
@@ -6,18 +6,197 @@ on:
|
||||
|
||||
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
|
||||
# - write:package, read:package (for docker push to git.fabledsword.com)
|
||||
# - write:release (for future release-cutting workflows)
|
||||
# - write:release (for ext-<version> release asset cache)
|
||||
# - write:issue (for future issue-management automation)
|
||||
# The injected GITHUB_TOKEN cannot be used — it lacks write:package.
|
||||
|
||||
jobs:
|
||||
build-web:
|
||||
# Sign-or-fetch-from-cache: signs the extension via AMO if no ext-<version>
|
||||
# Forgejo release exists yet, otherwise downloads the cached signed XPI.
|
||||
# Result is uploaded as an Actions artifact for build-web to consume.
|
||||
#
|
||||
# Why this lives in build.yml (not a separate workflow): the merge-commit's
|
||||
# docker image tagged `:latest` MUST carry the XPI. A separate sign workflow
|
||||
# racing build.yml leaves `:latest` without the XPI for ~5min (until the
|
||||
# commit-back triggers another build). Inline ordering eliminates the race.
|
||||
# Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic
|
||||
# Packages (cleaner API surface) and commit-back-to-side-branch (no extra
|
||||
# branch to manage). AMO blocks re-signing the same version (returns 409),
|
||||
# so signing is intentionally one-shot per version bump.
|
||||
sign-extension:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve extension version
|
||||
id: extver
|
||||
run: |
|
||||
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved extension version: $VERSION"
|
||||
|
||||
- name: Check Forgejo release-asset cache
|
||||
id: cache
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
VERSION=${{ steps.extver.outputs.version }}
|
||||
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)
|
||||
echo "Tag lookup HTTP status: $STATUS"
|
||||
# JSON parsing via python (ci-python:3.14 has stdlib json; jq is
|
||||
# not in the image and adding it per ci-requirements.md is not
|
||||
# warranted for a single consumer — operator-flagged 2026-05-26
|
||||
# after a sign job failed with `jq: not found`).
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
ASSET_ID=$(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]['id'] if xpis else '')")
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
echo "cached=true" >> "$GITHUB_OUTPUT"
|
||||
echo "asset_id=$ASSET_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Cached XPI exists at ext-$VERSION (asset id $ASSET_ID); skipping AMO sign"
|
||||
else
|
||||
echo "cached=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Release ext-$VERSION exists but has no .xpi asset; will re-sign + re-upload"
|
||||
fi
|
||||
else
|
||||
echo "cached=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No release named ext-$VERSION; will sign via AMO and upload"
|
||||
fi
|
||||
|
||||
# No "download cached XPI in sign-extension" step: build-web
|
||||
# fetches directly from the Forgejo ext-<version> release asset
|
||||
# (removed 2026-05-26 alongside the actions/upload-artifact
|
||||
# removal — sign-extension's job is just to ensure the cache
|
||||
# exists on Forgejo; the build-web side reads it independently).
|
||||
|
||||
- name: Sign via AMO (cache miss)
|
||||
if: steps.cache.outputs.cached != 'true'
|
||||
run: |
|
||||
cd extension && npm install --no-save --no-audit --no-fund && npm run sign
|
||||
env:
|
||||
WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }}
|
||||
WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }}
|
||||
|
||||
- name: Upload signed XPI to ext-<version> release (cache miss)
|
||||
if: steps.cache.outputs.cached != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eux
|
||||
VERSION=${{ steps.extver.outputs.version }}
|
||||
# AMO renames signed XPIs with its internal addon-id-safe-string;
|
||||
# canonicalize to fabledcurator-<version>.xpi so the FC server's
|
||||
# whitelist (backend/app/frontend.py expects 'fabledcurator-*.xpi')
|
||||
# keeps working.
|
||||
SIGNED=$(ls extension/web-ext-artifacts/*.xpi | head -1)
|
||||
XPI="extension/web-ext-artifacts/fabledcurator-$VERSION.xpi"
|
||||
cp "$SIGNED" "$XPI"
|
||||
# Find-or-create the ext-<version> release. Track whether WE
|
||||
# created it so an upload failure below can roll back (don't
|
||||
# leave an empty release tombstone that the next run's
|
||||
# cache-check mistakes for a partial-failure state).
|
||||
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
|
||||
CREATED_BY_US=false
|
||||
else
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"main\"}" \
|
||||
-o release.json \
|
||||
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases"
|
||||
CREATED_BY_US=true
|
||||
fi
|
||||
RELEASE_ID=$(python3 -c "import json; print(json.load(open('release.json'))['id'])")
|
||||
test -n "$RELEASE_ID"
|
||||
# Rollback-on-failure: if the asset upload fails AND we just
|
||||
# created the release in this run, delete it. Prevents an empty
|
||||
# ext-<version> release from poisoning the next workflow run
|
||||
# (operator-flagged 2026-05-26 — without rollback the next run
|
||||
# saw 'release exists, no asset → cache miss → sign' which AMO
|
||||
# then rejected with 409 'Version already exists').
|
||||
rollback_if_we_created() {
|
||||
if [ "$CREATED_BY_US" = "true" ]; then
|
||||
echo "Rolling back: deleting just-created release $RELEASE_ID"
|
||||
curl -s -X DELETE -H "Authorization: token $TOKEN" \
|
||||
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID" || true
|
||||
curl -s -X DELETE -H "Authorization: token $TOKEN" \
|
||||
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/tags/ext-$VERSION" || true
|
||||
fi
|
||||
}
|
||||
trap 'rollback_if_we_created' EXIT
|
||||
HTTP_CODE=$(curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$XPI" \
|
||||
-o /dev/null -w "%{http_code}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID/assets?name=fabledcurator-$VERSION.xpi")
|
||||
if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "Asset upload failed with HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
# Upload succeeded — clear the rollback trap.
|
||||
trap - EXIT
|
||||
echo "Uploaded fabledcurator-$VERSION.xpi to ext-$VERSION release"
|
||||
|
||||
# No actions/upload-artifact step: Forgejo Actions (and our
|
||||
# act_runner) doesn't support upload-artifact@v4+ (GHES limitation
|
||||
# surfaced 2026-05-26). Instead build-web reads the signed XPI
|
||||
# straight from the ext-<version> Forgejo release we just uploaded
|
||||
# to. Same source of truth; no double-store.
|
||||
|
||||
build-web:
|
||||
needs: [sign-extension]
|
||||
# sign-extension is main-only; on dev it's skipped, build-web still runs.
|
||||
if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download signed XPI from Forgejo release asset (main only)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eux
|
||||
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
# 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"
|
||||
mkdir -p frontend/public/extension
|
||||
DEST="frontend/public/extension/fabledcurator-$VERSION.xpi"
|
||||
# -f = fail on HTTP error (prevents silent corruption like the
|
||||
# 2026-05-26 incident); -L = follow redirects.
|
||||
curl -sfL -H "Authorization: token $TOKEN" -o "$DEST" "$DOWNLOAD_URL"
|
||||
# Sanity check: the binary should start with the ZIP magic (PK\x03\x04).
|
||||
# If it's anything else, the next docker build will ship a corrupt XPI.
|
||||
MAGIC=$(head -c 2 "$DEST" | od -An -c | tr -d ' \n')
|
||||
if [ "$MAGIC" != "PK" ]; then
|
||||
echo "ERROR: downloaded XPI does not start with ZIP magic 'PK' (got '$MAGIC')"
|
||||
echo "File contents preview:"
|
||||
head -c 200 "$DEST"
|
||||
exit 1
|
||||
fi
|
||||
cp "$DEST" "frontend/public/extension/fabledcurator-latest.xpi"
|
||||
ls -la frontend/public/extension/
|
||||
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
run: |
|
||||
|
||||
+168
-22
@@ -72,17 +72,28 @@ jobs:
|
||||
- run: npm run test:unit
|
||||
- run: npm run build
|
||||
|
||||
integration:
|
||||
# This act_runner (swarm-runner v0.6.1) puts service containers on the
|
||||
# default bridge with NO service-name DNS, and publishing fixed host
|
||||
# ports collides with the operator's running docker-compose dev stack on
|
||||
# the same shared daemon. Workaround: publish NO host ports, and reach
|
||||
# each service by its bridge IP — discovered at runtime via the mounted
|
||||
# docker socket (the ci-python image ships /usr/bin/docker). Default-bridge
|
||||
# containers can talk by IP (only embedded DNS is missing), so IP
|
||||
# addressing is reliable here. Everything runs in ONE step so resolved
|
||||
# values don't depend on cross-step env passing. Pattern documented in
|
||||
# FabledRulebook/forgejo.md "CI philosophy".
|
||||
# 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.
|
||||
#
|
||||
# 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 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
|
||||
@@ -113,7 +124,6 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache pip wheels
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -121,15 +131,14 @@ jobs:
|
||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-py314-
|
||||
|
||||
- name: Integration suite (resolve service IPs, migrate, test)
|
||||
- name: API integration shard (resolve service IPs, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
# Scope to THIS job's service containers (act_runner names them
|
||||
# ...JOB-integration...); the operator's compose stack uses the
|
||||
# same images but different names, so it won't match.
|
||||
PG=$(docker ps --filter "name=JOB-integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=JOB-integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
echo "=== end landscape ==="
|
||||
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")
|
||||
@@ -137,16 +146,153 @@ jobs:
|
||||
export DB_HOST="$PG_IP"
|
||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
||||
# Wait for Postgres to accept TCP (bash /dev/tcp; no extra tools).
|
||||
for i in $(seq 1 60); do
|
||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
# uv when available (5-10x faster wheel resolve); fall back to pip.
|
||||
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=25
|
||||
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: Cache pip wheels
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-py314-
|
||||
- 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: Cache pip wheels
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-py314-
|
||||
- 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'
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
name: extension
|
||||
# Lint-only workflow. The sign-and-publish dance moved into build.yml's
|
||||
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
|
||||
# because sign-extension runs as a build-web dependency in the SAME workflow,
|
||||
# eliminating the prior race between build.yml and a separate extension.yml.
|
||||
# Signed XPIs are cached in Forgejo Release Assets named `ext-<version>`.
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths: ['extension/**']
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.forgejo/workflows/extension.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -16,44 +27,3 @@ jobs:
|
||||
run: cd extension && npm install --no-save --no-audit --no-fund
|
||||
- name: Lint
|
||||
run: cd extension && npm run lint
|
||||
|
||||
sign-and-publish:
|
||||
needs: lint
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: node:22-bookworm-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
- name: Install web-ext + git
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||
cd extension && npm install --no-save --no-audit --no-fund
|
||||
- name: Sign XPI
|
||||
run: cd extension && npm run sign
|
||||
env:
|
||||
WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }}
|
||||
WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }}
|
||||
- name: Commit signed XPI to frontend/public/extension/
|
||||
run: |
|
||||
set -e
|
||||
XPI=$(ls extension/web-ext-artifacts/fabledcurator-*.xpi | head -1)
|
||||
if [ -z "$XPI" ]; then
|
||||
echo "No XPI produced by web-ext sign — exiting"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p frontend/public/extension
|
||||
cp "$XPI" frontend/public/extension/
|
||||
# Also copy as -latest.xpi so the FC server can serve a stable URL.
|
||||
cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi"
|
||||
git config user.name "FC extension CI"
|
||||
git config user.email "noreply@fabledsword.com"
|
||||
git add frontend/public/extension/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "ext: publish signed XPI $(basename $XPI)"
|
||||
git push origin HEAD:main
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""import_batch.refreshed counter for deep-scan sidecar re-application
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
Create Date: 2026-05-25
|
||||
|
||||
Adds a `refreshed` counter to `import_batch`, mirroring the existing
|
||||
`imported`/`skipped`/`failed`/`attachments` columns. Deep scan now
|
||||
re-applies sidecar metadata to already-imported files (the IR feature
|
||||
that didn't make the FC port the first time); a "refreshed" outcome
|
||||
increments this counter so the UI can surface "X new, Y refreshed"
|
||||
instead of the misleading "Scan complete — no new files" message.
|
||||
|
||||
server_default=0 backfills existing rows in place — no UPDATE needed.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0019"
|
||||
down_revision: Union[str, None] = "0018"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"import_batch",
|
||||
sa.Column(
|
||||
"refreshed", sa.Integer(),
|
||||
nullable=False, server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("import_batch", "refreshed")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""fc-cleanup: library_audit_run table for async transparency/single_color audits
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
Create Date: 2026-05-26
|
||||
|
||||
The table backs the async audit lifecycle: rule + params snapshot, status
|
||||
state machine ('running' → 'ready' → 'applied'/'cancelled'/'error'), and
|
||||
the matched_ids JSONB array that the apply step deletes. Capped at 50k IDs
|
||||
per row by the scan task (oversize = rule too aggressive, operator narrows
|
||||
before re-running).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0020"
|
||||
down_revision: Union[str, None] = "0019"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"library_audit_run",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rule", sa.String(32), nullable=False),
|
||||
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column(
|
||||
"status", sa.String(16),
|
||||
nullable=False, server_default="running",
|
||||
),
|
||||
sa.Column(
|
||||
"started_at", sa.DateTime(timezone=True),
|
||||
nullable=False, server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"scanned_count", sa.Integer(),
|
||||
nullable=False, server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"matched_count", sa.Integer(),
|
||||
nullable=False, server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"matched_ids", postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False, server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_library_audit_run_rule", "library_audit_run", ["rule"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_library_audit_run_status", "library_audit_run", ["status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_library_audit_run_status", table_name="library_audit_run")
|
||||
op.drop_index("ix_library_audit_run_rule", table_name="library_audit_run")
|
||||
op.drop_table("library_audit_run")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""provenance-race: dedupe + UNIQUE(image_record_id, post_id) on image_provenance
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-05-26
|
||||
|
||||
Closes the race in Importer._apply_sidecar's existence-check + INSERT pattern.
|
||||
Two workers writing for the same (image, post) pair both saw no existing row
|
||||
and both inserted, leaving duplicates that then broke .scalar_one_or_none()
|
||||
on every subsequent deep-scan rederive against those images
|
||||
(MultipleResultsFound). Most plausibly seeded when the 5-min recovery sweep
|
||||
re-enqueued a still-running long-import task and the second worker collided
|
||||
with the first inside _apply_sidecar.
|
||||
|
||||
Migration steps:
|
||||
1. DELETE all but min(id) per (image_record_id, post_id) pair. Operator's
|
||||
DB had 2 affected pairs at write-time; harmless no-op if zero.
|
||||
2. Add UNIQUE constraint so the importer's new savepoint+IntegrityError
|
||||
recovery path can trip on collision and re-select, mirroring
|
||||
uq_source_artist_platform_url and uq_post_source_external_id.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0021"
|
||||
down_revision: Union[str, None] = "0020"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM image_provenance ip1
|
||||
USING image_provenance ip2
|
||||
WHERE ip1.image_record_id = ip2.image_record_id
|
||||
AND ip1.post_id = ip2.post_id
|
||||
AND ip1.id > ip2.id
|
||||
"""
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_image_provenance_image_post",
|
||||
"image_provenance",
|
||||
["image_record_id", "post_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"uq_image_provenance_image_post",
|
||||
"image_provenance",
|
||||
type_="unique",
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""source-collapse: one Source per (artist, platform) — consolidate junk per-post Sources
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
Create Date: 2026-05-26
|
||||
|
||||
Closes the operator-flagged 2026-05-26 issue where the filesystem importer
|
||||
called _find_or_create_source(url=sd.post_url), creating one Source row per
|
||||
imported post URL. Operator's Atole artist had 406 Source rows where there
|
||||
should have been 1 (the /cw/Atole subscription Source).
|
||||
|
||||
Source represents a subscription feed (one per artist+platform — the
|
||||
gallery-dl URL polled by the FC-3 downloader). Posts hang off it. The
|
||||
filesystem importer was misusing Source as a per-post key.
|
||||
|
||||
Migration steps per (artist_id, platform) group with >1 Source:
|
||||
1. Pick canonical — prefer a URL NOT matching '/posts/<id>$' (real
|
||||
campaign URL like /cw/Atole); else min(id).
|
||||
2. Reparent Posts and ImageProvenance off the other Sources onto canonical.
|
||||
3. Merge any Posts that collide on (canonical_source_id, external_post_id)
|
||||
by repointing ImageProvenance + ImageRecord.primary_post_id to the
|
||||
earliest Post, dedupe ImageProvenance, delete the loser Posts.
|
||||
4. Delete the orphan Source rows.
|
||||
5. If the canonical Source's URL still looks like a per-post URL (no
|
||||
campaign URL existed among candidates), rewrite it to
|
||||
'sidecar:<platform>:<artist_slug>' so the artist detail page shows
|
||||
something readable.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision: str = "0022"
|
||||
down_revision: Union[str, None] = "0021"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_POST_URL_RE = r"/posts/[^/]+$"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Find (artist_id, platform) groups with > 1 Source row.
|
||||
groups = conn.execute(text("""
|
||||
SELECT artist_id, platform
|
||||
FROM source
|
||||
GROUP BY artist_id, platform
|
||||
HAVING COUNT(*) > 1
|
||||
""")).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()
|
||||
|
||||
# Canonical: first row whose URL doesn't look like a per-post URL;
|
||||
# else min(id).
|
||||
canonical_id = None
|
||||
for sid, url in rows:
|
||||
if not _matches_post_url(url):
|
||||
canonical_id = sid
|
||||
break
|
||||
if canonical_id is None:
|
||||
canonical_id = rows[0][0]
|
||||
|
||||
other_ids = [sid for sid, _ in rows if sid != canonical_id]
|
||||
if not other_ids:
|
||||
continue
|
||||
|
||||
# Reparent Posts off the other Sources.
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE post SET source_id = :canonical
|
||||
WHERE source_id = ANY(:others)
|
||||
"""),
|
||||
{"canonical": canonical_id, "others": other_ids},
|
||||
)
|
||||
# Reparent ImageProvenance.source_id similarly (denormalized FK).
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_provenance SET source_id = :canonical
|
||||
WHERE source_id = ANY(:others)
|
||||
"""),
|
||||
{"canonical": canonical_id, "others": other_ids},
|
||||
)
|
||||
|
||||
# Merge any Posts colliding on (canonical, external_post_id) after
|
||||
# reparent. In practice within a single (artist, platform) group
|
||||
# these should be rare — each gallery-dl post has a unique id — but
|
||||
# safe to handle.
|
||||
post_collisions = conn.execute(
|
||||
text("""
|
||||
SELECT external_post_id, ARRAY_AGG(id ORDER BY id) AS ids
|
||||
FROM post
|
||||
WHERE source_id = :canonical
|
||||
GROUP BY external_post_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""),
|
||||
{"canonical": canonical_id},
|
||||
).fetchall()
|
||||
for _epid, post_ids in post_collisions:
|
||||
keep = post_ids[0]
|
||||
drops = post_ids[1:]
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_provenance SET post_id = :keep
|
||||
WHERE post_id = ANY(:drops)
|
||||
"""),
|
||||
{"keep": keep, "drops": drops},
|
||||
)
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_record SET primary_post_id = :keep
|
||||
WHERE primary_post_id = ANY(:drops)
|
||||
"""),
|
||||
{"keep": keep, "drops": drops},
|
||||
)
|
||||
# Repointed provenance rows may now collide on
|
||||
# uq_image_provenance_image_post (alembic 0021). Same dedupe
|
||||
# pattern: keep min(id) per (image_record_id, post_id).
|
||||
conn.execute(text("""
|
||||
DELETE FROM image_provenance ip1
|
||||
USING image_provenance ip2
|
||||
WHERE ip1.image_record_id = ip2.image_record_id
|
||||
AND ip1.post_id = ip2.post_id
|
||||
AND ip1.id > ip2.id
|
||||
"""))
|
||||
conn.execute(
|
||||
text("DELETE FROM post WHERE id = ANY(:drops)"),
|
||||
{"drops": drops},
|
||||
)
|
||||
|
||||
# Drop the orphan Sources.
|
||||
conn.execute(
|
||||
text("DELETE FROM source WHERE id = ANY(:others)"),
|
||||
{"others": other_ids},
|
||||
)
|
||||
|
||||
# If the canonical's URL still looks per-post (no campaign URL
|
||||
# existed among the candidates), rewrite to a synthetic anchor so
|
||||
# the artist detail page renders something readable.
|
||||
canonical_url = conn.execute(
|
||||
text("SELECT url FROM source WHERE id = :id"),
|
||||
{"id": canonical_id},
|
||||
).scalar_one()
|
||||
if _matches_post_url(canonical_url):
|
||||
slug = conn.execute(
|
||||
text("SELECT slug FROM artist WHERE id = :id"),
|
||||
{"id": artist_id},
|
||||
).scalar_one()
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE source
|
||||
SET url = :new_url, enabled = false
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": canonical_id,
|
||||
"new_url": f"sidecar:{platform}:{slug}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Lossy migration — orphan Sources deleted, Posts reparented, Posts
|
||||
# merged. No safe downgrade. If you need to roll back the schema
|
||||
# invariant, fork from 0021 and re-run filesystem imports.
|
||||
pass
|
||||
|
||||
|
||||
def _matches_post_url(url: str) -> bool:
|
||||
"""True if url ends with /posts/<token> (gallery-dl-style per-post URL)."""
|
||||
import re
|
||||
return bool(re.search(_POST_URL_RE, url or ""))
|
||||
@@ -20,6 +20,7 @@ def all_blueprints() -> list[Blueprint]:
|
||||
from .artist import artist_bp
|
||||
from .artists import artists_bp
|
||||
from .attachments import attachments_bp
|
||||
from .cleanup import cleanup_bp
|
||||
from .credentials import credentials_bp
|
||||
from .downloads import downloads_bp
|
||||
from .extension import extension_bp
|
||||
@@ -37,6 +38,7 @@ def all_blueprints() -> list[Blueprint]:
|
||||
from .system_activity import system_activity_bp
|
||||
from .system_backup import system_backup_bp
|
||||
from .tags import tags_bp
|
||||
from .thumbnails import thumbnails_bp
|
||||
return [
|
||||
api_bp,
|
||||
attachments_bp,
|
||||
@@ -50,12 +52,14 @@ def all_blueprints() -> list[Blueprint]:
|
||||
system_activity_bp,
|
||||
system_backup_bp,
|
||||
admin_bp,
|
||||
cleanup_bp,
|
||||
import_admin_bp,
|
||||
migrate_bp,
|
||||
suggestions_bp,
|
||||
allowlist_bp,
|
||||
aliases_bp,
|
||||
ml_admin_bp,
|
||||
thumbnails_bp,
|
||||
sources_bp,
|
||||
platforms_bp,
|
||||
posts_bp,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""FC-Cleanup: /api/cleanup/* — retroactive enforcement of import filters.
|
||||
|
||||
Endpoints:
|
||||
POST /min-dimension/preview synchronous SQL audit
|
||||
POST /min-dimension/delete synchronous SQL delete (Tier-C token)
|
||||
POST /audit async transparency / single_color start
|
||||
GET /audit list recent audit_run rows
|
||||
GET /audit/<id> single audit_run row
|
||||
POST /audit/<id>/apply apply matched_ids deletes (Tier-C token)
|
||||
POST /audit/<id>/cancel flip running audit to cancelled
|
||||
|
||||
Unused-tags retroactive prune intentionally NOT in this namespace —
|
||||
TagMaintenanceCard (Maintenance tab → moved to Cleanup tab in v26.05.25.7)
|
||||
uses the existing /api/admin/tags/prune-unused endpoint via the admin
|
||||
store. No duplicate route here.
|
||||
|
||||
Confirm-token format matches modal/DestructiveConfirmModal.vue convention:
|
||||
`delete-min-dim-<sha8(w,h)>` for min-dim delete
|
||||
`delete-audit-<id>` for audit apply
|
||||
(Modal hardcodes action ∈ {'restore', 'delete'}; "apply audit" is semantically a delete of the matched images, so we use `delete-audit-<id>`.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import LibraryAuditRun
|
||||
from ..services import cleanup_service
|
||||
|
||||
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.
|
||||
canon = f"{min_w}x{min_h}"
|
||||
return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"
|
||||
|
||||
|
||||
def _serialize_audit_run(audit: LibraryAuditRun) -> dict:
|
||||
return {
|
||||
"id": audit.id,
|
||||
"rule": audit.rule,
|
||||
"params": audit.params,
|
||||
"status": audit.status,
|
||||
"started_at": audit.started_at.isoformat() if audit.started_at else None,
|
||||
"finished_at": audit.finished_at.isoformat() if audit.finished_at else None,
|
||||
"scanned_count": audit.scanned_count,
|
||||
"matched_count": audit.matched_count,
|
||||
"matched_ids": audit.matched_ids,
|
||||
"error": audit.error,
|
||||
}
|
||||
|
||||
|
||||
@cleanup_bp.route("/min-dimension/preview", methods=["POST"])
|
||||
async def min_dim_preview():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
try:
|
||||
min_w = int(body.get("min_width", 0))
|
||||
min_h = int(body.get("min_height", 0))
|
||||
except (TypeError, ValueError):
|
||||
return _bad("invalid_dimensions")
|
||||
if min_w < 0 or min_h < 0:
|
||||
return _bad("invalid_dimensions")
|
||||
async with get_session() as session:
|
||||
projection = await session.run_sync(
|
||||
lambda s: cleanup_service.project_min_dimension_violations(
|
||||
s, min_width=min_w, min_height=min_h,
|
||||
)
|
||||
)
|
||||
return jsonify(projection)
|
||||
|
||||
|
||||
@cleanup_bp.route("/min-dimension/delete", methods=["POST"])
|
||||
async def min_dim_delete():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
try:
|
||||
min_w = int(body.get("min_width", 0))
|
||||
min_h = int(body.get("min_height", 0))
|
||||
except (TypeError, ValueError):
|
||||
return _bad("invalid_dimensions")
|
||||
if min_w < 0 or min_h < 0:
|
||||
return _bad("invalid_dimensions")
|
||||
supplied = body.get("confirm", "")
|
||||
expected = _min_dim_token(min_w, min_h)
|
||||
if supplied != expected:
|
||||
return _bad("confirm_mismatch", expected=expected)
|
||||
async with get_session() as session:
|
||||
deleted = await session.run_sync(
|
||||
lambda s: cleanup_service.delete_min_dimension_violations(
|
||||
s, min_width=min_w, min_height=min_h, images_root=IMAGES_ROOT,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"deleted": deleted})
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit", methods=["POST"])
|
||||
async def audit_create():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
rule = body.get("rule")
|
||||
params = body.get("params") or {}
|
||||
if rule not in ("transparency", "single_color"):
|
||||
return _bad("invalid_rule")
|
||||
if not isinstance(params, dict):
|
||||
return _bad("invalid_params")
|
||||
async with get_session() as session:
|
||||
try:
|
||||
audit_id = await session.run_sync(
|
||||
lambda s: cleanup_service.start_audit_run(
|
||||
s, rule=rule, params=params,
|
||||
)
|
||||
)
|
||||
except cleanup_service.AuditAlreadyRunning as running_id:
|
||||
return _bad(
|
||||
"audit_already_running", status=409,
|
||||
running_id=int(str(running_id)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _bad(str(exc))
|
||||
await session.commit()
|
||||
return jsonify({"audit_id": audit_id, "status": "running"}), 202
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit/<int:audit_id>", methods=["GET"])
|
||||
async def audit_get(audit_id: int):
|
||||
async with get_session() as session:
|
||||
audit = (await session.execute(
|
||||
select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
|
||||
)).scalar_one_or_none()
|
||||
if audit is None:
|
||||
return _bad("not_found", status=404)
|
||||
return jsonify(_serialize_audit_run(audit))
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit", methods=["GET"])
|
||||
async def audit_history():
|
||||
try:
|
||||
limit = min(int(request.args.get("limit", "20")), 100)
|
||||
except ValueError:
|
||||
return _bad("invalid_limit")
|
||||
async with get_session() as session:
|
||||
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]})
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit/<int:audit_id>/apply", methods=["POST"])
|
||||
async def audit_apply(audit_id: int):
|
||||
body = await request.get_json(silent=True) or {}
|
||||
confirm = body.get("confirm", "")
|
||||
async with get_session() as session:
|
||||
try:
|
||||
deleted = await session.run_sync(
|
||||
lambda s: cleanup_service.apply_audit_run(
|
||||
s, audit_id=audit_id, confirm_token=confirm,
|
||||
images_root=IMAGES_ROOT,
|
||||
)
|
||||
)
|
||||
except cleanup_service.AuditNotReady as exc:
|
||||
return _bad("audit_not_ready", current_status=str(exc))
|
||||
except cleanup_service.ConfirmTokenMismatch as exc:
|
||||
return _bad("confirm_mismatch", expected=str(exc))
|
||||
except ValueError as exc:
|
||||
return _bad("not_found", status=404, detail=str(exc))
|
||||
await session.commit()
|
||||
return jsonify({"deleted": deleted})
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit/<int:audit_id>/cancel", methods=["POST"])
|
||||
async def audit_cancel(audit_id: int):
|
||||
async with get_session() as session:
|
||||
await session.run_sync(
|
||||
lambda s: cleanup_service.cancel_audit_run(s, audit_id=audit_id)
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"cancelled": True})
|
||||
@@ -93,10 +93,20 @@ def _read_manifest_sync() -> dict | None:
|
||||
asyncio.to_thread (ASYNC240: no pathlib I/O in async functions)."""
|
||||
if not XPI_DIR.is_dir():
|
||||
return None
|
||||
xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime)
|
||||
if not xpis:
|
||||
# Exclude the `fabledcurator-latest.xpi` alias when picking the file to
|
||||
# extract a version from — it's a copy of the latest versioned XPI,
|
||||
# written at the same mtime by build.yml, and would otherwise tie or
|
||||
# win the sort (operator-flagged 2026-05-26: UI displayed "v latest"
|
||||
# because `_extract_version("fabledcurator-latest.xpi")` returns
|
||||
# the literal "latest"). The alias still serves as `latest_url`.
|
||||
versioned = [
|
||||
p for p in XPI_DIR.glob("fabledcurator-*.xpi")
|
||||
if p.name != "fabledcurator-latest.xpi"
|
||||
]
|
||||
if not versioned:
|
||||
return None
|
||||
latest = xpis[-1]
|
||||
versioned.sort(key=lambda p: p.stat().st_mtime)
|
||||
latest = versioned[-1]
|
||||
return {
|
||||
"installed": True,
|
||||
"version": _extract_version(latest.name),
|
||||
|
||||
@@ -47,10 +47,13 @@ async def status():
|
||||
if active:
|
||||
payload["active_batch"] = {
|
||||
"id": active.id,
|
||||
"source_path": active.source_path,
|
||||
"scan_mode": active.scan_mode,
|
||||
"total_files": active.total_files,
|
||||
"imported": active.imported,
|
||||
"skipped": active.skipped,
|
||||
"failed": active.failed,
|
||||
"refreshed": active.refreshed,
|
||||
"started_at": active.started_at.isoformat(),
|
||||
}
|
||||
return jsonify(payload)
|
||||
@@ -100,17 +103,22 @@ async def list_tasks():
|
||||
|
||||
@import_admin_bp.route("/retry-failed", methods=["POST"])
|
||||
async def retry_failed():
|
||||
# Fold SELECT into UPDATE…WHERE…RETURNING — the prior SELECT-then-
|
||||
# UPDATE-WHERE-id-IN pattern blew past psycopg's 65535-parameter
|
||||
# ceiling once failed_ids exceeded ~65k rows.
|
||||
async with get_session() as session:
|
||||
failed_ids = (
|
||||
await session.execute(select(ImportTask.id).where(ImportTask.status == "failed"))
|
||||
).scalars().all()
|
||||
result = await session.execute(
|
||||
update(ImportTask)
|
||||
.where(ImportTask.status == "failed")
|
||||
.values(
|
||||
status="queued", error=None,
|
||||
started_at=None, finished_at=None,
|
||||
)
|
||||
.returning(ImportTask.id)
|
||||
)
|
||||
failed_ids = [row[0] for row in result.all()]
|
||||
if not failed_ids:
|
||||
return jsonify({"retried": 0})
|
||||
await session.execute(
|
||||
update(ImportTask)
|
||||
.where(ImportTask.id.in_(failed_ids))
|
||||
.values(status="queued", error=None, started_at=None, finished_at=None)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
from ..tasks.import_file import import_media_file
|
||||
@@ -135,28 +143,26 @@ async def clear_stuck():
|
||||
autoretry-looped for 2 days after a corrupt-data PIL OSError.
|
||||
"""
|
||||
async with get_session() as session:
|
||||
stuck_ids = (
|
||||
await session.execute(
|
||||
select(ImportTask.id).where(
|
||||
ImportTask.status.in_(["pending", "queued", "processing"])
|
||||
)
|
||||
# Fold SELECT into UPDATE…WHERE — see /retry-failed for the
|
||||
# 65535-parameter ceiling rationale. rowcount is enough here
|
||||
# because we don't need the ids afterward (no .delay()).
|
||||
clear_result = await session.execute(
|
||||
update(ImportTask)
|
||||
.where(
|
||||
ImportTask.status.in_(["pending", "queued", "processing"])
|
||||
)
|
||||
).scalars().all()
|
||||
if stuck_ids:
|
||||
await session.execute(
|
||||
update(ImportTask)
|
||||
.where(ImportTask.id.in_(stuck_ids))
|
||||
.values(
|
||||
status="failed",
|
||||
finished_at=datetime.now(UTC),
|
||||
error=(
|
||||
"manually cleared via /api/import/clear-stuck "
|
||||
"— stuck in non-terminal state; retry once "
|
||||
"underlying cause (corrupt file, missing model, "
|
||||
"etc.) is resolved"
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="failed",
|
||||
finished_at=datetime.now(UTC),
|
||||
error=(
|
||||
"manually cleared via /api/import/clear-stuck "
|
||||
"— stuck in non-terminal state; retry once "
|
||||
"underlying cause (corrupt file, missing model, "
|
||||
"etc.) is resolved"
|
||||
),
|
||||
)
|
||||
)
|
||||
tasks_failed = clear_result.rowcount or 0
|
||||
|
||||
# Finalize any 'running' ImportBatch that no longer has any
|
||||
# active children. The "Scanning..." banner is driven by
|
||||
@@ -192,7 +198,7 @@ async def clear_stuck():
|
||||
await session.commit()
|
||||
|
||||
return jsonify({
|
||||
"tasks_failed": len(stuck_ids),
|
||||
"tasks_failed": tasks_failed,
|
||||
"batches_finalized": finalized_batches,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Thumbnail admin API: backfill trigger."""
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
|
||||
|
||||
|
||||
@thumbnails_bp.route("/backfill", methods=["POST"])
|
||||
async def trigger_backfill():
|
||||
from ..tasks.thumbnail import backfill_thumbnails
|
||||
|
||||
r = backfill_thumbnails.delay()
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
@@ -33,6 +33,7 @@ def make_celery() -> Celery:
|
||||
"backend.app.tasks.download",
|
||||
"backend.app.tasks.backup",
|
||||
"backend.app.tasks.admin",
|
||||
"backend.app.tasks.library_audit",
|
||||
],
|
||||
)
|
||||
app.conf.update(
|
||||
@@ -47,6 +48,7 @@ def make_celery() -> Celery:
|
||||
"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"},
|
||||
},
|
||||
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
|
||||
task_acks_late=True,
|
||||
|
||||
@@ -11,6 +11,7 @@ from .image_record import ImageRecord
|
||||
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 .post import Post
|
||||
@@ -43,6 +44,7 @@ __all__ = [
|
||||
"ImportBatch",
|
||||
"ImportTask",
|
||||
"ImportSettings",
|
||||
"LibraryAuditRun",
|
||||
"MLSettings",
|
||||
"MigrationRun",
|
||||
"TagAlias",
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""ImageProvenance — links an ImageRecord to a Post.
|
||||
|
||||
Many-to-one (one image, many provenance rows) enables the enrich-on-duplicate
|
||||
rule (spec §3): when a downloaded image is a pHash dupe of an existing
|
||||
record, we append a new provenance row to the existing record rather than
|
||||
dropping the metadata.
|
||||
One image can have many provenance rows — different posts each contribute
|
||||
metadata (enrich-on-duplicate rule, spec §3: a downloaded image that is a
|
||||
pHash dupe of an existing record gets a NEW provenance row for the new post
|
||||
appended, rather than the metadata being dropped). But the (image, post)
|
||||
pair is unique — alembic 0021 enforces uq_image_provenance_image_post
|
||||
after operator-flagged 2026-05-26 saw _apply_sidecar's existence-check +
|
||||
INSERT race plant duplicates that then broke .scalar_one_or_none() on
|
||||
every later deep-scan rederive (MultipleResultsFound).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, func
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -16,6 +20,12 @@ from .base import Base
|
||||
|
||||
class ImageProvenance(Base):
|
||||
__tablename__ = "image_provenance"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"image_record_id", "post_id",
|
||||
name="uq_image_provenance_image_post",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
image_record_id: Mapped[int] = mapped_column(
|
||||
|
||||
@@ -26,6 +26,10 @@ class ImportBatch(Base):
|
||||
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# Deep-scan only: count of already-imported files whose sidecar metadata
|
||||
# got re-applied this run (post/source/provenance upsert). Stays 0 on
|
||||
# quick-scan batches. See `Importer.import_one(deep_scan=True)`.
|
||||
refreshed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
|
||||
# running | complete | cancelled
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""LibraryAuditRun — async transparency / single_color audit lifecycle.
|
||||
|
||||
State machine: running → ready → applied / cancelled / error.
|
||||
matched_ids JSONB is appended-to by scan_library_for_rule; apply_audit_run
|
||||
reads it and routes through cleanup_service.delete_images.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class LibraryAuditRun(Base):
|
||||
__tablename__ = "library_audit_run"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
rule: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="running", index=True,
|
||||
)
|
||||
# running | ready | applied | cancelled | error
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -111,12 +111,22 @@ class ArtistService:
|
||||
)
|
||||
).all()
|
||||
|
||||
post_count = (
|
||||
await self.session.execute(
|
||||
select(func.count(func.distinct(Post.id)))
|
||||
.select_from(Post)
|
||||
.join(Source, Source.id == Post.source_id)
|
||||
.where(Source.artist_id == aid)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
return {
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
"slug": artist.slug,
|
||||
"is_subscription": bool(artist.is_subscription),
|
||||
"image_count": int(image_count),
|
||||
"post_count": int(post_count),
|
||||
"date_range": {
|
||||
"min": dmin.isoformat() if dmin else None,
|
||||
"max": dmax.isoformat() if dmax else None,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Audit rule modules. Each module exposes evaluate(pil_image, **params) -> bool.
|
||||
|
||||
The retroactive library-cleanup tab and (future) import-time filter logic
|
||||
both consume these. Importers should NOT inline rule logic going forward;
|
||||
add the rule here and call from both sides.
|
||||
"""
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Single-color audit: matches images where one color dominates beyond
|
||||
the threshold (within the given Euclidean RGB tolerance). The first
|
||||
canonical implementation — the import-side filter (SkipReason.single_color)
|
||||
was never wired; FC-Cleanup's audit module is the source of truth and a
|
||||
future spec can adopt it on the import path too.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
_THUMB_SIZE = (64, 64)
|
||||
|
||||
|
||||
def evaluate(
|
||||
pil_image,
|
||||
*,
|
||||
threshold: float,
|
||||
tolerance: int,
|
||||
) -> bool:
|
||||
"""True iff the fraction of pixels within `tolerance` (Euclidean RGB
|
||||
distance) of the dominant color exceeds `threshold`.
|
||||
|
||||
Downsamples to 64x64 for speed (~4ms regardless of source size).
|
||||
Alpha channels are stripped; only RGB is considered. Animated images
|
||||
use frame 0 (PIL's default after Image.open without seek).
|
||||
"""
|
||||
im = pil_image
|
||||
if im.mode == "RGBA":
|
||||
im = im.convert("RGB")
|
||||
elif im.mode not in ("RGB", "L"):
|
||||
im = im.convert("RGB")
|
||||
if im.size != _THUMB_SIZE:
|
||||
im = im.resize(_THUMB_SIZE, Image.Resampling.BILINEAR)
|
||||
pixels = list(im.getdata())
|
||||
if not pixels:
|
||||
return False
|
||||
# Normalize L-mode pixels to RGB tuples for distance math.
|
||||
if isinstance(pixels[0], int):
|
||||
pixels = [(p, p, p) for p in pixels]
|
||||
# Dominant color = mean RGB.
|
||||
n = len(pixels)
|
||||
sum_r = sum(p[0] for p in pixels)
|
||||
sum_g = sum(p[1] for p in pixels)
|
||||
sum_b = sum(p[2] for p in pixels)
|
||||
dom = (sum_r / n, sum_g / n, sum_b / n)
|
||||
tol_sq = tolerance * tolerance
|
||||
within = 0
|
||||
for r, g, b in pixels:
|
||||
dr = r - dom[0]
|
||||
dg = g - dom[1]
|
||||
db = b - dom[2]
|
||||
if dr * dr + dg * dg + db * db <= tol_sq:
|
||||
within += 1
|
||||
return (within / n) > threshold
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Transparency audit: matches images whose transparent-pixel fraction
|
||||
exceeds the threshold. Animated images short-circuit (skipped) to avoid
|
||||
the multi-frame PIL decode that hits Celery's hard time limit."""
|
||||
|
||||
|
||||
def evaluate(pil_image, *, threshold: float) -> bool:
|
||||
"""True iff the image's transparent-pixel fraction exceeds threshold.
|
||||
|
||||
False for non-alpha modes and animated images. Mirrors the import-side
|
||||
Importer._transparency_pct logic so retroactive enforcement matches
|
||||
prospective filtering.
|
||||
"""
|
||||
if getattr(pil_image, "is_animated", False):
|
||||
return False
|
||||
if pil_image.mode not in ("RGBA", "LA") and not (
|
||||
pil_image.mode == "P" and "transparency" in pil_image.info
|
||||
):
|
||||
return False
|
||||
im = pil_image
|
||||
if im.mode != "RGBA":
|
||||
im = im.convert("RGBA")
|
||||
alpha = im.getchannel("A")
|
||||
histogram = alpha.histogram()
|
||||
transparent = histogram[0]
|
||||
total = sum(histogram)
|
||||
pct = transparent / total if total else 0.0
|
||||
return pct > threshold
|
||||
@@ -12,12 +12,14 @@ re-exports from this module and then delete the wrapper.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Artist, ImageRecord, Tag
|
||||
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
|
||||
from ..models.series_page import SeriesPage
|
||||
from ..models.tag import image_tag
|
||||
|
||||
@@ -365,3 +367,146 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
|
||||
)
|
||||
session.commit()
|
||||
return {"deleted": len(ids), "sample_names": sample}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MIN_DIM_SAMPLE_CAP = 50
|
||||
|
||||
|
||||
def project_min_dimension_violations(
|
||||
session: Session, *, min_width: int, min_height: int,
|
||||
) -> dict:
|
||||
"""Return {count, sample_ids} for image_record rows with width or
|
||||
height below the thresholds. Synchronous SQL — no PIL inspection
|
||||
needed since width/height are stored columns."""
|
||||
base = select(ImageRecord.id).where(
|
||||
(ImageRecord.width < min_width) | (ImageRecord.height < min_height)
|
||||
)
|
||||
count = session.execute(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
).scalar_one()
|
||||
sample_ids = session.execute(
|
||||
base.order_by(ImageRecord.id).limit(_MIN_DIM_SAMPLE_CAP)
|
||||
).scalars().all()
|
||||
return {"count": count, "sample_ids": list(sample_ids)}
|
||||
|
||||
|
||||
def delete_min_dimension_violations(
|
||||
session: Session, *, min_width: int, min_height: int, images_root: Path,
|
||||
) -> int:
|
||||
"""Delete every image_record where width<min_w OR height<min_h.
|
||||
Routes through delete_images so file-unlink + cascading FKs
|
||||
(image_tag / image_provenance / etc.) are handled uniformly."""
|
||||
ids = session.execute(
|
||||
select(ImageRecord.id).where(
|
||||
(ImageRecord.width < min_width) | (ImageRecord.height < min_height)
|
||||
)
|
||||
).scalars().all()
|
||||
if not ids:
|
||||
return 0
|
||||
result = delete_images(
|
||||
session, image_ids=list(ids), images_root=images_root,
|
||||
)
|
||||
return result["images_deleted"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit lifecycle (transparency + single_color async scans).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditAlreadyRunning(Exception):
|
||||
"""Another audit_run is currently in status='running' — wait or
|
||||
cancel it before starting a new one. Surfaces as HTTP 409 in the
|
||||
/api/cleanup/audit POST endpoint."""
|
||||
|
||||
|
||||
class AuditNotReady(Exception):
|
||||
"""apply_audit_run called on an audit whose status is not 'ready'."""
|
||||
|
||||
|
||||
class ConfirmTokenMismatch(Exception):
|
||||
"""Operator-supplied confirm token did not match server-recomputed token."""
|
||||
|
||||
|
||||
_VALID_RULES = ("transparency", "single_color")
|
||||
|
||||
|
||||
def start_audit_run(
|
||||
session: Session, *, rule: str, params: dict[str, Any],
|
||||
) -> int:
|
||||
"""Create a LibraryAuditRun row in status='running' and dispatch the
|
||||
scan_library_for_rule Celery task. Returns the new audit_id.
|
||||
|
||||
Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run
|
||||
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}")
|
||||
existing = session.execute(
|
||||
select(LibraryAuditRun.id).where(LibraryAuditRun.status == "running")
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise AuditAlreadyRunning(existing)
|
||||
audit = LibraryAuditRun(
|
||||
rule=rule,
|
||||
params=params,
|
||||
status="running",
|
||||
scanned_count=0,
|
||||
matched_count=0,
|
||||
matched_ids=[],
|
||||
)
|
||||
session.add(audit)
|
||||
session.flush()
|
||||
audit_id = audit.id
|
||||
# Dispatch after flush so audit_id is populated; commit happens in
|
||||
# the API handler so the audit row + dispatch are visible together.
|
||||
from ..tasks.library_audit import scan_library_for_rule
|
||||
scan_library_for_rule.delay(audit_id)
|
||||
return audit_id
|
||||
|
||||
|
||||
def apply_audit_run(
|
||||
session: Session, *, audit_id: int, confirm_token: str, images_root: Path,
|
||||
) -> int:
|
||||
"""Delete all images in audit_run.matched_ids after confirming token.
|
||||
Marks audit status='applied'. Routes through delete_images so files
|
||||
+ cascading FK rows are handled uniformly."""
|
||||
audit = session.execute(
|
||||
select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
|
||||
).scalar_one_or_none()
|
||||
if audit is None:
|
||||
raise ValueError(f"audit_run {audit_id} not found")
|
||||
if audit.status != "ready":
|
||||
raise AuditNotReady(audit.status)
|
||||
# Token format matches modal/DestructiveConfirmModal.vue convention:
|
||||
# ${action}-${kind}-${runId}. The modal hardcodes action ∈ {'restore',
|
||||
# 'delete'}; "apply audit" is semantically a delete of the matched
|
||||
# images, so we use 'delete-audit-<id>' (not 'apply-audit-<id>').
|
||||
expected = f"delete-audit-{audit_id}"
|
||||
if confirm_token != expected:
|
||||
raise ConfirmTokenMismatch(expected)
|
||||
ids = list(audit.matched_ids or [])
|
||||
deleted = 0
|
||||
if ids:
|
||||
result = delete_images(session, image_ids=ids, images_root=images_root)
|
||||
deleted = result["images_deleted"]
|
||||
session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.id == audit_id)
|
||||
.values(status="applied", finished_at=datetime.now(UTC))
|
||||
)
|
||||
return deleted
|
||||
|
||||
|
||||
def cancel_audit_run(session: Session, *, audit_id: int) -> None:
|
||||
"""Flip a running audit_run to 'cancelled'. The scan task checks
|
||||
for status=='cancelled' between batches and exits cleanly."""
|
||||
session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.id == audit_id)
|
||||
.where(LibraryAuditRun.status == "running")
|
||||
.values(status="cancelled", finished_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import re
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Artist, Source
|
||||
@@ -97,20 +98,40 @@ class ExtensionService:
|
||||
raise UnknownPlatformError(f"no platform pattern matched {url!r}")
|
||||
|
||||
async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]:
|
||||
"""Race-safe find-or-create on Artist by slug. Mirrors the
|
||||
savepoint + IntegrityError recovery pattern used in
|
||||
Importer._find_or_create_source/post (see
|
||||
reference_scalar_one_or_none_duplicates memory). Without this,
|
||||
two concurrent quick-add-source calls hitting the same artist
|
||||
would both miss the existence check and the second INSERT would
|
||||
500 against uq_artist_slug.
|
||||
"""
|
||||
slug = slugify(raw_name)
|
||||
existing = (await self.session.execute(
|
||||
select(Artist).where(Artist.slug == slug)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
artist = Artist(name=raw_name, slug=slug, is_subscription=True)
|
||||
self.session.add(artist)
|
||||
await self.session.flush()
|
||||
return artist, True
|
||||
sp = await self.session.begin_nested()
|
||||
try:
|
||||
artist = Artist(name=raw_name, slug=slug, is_subscription=True)
|
||||
self.session.add(artist)
|
||||
await self.session.flush()
|
||||
await sp.commit()
|
||||
return artist, True
|
||||
except IntegrityError:
|
||||
await sp.rollback()
|
||||
recovered = (await self.session.execute(
|
||||
select(Artist).where(Artist.slug == slug)
|
||||
)).scalar_one()
|
||||
return recovered, False
|
||||
|
||||
async def _find_or_create_source(
|
||||
self, *, artist_id: int, platform: str, url: str,
|
||||
) -> tuple[Source, bool]:
|
||||
"""Race-safe — same pattern as _find_or_create_artist above. The
|
||||
uq_source_artist_platform_url constraint catches the duplicate
|
||||
insert; we roll the savepoint back and re-select."""
|
||||
existing = (await self.session.execute(
|
||||
select(Source).where(
|
||||
Source.artist_id == artist_id,
|
||||
@@ -120,8 +141,24 @@ class ExtensionService:
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
src = Source(artist_id=artist_id, platform=platform, url=url, enabled=True)
|
||||
self.session.add(src)
|
||||
await self.session.flush()
|
||||
sp = await self.session.begin_nested()
|
||||
try:
|
||||
src = Source(
|
||||
artist_id=artist_id, platform=platform,
|
||||
url=url, enabled=True,
|
||||
)
|
||||
self.session.add(src)
|
||||
await self.session.flush()
|
||||
await sp.commit()
|
||||
except IntegrityError:
|
||||
await sp.rollback()
|
||||
recovered = (await self.session.execute(
|
||||
select(Source).where(
|
||||
Source.artist_id == artist_id,
|
||||
Source.platform == platform,
|
||||
Source.url == url,
|
||||
)
|
||||
)).scalar_one()
|
||||
return recovered, False
|
||||
await self.session.commit()
|
||||
return src, True
|
||||
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import (
|
||||
@@ -51,7 +52,14 @@ class SkipReason(StrEnum):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportResult:
|
||||
status: str # 'imported'|'skipped'|'failed'|'superseded'|'attached'
|
||||
# 'imported' — new ImageRecord row created
|
||||
# 'superseded' — existing ImageRecord row got the new file (larger) + sidecar
|
||||
# 'attached' — non-media saved as PostAttachment
|
||||
# 'refreshed' — deep scan re-applied sidecar / filled NULL phash / NULL
|
||||
# artist on an already-imported row (no new ImageRecord)
|
||||
# 'skipped' — no work done (true duplicate, too small, etc.)
|
||||
# 'failed' — pipeline error
|
||||
status: str
|
||||
image_id: int | None = None
|
||||
skip_reason: SkipReason | None = None
|
||||
error: str | None = None
|
||||
@@ -71,6 +79,31 @@ def is_video(path: Path) -> bool:
|
||||
return path.suffix.lower() in VIDEO_EXTS
|
||||
|
||||
|
||||
def _safe_ext(path: Path) -> str:
|
||||
"""Conservatively extract a file extension for PostAttachment.ext
|
||||
(varchar(32)).
|
||||
|
||||
gallery-dl produces some filenames with URL-encoded query-string
|
||||
artifacts embedded into the basename (e.g.
|
||||
`79507046_media_..._https___www.patreon.com_media-u_Z0FBQUFBQm5q...`).
|
||||
`Path.suffix` finds the LAST dot and returns everything after, which
|
||||
in those cases yields a 50+ char "extension" of mostly base64-ish
|
||||
junk. That blows the column. Operator-flagged 2026-05-25.
|
||||
|
||||
Real extensions are short and alphanumeric. We accept anything ≤ 16
|
||||
chars where every post-dot character is alphanumeric; anything else
|
||||
means the input wasn't a real extension and we return the empty
|
||||
string. ext is nullable-ish (empty string still satisfies NOT NULL)
|
||||
and consumers should treat "" as "no known extension".
|
||||
"""
|
||||
suffix = path.suffix.lower()
|
||||
if not suffix or len(suffix) > 16:
|
||||
return ""
|
||||
if not all(c.isalnum() for c in suffix[1:]):
|
||||
return ""
|
||||
return suffix
|
||||
|
||||
|
||||
def _mime_for(path: Path) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
image_mimes = {
|
||||
@@ -126,6 +159,179 @@ class Importer:
|
||||
self.settings = settings
|
||||
self.deep = deep
|
||||
self.attachments = AttachmentStore(images_root)
|
||||
# phash near-dup candidate cache. Archive imports call _import_media
|
||||
# per-member; without this cache the per-member SELECT *FROM
|
||||
# image_record WHERE phash IS NOT NULL fetch repeats N times and a
|
||||
# large library × many-member archive blew past soft_time_limit
|
||||
# (300s) — operator-flagged 2026-05-25. Loaded lazily on first
|
||||
# need, appended to on every imported/superseded outcome, never
|
||||
# invalidated mid-Importer (Importer instances are per-task /
|
||||
# per-archive-import so cross-instance staleness is harmless).
|
||||
self._phash_candidates: list[tuple] | None = None
|
||||
|
||||
def _phash_candidates_cache(self) -> list[tuple]:
|
||||
"""Cached `(phash, width, height, id)` rows from image_record.
|
||||
Loaded on first call, appended-to on subsequent imported/
|
||||
superseded outcomes. Soft-timeout pattern: an archive with N
|
||||
members + a library of M existing rows used to do N × M-row
|
||||
fetches (operator-flagged 2026-05-25); now it's exactly one.
|
||||
|
||||
The per-task lifecycle of Importer (instantiated fresh by
|
||||
import_media_file) bounds the cache's staleness window: cross-
|
||||
process changes (other workers importing concurrently) won't
|
||||
be reflected, but that's the same race the un-cached version
|
||||
had — `find_similar` is best-effort anyway."""
|
||||
if self._phash_candidates is None:
|
||||
rows = self.session.execute(
|
||||
select(
|
||||
ImageRecord.phash,
|
||||
ImageRecord.width,
|
||||
ImageRecord.height,
|
||||
ImageRecord.id,
|
||||
).where(ImageRecord.phash.is_not(None))
|
||||
).all()
|
||||
self._phash_candidates = [
|
||||
(r.phash, r.width or 0, r.height or 0, r.id) for r in rows
|
||||
]
|
||||
return self._phash_candidates
|
||||
|
||||
def _phash_cache_append(self, phash, width, height, image_id) -> None:
|
||||
"""Append a freshly-imported row to the cache so subsequent
|
||||
members of the same archive can match against it."""
|
||||
if self._phash_candidates is not None and phash is not None:
|
||||
self._phash_candidates.append(
|
||||
(phash, width or 0, height or 0, image_id)
|
||||
)
|
||||
|
||||
def _find_or_create_source(
|
||||
self, *, artist_id: int, platform: str, url: str,
|
||||
) -> Source:
|
||||
"""Race-safe find-or-create on `source` keyed by
|
||||
(artist_id, platform, url) — the same key as the
|
||||
`uq_source_artist_platform_url` constraint.
|
||||
|
||||
Two concurrent workers processing different files in the same
|
||||
post can both find no existing Source row then both INSERT,
|
||||
which trips the unique constraint and poisons the session with
|
||||
`psycopg.errors.UniqueViolation`. Operator-flagged 2026-05-26.
|
||||
|
||||
Pattern: select; if absent, open a savepoint and INSERT.
|
||||
On IntegrityError, roll the savepoint back (NOT the outer
|
||||
transaction, which would lose the surrounding scan's progress)
|
||||
and re-select — the concurrent op just created the row we
|
||||
wanted, so the second select will find it.
|
||||
"""
|
||||
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 _source_for_sidecar(
|
||||
self, *, artist_id: int, platform: str, artist_slug: str,
|
||||
) -> Source:
|
||||
"""Filesystem-import sidecar Source resolver.
|
||||
|
||||
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).
|
||||
"""
|
||||
existing = self.session.execute(
|
||||
select(Source)
|
||||
.where(
|
||||
Source.artist_id == artist_id,
|
||||
Source.platform == platform,
|
||||
)
|
||||
.order_by(Source.id.asc())
|
||||
.limit(1)
|
||||
).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, external_post_id: str,
|
||||
) -> Post:
|
||||
"""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,
|
||||
)
|
||||
).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
|
||||
@@ -165,30 +371,13 @@ class Importer:
|
||||
return None
|
||||
sd = parse_sidecar(data)
|
||||
platform = sd.platform or "unknown"
|
||||
url = sd.post_url or f"sidecar:{platform}"
|
||||
src = self.session.execute(
|
||||
select(Source).where(
|
||||
Source.artist_id == artist.id,
|
||||
Source.platform == platform,
|
||||
Source.url == url,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if src is None:
|
||||
src = Source(artist_id=artist.id, platform=platform, url=url)
|
||||
self.session.add(src)
|
||||
self.session.flush()
|
||||
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.session.execute(
|
||||
select(Post).where(
|
||||
Post.source_id == src.id,
|
||||
Post.external_post_id == epid,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if post is None:
|
||||
post = Post(source_id=src.id, external_post_id=epid)
|
||||
self.session.add(post)
|
||||
self.session.flush()
|
||||
return post
|
||||
return self._find_or_create_post(
|
||||
source_id=src.id, external_post_id=epid,
|
||||
)
|
||||
|
||||
def _capture_attachment(
|
||||
self, source: Path, *, post: Post | None = None,
|
||||
@@ -209,7 +398,7 @@ class Importer:
|
||||
sha256=sha,
|
||||
path=stored,
|
||||
original_filename=source.name,
|
||||
ext=source.suffix.lower(),
|
||||
ext=_safe_ext(source),
|
||||
mime=_mime_for(source),
|
||||
size_bytes=source.stat().st_size,
|
||||
))
|
||||
@@ -324,18 +513,7 @@ class Importer:
|
||||
error=f"PIL load failed during phash compute: {exc}",
|
||||
)
|
||||
if phash is not None:
|
||||
cand_rows = self.session.execute(
|
||||
select(
|
||||
ImageRecord.phash,
|
||||
ImageRecord.width,
|
||||
ImageRecord.height,
|
||||
ImageRecord.id,
|
||||
).where(ImageRecord.phash.is_not(None))
|
||||
).all()
|
||||
candidates = [
|
||||
(c.phash, c.width or 0, c.height or 0, c.id)
|
||||
for c in cand_rows
|
||||
]
|
||||
candidates = self._phash_candidates_cache()
|
||||
rel, match_id = find_similar(
|
||||
phash, width or 0, height or 0,
|
||||
candidates, self.settings.phash_threshold,
|
||||
@@ -369,6 +547,7 @@ class Importer:
|
||||
)
|
||||
self.session.add(record)
|
||||
self.session.flush()
|
||||
self._phash_cache_append(phash, width, height, record.id)
|
||||
|
||||
# Folder→artist (anchored to attribution_path).
|
||||
artist = None
|
||||
@@ -392,13 +571,27 @@ class Importer:
|
||||
) -> ImportResult:
|
||||
"""Deep scan: backfill phash/provenance/artist on an
|
||||
already-imported record. METADATA ONLY — never re-runs the pHash
|
||||
near-dup / supersede path. NULL-only, idempotent."""
|
||||
near-dup / supersede path. NULL-only on phash/artist, additive on
|
||||
sidecar Post/Source/ImageProvenance (via _apply_sidecar).
|
||||
Idempotent: a second deep-scan over the same file finds nothing
|
||||
to refresh and is a no-op.
|
||||
|
||||
Returns status="refreshed" so the UI can surface the work done
|
||||
instead of the prior misleading "skipped/duplicate_hash" reading.
|
||||
Operator-flagged 2026-05-25 — IR has had this; FC inherited it
|
||||
as a no-op skip during the original port and the UI showed deep
|
||||
scan as "completed with no changes" even when sidecar metadata
|
||||
actually got re-applied to N existing rows.
|
||||
"""
|
||||
if existing.phash is None and not is_video(source):
|
||||
try:
|
||||
with Image.open(source) as im:
|
||||
ph = compute_phash(im)
|
||||
if ph is not None:
|
||||
existing.phash = ph
|
||||
# Promoted from NULL to non-NULL → cache is now stale
|
||||
# (this row would newly qualify for the candidates set).
|
||||
self._phash_candidates = None
|
||||
except Exception as exc:
|
||||
log.warning("deep rephash failed for %s: %s", source, exc)
|
||||
|
||||
@@ -411,10 +604,7 @@ class Importer:
|
||||
|
||||
self._apply_sidecar(existing, attribution_path, artist)
|
||||
self.session.commit()
|
||||
return ImportResult(
|
||||
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
||||
image_id=existing.id, error="deep: re-derived",
|
||||
)
|
||||
return ImportResult(status="refreshed", image_id=existing.id)
|
||||
|
||||
def attach_in_place(
|
||||
self,
|
||||
@@ -498,18 +688,7 @@ class Importer:
|
||||
except Exception:
|
||||
phash = None
|
||||
if phash is not None:
|
||||
cand_rows = self.session.execute(
|
||||
select(
|
||||
ImageRecord.phash,
|
||||
ImageRecord.width,
|
||||
ImageRecord.height,
|
||||
ImageRecord.id,
|
||||
).where(ImageRecord.phash.is_not(None))
|
||||
).all()
|
||||
candidates = [
|
||||
(c.phash, c.width or 0, c.height or 0, c.id)
|
||||
for c in cand_rows
|
||||
]
|
||||
candidates = self._phash_candidates_cache()
|
||||
rel, match_id = find_similar(
|
||||
phash, width or 0, height or 0,
|
||||
candidates, self.settings.phash_threshold,
|
||||
@@ -544,6 +723,7 @@ class Importer:
|
||||
record.artist_id = artist.id
|
||||
self.session.add(record)
|
||||
self.session.flush()
|
||||
self._phash_cache_append(phash, width, height, record.id)
|
||||
|
||||
# Sidecar provenance (best-effort). When `source` is passed, link
|
||||
# the post to that subscription Source instead of creating a new
|
||||
@@ -638,30 +818,15 @@ class Importer:
|
||||
src = explicit_source
|
||||
else:
|
||||
platform = sd.platform or "unknown"
|
||||
url = sd.post_url or f"sidecar:{platform}"
|
||||
src = self.session.execute(
|
||||
select(Source).where(
|
||||
Source.artist_id == artist.id,
|
||||
Source.platform == platform,
|
||||
Source.url == url,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if src is None:
|
||||
src = Source(artist_id=artist.id, platform=platform, url=url)
|
||||
self.session.add(src)
|
||||
self.session.flush()
|
||||
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.session.execute(
|
||||
select(Post).where(
|
||||
Post.source_id == src.id,
|
||||
Post.external_post_id == epid,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if post is None:
|
||||
post = Post(source_id=src.id, external_post_id=epid)
|
||||
self.session.add(post)
|
||||
self.session.flush()
|
||||
post = self._find_or_create_post(
|
||||
source_id=src.id, external_post_id=epid,
|
||||
)
|
||||
if sd.post_url is not None:
|
||||
post.post_url = sd.post_url
|
||||
if sd.post_title is not None:
|
||||
@@ -674,6 +839,15 @@ class Importer:
|
||||
post.attachment_count = sd.attachment_count
|
||||
post.raw_metadata = sd.raw
|
||||
|
||||
# Race-safe (image_record_id, post_id) upsert — mirrors the
|
||||
# _find_or_create_source/post savepoint pattern. The plain
|
||||
# SELECT-then-INSERT pattern lost a race when two workers ran
|
||||
# _apply_sidecar on the same (image, post) pair (e.g. the 5-min
|
||||
# recovery sweep re-enqueued a still-running long import), planting
|
||||
# duplicates that then broke .scalar_one_or_none() on every later
|
||||
# deep-scan rederive (MultipleResultsFound). Alembic 0021 adds the
|
||||
# uq_image_provenance_image_post UNIQUE so this savepoint actually
|
||||
# trips on collision.
|
||||
exists = self.session.execute(
|
||||
select(ImageProvenance.id).where(
|
||||
ImageProvenance.image_record_id == record.id,
|
||||
@@ -681,14 +855,20 @@ class Importer:
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
self.session.add(
|
||||
ImageProvenance(
|
||||
image_record_id=record.id,
|
||||
post_id=post.id,
|
||||
source_id=src.id,
|
||||
captured_metadata=sd.raw,
|
||||
sp = self.session.begin_nested()
|
||||
try:
|
||||
self.session.add(
|
||||
ImageProvenance(
|
||||
image_record_id=record.id,
|
||||
post_id=post.id,
|
||||
source_id=src.id,
|
||||
captured_metadata=sd.raw,
|
||||
)
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
sp.commit()
|
||||
except IntegrityError:
|
||||
sp.rollback()
|
||||
if record.primary_post_id is None:
|
||||
record.primary_post_id = post.id
|
||||
self.session.flush()
|
||||
@@ -759,6 +939,10 @@ class Importer:
|
||||
# created_at intentionally preserved; updated_at auto-bumps.
|
||||
self.session.flush()
|
||||
self.session.commit()
|
||||
# The phash candidate cache (used to avoid N+1 selects during
|
||||
# archive imports) is now stale for `existing.id` — the row's
|
||||
# phash/dimensions changed. Invalidate; the next call re-fetches.
|
||||
self._phash_candidates = None
|
||||
|
||||
# Sidecar enrichment from the new (larger) file's location.
|
||||
# _apply_sidecar resolves artist from the sidecar itself if the
|
||||
@@ -790,8 +974,26 @@ class Importer:
|
||||
pass
|
||||
|
||||
def _transparency_pct(self, source: Path) -> float:
|
||||
"""Fraction of fully-transparent pixels in the image. 0.0 if no alpha."""
|
||||
"""Fraction of fully-transparent pixels in the image. 0.0 if no alpha.
|
||||
|
||||
For animated formats (multi-frame WebP / GIF / APNG), short-circuit
|
||||
to 0.0 instead of decoding every frame. PIL's `getchannel("A")`
|
||||
forces a full decode of all frames in an animated image, which for
|
||||
a large animated WebP takes 5+ minutes and blows past the Celery
|
||||
soft+hard time limits (300s/360s → SIGKILL). Operator-flagged
|
||||
2026-05-26. Transparency analysis on a multi-frame image isn't
|
||||
meaningful for art-curation purposes anyway — different frames
|
||||
have different alpha — so the existing too_transparent skip rule
|
||||
is bypassed entirely for animated content.
|
||||
"""
|
||||
with Image.open(source) as im:
|
||||
if getattr(im, "is_animated", False):
|
||||
log.info(
|
||||
"skipping transparency check for animated image %s "
|
||||
"(n_frames=%d) — avoids multi-frame decode timeout",
|
||||
source, getattr(im, "n_frames", 0),
|
||||
)
|
||||
return 0.0
|
||||
if im.mode not in ("RGBA", "LA") and not (
|
||||
im.mode == "P" and "transparency" in im.info
|
||||
):
|
||||
|
||||
@@ -34,10 +34,21 @@ class Embedder:
|
||||
if self._model is not None:
|
||||
return
|
||||
import torch
|
||||
from transformers import AutoModel, AutoProcessor
|
||||
from transformers import AutoModel, SiglipImageProcessor
|
||||
|
||||
self._torch = torch
|
||||
self._processor = AutoProcessor.from_pretrained(str(self._model_dir))
|
||||
# FC's embedder only does IMAGE inference — never text. AutoProcessor
|
||||
# loads the full processor including SiglipTokenizer, which requires
|
||||
# the sentencepiece library at import time even if we never call it.
|
||||
# SiglipImageProcessor loads ONLY preprocessor_config.json (image
|
||||
# side) and skips the tokenizer config entirely. Operator hit the
|
||||
# ImportError 2026-05-25 once the ml-worker started actually running
|
||||
# tag_and_embed; switching to the image-only loader avoids the
|
||||
# tokenizer dep without adding ~30 MB of unused C++ build to the
|
||||
# lean ml-worker image.
|
||||
self._processor = SiglipImageProcessor.from_pretrained(
|
||||
str(self._model_dir)
|
||||
)
|
||||
self._model = AutoModel.from_pretrained(str(self._model_dir))
|
||||
self._model.eval()
|
||||
|
||||
|
||||
@@ -29,10 +29,12 @@ IMAGES_ROOT = Path("/images")
|
||||
def _map_result_to_status(result):
|
||||
"""(ImportTask.status, should_requeue_ml_and_thumb) for an ImportResult.
|
||||
'superseded' = the kept row's file/ML changed → complete + re-derive.
|
||||
'attached' = a non-art file preserved → complete, no ML/thumb."""
|
||||
'attached' = a non-art file preserved → complete, no ML/thumb.
|
||||
'refreshed' = deep scan refreshed sidecar/phash on an existing row →
|
||||
complete, no ML/thumb re-derive (file/pixels unchanged)."""
|
||||
if result.status in ("imported", "superseded"):
|
||||
return ("complete", True)
|
||||
if result.status == "attached":
|
||||
if result.status in ("attached", "refreshed"):
|
||||
return ("complete", False)
|
||||
if result.status == "skipped":
|
||||
return ("skipped", False)
|
||||
@@ -138,6 +140,15 @@ def _do_import(session, task, import_task_id: int) -> dict:
|
||||
task.result_image_id = result.image_id
|
||||
counter_col_name = "imported"
|
||||
counter_col = ImportBatch.imported
|
||||
elif result.status == "refreshed":
|
||||
# Deep-scan rederive: existing row got phash/artist/sidecar
|
||||
# refreshed. Task is complete (no further work), but counted in
|
||||
# `refreshed` not `imported` so the UI can surface the actual
|
||||
# work done. operator-flagged 2026-05-25.
|
||||
task.status = "complete"
|
||||
task.result_image_id = result.image_id
|
||||
counter_col_name = "refreshed"
|
||||
counter_col = ImportBatch.refreshed
|
||||
elif result.status == "attached":
|
||||
task.status = "complete"
|
||||
counter_col_name = "attachments"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""scan_library_for_rule Celery task — iterates image_record in keyset-
|
||||
paginated batches, evaluates the audit rule per image, populates
|
||||
LibraryAuditRun.matched_ids. Runs on the maintenance queue with a 2h soft
|
||||
time limit (plenty of margin for 100k+ image libraries at ~100ms PIL
|
||||
decode + histogram per image).
|
||||
|
||||
State machine:
|
||||
start: status='running'
|
||||
end success: status='ready'
|
||||
end error: status='error', error=traceback
|
||||
oversize: status='error', error='matched too many images; tighten threshold'
|
||||
external cancel: scan sees status='cancelled' between batches, exits.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from PIL import Image
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||
|
||||
from ..celery_app import celery
|
||||
from ..models import ImageRecord, LibraryAuditRun
|
||||
from ..services.audits import single_color, transparency
|
||||
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BATCH = 500
|
||||
_PROGRESS_TICK = 100
|
||||
_MAX_MATCHED = 50_000
|
||||
|
||||
_RULES = {
|
||||
"transparency": transparency.evaluate,
|
||||
"single_color": single_color.evaluate,
|
||||
}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.library_audit.scan_library_for_rule",
|
||||
bind=True,
|
||||
autoretry_for=(OperationalError, DBAPIError),
|
||||
retry_backoff=5,
|
||||
retry_backoff_max=60,
|
||||
retry_jitter=True,
|
||||
max_retries=3,
|
||||
soft_time_limit=7200,
|
||||
time_limit=7500,
|
||||
)
|
||||
def scan_library_for_rule(self, audit_id: int) -> dict:
|
||||
"""See module docstring. Returns a small summary dict for eager-mode
|
||||
test assertions (real workers ignore the return value)."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
audit = session.get(LibraryAuditRun, audit_id)
|
||||
if audit is None:
|
||||
return {"audit_id": audit_id, "status": "missing"}
|
||||
evaluate = _RULES.get(audit.rule)
|
||||
if evaluate is None:
|
||||
_mark_error(session, audit_id, f"unknown rule {audit.rule!r}")
|
||||
return {"audit_id": audit_id, "status": "error"}
|
||||
params = dict(audit.params or {})
|
||||
matched: list[int] = []
|
||||
scanned = 0
|
||||
last_id = 0
|
||||
while True:
|
||||
# Cancellation check between batches.
|
||||
current_status = session.execute(
|
||||
select(LibraryAuditRun.status)
|
||||
.where(LibraryAuditRun.id == audit_id)
|
||||
).scalar_one()
|
||||
if current_status == "cancelled":
|
||||
return {"audit_id": audit_id, "status": "cancelled"}
|
||||
rows = session.execute(
|
||||
select(ImageRecord.id, ImageRecord.path)
|
||||
.where(ImageRecord.id > last_id)
|
||||
.where(ImageRecord.mime.like("image/%"))
|
||||
.order_by(ImageRecord.id.asc())
|
||||
.limit(_BATCH)
|
||||
).all()
|
||||
if not rows:
|
||||
break
|
||||
for image_id, image_path in rows:
|
||||
last_id = image_id
|
||||
scanned += 1
|
||||
try:
|
||||
with Image.open(image_path) as im:
|
||||
try:
|
||||
if evaluate(im, **params):
|
||||
matched.append(image_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"audit %s: rule evaluate failed on %s: %s",
|
||||
audit_id, image_path, exc,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
log.warning(
|
||||
"audit %s: image_record %s file missing at %s; skipping",
|
||||
audit_id, image_id, image_path,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"audit %s: PIL load failed for %s: %s",
|
||||
audit_id, image_path, exc,
|
||||
)
|
||||
if len(matched) > _MAX_MATCHED:
|
||||
_mark_error(
|
||||
session, audit_id,
|
||||
f"matched > {_MAX_MATCHED} images; "
|
||||
"tighten threshold and re-run",
|
||||
)
|
||||
return {"audit_id": audit_id, "status": "error"}
|
||||
if scanned % _PROGRESS_TICK == 0:
|
||||
session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.id == audit_id)
|
||||
.values(scanned_count=scanned)
|
||||
)
|
||||
session.commit()
|
||||
# Final state.
|
||||
session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.id == audit_id)
|
||||
.values(
|
||||
scanned_count=scanned,
|
||||
matched_count=len(matched),
|
||||
matched_ids=matched,
|
||||
status="ready",
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return {
|
||||
"audit_id": audit_id,
|
||||
"status": "ready",
|
||||
"scanned": scanned,
|
||||
"matched": len(matched),
|
||||
}
|
||||
except SoftTimeLimitExceeded:
|
||||
with SessionLocal() as session:
|
||||
_mark_error(session, audit_id, "soft_time_limit exceeded (>7200s)")
|
||||
raise
|
||||
except (OperationalError, DBAPIError):
|
||||
# Retryable per the decorator; leave row in 'running' and let
|
||||
# autoretry try again. Recovery sweep catches if all retries fail.
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
tb = traceback.format_exc()
|
||||
with SessionLocal() as session:
|
||||
_mark_error(session, audit_id, tb)
|
||||
raise
|
||||
|
||||
|
||||
def _mark_error(session, audit_id: int, error_msg: str) -> None:
|
||||
session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.id == audit_id)
|
||||
.values(
|
||||
status="error",
|
||||
error=error_msg,
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@@ -54,41 +54,40 @@ def recover_interrupted_tasks() -> int:
|
||||
processing_cutoff = now - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
|
||||
orphan_cutoff = now - timedelta(minutes=ORPHAN_PENDING_THRESHOLD_MINUTES)
|
||||
with SessionLocal() as session:
|
||||
stuck_ids = session.execute(
|
||||
select(ImportTask.id)
|
||||
# Both sweeps used to be SELECT ids → UPDATE WHERE id IN (...) which
|
||||
# blew past psycopg's 65535-parameter ceiling once a sweep covered
|
||||
# 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 ids that flipped so the stuck sweep can still
|
||||
# .delay() each one.
|
||||
stuck_result = session.execute(
|
||||
update(ImportTask)
|
||||
.where(ImportTask.status == "processing")
|
||||
.where(ImportTask.started_at < processing_cutoff)
|
||||
).scalars().all()
|
||||
.values(
|
||||
status="queued",
|
||||
started_at=None,
|
||||
error="recovered from stuck state",
|
||||
)
|
||||
.returning(ImportTask.id)
|
||||
)
|
||||
stuck_ids = [row[0] for row in stuck_result.all()]
|
||||
|
||||
orphan_ids = session.execute(
|
||||
select(ImportTask.id)
|
||||
orphan_result = session.execute(
|
||||
update(ImportTask)
|
||||
.where(ImportTask.status.in_(["pending", "queued"]))
|
||||
.where(ImportTask.created_at < orphan_cutoff)
|
||||
).scalars().all()
|
||||
|
||||
if not stuck_ids and not orphan_ids:
|
||||
return 0
|
||||
|
||||
if stuck_ids:
|
||||
session.execute(
|
||||
update(ImportTask)
|
||||
.where(ImportTask.id.in_(stuck_ids))
|
||||
.values(status="queued", started_at=None, error="recovered from stuck state")
|
||||
)
|
||||
|
||||
if orphan_ids:
|
||||
session.execute(
|
||||
update(ImportTask)
|
||||
.where(ImportTask.id.in_(orphan_ids))
|
||||
.values(
|
||||
status="failed",
|
||||
error=(
|
||||
"orphan pending/queued swept by recover_interrupted_tasks "
|
||||
"(scanner likely crashed mid-enqueue); retry via "
|
||||
"/api/import/retry-failed"
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="failed",
|
||||
error=(
|
||||
"orphan pending/queued swept by recover_interrupted_tasks "
|
||||
"(scanner likely crashed mid-enqueue); retry via "
|
||||
"/api/import/retry-failed"
|
||||
),
|
||||
)
|
||||
)
|
||||
orphan_count = orphan_result.rowcount or 0
|
||||
|
||||
session.commit()
|
||||
|
||||
@@ -97,7 +96,7 @@ def recover_interrupted_tasks() -> int:
|
||||
for tid in stuck_ids:
|
||||
import_media_file.delay(tid)
|
||||
|
||||
return len(stuck_ids) + len(orphan_ids)
|
||||
return len(stuck_ids) + orphan_count
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_tasks")
|
||||
|
||||
@@ -59,16 +59,25 @@ def scan_directory(self, triggered_by: str = "manual",
|
||||
session.flush()
|
||||
batch_id = batch.id
|
||||
|
||||
# Skip-set: any source_path that already has a non-failed ImportTask
|
||||
# row. Re-running scan_directory must not re-enqueue files the
|
||||
# importer has already handled (or is currently handling); doing so
|
||||
# creates duplicate work and inflates the queue. Failed prior tasks
|
||||
# are eligible for retry.
|
||||
# Skip-set behavior splits by mode (operator-flagged 2026-05-25):
|
||||
#
|
||||
# quick: any non-failed prior ImportTask (active OR finished) is
|
||||
# skipped — quick scan only does new-file enqueue, so re-touching
|
||||
# already-imported files is wasted work.
|
||||
#
|
||||
# deep: ONLY currently-in-flight tasks (pending/queued/processing)
|
||||
# are skipped. Completed and skipped tasks ARE re-queued because
|
||||
# deep scan exists precisely to re-touch already-imported files
|
||||
# (refresh sidecar metadata, fill NULL phash, fill NULL artist
|
||||
# via Importer._deep_rederive). Matches IR's deep-scan behavior.
|
||||
active_statuses = ["pending", "queued", "processing"]
|
||||
if mode == "deep":
|
||||
skip_statuses = active_statuses
|
||||
else:
|
||||
skip_statuses = active_statuses + ["complete", "skipped"]
|
||||
non_failed_existing = set(session.execute(
|
||||
select(ImportTask.source_path).where(
|
||||
ImportTask.status.in_(
|
||||
["pending", "queued", "processing", "complete", "skipped"]
|
||||
),
|
||||
ImportTask.status.in_(skip_statuses),
|
||||
)
|
||||
).scalars().all())
|
||||
|
||||
|
||||
@@ -17,6 +17,30 @@ from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||
|
||||
IMAGES_ROOT = Path("/images")
|
||||
|
||||
THUMB_MAGIC_JPEG = b"\xff\xd8\xff"
|
||||
THUMB_MAGIC_PNG = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def _thumb_is_valid(path: Path) -> bool:
|
||||
"""Return True iff `path` exists and starts with a JPEG or PNG magic header.
|
||||
|
||||
The on-disk thumbnail format is set by services/thumbnailer.py — JPEG for
|
||||
opaque sources, PNG for alpha sources. Anything else (missing file, OSError,
|
||||
truncated, wrong magic) is invalid.
|
||||
"""
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
head = f.read(12)
|
||||
except OSError:
|
||||
return False
|
||||
if len(head) < 8:
|
||||
return False
|
||||
if head[:3] == THUMB_MAGIC_JPEG:
|
||||
return True
|
||||
if head[:8] == THUMB_MAGIC_PNG:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.thumbnail.generate_thumbnail",
|
||||
@@ -50,3 +74,58 @@ def generate_thumbnail(self, image_id: int) -> dict:
|
||||
session.add(record)
|
||||
session.commit()
|
||||
return {"status": "ok", "image_id": image_id, "path": str(result.path)}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.thumbnail.backfill_thumbnails",
|
||||
bind=True,
|
||||
)
|
||||
def backfill_thumbnails(self) -> dict:
|
||||
"""Scan ImageRecord and enqueue generate_thumbnail for rows whose
|
||||
thumbnail is missing, gone from disk, or has wrong magic bytes.
|
||||
|
||||
Keyset paginates by id ASC, page size 500. NULLs out thumbnail_path for
|
||||
rows that point at a missing or corrupt file before enqueueing — keeps
|
||||
the DB self-consistent on partial runs and makes re-runs safe.
|
||||
|
||||
Returns {"enqueued": N, "ok": M, "regenerated": K} where:
|
||||
- enqueued = total generate_thumbnail.delay() calls
|
||||
- ok = rows whose existing thumbnail file is valid (skipped)
|
||||
- regenerated = subset of enqueued that had a non-NULL thumbnail_path
|
||||
cleared (i.e. missing + corrupt)
|
||||
"""
|
||||
from sqlalchemy import select, update
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
enqueued = 0
|
||||
ok = 0
|
||||
regenerated = 0
|
||||
last_id = 0
|
||||
with SessionLocal() as session:
|
||||
while True:
|
||||
rows = session.execute(
|
||||
select(ImageRecord.id, ImageRecord.thumbnail_path)
|
||||
.where(ImageRecord.id > last_id)
|
||||
.order_by(ImageRecord.id.asc())
|
||||
.limit(500)
|
||||
).all()
|
||||
if not rows:
|
||||
break
|
||||
for image_id, thumb_path in rows:
|
||||
if thumb_path is None:
|
||||
generate_thumbnail.delay(image_id)
|
||||
enqueued += 1
|
||||
elif _thumb_is_valid(Path(thumb_path)):
|
||||
ok += 1
|
||||
else:
|
||||
session.execute(
|
||||
update(ImageRecord)
|
||||
.where(ImageRecord.id == image_id)
|
||||
.values(thumbnail_path=None)
|
||||
)
|
||||
generate_thumbnail.delay(image_id)
|
||||
enqueued += 1
|
||||
regenerated += 1
|
||||
session.commit()
|
||||
last_id = rows[-1][0]
|
||||
return {"enqueued": enqueued, "ok": ok, "regenerated": regenerated}
|
||||
|
||||
@@ -13,8 +13,21 @@ HASH_SIZE = 8
|
||||
|
||||
def compute_phash(pil_image) -> str | None:
|
||||
"""Perceptual hash of an opened PIL image, as a hex string. None on any
|
||||
failure (videos/unreadable/non-image)."""
|
||||
failure (videos/unreadable/non-image).
|
||||
|
||||
For animated images (multi-frame WebP/GIF/APNG), explicitly seek to
|
||||
frame 0 first. Without this, some PIL operations downstream of
|
||||
imagehash.phash (convert("L"), resize) can iterate all frames and
|
||||
blow past Celery's hard time limit on large animations
|
||||
(operator-flagged 2026-05-26 against animated WebPs). The pHash of
|
||||
frame 0 is the conventional choice for animated content.
|
||||
"""
|
||||
try:
|
||||
if getattr(pil_image, "is_animated", False):
|
||||
try:
|
||||
pil_image.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
return str(imagehash.phash(pil_image, hash_size=HASH_SIZE))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -4,6 +4,7 @@ No per-platform branching: a small common key set with fallbacks; the
|
||||
full JSON is kept in raw so anything unmapped is recoverable later.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -21,10 +22,28 @@ class SidecarData:
|
||||
raw: dict
|
||||
|
||||
|
||||
# gallery-dl prefixes media filenames with `NN_` for in-post ordering
|
||||
# (`01_HOLLOW-ICHIGO.png`, `02_HOLOW ICHIGO.zip`) but writes the sidecar
|
||||
# under the attachment's stem WITHOUT that ordering prefix
|
||||
# (`HOLLOW-ICHIGO.json`). Strip the prefix when looking for sidecars.
|
||||
# Confirmed against real Patreon downloads 2026-05-26 — without this
|
||||
# strip, every gallery-dl post-level sidecar was invisible to FC since
|
||||
# FC-3 shipped (24 deep-refresh calls produced 0 Posts in operator's DB).
|
||||
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")
|
||||
|
||||
|
||||
def find_sidecar(media: Path) -> Path | None:
|
||||
# Attachment-level sidecars (image.jpg.json, image.json).
|
||||
for cand in (media.with_suffix(".json"), Path(str(media) + ".json")):
|
||||
if cand.is_file():
|
||||
return cand
|
||||
# gallery-dl post-numbered convention: strip the `NN_` prefix from
|
||||
# the stem and look for that.json in the same directory.
|
||||
m = _NUMBERING_PREFIX.match(media.stem)
|
||||
if m:
|
||||
cand = media.parent / f"{m.group(1)}.json"
|
||||
if cand.is_file():
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
"lint": "web-ext lint --source-dir=.",
|
||||
"start": "web-ext run --source-dir=. --firefox=firefox",
|
||||
"build": "web-ext build --source-dir=. --overwrite-dest",
|
||||
"sign": "web-ext sign --source-dir=. --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||
"lint": "web-ext lint --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore",
|
||||
"start": "web-ext run --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --firefox=firefox",
|
||||
"build": "web-ext build --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --overwrite-dest",
|
||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||
},
|
||||
"devDependencies": {
|
||||
"web-ext": "^8.0.0"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
module.exports = {
|
||||
sourceDir: '.',
|
||||
artifactsDir: './web-ext-artifacts',
|
||||
ignoreFiles: [
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'web-ext-config.cjs',
|
||||
'web-ext-artifacts',
|
||||
'node_modules',
|
||||
'README.md',
|
||||
'.gitignore',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="fc-artist-gallery">
|
||||
<MasonryGrid
|
||||
:items="store.images"
|
||||
:loading="store.imagesLoading"
|
||||
:has-more="store.hasMoreImages"
|
||||
@load-more="store.loadMoreImages(props.slug)"
|
||||
@open="openImage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useArtistStore } from '../../stores/artist.js'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import MasonryGrid from '../discovery/MasonryGrid.vue'
|
||||
|
||||
const props = defineProps({
|
||||
slug: { type: String, required: true },
|
||||
})
|
||||
|
||||
const store = useArtistStore()
|
||||
const modal = useModalStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
const initial = parseInt(route.query.image, 10)
|
||||
if (!isNaN(initial)) modal.open(initial)
|
||||
})
|
||||
|
||||
watch(() => route.query.image, (q) => {
|
||||
const id = parseInt(q, 10)
|
||||
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
|
||||
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
|
||||
})
|
||||
|
||||
function openImage (id) {
|
||||
router.push({ query: { ...route.query, image: id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-artist-gallery { min-width: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<header class="fc-artist-header">
|
||||
<div class="fc-artist-header__left">
|
||||
<h1 class="fc-artist-header__name">{{ name }}</h1>
|
||||
<span v-if="stats" class="fc-artist-header__stats">{{ stats }}</span>
|
||||
</div>
|
||||
<v-tabs
|
||||
:model-value="modelValue"
|
||||
color="accent"
|
||||
density="compact"
|
||||
class="fc-artist-header__tabs"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-tab value="posts">
|
||||
Posts
|
||||
<span v-if="postCount != null" class="fc-artist-header__tab-count">
|
||||
({{ postCount }})
|
||||
</span>
|
||||
</v-tab>
|
||||
<v-tab value="gallery">
|
||||
Gallery
|
||||
<span v-if="imageCount != null" class="fc-artist-header__tab-count">
|
||||
({{ imageCount }})
|
||||
</span>
|
||||
</v-tab>
|
||||
<v-tab value="management">Management</v-tab>
|
||||
</v-tabs>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
imageCount: { type: Number, default: null },
|
||||
postCount: { type: Number, default: null },
|
||||
lastAdded: { type: String, default: null },
|
||||
modelValue: { type: String, required: true },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const stats = computed(() => {
|
||||
const parts = []
|
||||
if (props.imageCount != null) {
|
||||
parts.push(`${props.imageCount} image${props.imageCount === 1 ? '' : 's'}`)
|
||||
}
|
||||
if (props.lastAdded) {
|
||||
parts.push(`last added ${props.lastAdded.slice(0, 10)}`)
|
||||
}
|
||||
return parts.join(' · ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Matches TopNav.vue's frosted recipe exactly — top:64px parks it under
|
||||
the 64px-tall TopNav with no visible seam. */
|
||||
.fc-artist-header {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.fc-artist-header__left {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fc-artist-header__name {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fc-artist-header__stats {
|
||||
font-size: 13px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fc-artist-header__tabs {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fc-artist-header__tab-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="fc-artist-mgmt">
|
||||
<section class="fc-artist-mgmt__sec">
|
||||
<h2 class="fc-h2">Overview</h2>
|
||||
<div class="fc-artist-mgmt__chips">
|
||||
<v-chip
|
||||
size="small"
|
||||
:variant="overview.is_subscription ? 'flat' : 'outlined'"
|
||||
:color="overview.is_subscription ? 'accent' : undefined"
|
||||
prepend-icon="mdi-rss"
|
||||
>{{ overview.is_subscription ? 'Subscription' : 'One-off' }}</v-chip>
|
||||
<v-chip
|
||||
size="small" variant="outlined" prepend-icon="mdi-link-variant"
|
||||
:to="`/subscriptions?artist_id=${overview.id}`"
|
||||
>{{ overview.sources.length }} subscription{{ overview.sources.length === 1 ? '' : 's' }}</v-chip>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="overview.cooccurring_tags.length" class="fc-artist-mgmt__sec">
|
||||
<h2 class="fc-h2">Frequent tags</h2>
|
||||
<div class="fc-artist-mgmt__tags">
|
||||
<v-chip
|
||||
v-for="t in overview.cooccurring_tags" :key="t.id"
|
||||
size="small" @click="openTag(t.id)"
|
||||
>{{ t.name }} <span class="fc-artist-mgmt__tagc">{{ t.count }}</span></v-chip>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="overview.activity.length" class="fc-artist-mgmt__sec">
|
||||
<h2 class="fc-h2">Activity</h2>
|
||||
<svg
|
||||
class="fc-artist-mgmt__spark" :viewBox="`0 0 ${sparkW} ${sparkH}`"
|
||||
preserveAspectRatio="none" role="img" aria-label="posts over time"
|
||||
>
|
||||
<polyline :points="sparkPoints" fill="none"
|
||||
stroke="rgb(var(--v-theme-accent))" stroke-width="2" />
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<section v-if="overview.sources.length" class="fc-artist-mgmt__sec">
|
||||
<div class="fc-artist-mgmt__sec-head">
|
||||
<h2 class="fc-h2">Subscriptions</h2>
|
||||
<RouterLink
|
||||
:to="`/subscriptions?artist_id=${overview.id}`"
|
||||
class="fc-artist-mgmt__manage"
|
||||
>Manage subscriptions →</RouterLink>
|
||||
</div>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in overview.sources" :key="s.id">
|
||||
<td>{{ s.platform }}</td>
|
||||
<td class="fc-artist-mgmt__url">{{ s.url }}</td>
|
||||
<td class="text-right">{{ s.image_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</section>
|
||||
|
||||
<section class="fc-artist-mgmt__sec">
|
||||
<h2 class="fc-h2">Danger zone</h2>
|
||||
<ArtistDangerZone
|
||||
:slug="overview.slug"
|
||||
:artist-id="overview.id"
|
||||
:artist-name="overview.name"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
|
||||
import ArtistDangerZone from './ArtistDangerZone.vue'
|
||||
|
||||
const props = defineProps({
|
||||
overview: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const sparkW = 600
|
||||
const sparkH = 80
|
||||
const sparkPoints = computed(() => {
|
||||
const a = props.overview.activity ?? []
|
||||
if (a.length === 0) return ''
|
||||
const max = Math.max(...a.map(p => p.count), 1)
|
||||
const stepX = a.length > 1 ? sparkW / (a.length - 1) : 0
|
||||
return a.map((p, i) => {
|
||||
const x = i * stepX
|
||||
const y = sparkH - (p.count / max) * (sparkH - 4) - 2
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
})
|
||||
|
||||
function openTag (tagId) {
|
||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-artist-mgmt { padding-top: 1rem; }
|
||||
.fc-h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 20px; font-weight: 500; margin-bottom: 8px;
|
||||
}
|
||||
.fc-artist-mgmt__sec { margin-bottom: 28px; }
|
||||
.fc-artist-mgmt__chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.fc-artist-mgmt__tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.fc-artist-mgmt__tagc {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-left: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fc-artist-mgmt__spark { width: 100%; height: 80px; }
|
||||
.fc-artist-mgmt__url {
|
||||
max-width: 380px; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fc-artist-mgmt__sec-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.fc-artist-mgmt__manage {
|
||||
font-size: 0.85rem;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
text-decoration: none;
|
||||
}
|
||||
.fc-artist-mgmt__manage:hover { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="fc-artist-posts">
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="store.loading && store.items.length === 0" class="fc-artist-posts__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.items.length === 0 && store.done" class="fc-artist-posts__empty">
|
||||
<p>No posts for this artist yet. Switch to
|
||||
<a href="#" @click.prevent="$emit('switch-tab', 'gallery')">Gallery</a>
|
||||
to see imported images, or visit
|
||||
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
|
||||
to start capturing posts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
|
||||
<div ref="sentinel" class="fc-artist-posts__sentinel">
|
||||
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
|
||||
<span v-else-if="store.done" class="fc-artist-posts__end">End of stream</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import PostCard from '../posts/PostCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
artistId: { type: Number, required: true },
|
||||
})
|
||||
|
||||
defineEmits(['switch-tab'])
|
||||
|
||||
const store = usePostsStore()
|
||||
const sentinel = ref(null)
|
||||
let observer = null
|
||||
|
||||
async function reload () {
|
||||
await store.loadInitial({ artist_id: props.artistId, platform: null })
|
||||
}
|
||||
|
||||
watch(() => props.artistId, reload)
|
||||
|
||||
onMounted(async () => {
|
||||
await reload()
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) {
|
||||
store.loadMore()
|
||||
}
|
||||
}, { rootMargin: '400px 0px' })
|
||||
if (sentinel.value) observer.observe(sentinel.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) observer.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-artist-posts {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.fc-artist-posts__loading,
|
||||
.fc-artist-posts__empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-artist-posts__sentinel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 0;
|
||||
min-height: 2rem;
|
||||
}
|
||||
.fc-artist-posts__end {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<v-card class="fc-clean-card">
|
||||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||
<v-icon icon="mdi-image-size-select-small" size="small" />
|
||||
<span>Minimum dimensions</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Find and delete images smaller than the threshold. Mirrors the
|
||||
import-time <code>min_width</code> / <code>min_height</code>
|
||||
filter, applied retroactively to the existing library.
|
||||
</p>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="minW" label="Min width (px)" type="number"
|
||||
min="0" density="compact" hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="minH" label="Min height (px)" type="number"
|
||||
min="0" density="compact" hide-details
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="d-flex align-center mt-3" style="gap: 10px;">
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="busy"
|
||||
@click="onPreview"
|
||||
>Preview</v-btn>
|
||||
|
||||
<span v-if="preview" class="text-body-2">
|
||||
<strong>{{ preview.count }}</strong> image(s) would be deleted.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
v-if="preview && preview.count > 0"
|
||||
class="mt-3"
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="onDeleteClick"
|
||||
>Delete {{ preview.count }} matching...</v-btn>
|
||||
</v-card-text>
|
||||
|
||||
<DestructiveConfirmModal
|
||||
v-model="showModal"
|
||||
action="delete"
|
||||
kind="min-dim"
|
||||
:run-id="tokenSha8"
|
||||
tier="C"
|
||||
:projected-counts="projectedCounts"
|
||||
:description="`Width < ${minW} OR height < ${minH}`"
|
||||
@confirm="onConfirmedDelete"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||
import { useCleanupStore } from '../../stores/cleanup.js'
|
||||
|
||||
const store = useCleanupStore()
|
||||
const minW = ref(0)
|
||||
const minH = ref(0)
|
||||
const preview = ref(null)
|
||||
const busy = ref(false)
|
||||
const showModal = ref(false)
|
||||
const tokenSha8 = ref('')
|
||||
const projectedCounts = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadDefaults()
|
||||
minW.value = store.defaults.min_width
|
||||
minH.value = store.defaults.min_height
|
||||
})
|
||||
|
||||
// SHA-256 truncated to 8 hex chars — matches the backend's
|
||||
// _min_dim_token() exactly. Web Crypto rejects MD5 as insecure.
|
||||
async function sha8(canon) {
|
||||
const enc = new TextEncoder()
|
||||
const buf = await crypto.subtle.digest('SHA-256', enc.encode(canon))
|
||||
const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
return hex.slice(0, 8)
|
||||
}
|
||||
|
||||
async function onPreview() {
|
||||
busy.value = true
|
||||
try {
|
||||
preview.value = await store.previewMinDim(minW.value, minH.value)
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Preview failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteClick() {
|
||||
tokenSha8.value = await sha8(`${minW.value}x${minH.value}`)
|
||||
projectedCounts.value = { 'Images to delete': preview.value.count }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function onConfirmedDelete(token) {
|
||||
try {
|
||||
const res = await store.deleteMinDim(minW.value, minH.value, token)
|
||||
window.__fcToast?.({
|
||||
text: `Deleted ${res.deleted} image(s)`, type: 'success',
|
||||
})
|
||||
preview.value = null
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Delete failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-clean-card { border-radius: 8px; }
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<v-card class="fc-clean-card">
|
||||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||
<v-icon icon="mdi-palette-swatch" size="small" />
|
||||
<span>Single-color audit</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Scan library for images dominated by one color within the
|
||||
tolerance. Catches placeholder / solid-fill / error-page images
|
||||
that slipped through the import filter. Same background-scan
|
||||
cadence as the transparency audit.
|
||||
</p>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="threshold" label="Threshold (0–1)"
|
||||
type="number" min="0" max="1" step="0.01"
|
||||
density="compact" hide-details
|
||||
:disabled="audit && audit.status === 'running'"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="tolerance" label="Color tolerance (0–441)"
|
||||
type="number" min="0" max="441"
|
||||
density="compact" hide-details
|
||||
:disabled="audit && audit.status === 'running'"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-btn
|
||||
v-if="!audit || audit.status !== 'running'"
|
||||
class="mt-3"
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify-scan"
|
||||
:loading="busy"
|
||||
@click="onStart"
|
||||
>Scan library</v-btn>
|
||||
|
||||
<div v-if="audit && audit.status === 'running'" class="mt-3">
|
||||
<v-progress-linear indeterminate color="accent" />
|
||||
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
|
||||
<span>
|
||||
Scanning… {{ audit.scanned_count }} checked,
|
||||
{{ audit.matched_count }} matched
|
||||
</span>
|
||||
<v-btn
|
||||
variant="text" size="small" color="warning" rounded="pill"
|
||||
@click="onCancel"
|
||||
>Cancel</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="audit && audit.status === 'ready'" class="mt-3">
|
||||
<p class="text-body-2 mb-2">
|
||||
Scan complete. <strong>{{ audit.matched_count }}</strong>
|
||||
image(s) match.
|
||||
</p>
|
||||
<v-btn
|
||||
v-if="audit.matched_count > 0"
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="onApplyClick"
|
||||
>Delete {{ audit.matched_count }} matching...</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="audit && audit.status === 'error'"
|
||||
type="error" variant="tonal" density="compact" class="mt-3"
|
||||
>Scan failed: {{ audit.error }}</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="audit && audit.status === 'applied'"
|
||||
type="success" variant="tonal" density="compact" class="mt-3"
|
||||
>Applied — matched images deleted.</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<DestructiveConfirmModal
|
||||
v-if="audit"
|
||||
v-model="showModal"
|
||||
action="delete"
|
||||
kind="audit"
|
||||
:run-id="audit.id"
|
||||
tier="C"
|
||||
:projected-counts="projectedCounts"
|
||||
description="Permanently deletes images matched by the single-color scan."
|
||||
@confirm="onConfirmedApply"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||
import { useCleanupStore } from '../../stores/cleanup.js'
|
||||
|
||||
const store = useCleanupStore()
|
||||
const threshold = ref(0.95)
|
||||
const tolerance = ref(30)
|
||||
const audit = ref(null)
|
||||
const busy = ref(false)
|
||||
const showModal = ref(false)
|
||||
const projectedCounts = ref({})
|
||||
let pollTimer = null
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadDefaults()
|
||||
threshold.value = store.defaults.single_color_threshold
|
||||
tolerance.value = store.defaults.single_color_tolerance
|
||||
})
|
||||
|
||||
onUnmounted(() => stopPoll())
|
||||
|
||||
function startPoll(id) {
|
||||
stopPoll()
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const fresh = await store.getAudit(id)
|
||||
audit.value = fresh
|
||||
if (fresh.status !== 'running') stopPoll()
|
||||
} catch (e) {
|
||||
stopPoll()
|
||||
window.__fcToast?.({ text: `Audit poll failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
async function onStart() {
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await store.startAudit('single_color', {
|
||||
threshold: threshold.value, tolerance: tolerance.value,
|
||||
})
|
||||
audit.value = await store.getAudit(res.audit_id)
|
||||
startPoll(res.audit_id)
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Scan start failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (!audit.value) return
|
||||
try {
|
||||
await store.cancelAudit(audit.value.id)
|
||||
audit.value = await store.getAudit(audit.value.id)
|
||||
stopPoll()
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Cancel failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
function onApplyClick() {
|
||||
projectedCounts.value = { 'Images to delete': audit.value.matched_count }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function onConfirmedApply(token) {
|
||||
try {
|
||||
const res = await store.applyAudit(audit.value.id, token)
|
||||
window.__fcToast?.({
|
||||
text: `Deleted ${res.deleted} image(s)`, type: 'success',
|
||||
})
|
||||
audit.value = await store.getAudit(audit.value.id)
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Apply failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-clean-card { border-radius: 8px; }
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<v-card class="fc-clean-card">
|
||||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||
<v-icon icon="mdi-checkerboard" size="small" />
|
||||
<span>Transparency audit</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Scan library for images whose transparent-pixel fraction exceeds
|
||||
the threshold. Animated WebPs / GIFs are skipped (the import-side
|
||||
rule does the same). Runs as a background task — ~50ms per image,
|
||||
so a 57k library takes ~50 minutes.
|
||||
</p>
|
||||
|
||||
<v-text-field
|
||||
v-model.number="threshold" label="Transparency threshold (0–1)"
|
||||
type="number" min="0" max="1" step="0.01" density="compact" hide-details
|
||||
:disabled="audit && audit.status === 'running'"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
v-if="!audit || audit.status !== 'running'"
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify-scan"
|
||||
:loading="busy"
|
||||
@click="onStart"
|
||||
>Scan library</v-btn>
|
||||
|
||||
<div v-if="audit && audit.status === 'running'" class="mt-3">
|
||||
<v-progress-linear indeterminate color="accent" />
|
||||
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
|
||||
<span>
|
||||
Scanning… {{ audit.scanned_count }} checked,
|
||||
{{ audit.matched_count }} matched
|
||||
</span>
|
||||
<v-btn
|
||||
variant="text" size="small" color="warning" rounded="pill"
|
||||
@click="onCancel"
|
||||
>Cancel</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="audit && audit.status === 'ready'" class="mt-3">
|
||||
<p class="text-body-2 mb-2">
|
||||
Scan complete. <strong>{{ audit.matched_count }}</strong>
|
||||
image(s) match.
|
||||
</p>
|
||||
<v-btn
|
||||
v-if="audit.matched_count > 0"
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="onApplyClick"
|
||||
>Delete {{ audit.matched_count }} matching...</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="audit && audit.status === 'error'"
|
||||
type="error" variant="tonal" density="compact" class="mt-3"
|
||||
>Scan failed: {{ audit.error }}</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="audit && audit.status === 'applied'"
|
||||
type="success" variant="tonal" density="compact" class="mt-3"
|
||||
>Applied — matched images deleted.</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<DestructiveConfirmModal
|
||||
v-if="audit"
|
||||
v-model="showModal"
|
||||
action="delete"
|
||||
kind="audit"
|
||||
:run-id="audit.id"
|
||||
tier="C"
|
||||
:projected-counts="projectedCounts"
|
||||
description="Permanently deletes images matched by the transparency scan."
|
||||
@confirm="onConfirmedApply"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||
import { useCleanupStore } from '../../stores/cleanup.js'
|
||||
|
||||
const store = useCleanupStore()
|
||||
const threshold = ref(0.9)
|
||||
const audit = ref(null)
|
||||
const busy = ref(false)
|
||||
const showModal = ref(false)
|
||||
const projectedCounts = ref({})
|
||||
let pollTimer = null
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadDefaults()
|
||||
threshold.value = store.defaults.transparency_threshold
|
||||
})
|
||||
|
||||
onUnmounted(() => stopPoll())
|
||||
|
||||
function startPoll(id) {
|
||||
stopPoll()
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const fresh = await store.getAudit(id)
|
||||
audit.value = fresh
|
||||
if (fresh.status !== 'running') stopPoll()
|
||||
} catch (e) {
|
||||
stopPoll()
|
||||
window.__fcToast?.({ text: `Audit poll failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
async function onStart() {
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await store.startAudit('transparency', { threshold: threshold.value })
|
||||
audit.value = await store.getAudit(res.audit_id)
|
||||
startPoll(res.audit_id)
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Scan start failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (!audit.value) return
|
||||
try {
|
||||
await store.cancelAudit(audit.value.id)
|
||||
audit.value = await store.getAudit(audit.value.id)
|
||||
stopPoll()
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Cancel failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
function onApplyClick() {
|
||||
projectedCounts.value = { 'Images to delete': audit.value.matched_count }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function onConfirmedApply(token) {
|
||||
try {
|
||||
const res = await store.applyAudit(audit.value.id, token)
|
||||
window.__fcToast?.({
|
||||
text: `Deleted ${res.deleted} image(s)`, type: 'success',
|
||||
})
|
||||
audit.value = await store.getAudit(audit.value.id)
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Apply failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-clean-card { border-radius: 8px; }
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" max-width="900"
|
||||
@update:model-value="$emit('update:modelValue', $event)">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center" style="gap: 12px;">
|
||||
<v-icon icon="mdi-alert-circle-outline" color="error" />
|
||||
<span>{{ displayTitle }}</span>
|
||||
<v-spacer />
|
||||
<v-btn icon variant="text" size="small" @click="close">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<dl v-if="contextRows.length" class="fc-err-context">
|
||||
<template v-for="(row, idx) in contextRows" :key="idx">
|
||||
<dt>{{ row[0] }}</dt>
|
||||
<dd>{{ row[1] }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<pre class="fc-err-pre">{{ displayMessage || '(no error message)' }}</pre>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
variant="text" rounded="pill" size="small"
|
||||
:prepend-icon="copied ? 'mdi-check' : 'mdi-content-copy'"
|
||||
@click="onCopy"
|
||||
>{{ copied ? 'Copied' : 'Copy' }}</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" rounded="pill" @click="close">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { copyText } from '../../utils/clipboard.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
// Legacy mode: pass title + message strings. Used by callers whose row
|
||||
// shape lacks structured context (e.g. ImportTaskList where `error` is
|
||||
// a plain string on the import_task row, not a TaskRun).
|
||||
title: { type: String, default: 'Error details' },
|
||||
message: { type: String, default: '' },
|
||||
// Row mode: pass the full TaskRun-shaped dict from /api/system_activity.
|
||||
// When set, displayTitle/displayMessage derive from the row and a context
|
||||
// panel of task_name/queue/target/duration/etc. renders above the error.
|
||||
row: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const copied = ref(false)
|
||||
let copiedTimer = null
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (props.row) return props.row.error_type || 'Error details'
|
||||
return props.title
|
||||
})
|
||||
|
||||
const displayMessage = computed(() => {
|
||||
if (props.row) return props.row.error_message || ''
|
||||
return props.message
|
||||
})
|
||||
|
||||
function _shortTaskName (name) {
|
||||
if (!name) return ''
|
||||
const parts = String(name).split('.')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
function _formatDuration (ms) {
|
||||
if (ms == null) return null
|
||||
if (ms < 1000) return `${ms} ms`
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
|
||||
return `${(ms / 60_000).toFixed(1)} min`
|
||||
}
|
||||
|
||||
const contextRows = computed(() => {
|
||||
const r = props.row
|
||||
if (!r) return []
|
||||
const rows = []
|
||||
if (r.task_name) rows.push(['Task', _shortTaskName(r.task_name)])
|
||||
if (r.queue) rows.push(['Queue', r.queue])
|
||||
if (r.target_id != null) rows.push(['Target', r.target_id])
|
||||
const dur = _formatDuration(r.duration_ms)
|
||||
if (dur != null) rows.push(['Duration', dur])
|
||||
if (r.started_at) rows.push(['Started', r.started_at])
|
||||
if (r.finished_at) rows.push(['Finished', r.finished_at])
|
||||
if (r.retry_count) rows.push(['Retries', r.retry_count])
|
||||
if (r.worker_hostname) rows.push(['Worker', r.worker_hostname])
|
||||
if (r.celery_task_id) rows.push(['Celery ID', r.celery_task_id])
|
||||
if (r.args_summary) rows.push(['Args', r.args_summary])
|
||||
return rows
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (!open) {
|
||||
copied.value = false
|
||||
if (copiedTimer) { clearTimeout(copiedTimer); copiedTimer = null }
|
||||
}
|
||||
})
|
||||
|
||||
function close () {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function onCopy () {
|
||||
let text = displayMessage.value || ''
|
||||
if (contextRows.value.length) {
|
||||
const header = contextRows.value.map(([k, v]) => `${k}: ${v}`).join('\n')
|
||||
text = `${header}\n\nError: ${displayTitle.value}\n${text}`
|
||||
}
|
||||
try {
|
||||
await copyText(text)
|
||||
copied.value = true
|
||||
if (copiedTimer) clearTimeout(copiedTimer)
|
||||
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Context panel: muted labels in vellum, crisp values in parchment. The
|
||||
2-column key/value grid keeps rows visually scannable when there are
|
||||
many fields (Task, Queue, Target, Duration, Started, Finished, Retries,
|
||||
Worker, Celery ID, Args). */
|
||||
.fc-err-context {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 14px;
|
||||
row-gap: 4px;
|
||||
margin: 0 0 14px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.fc-err-context dt {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fc-err-context dd {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Error pre block: high-contrast pairing. The page's `background` token
|
||||
(obsidian #14171A) is darker than the modal card's `surface` (iron
|
||||
#1E2228), so parchment text reads crisply against it. The prior pairing
|
||||
used `surface-variant` which Vuetify auto-derives to a near-parchment
|
||||
light value in this theme — pale-on-pale and unreadable. Operator-
|
||||
flagged 2026-05-26 ("ui contrast is poor"). */
|
||||
.fc-err-pre {
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgb(var(--v-theme-background));
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,7 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useCredentialsStore } from '../../stores/credentials.js'
|
||||
import { copyText } from '../../utils/clipboard.js'
|
||||
|
||||
const store = useCredentialsStore()
|
||||
const showRotateConfirm = ref(false)
|
||||
@@ -37,7 +38,7 @@ onMounted(() => store.loadKey())
|
||||
async function copyKey() {
|
||||
if (!store.extensionKey) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(store.extensionKey)
|
||||
await copyText(store.extensionKey)
|
||||
globalThis.window?.__fcToast?.({ text: 'Copied', type: 'success' })
|
||||
} catch {
|
||||
globalThis.window?.__fcToast?.({ text: 'Copy failed', type: 'error' })
|
||||
|
||||
@@ -34,11 +34,18 @@
|
||||
|
||||
<template v-else-if="manifest?.installed">
|
||||
<div class="fc-ext-install mt-3">
|
||||
<!-- Install button: direct :href anchor click (no programmatic
|
||||
window.location.assign). Firefox's XPI-install gesture
|
||||
requires a user-clicked anchor pointing at an
|
||||
application/x-xpinstall response; programmatic navigation
|
||||
sometimes triggered nothing instead of the install dialog
|
||||
(operator-flagged 2026-05-26). No `download` attribute —
|
||||
that would force a save dialog instead of install. -->
|
||||
<v-btn
|
||||
v-if="isFirefox"
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-firefox"
|
||||
@click="installXpi"
|
||||
:href="manifest.latest_url"
|
||||
>Install Firefox extension</v-btn>
|
||||
|
||||
<v-btn
|
||||
@@ -103,6 +110,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { copyText } from '../../utils/clipboard.js'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -146,11 +154,6 @@ async function loadKey() {
|
||||
}
|
||||
}
|
||||
|
||||
function installXpi() {
|
||||
if (!manifest.value?.latest_url) return
|
||||
window.location.assign(manifest.value.latest_url)
|
||||
}
|
||||
|
||||
async function rotateKey() {
|
||||
rotating.value = true
|
||||
try {
|
||||
@@ -170,7 +173,7 @@ async function rotateKey() {
|
||||
|
||||
async function copy(text, label) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
await copyText(text)
|
||||
window.__fcToast?.({ text: `${label} copied.`, type: 'success' })
|
||||
} catch (e) {
|
||||
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
|
||||
|
||||
@@ -45,9 +45,11 @@
|
||||
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
|
||||
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
|
||||
<template #item.error="{ item }">
|
||||
<span v-if="item.error" :title="item.error" class="text-caption">
|
||||
{{ shorten(item.error, 60) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="item.error" type="button" class="fc-err-link text-caption"
|
||||
@click="openError(`Task ${item.id} failed`, item.error)"
|
||||
title="Click for full error"
|
||||
>{{ shorten(item.error, 60) }}</button>
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
<div v-if="store.hasMore" class="d-flex justify-center py-3">
|
||||
@@ -100,16 +102,35 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<ErrorDetailModal
|
||||
v-model="showErrorModal"
|
||||
:title="errorModalTitle"
|
||||
:message="errorModalMessage"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||
|
||||
const store = useImportStore()
|
||||
const statusFilter = ref(null)
|
||||
const clearDialog = ref(false)
|
||||
// Click-to-open modal for full error text (operator-flagged 2026-05-26
|
||||
// — the prior :title="..." tooltip cramped multi-line SQLAlchemy
|
||||
// tracebacks into an unusable popup with no copy-paste affordance).
|
||||
const showErrorModal = ref(false)
|
||||
const errorModalTitle = ref('')
|
||||
const errorModalMessage = ref('')
|
||||
|
||||
function openError(title, message) {
|
||||
errorModalTitle.value = title
|
||||
errorModalMessage.value = message || ''
|
||||
showErrorModal.value = true
|
||||
}
|
||||
const clearAgeDays = ref(7)
|
||||
const clearStuckDialog = ref(false)
|
||||
|
||||
@@ -179,3 +200,21 @@ async function onClearStuckConfirm() {
|
||||
clearStuckDialog.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-err-link {
|
||||
/* Truncated error preview as a clickable button — opens
|
||||
ErrorDetailModal with the full text. Inherits the row's font
|
||||
sizing so it doesn't visually drift from the prior tooltip-bearing
|
||||
span. */
|
||||
color: rgb(var(--v-theme-error, 220 80 80));
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: underline dotted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-err-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
indeterminate color="accent" size="20"
|
||||
/>
|
||||
<span>
|
||||
Scanning {{ store.activeBatch.source_path }} —
|
||||
{{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }}
|
||||
{{ store.activeBatch.source_path || '/import' }} —
|
||||
imported {{ store.activeBatch.imported }},
|
||||
<template v-if="store.activeBatch.scan_mode === 'deep'">
|
||||
refreshed {{ store.activeBatch.refreshed || 0 }},
|
||||
</template>
|
||||
skipped {{ store.activeBatch.skipped }},
|
||||
failed {{ store.activeBatch.failed }} /
|
||||
{{ store.activeBatch.total_files }} files
|
||||
@@ -24,8 +28,13 @@
|
||||
|
||||
<p class="text-body-2 mb-3">
|
||||
<span v-if="!store.activeBatch">
|
||||
Run a quick scan of the import directory. Deep scan (pHash dedup,
|
||||
archives) lands in FC-2d.
|
||||
<strong>Quick scan</strong> walks <code>/import</code> and enqueues
|
||||
new files only.
|
||||
<strong>Deep scan</strong> additionally re-walks already-imported
|
||||
files so updated sidecar metadata (post title/date/attribution) and
|
||||
previously-NULL phashes / artist links get refreshed. Use after
|
||||
bulk-downloading fresh sidecars for existing content. Both modes
|
||||
route non-media + sidecar pairs through PostAttachment capture.
|
||||
</span>
|
||||
<span v-else>
|
||||
An active batch is in progress. Wait for it to finish, or click
|
||||
@@ -34,15 +43,26 @@
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<v-btn
|
||||
color="primary" rounded="pill"
|
||||
:disabled="!!store.activeBatch"
|
||||
:loading="busy"
|
||||
@click="trigger"
|
||||
>
|
||||
<v-icon start>mdi-magnify-scan</v-icon>
|
||||
Quick scan
|
||||
</v-btn>
|
||||
<div class="d-flex flex-wrap" style="gap: 12px;">
|
||||
<v-btn
|
||||
color="primary" rounded="pill"
|
||||
:disabled="!!store.activeBatch"
|
||||
:loading="busy === 'quick'"
|
||||
@click="trigger('quick')"
|
||||
>
|
||||
<v-icon start>mdi-magnify-scan</v-icon>
|
||||
Quick scan
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="secondary" rounded="pill" variant="tonal"
|
||||
:disabled="!!store.activeBatch"
|
||||
:loading="busy === 'deep'"
|
||||
@click="trigger('deep')"
|
||||
>
|
||||
<v-icon start>mdi-magnify-plus-outline</v-icon>
|
||||
Deep scan
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
|
||||
{{ store.triggerError }}
|
||||
@@ -56,12 +76,12 @@ import { ref } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
|
||||
const store = useImportStore()
|
||||
const busy = ref(false)
|
||||
const busy = ref(null)
|
||||
const clearing = ref(false)
|
||||
|
||||
async function trigger() {
|
||||
busy.value = true
|
||||
try { await store.triggerScan() } catch {} finally { busy.value = false }
|
||||
async function trigger(mode) {
|
||||
busy.value = mode
|
||||
try { await store.triggerScan(mode) } catch {} finally { busy.value = null }
|
||||
}
|
||||
|
||||
async function onClearStuck() {
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<template>
|
||||
<div class="fc-maint">
|
||||
<p class="text-body-2 mb-4">
|
||||
Machine-assisted tagging controls. Backfill and centroid recompute run
|
||||
nightly automatically; the allowlist auto-applies accepted tags to new
|
||||
and existing images.
|
||||
Operational backfills and tagging controls. The ML backfill and centroid
|
||||
recompute run nightly automatically; the allowlist auto-applies accepted
|
||||
tags to new and existing images. Use the cards below to trigger a
|
||||
one-off pass.
|
||||
</p>
|
||||
<div class="fc-maint__grid">
|
||||
<MLBackfillCard />
|
||||
<CentroidRecomputeCard />
|
||||
<ThumbnailBackfillCard />
|
||||
</div>
|
||||
<MLThresholdSliders class="mt-4" />
|
||||
<AllowlistTable class="mt-4" />
|
||||
<AliasTable class="mt-4" />
|
||||
<BackupCard class="mt-6" />
|
||||
<TagMaintenanceCard class="mt-6" />
|
||||
<BrowserExtensionCard class="mt-6" />
|
||||
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
|
||||
operates on the existing library which fits the Cleanup-tab
|
||||
theme, and clusters with the other audit cards. -->
|
||||
<LegacyMigrationCard class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,12 +25,11 @@
|
||||
<script setup>
|
||||
import MLBackfillCard from './MLBackfillCard.vue'
|
||||
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
|
||||
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import AllowlistTable from './AllowlistTable.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
import BackupCard from './BackupCard.vue'
|
||||
import TagMaintenanceCard from './TagMaintenanceCard.vue'
|
||||
import BrowserExtensionCard from './BrowserExtensionCard.vue'
|
||||
import LegacyMigrationCard from './LegacyMigrationCard.vue'
|
||||
</script>
|
||||
|
||||
|
||||
@@ -59,8 +59,12 @@
|
||||
<td>{{ r.queue }}</td>
|
||||
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
|
||||
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
|
||||
<td class="fc-err" :title="r.error_message">
|
||||
{{ r.error_type }}
|
||||
<td>
|
||||
<button
|
||||
type="button" class="fc-err-link"
|
||||
@click="openError(r)"
|
||||
:title="'Click for full error'"
|
||||
>{{ r.error_type }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredFailures.length">
|
||||
@@ -138,6 +142,11 @@
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<ErrorDetailModal
|
||||
v-model="showErrorModal"
|
||||
:row="errorModalRow"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -145,8 +154,21 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
||||
import QueuesTable from './QueuesTable.vue'
|
||||
|
||||
// Click-to-open modal for full error text. Replaces the unusable
|
||||
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
|
||||
// rollback + traceback content rendered as a cramped browser tooltip
|
||||
// you couldn't copy from or scroll within).
|
||||
const showErrorModal = ref(false)
|
||||
const errorModalRow = ref(null)
|
||||
|
||||
function openError(row) {
|
||||
errorModalRow.value = row
|
||||
showErrorModal.value = true
|
||||
}
|
||||
|
||||
const store = useSystemActivityStore()
|
||||
|
||||
const filterQueue = ref(null)
|
||||
@@ -276,5 +298,18 @@ function formatRelative(iso) {
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
|
||||
.fc-err-link {
|
||||
/* Styled as a text-only button so the error_type cell stays
|
||||
visually identical to the prior tooltip-bearing row, but is
|
||||
now a real clickable target with hover affordance. */
|
||||
color: rgb(var(--v-theme-error, 220 80 80));
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-decoration: underline dotted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-err-link:hover { text-decoration: underline; }
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Thumbnail backfill</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">
|
||||
Scan the library for images with no thumbnail, or whose thumbnail file
|
||||
is missing or corrupt on disk. Repair candidates are re-enqueued for
|
||||
thumbnail generation. Safe to re-run.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-icon start>mdi-image-refresh</v-icon> Run backfill now
|
||||
</v-btn>
|
||||
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useThumbnailsStore } from '../../stores/thumbnails.js'
|
||||
const store = useThumbnailsStore()
|
||||
const busy = ref(false)
|
||||
const done = ref(false)
|
||||
async function run () {
|
||||
busy.value = true
|
||||
try { await store.triggerBackfill(); done.value = true }
|
||||
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { usePostsStore } from './posts.js'
|
||||
|
||||
const PAGE = 60
|
||||
|
||||
@@ -15,7 +16,11 @@ export const useArtistStore = defineStore('artist', () => {
|
||||
const notFound = ref(false)
|
||||
let started = false
|
||||
|
||||
async function load(slug) {
|
||||
async function load (slug) {
|
||||
// Cross-artist reset: clear this store AND the posts store so the new
|
||||
// artist doesn't briefly render with the previous artist's content
|
||||
// when the user is on the Posts tab. (Gallery tab uses this artist
|
||||
// store's own images list — cleared above.)
|
||||
overview.value = null
|
||||
images.value = []
|
||||
nextCursor.value = null
|
||||
@@ -23,6 +28,7 @@ export const useArtistStore = defineStore('artist', () => {
|
||||
started = false
|
||||
error.value = null
|
||||
loading.value = true
|
||||
usePostsStore().$reset?.()
|
||||
try {
|
||||
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
|
||||
await loadMoreImages(slug)
|
||||
@@ -34,7 +40,7 @@ export const useArtistStore = defineStore('artist', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreImages(slug) {
|
||||
async function loadMoreImages (slug) {
|
||||
if (imagesLoading.value) return
|
||||
if (started && nextCursor.value === null) return
|
||||
imagesLoading.value = true
|
||||
@@ -55,9 +61,13 @@ export const useArtistStore = defineStore('artist', () => {
|
||||
}
|
||||
|
||||
const hasMoreImages = computed(() => !started || nextCursor.value !== null)
|
||||
const postCount = computed(() => overview.value?.post_count ?? null)
|
||||
const imageCount = computed(() => overview.value?.image_count ?? null)
|
||||
const lastAdded = computed(() => overview.value?.date_range?.max ?? null)
|
||||
|
||||
return {
|
||||
overview, images, loading, imagesLoading, error, notFound,
|
||||
hasMoreImages, load, loadMoreImages
|
||||
hasMoreImages, postCount, imageCount, lastAdded,
|
||||
load, loadMoreImages,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
|
||||
export const useCleanupStore = defineStore('cleanup', () => {
|
||||
const api = useApi()
|
||||
|
||||
// Defaults sourced from ImportSettings on mount. Cards pre-fill from
|
||||
// these so the common case ("apply current import filters
|
||||
// retroactively") is one click; operator can override per-audit.
|
||||
const defaults = ref({
|
||||
min_width: 0,
|
||||
min_height: 0,
|
||||
transparency_threshold: 0.9,
|
||||
single_color_threshold: 0.95,
|
||||
single_color_tolerance: 30,
|
||||
})
|
||||
|
||||
const recentRuns = ref([])
|
||||
|
||||
async function loadDefaults() {
|
||||
const s = await api.get('/api/settings/import')
|
||||
defaults.value = {
|
||||
min_width: s.min_width ?? 0,
|
||||
min_height: s.min_height ?? 0,
|
||||
transparency_threshold: s.transparency_threshold ?? 0.9,
|
||||
single_color_threshold: s.single_color_threshold ?? 0.95,
|
||||
single_color_tolerance: s.single_color_tolerance ?? 30,
|
||||
}
|
||||
}
|
||||
|
||||
async function previewMinDim(min_width, min_height) {
|
||||
return await api.post('/api/cleanup/min-dimension/preview', {
|
||||
body: { min_width, min_height },
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteMinDim(min_width, min_height, confirm) {
|
||||
return await api.post('/api/cleanup/min-dimension/delete', {
|
||||
body: { min_width, min_height, confirm },
|
||||
})
|
||||
}
|
||||
|
||||
async function startAudit(rule, params) {
|
||||
return await api.post('/api/cleanup/audit', { body: { rule, params } })
|
||||
}
|
||||
|
||||
async function getAudit(id) {
|
||||
return await api.get(`/api/cleanup/audit/${id}`)
|
||||
}
|
||||
|
||||
async function loadHistory(limit = 20) {
|
||||
const body = await api.get(`/api/cleanup/audit?limit=${limit}`)
|
||||
recentRuns.value = body.runs
|
||||
return body.runs
|
||||
}
|
||||
|
||||
async function applyAudit(id, confirm) {
|
||||
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
|
||||
}
|
||||
|
||||
async function cancelAudit(id) {
|
||||
return await api.post(`/api/cleanup/audit/${id}/cancel`)
|
||||
}
|
||||
|
||||
return {
|
||||
defaults, recentRuns,
|
||||
loadDefaults,
|
||||
previewMinDim, deleteMinDim,
|
||||
startAudit, getAudit, loadHistory, applyAudit, cancelAudit,
|
||||
}
|
||||
})
|
||||
@@ -52,29 +52,56 @@ export const useImportStore = defineStore('import', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerScan() {
|
||||
async function triggerScan(mode = 'quick') {
|
||||
if (!['quick', 'deep', 'verify'].includes(mode)) {
|
||||
throw new Error(`unsupported scan mode: ${mode}`)
|
||||
}
|
||||
triggerError.value = null
|
||||
try {
|
||||
await api.post('/api/import/trigger', { body: { mode: 'quick' } })
|
||||
await api.post('/api/import/trigger', { body: { mode } })
|
||||
// Acknowledge immediately so the click isn't invisible. scan_directory
|
||||
// can finalize the batch synchronously when every file in /import is
|
||||
// already on a non-failed ImportTask (operator-flagged 2026-05-25:
|
||||
// 233k existing tasks → all paths in skip-set → files_seen=0 →
|
||||
// batch flashes 'running' for <100ms then 'complete' before the
|
||||
// first refreshStatus() lands; UI never sees the active state).
|
||||
window.__fcToast?.({ text: 'Scan triggered', type: 'success' })
|
||||
const label = mode === 'deep'
|
||||
? 'Deep scan triggered (re-applying sidecar metadata + filling NULL phash/artist on existing rows)'
|
||||
: mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered'
|
||||
window.__fcToast?.({ text: label, type: 'success' })
|
||||
await refreshStatus()
|
||||
// Re-poll twice over ~5s to catch quick-finalize transitions and
|
||||
// surface a result toast either way.
|
||||
// Re-poll twice over ~5s and produce an HONEST follow-up toast.
|
||||
// Operator-flagged 2026-05-25: the prior "no new files" message was
|
||||
// misleading because deep scan IS doing work (refresh) even when
|
||||
// there are no new files to import. Surface the real workload count
|
||||
// (imported + refreshed + queued) instead. For quick scan + zero
|
||||
// queued work, fall back to "up to date" instead of the old
|
||||
// implementation-detail-leaking message.
|
||||
setTimeout(async () => {
|
||||
await refreshStatus()
|
||||
if (!activeBatch.value) {
|
||||
// Either scan completed with zero new files, or it never visibly
|
||||
// started. Fetch the freshest task to differentiate.
|
||||
await loadTasks(true)
|
||||
if (activeBatch.value || mode === 'verify') return
|
||||
// Batch finalized quickly; figure out what actually happened.
|
||||
// The task list was just refreshed; the freshest row(s) carry
|
||||
// the batch outcome.
|
||||
const batchId = tasks.value[0]?.batch_id
|
||||
const sameBatch = batchId
|
||||
? tasks.value.filter(t => t.batch_id === batchId)
|
||||
: []
|
||||
const refreshedCount = sameBatch.filter(t => t.status === 'complete' && t.result_image_id).length
|
||||
const newImported = sameBatch.filter(t => t.status === 'complete' && t.result_image_id && !t.error).length
|
||||
if (mode === 'deep' && sameBatch.length > 0) {
|
||||
window.__fcToast?.({
|
||||
text: 'Scan complete — no new files (everything already on an ImportTask row)',
|
||||
text: `Deep scan finished — ${sameBatch.length} file(s) processed`,
|
||||
type: 'info',
|
||||
})
|
||||
} else if (mode === 'quick' && sameBatch.length > 0) {
|
||||
window.__fcToast?.({
|
||||
text: `Quick scan finished — ${newImported} new file(s) queued`,
|
||||
type: 'info',
|
||||
})
|
||||
} else {
|
||||
window.__fcToast?.({ text: 'Library is up to date', type: 'info' })
|
||||
}
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
|
||||
export const useThumbnailsStore = defineStore('thumbnails', () => {
|
||||
const api = useApi()
|
||||
|
||||
async function triggerBackfill () {
|
||||
await api.post('/api/thumbnails/backfill')
|
||||
}
|
||||
|
||||
return { triggerBackfill }
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
// Clipboard write that works on plain-HTTP self-hosted deployments.
|
||||
//
|
||||
// navigator.clipboard is gated by the browser's Secure Context restriction
|
||||
// (HTTPS or localhost only). FabledCurator runs over plain HTTP per the
|
||||
// homelab posture, so the modern API is undefined in production. We fall
|
||||
// back to the legacy execCommand('copy') path via a temporary off-screen
|
||||
// textarea — wide browser support, no HTTPS requirement.
|
||||
|
||||
export async function copyText (text) {
|
||||
const str = text == null ? '' : String(text)
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(str)
|
||||
return
|
||||
} catch {
|
||||
// Fall through to the legacy path. Some browsers throw even when
|
||||
// the API exists (permission denied, focus loss, etc.).
|
||||
}
|
||||
}
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = str
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.top = '0'
|
||||
ta.style.left = '0'
|
||||
ta.style.opacity = '0'
|
||||
ta.style.pointerEvents = 'none'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, str.length)
|
||||
let ok = false
|
||||
try { ok = document.execCommand('copy') } catch { ok = false }
|
||||
document.body.removeChild(ta)
|
||||
if (!ok) throw new Error('clipboard copy not supported')
|
||||
}
|
||||
@@ -1,187 +1,96 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<div v-if="store.loading && !store.overview" class="fc-artist__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
<div v-if="store.loading && !store.overview" class="fc-artist__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<v-alert v-else-if="store.notFound" type="warning" variant="tonal">
|
||||
Artist not found.
|
||||
</v-alert>
|
||||
|
||||
<v-alert v-else-if="store.error" type="error" variant="tonal" closable>
|
||||
{{ store.error }}
|
||||
</v-alert>
|
||||
|
||||
<template v-else-if="store.overview">
|
||||
<header class="fc-artist__head">
|
||||
<h1 class="fc-h1">{{ store.overview.name }}</h1>
|
||||
<div class="fc-artist__stats">
|
||||
<span>{{ store.overview.image_count }} images</span>
|
||||
<span v-if="dateRange">· {{ dateRange }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="fc-artist__fc4">
|
||||
<v-chip
|
||||
size="small"
|
||||
:variant="store.overview.is_subscription ? 'flat' : 'outlined'"
|
||||
:color="store.overview.is_subscription ? 'accent' : undefined"
|
||||
prepend-icon="mdi-rss"
|
||||
>{{ store.overview.is_subscription ? 'Subscription' : 'One-off' }}</v-chip>
|
||||
|
||||
<v-chip
|
||||
size="small" variant="outlined" prepend-icon="mdi-link-variant"
|
||||
:to="`/subscriptions?artist_id=${store.overview.id}`"
|
||||
>{{ store.overview.sources.length }} source{{ store.overview.sources.length === 1 ? '' : 's' }}</v-chip>
|
||||
|
||||
<v-chip
|
||||
size="small" variant="outlined" prepend-icon="mdi-rss"
|
||||
:to="`/posts?artist_id=${store.overview.id}`"
|
||||
>View posts</v-chip>
|
||||
|
||||
<v-chip
|
||||
size="small" variant="outlined" disabled
|
||||
prepend-icon="mdi-clock-outline"
|
||||
>Credential health · FC-3b</v-chip>
|
||||
</div>
|
||||
|
||||
<section v-if="store.overview.cooccurring_tags.length" class="fc-artist__sec">
|
||||
<h2 class="fc-h2">Frequent tags</h2>
|
||||
<div class="fc-artist__tags">
|
||||
<v-chip
|
||||
v-for="t in store.overview.cooccurring_tags" :key="t.id"
|
||||
size="small" @click="openTag(t.id)"
|
||||
>{{ t.name }} <span class="fc-artist__tagc">{{ t.count }}</span></v-chip>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="store.overview.activity.length" class="fc-artist__sec">
|
||||
<h2 class="fc-h2">Activity</h2>
|
||||
<svg class="fc-artist__spark" :viewBox="`0 0 ${sparkW} ${sparkH}`"
|
||||
preserveAspectRatio="none" role="img" aria-label="posts over time">
|
||||
<polyline :points="sparkPoints" fill="none"
|
||||
stroke="rgb(var(--v-theme-accent))" stroke-width="2" />
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<section v-if="store.overview.sources.length" class="fc-artist__sec">
|
||||
<div class="fc-artist__sec-head">
|
||||
<h2 class="fc-h2">Sources</h2>
|
||||
<RouterLink
|
||||
:to="`/subscriptions?artist_id=${store.overview.id}`"
|
||||
class="fc-artist__manage"
|
||||
>Manage subscriptions →</RouterLink>
|
||||
</div>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in store.overview.sources" :key="s.id">
|
||||
<td>{{ s.platform }}</td>
|
||||
<td class="fc-artist__url">{{ s.url }}</td>
|
||||
<td class="text-right">{{ s.image_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</section>
|
||||
|
||||
<section class="fc-artist__sec">
|
||||
<h2 class="fc-h2">Images</h2>
|
||||
<MasonryGrid
|
||||
:items="store.images"
|
||||
:loading="store.imagesLoading"
|
||||
:has-more="store.hasMoreImages"
|
||||
@load-more="store.loadMoreImages(slug)"
|
||||
@open="openImage"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ArtistDangerZone
|
||||
:slug="slug"
|
||||
:artist-id="store.overview.id"
|
||||
:artist-name="store.overview.name"
|
||||
/>
|
||||
</template>
|
||||
<v-container v-else-if="store.notFound" class="py-6">
|
||||
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
|
||||
</v-container>
|
||||
|
||||
<v-container v-else-if="store.error && !store.overview" class="py-6">
|
||||
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
|
||||
</v-container>
|
||||
|
||||
<template v-else-if="store.overview">
|
||||
<ArtistHeader
|
||||
v-model="tab"
|
||||
:name="store.overview.name"
|
||||
:image-count="store.imageCount"
|
||||
:post-count="store.postCount"
|
||||
:last-added="store.lastAdded"
|
||||
/>
|
||||
<v-container fluid class="py-4">
|
||||
<v-window v-model="tab">
|
||||
<v-window-item value="posts">
|
||||
<ArtistPostsTab
|
||||
:artist-id="store.overview.id"
|
||||
@switch-tab="(t) => tab = t"
|
||||
/>
|
||||
</v-window-item>
|
||||
<v-window-item value="gallery">
|
||||
<ArtistGalleryTab :slug="slug" />
|
||||
</v-window-item>
|
||||
<v-window-item value="management">
|
||||
<ArtistManagementTab :overview="store.overview" />
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-container>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useArtistStore } from '../stores/artist.js'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import MasonryGrid from '../components/discovery/MasonryGrid.vue'
|
||||
import ArtistDangerZone from '../components/artist/ArtistDangerZone.vue'
|
||||
import ArtistHeader from '../components/artist/ArtistHeader.vue'
|
||||
import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue'
|
||||
import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue'
|
||||
import ArtistManagementTab from '../components/artist/ArtistManagementTab.vue'
|
||||
|
||||
const VALID_TABS = ['posts', 'gallery', 'management']
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useArtistStore()
|
||||
const modal = useModalStore()
|
||||
|
||||
const slug = computed(() => route.params.slug)
|
||||
const tab = ref('posts')
|
||||
|
||||
watch(slug, (s) => { if (s) store.load(s) }, { immediate: true })
|
||||
function resolveDefaultTab () {
|
||||
const fromUrl = route.query.tab
|
||||
if (VALID_TABS.includes(fromUrl)) return fromUrl
|
||||
if ((store.postCount ?? 0) > 0) return 'posts'
|
||||
return 'gallery'
|
||||
}
|
||||
|
||||
const dateRange = computed(() => {
|
||||
const r = store.overview?.date_range
|
||||
if (!r || !r.min) return null
|
||||
const fmt = (iso) => iso.slice(0, 10)
|
||||
return r.min === r.max ? fmt(r.min) : `${fmt(r.min)} → ${fmt(r.max)}`
|
||||
watch(slug, async (s) => {
|
||||
if (!s) return
|
||||
await store.load(s)
|
||||
document.title = store.overview
|
||||
? `${store.overview.name} — FabledCurator`
|
||||
: 'FabledCurator'
|
||||
tab.value = resolveDefaultTab()
|
||||
}, { immediate: true })
|
||||
|
||||
// Reflect tab changes back into the URL so refresh/back/forward work.
|
||||
watch(tab, (newTab) => {
|
||||
if (route.query.tab === newTab) return
|
||||
router.replace({
|
||||
query: { ...route.query, tab: newTab },
|
||||
})
|
||||
})
|
||||
|
||||
const sparkW = 600
|
||||
const sparkH = 80
|
||||
const sparkPoints = computed(() => {
|
||||
const a = store.overview?.activity ?? []
|
||||
if (a.length === 0) return ''
|
||||
const max = Math.max(...a.map(p => p.count), 1)
|
||||
const stepX = a.length > 1 ? sparkW / (a.length - 1) : 0
|
||||
return a.map((p, i) => {
|
||||
const x = i * stepX
|
||||
const y = sparkH - (p.count / max) * (sparkH - 4) - 2
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
// React to URL-tab changes (e.g., back/forward).
|
||||
watch(() => route.query.tab, (q) => {
|
||||
if (q && VALID_TABS.includes(q) && tab.value !== q) {
|
||||
tab.value = q
|
||||
}
|
||||
})
|
||||
|
||||
function openImage(id) {
|
||||
modal.open(id)
|
||||
}
|
||||
function openTag(tagId) {
|
||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-h1 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 32px; font-weight: 500;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
.fc-artist__loading {
|
||||
display: flex; justify-content: center; padding: 64px 0;
|
||||
}
|
||||
.fc-h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 20px; font-weight: 500; margin-bottom: 8px;
|
||||
}
|
||||
.fc-artist__loading { display: flex; justify-content: center; padding: 64px 0; }
|
||||
.fc-artist__head { margin-bottom: 12px; }
|
||||
.fc-artist__stats { opacity: 0.75; margin-top: 4px; }
|
||||
.fc-artist__fc4 { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.fc-artist__sec { margin-bottom: 28px; }
|
||||
.fc-artist__tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.fc-artist__tagc { opacity: 0.6; margin-left: 4px; font-variant-numeric: tabular-nums; }
|
||||
.fc-artist__spark { width: 100%; height: 80px; }
|
||||
.fc-artist__url {
|
||||
max-width: 380px; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fc-artist__sec-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.fc-artist__manage {
|
||||
font-size: 0.85rem;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
text-decoration: none;
|
||||
}
|
||||
.fc-artist__manage:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="fc-cleanup">
|
||||
<p class="fc-muted text-body-2 mb-4">
|
||||
Retroactive enforcement of import-filter rules. Each card scans the
|
||||
existing library for content that the current import filters would
|
||||
now exclude. Destructive — typed-token confirmation required.
|
||||
</p>
|
||||
|
||||
<MinDimensionCard class="mb-4" />
|
||||
<TransparencyAuditCard class="mb-4" />
|
||||
<SingleColorAuditCard class="mb-4" />
|
||||
<TagMaintenanceCard />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MinDimensionCard from '../components/cleanup/MinDimensionCard.vue'
|
||||
import TransparencyAuditCard from '../components/cleanup/TransparencyAuditCard.vue'
|
||||
import SingleColorAuditCard from '../components/cleanup/SingleColorAuditCard.vue'
|
||||
// Reuse existing TagMaintenanceCard (FC-3k) as-is — it already handles
|
||||
// preview + commit of prune-unused-tags via the admin store. Operator
|
||||
// confirmed 2026-05-26: don't duplicate into a new UnusedTagsCard.
|
||||
// MaintenancePanel drops its TagMaintenanceCard reference in the
|
||||
// SettingsView edit (Task 16) so this is now the sole rendering site.
|
||||
import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-cleanup { max-width: 900px; }
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
@@ -1,9 +1,20 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-tabs v-model="tab" color="accent" class="mb-4">
|
||||
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||
panels pushed the tab strip out of the viewport, forcing a scroll-
|
||||
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
||||
tab strip lives directly under it. Background uses the theme surface
|
||||
token so it visually merges with the page rather than the
|
||||
translucent v-tabs default. -->
|
||||
<v-tabs
|
||||
v-model="tab" color="accent" class="mb-4"
|
||||
style="position: sticky; top: 64px; z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));"
|
||||
>
|
||||
<v-tab value="overview">Overview</v-tab>
|
||||
<v-tab value="activity">Activity</v-tab>
|
||||
<v-tab value="import">Import</v-tab>
|
||||
<v-tab value="cleanup">Cleanup</v-tab>
|
||||
<v-tab value="maintenance">Maintenance</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
@@ -21,6 +32,11 @@
|
||||
{{ system.stats.tasks.pending + system.stats.tasks.queued }} import task(s) pending.
|
||||
<v-btn variant="text" size="small" @click="tab = 'import'">Go to Import tab</v-btn>
|
||||
</v-alert>
|
||||
<!-- Browser-extension install/download lives on Overview (moved
|
||||
from Maintenance 2026-05-25). Overview is the discovery
|
||||
surface for "things to set up"; Maintenance is for
|
||||
housekeeping of already-set-up systems. -->
|
||||
<BrowserExtensionCard class="mt-6" />
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="activity">
|
||||
@@ -28,11 +44,18 @@
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="import">
|
||||
<!-- Order: trigger → recent tasks → filters. Tasks sit directly
|
||||
below the trigger so operator sees hit/miss feedback without
|
||||
scrolling past the filter card (operator-flagged 2026-05-25). -->
|
||||
<ImportTriggerPanel />
|
||||
<v-divider class="my-6" />
|
||||
<ImportFiltersForm />
|
||||
<v-divider class="my-6" />
|
||||
<ImportTaskList />
|
||||
<v-divider class="my-6" />
|
||||
<ImportFiltersForm />
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="cleanup">
|
||||
<CleanupView />
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="maintenance">
|
||||
@@ -49,10 +72,12 @@ import { useImportStore } from '../stores/import.js'
|
||||
import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
|
||||
import SystemActivitySummary from '../components/settings/SystemActivitySummary.vue'
|
||||
import SystemActivityTab from '../components/settings/SystemActivityTab.vue'
|
||||
import BrowserExtensionCard from '../components/settings/BrowserExtensionCard.vue'
|
||||
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
|
||||
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
|
||||
import ImportTaskList from '../components/settings/ImportTaskList.vue'
|
||||
import MaintenancePanel from '../components/settings/MaintenancePanel.vue'
|
||||
import CleanupView from './CleanupView.vue'
|
||||
import { useMLStore } from '../stores/ml.js'
|
||||
|
||||
const tab = ref('overview')
|
||||
|
||||
@@ -29,6 +29,30 @@ async def test_artist_overview_ok(client, db):
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "Mira"
|
||||
assert body["image_count"] == 0
|
||||
assert body["post_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_artist_overview_post_count(client, db):
|
||||
from backend.app.models import Post, Source
|
||||
|
||||
a = Artist(name="Lyra", slug="lyra")
|
||||
db.add(a)
|
||||
await db.flush()
|
||||
s = Source(
|
||||
artist_id=a.id, platform="patreon",
|
||||
url="https://patreon.com/cw/lyra", enabled=True,
|
||||
)
|
||||
db.add(s)
|
||||
await db.flush()
|
||||
db.add(Post(source_id=s.id, external_post_id="p1"))
|
||||
db.add(Post(source_id=s.id, external_post_id="p2"))
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
resp = await client.get("/api/artist/lyra")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["post_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""API tests for the /api/cleanup/* blueprint.
|
||||
|
||||
Per reference-async-coredml-test-assertions, post-DML state checks go
|
||||
via column selects, not ORM entity access.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from backend.app import create_app
|
||||
from backend.app.celery_app import celery
|
||||
from backend.app.models import ImageRecord, LibraryAuditRun
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_celery_eager(monkeypatch):
|
||||
monkeypatch.setattr(celery.conf, "task_always_eager", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app():
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app):
|
||||
async with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _sha256_min_dim_token(min_w: int, min_h: int) -> str:
|
||||
canon = f"{min_w}x{min_h}"
|
||||
return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"
|
||||
|
||||
|
||||
async def _seed_image(db, tmp_path, *, w, h, name):
|
||||
path = tmp_path / name
|
||||
Image.new("RGB", (w, h), (w % 256, h % 256, 0)).save(path)
|
||||
sha = f"api-cleanup-{name}".ljust(64, "x")[:64]
|
||||
rec = ImageRecord(
|
||||
path=str(path), sha256=sha,
|
||||
size_bytes=path.stat().st_size, mime="image/png",
|
||||
width=w, height=h, origin="imported_filesystem",
|
||||
integrity_status="ok",
|
||||
)
|
||||
db.add(rec)
|
||||
await db.flush()
|
||||
return rec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_min_dimension_preview_returns_count(client, db, tmp_path):
|
||||
await _seed_image(db, tmp_path, w=50, h=50, name="small.png")
|
||||
await _seed_image(db, tmp_path, w=500, h=500, name="big.png")
|
||||
await db.commit()
|
||||
resp = await client.post(
|
||||
"/api/cleanup/min-dimension/preview",
|
||||
json={"min_width": 200, "min_height": 200},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_min_dimension_delete_with_token_removes_rows(client, db, tmp_path):
|
||||
await _seed_image(db, tmp_path, w=50, h=50, name="s2.png")
|
||||
await _seed_image(db, tmp_path, w=500, h=500, name="b2.png")
|
||||
await db.commit()
|
||||
token = _sha256_min_dim_token(200, 200)
|
||||
resp = await client.post(
|
||||
"/api/cleanup/min-dimension/delete",
|
||||
json={"min_width": 200, "min_height": 200, "confirm": token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["deleted"] == 1
|
||||
remaining = await db.execute(select(func.count()).select_from(ImageRecord))
|
||||
assert remaining.scalar_one() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_min_dimension_delete_with_bad_token_returns_400(client, db, tmp_path):
|
||||
await _seed_image(db, tmp_path, w=50, h=50, name="s3.png")
|
||||
await db.commit()
|
||||
resp = await client.post(
|
||||
"/api/cleanup/min-dimension/delete",
|
||||
json={"min_width": 200, "min_height": 200, "confirm": "nope"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = await resp.get_json()
|
||||
assert body["error"] == "confirm_mismatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_create_returns_id_and_running_status(
|
||||
client, db, monkeypatch,
|
||||
):
|
||||
from backend.app.tasks import library_audit
|
||||
monkeypatch.setattr(
|
||||
library_audit.scan_library_for_rule, "delay", lambda audit_id: None,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/cleanup/audit",
|
||||
json={"rule": "transparency", "params": {"threshold": 0.9}},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
body = await resp.get_json()
|
||||
assert body["status"] == "running"
|
||||
assert isinstance(body["audit_id"], int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_create_returns_409_when_another_is_running(client, db):
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.9},
|
||||
status="running", matched_ids=[],
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
resp = await client.post(
|
||||
"/api/cleanup/audit",
|
||||
json={"rule": "single_color", "params": {"threshold": 0.95, "tolerance": 30}},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_get_by_id_returns_full_row(client, db):
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.85},
|
||||
status="ready", scanned_count=100, matched_count=3,
|
||||
matched_ids=[1, 2, 3],
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
resp = await client.get(f"/api/cleanup/audit/{audit.id}")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["rule"] == "transparency"
|
||||
assert body["matched_count"] == 3
|
||||
assert body["matched_ids"] == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_history_returns_recent_runs(client, db):
|
||||
for _ in range(3):
|
||||
db.add(LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.9},
|
||||
status="applied", matched_ids=[],
|
||||
finished_at=datetime.now(UTC),
|
||||
))
|
||||
await db.commit()
|
||||
resp = await client.get("/api/cleanup/audit?limit=5")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert len(body["runs"]) >= 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_apply_with_token_deletes(client, db, tmp_path):
|
||||
rec = await _seed_image(db, tmp_path, w=100, h=100, name="apply.png")
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.9},
|
||||
status="ready", scanned_count=1, matched_count=1,
|
||||
matched_ids=[rec.id],
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
resp = await client.post(
|
||||
f"/api/cleanup/audit/{audit.id}/apply",
|
||||
json={"confirm": f"delete-audit-{audit.id}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["deleted"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_apply_with_bad_token_returns_400(client, db):
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.9},
|
||||
status="ready", matched_ids=[],
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
resp = await client.post(
|
||||
f"/api/cleanup/audit/{audit.id}/apply",
|
||||
json={"confirm": "wrong-token"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = await resp.get_json()
|
||||
assert body["error"] == "confirm_mismatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_cancel_flips_status(client, db):
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.9},
|
||||
status="running", matched_ids=[],
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
resp = await client.post(f"/api/cleanup/audit/{audit.id}/cancel")
|
||||
assert resp.status_code == 200
|
||||
new_status = await db.execute(
|
||||
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit.id)
|
||||
)
|
||||
assert new_status.scalar_one() == "cancelled"
|
||||
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
|
||||
from backend.app import create_app
|
||||
from backend.app.celery_app import celery
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def eager():
|
||||
celery.conf.task_always_eager = True
|
||||
yield
|
||||
celery.conf.task_always_eager = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app():
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app):
|
||||
async with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_thumbnail_backfill(client):
|
||||
r = await client.post("/api/thumbnails/backfill")
|
||||
assert r.status_code == 202
|
||||
body = await r.get_json()
|
||||
assert "celery_task_id" in body
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests for the single-color audit rule.
|
||||
|
||||
The rule downsamples + measures the fraction of pixels within `tolerance`
|
||||
(Euclidean RGB distance) of the dominant color. Matches if that fraction
|
||||
exceeds `threshold`. Single-color content is typically uploaded by
|
||||
mistake (placeholder/error/preview images) and should be flagged.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from backend.app.services.audits import single_color
|
||||
|
||||
|
||||
def test_single_color_evaluate_true_for_uniform_image():
|
||||
im = Image.new("RGB", (50, 50), (128, 64, 200))
|
||||
assert single_color.evaluate(im, threshold=0.9, tolerance=10) is True
|
||||
|
||||
|
||||
def test_single_color_evaluate_false_for_diverse_image():
|
||||
# Half black, half white — no single color dominates.
|
||||
im = Image.new("RGB", (50, 50), (0, 0, 0))
|
||||
for x in range(25):
|
||||
for y in range(50):
|
||||
im.putpixel((x, y), (255, 255, 255))
|
||||
assert single_color.evaluate(im, threshold=0.9, tolerance=10) is False
|
||||
|
||||
|
||||
def test_single_color_evaluate_respects_tolerance_widening():
|
||||
# Gradient image: pixels span 0..50 in R channel. Tight tolerance
|
||||
# rejects (no concentration), wide tolerance accepts (all near 25).
|
||||
im = Image.new("RGB", (50, 50), (0, 0, 0))
|
||||
for x in range(50):
|
||||
for y in range(50):
|
||||
im.putpixel((x, y), (x, 0, 0))
|
||||
assert single_color.evaluate(im, threshold=0.9, tolerance=5) is False
|
||||
assert single_color.evaluate(im, threshold=0.9, tolerance=50) is True
|
||||
|
||||
|
||||
def test_single_color_evaluate_handles_rgba_input():
|
||||
# Alpha channel should be ignored — only RGB matters for the rule.
|
||||
im = Image.new("RGBA", (50, 50), (100, 100, 100, 128))
|
||||
assert single_color.evaluate(im, threshold=0.9, tolerance=10) is True
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for the transparency audit rule.
|
||||
|
||||
The rule mirrors `Importer._transparency_pct` semantics for retroactive
|
||||
enforcement: returns True iff the fraction of fully-transparent pixels
|
||||
exceeds the threshold. Animated images short-circuit to False to avoid
|
||||
the multi-frame PIL decode that triggered SoftTimeLimitExceeded
|
||||
2026-05-26 against animated WebPs.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from backend.app.services.audits import transparency
|
||||
|
||||
|
||||
def test_transparency_evaluate_true_when_fully_transparent():
|
||||
im = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
|
||||
assert transparency.evaluate(im, threshold=0.5) is True
|
||||
|
||||
|
||||
def test_transparency_evaluate_false_when_fully_opaque():
|
||||
im = Image.new("RGBA", (10, 10), (200, 100, 50, 255))
|
||||
assert transparency.evaluate(im, threshold=0.5) is False
|
||||
|
||||
|
||||
def test_transparency_evaluate_respects_threshold_boundary():
|
||||
# Half-transparent image: 50% alpha=0 pixels, 50% alpha=255.
|
||||
im = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
|
||||
for x in range(5):
|
||||
for y in range(10):
|
||||
im.putpixel((x, y), (0, 0, 0, 255))
|
||||
# 50% transparent. threshold=0.4 → True; threshold=0.6 → False.
|
||||
assert transparency.evaluate(im, threshold=0.4) is True
|
||||
assert transparency.evaluate(im, threshold=0.6) is False
|
||||
|
||||
|
||||
def test_transparency_evaluate_false_for_rgb_image_without_alpha():
|
||||
im = Image.new("RGB", (10, 10), (128, 128, 128))
|
||||
assert transparency.evaluate(im, threshold=0.5) is False
|
||||
|
||||
|
||||
def test_transparency_evaluate_false_for_animated_image():
|
||||
im = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
|
||||
# Mark as animated (mimics PIL's WebP/GIF multi-frame attribute).
|
||||
im.is_animated = True # type: ignore[attr-defined]
|
||||
im.n_frames = 5 # type: ignore[attr-defined]
|
||||
assert transparency.evaluate(im, threshold=0.5) is False
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Thumbnail backfill: _thumb_is_valid helper + backfill_thumbnails planner."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models import ImageRecord
|
||||
from backend.app.tasks.thumbnail import _thumb_is_valid
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def test_thumb_is_valid_jpeg(tmp_path):
|
||||
p = tmp_path / "good.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||
assert _thumb_is_valid(p) is True
|
||||
|
||||
|
||||
def test_thumb_is_valid_png(tmp_path):
|
||||
p = tmp_path / "good.png"
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
assert _thumb_is_valid(p) is True
|
||||
|
||||
|
||||
def test_thumb_is_valid_too_short(tmp_path):
|
||||
p = tmp_path / "tiny"
|
||||
p.write_bytes(b"\xff\xd8")
|
||||
assert _thumb_is_valid(p) is False
|
||||
|
||||
|
||||
def test_thumb_is_valid_wrong_magic(tmp_path):
|
||||
p = tmp_path / "garbage"
|
||||
p.write_bytes(b"\x00" * 12)
|
||||
assert _thumb_is_valid(p) is False
|
||||
|
||||
|
||||
def test_thumb_is_valid_missing_file(tmp_path):
|
||||
assert _thumb_is_valid(tmp_path / "nope") is False
|
||||
|
||||
|
||||
# --- backfill_thumbnails planner tests ------------------------------------
|
||||
|
||||
|
||||
class _Ctx:
|
||||
def __init__(self, s):
|
||||
self.s = s
|
||||
|
||||
def __enter__(self):
|
||||
return self.s
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def _sf(db_sync):
|
||||
"""sessionmaker-like returning the test's bound session, matching the
|
||||
pattern in tests/test_backfill_phash.py."""
|
||||
class _SM:
|
||||
def __call__(self):
|
||||
return _Ctx(db_sync)
|
||||
|
||||
return _SM()
|
||||
|
||||
|
||||
def _sha(prefix: str) -> str:
|
||||
return f"{prefix}".ljust(64, "0")[:64]
|
||||
|
||||
|
||||
def _rec(db_sync, path, *, sha, thumb_path=None, mime="image/jpeg"):
|
||||
rec = ImageRecord(
|
||||
path=str(path), sha256=sha, size_bytes=1, mime=mime,
|
||||
width=64, height=64, origin="imported_filesystem",
|
||||
integrity_status="unknown",
|
||||
thumbnail_path=str(thumb_path) if thumb_path is not None else None,
|
||||
)
|
||||
db_sync.add(rec)
|
||||
db_sync.flush()
|
||||
return rec
|
||||
|
||||
|
||||
def _write_jpeg(p: Path) -> Path:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||
return p
|
||||
|
||||
|
||||
def _write_png(p: Path) -> Path:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
return p
|
||||
|
||||
|
||||
def _write_garbage(p: Path) -> Path:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(b"\x00" * 12)
|
||||
return p
|
||||
|
||||
|
||||
def test_backfill_null_path_enqueued(db_sync, tmp_path, monkeypatch):
|
||||
from backend.app.tasks import thumbnail as m
|
||||
|
||||
src = tmp_path / "a.bin"
|
||||
src.write_bytes(b"x")
|
||||
rec = _rec(db_sync, src, sha=_sha("a"), thumb_path=None)
|
||||
db_sync.commit()
|
||||
|
||||
monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync))
|
||||
delayed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id)
|
||||
)
|
||||
|
||||
result = m.backfill_thumbnails()
|
||||
assert result == {"enqueued": 1, "ok": 0, "regenerated": 0}
|
||||
assert delayed == [rec.id]
|
||||
|
||||
|
||||
def test_backfill_missing_file_clears_and_enqueues(db_sync, tmp_path, monkeypatch):
|
||||
from backend.app.tasks import thumbnail as m
|
||||
|
||||
src = tmp_path / "b.bin"
|
||||
src.write_bytes(b"x")
|
||||
rec = _rec(
|
||||
db_sync, src, sha=_sha("b"),
|
||||
thumb_path=tmp_path / "thumbs" / "missing.jpg",
|
||||
)
|
||||
db_sync.commit()
|
||||
|
||||
monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync))
|
||||
delayed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id)
|
||||
)
|
||||
|
||||
result = m.backfill_thumbnails()
|
||||
db_sync.expire_all()
|
||||
assert result == {"enqueued": 1, "ok": 0, "regenerated": 1}
|
||||
assert delayed == [rec.id]
|
||||
assert db_sync.get(ImageRecord, rec.id).thumbnail_path is None
|
||||
|
||||
|
||||
def test_backfill_valid_jpeg_skipped(db_sync, tmp_path, monkeypatch):
|
||||
from backend.app.tasks import thumbnail as m
|
||||
|
||||
src = tmp_path / "c.bin"
|
||||
src.write_bytes(b"x")
|
||||
thumb = _write_jpeg(tmp_path / "thumbs" / "c.jpg")
|
||||
rec = _rec(db_sync, src, sha=_sha("c"), thumb_path=thumb)
|
||||
db_sync.commit()
|
||||
|
||||
monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync))
|
||||
delayed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id)
|
||||
)
|
||||
|
||||
result = m.backfill_thumbnails()
|
||||
db_sync.expire_all()
|
||||
assert result == {"enqueued": 0, "ok": 1, "regenerated": 0}
|
||||
assert delayed == []
|
||||
assert db_sync.get(ImageRecord, rec.id).thumbnail_path == str(thumb)
|
||||
|
||||
|
||||
def test_backfill_valid_png_skipped(db_sync, tmp_path, monkeypatch):
|
||||
from backend.app.tasks import thumbnail as m
|
||||
|
||||
src = tmp_path / "d.bin"
|
||||
src.write_bytes(b"x")
|
||||
thumb = _write_png(tmp_path / "thumbs" / "d.png")
|
||||
_rec(db_sync, src, sha=_sha("d"), thumb_path=thumb)
|
||||
db_sync.commit()
|
||||
|
||||
monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync))
|
||||
delayed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id)
|
||||
)
|
||||
|
||||
result = m.backfill_thumbnails()
|
||||
assert result == {"enqueued": 0, "ok": 1, "regenerated": 0}
|
||||
assert delayed == []
|
||||
|
||||
|
||||
def test_backfill_corrupt_magic_clears_and_enqueues(db_sync, tmp_path, monkeypatch):
|
||||
from backend.app.tasks import thumbnail as m
|
||||
|
||||
src = tmp_path / "e.bin"
|
||||
src.write_bytes(b"x")
|
||||
thumb = _write_garbage(tmp_path / "thumbs" / "e.jpg")
|
||||
rec = _rec(db_sync, src, sha=_sha("e"), thumb_path=thumb)
|
||||
db_sync.commit()
|
||||
|
||||
monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync))
|
||||
delayed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id)
|
||||
)
|
||||
|
||||
result = m.backfill_thumbnails()
|
||||
db_sync.expire_all()
|
||||
assert result == {"enqueued": 1, "ok": 0, "regenerated": 1}
|
||||
assert delayed == [rec.id]
|
||||
assert db_sync.get(ImageRecord, rec.id).thumbnail_path is None
|
||||
|
||||
|
||||
def test_backfill_mixed_aggregate(db_sync, tmp_path, monkeypatch):
|
||||
from backend.app.tasks import thumbnail as m
|
||||
|
||||
src_null = tmp_path / "src_null.bin"
|
||||
src_null.write_bytes(b"x")
|
||||
src_jpeg = tmp_path / "src_jpeg.bin"
|
||||
src_jpeg.write_bytes(b"x")
|
||||
src_png = tmp_path / "src_png.bin"
|
||||
src_png.write_bytes(b"x")
|
||||
src_missing = tmp_path / "src_missing.bin"
|
||||
src_missing.write_bytes(b"x")
|
||||
src_bad = tmp_path / "src_bad.bin"
|
||||
src_bad.write_bytes(b"x")
|
||||
|
||||
jpeg = _write_jpeg(tmp_path / "thumbs" / "ok.jpg")
|
||||
png = _write_png(tmp_path / "thumbs" / "ok.png")
|
||||
bad = _write_garbage(tmp_path / "thumbs" / "bad.jpg")
|
||||
|
||||
r_null = _rec(db_sync, src_null, sha=_sha("aa"), thumb_path=None)
|
||||
_rec(db_sync, src_jpeg, sha=_sha("bb"), thumb_path=jpeg)
|
||||
_rec(db_sync, src_png, sha=_sha("cc"), thumb_path=png)
|
||||
r_missing = _rec(
|
||||
db_sync, src_missing, sha=_sha("dd"),
|
||||
thumb_path=tmp_path / "thumbs" / "missing.jpg",
|
||||
)
|
||||
r_bad = _rec(db_sync, src_bad, sha=_sha("ee"), thumb_path=bad)
|
||||
db_sync.commit()
|
||||
|
||||
monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync))
|
||||
delayed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id)
|
||||
)
|
||||
|
||||
result = m.backfill_thumbnails()
|
||||
assert result == {"enqueued": 3, "ok": 2, "regenerated": 2}
|
||||
assert sorted(delayed) == sorted([r_null.id, r_missing.id, r_bad.id])
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for cleanup_service's library-audit additions.
|
||||
|
||||
Covers:
|
||||
- project_min_dimension_violations: SQL-only query against width/height
|
||||
- delete_min_dimension_violations: routes through existing delete_images
|
||||
- audit lifecycle (start_audit_run / apply_audit_run / cancel_audit_run)
|
||||
|
||||
Tests assert via column selects per reference-async-coredml-test-assertions
|
||||
(post-DML ORM entity access via session.get() raises MissingGreenlet on
|
||||
async sessions; we use db_sync here but the convention is preserved for
|
||||
consistency).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from backend.app.models import ImageRecord, LibraryAuditRun
|
||||
from backend.app.services import cleanup_service
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _make_image_record(db_sync, tmp_path, *, width, height, color):
|
||||
"""Helper: write a real PIL file to a UNIQUE path (reference-image-record-path-unique)
|
||||
and insert an ImageRecord row with all required NOT-NULL columns set."""
|
||||
path = tmp_path / f"img_{width}x{height}_{color[0]}.png"
|
||||
Image.new("RGB", (width, height), color).save(path)
|
||||
# sha256 column is varchar(64) — use a deterministic 64-char pseudo
|
||||
# hash built from the unique inputs.
|
||||
sha = f"{width:04d}{height:04d}{color[0]:03d}".ljust(64, "0")[:64]
|
||||
rec = ImageRecord(
|
||||
path=str(path),
|
||||
sha256=sha,
|
||||
size_bytes=path.stat().st_size,
|
||||
mime="image/png", # reference-image-record-required-columns
|
||||
width=width,
|
||||
height=height,
|
||||
origin="imported_filesystem", # feedback-check-existing-enums
|
||||
integrity_status="ok",
|
||||
)
|
||||
db_sync.add(rec)
|
||||
db_sync.flush()
|
||||
return rec
|
||||
|
||||
|
||||
def test_project_min_dimension_violations_returns_count_and_samples(db_sync, tmp_path):
|
||||
_make_image_record(db_sync, tmp_path, width=100, height=100, color=(10, 0, 0))
|
||||
_make_image_record(db_sync, tmp_path, width=50, height=50, color=(20, 0, 0))
|
||||
_make_image_record(db_sync, tmp_path, width=400, height=400, color=(30, 0, 0))
|
||||
db_sync.commit()
|
||||
|
||||
result = cleanup_service.project_min_dimension_violations(
|
||||
db_sync, min_width=200, min_height=200,
|
||||
)
|
||||
assert result["count"] == 2 # 100x100 and 50x50 violate
|
||||
assert len(result["sample_ids"]) == 2
|
||||
|
||||
|
||||
def test_delete_min_dimension_violations_unlinks_and_cascades(db_sync, tmp_path):
|
||||
rec_small = _make_image_record(db_sync, tmp_path, width=50, height=50, color=(40, 0, 0))
|
||||
rec_big = _make_image_record(db_sync, tmp_path, width=500, height=500, color=(50, 0, 0))
|
||||
small_path = Path(rec_small.path)
|
||||
db_sync.commit()
|
||||
|
||||
# images_root is tmp_path here because the test fixtures stored files there;
|
||||
# delete_images() uses it to unlink originals + thumbs from the right tree.
|
||||
deleted = cleanup_service.delete_min_dimension_violations(
|
||||
db_sync, min_width=200, min_height=200, images_root=tmp_path,
|
||||
)
|
||||
assert deleted == 1
|
||||
# Verify via column selects per banked rule.
|
||||
remaining_ids = db_sync.execute(
|
||||
select(ImageRecord.id).order_by(ImageRecord.id)
|
||||
).scalars().all()
|
||||
assert remaining_ids == [rec_big.id]
|
||||
assert not small_path.exists() # file unlinked
|
||||
|
||||
|
||||
# --- Audit lifecycle tests (Task 5) ---
|
||||
|
||||
import backend.app.tasks.library_audit # noqa: F401, E402 — celery registration
|
||||
|
||||
|
||||
def test_start_audit_run_creates_row_and_dispatches(db_sync, monkeypatch):
|
||||
dispatched = []
|
||||
from backend.app.tasks import library_audit as la_mod
|
||||
monkeypatch.setattr(
|
||||
la_mod.scan_library_for_rule, "delay",
|
||||
lambda audit_id: dispatched.append(audit_id),
|
||||
)
|
||||
audit_id = cleanup_service.start_audit_run(
|
||||
db_sync, rule="transparency", params={"threshold": 0.9},
|
||||
)
|
||||
db_sync.commit()
|
||||
row_status = db_sync.execute(
|
||||
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit_id)
|
||||
).scalar_one()
|
||||
assert row_status == "running"
|
||||
assert dispatched == [audit_id]
|
||||
|
||||
|
||||
def test_start_audit_run_rejects_when_another_is_running(db_sync, monkeypatch):
|
||||
from backend.app.tasks import library_audit as la_mod
|
||||
monkeypatch.setattr(
|
||||
la_mod.scan_library_for_rule, "delay", lambda audit_id: None,
|
||||
)
|
||||
cleanup_service.start_audit_run(
|
||||
db_sync, rule="transparency", params={"threshold": 0.9},
|
||||
)
|
||||
db_sync.commit()
|
||||
with pytest.raises(cleanup_service.AuditAlreadyRunning):
|
||||
cleanup_service.start_audit_run(
|
||||
db_sync, rule="single_color",
|
||||
params={"threshold": 0.95, "tolerance": 30},
|
||||
)
|
||||
|
||||
|
||||
def test_apply_audit_run_with_correct_token_deletes_matched(db_sync, tmp_path):
|
||||
rec = _make_image_record(
|
||||
db_sync, tmp_path, width=100, height=100, color=(60, 0, 0),
|
||||
)
|
||||
db_sync.flush()
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.9},
|
||||
status="ready", scanned_count=1, matched_count=1,
|
||||
matched_ids=[rec.id],
|
||||
)
|
||||
db_sync.add(audit)
|
||||
db_sync.commit()
|
||||
|
||||
deleted = cleanup_service.apply_audit_run(
|
||||
db_sync, audit_id=audit.id,
|
||||
confirm_token=f"delete-audit-{audit.id}",
|
||||
images_root=tmp_path,
|
||||
)
|
||||
assert deleted == 1
|
||||
remaining_count = db_sync.execute(
|
||||
select(func.count()).select_from(ImageRecord)
|
||||
).scalar_one()
|
||||
assert remaining_count == 0
|
||||
new_status = db_sync.execute(
|
||||
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit.id)
|
||||
).scalar_one()
|
||||
assert new_status == "applied"
|
||||
|
||||
|
||||
def test_apply_audit_run_with_wrong_token_raises(db_sync, tmp_path):
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.9},
|
||||
status="ready", matched_ids=[],
|
||||
)
|
||||
db_sync.add(audit)
|
||||
db_sync.commit()
|
||||
with pytest.raises(cleanup_service.ConfirmTokenMismatch):
|
||||
cleanup_service.apply_audit_run(
|
||||
db_sync, audit_id=audit.id,
|
||||
confirm_token="wrong-token", images_root=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_cancel_audit_run_flips_status(db_sync):
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.9},
|
||||
status="running", matched_ids=[],
|
||||
)
|
||||
db_sync.add(audit)
|
||||
db_sync.commit()
|
||||
cleanup_service.cancel_audit_run(db_sync, audit_id=audit.id)
|
||||
new_status = db_sync.execute(
|
||||
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit.id)
|
||||
).scalar_one()
|
||||
assert new_status == "cancelled"
|
||||
@@ -69,20 +69,13 @@ async def test_scroll_post_id_filter(db):
|
||||
assert {x.id for x in page.images} == {i1.id, i2.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_post_id_dedups_multi_rows(db):
|
||||
i1 = await _img(db, 1)
|
||||
_, s, p = await _post(db, "A", "a", "10")
|
||||
# two provenance rows, same image+post (enrich-on-duplicate shape)
|
||||
db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id,
|
||||
source_id=s.id))
|
||||
await db.flush()
|
||||
db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id,
|
||||
source_id=s.id))
|
||||
await db.flush()
|
||||
svc = GalleryService(db)
|
||||
page = await svc.scroll(cursor=None, limit=10, post_id=p.id)
|
||||
assert [x.id for x in page.images] == [i1.id] # appears once
|
||||
# test_scroll_post_id_dedups_multi_rows removed 2026-05-26: it deliberately
|
||||
# inserted two ImageProvenance rows with the same (image_record_id, post_id),
|
||||
# now prevented at the DB layer by uq_image_provenance_image_post (alembic
|
||||
# 0021). The EXISTS-based dedup in _provenance_clause is still useful for the
|
||||
# artist-id filter (one image legitimately joins many provenance rows via
|
||||
# different posts under the same artist), so the gallery_service logic is
|
||||
# unchanged.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -59,3 +59,25 @@ def test_json_sidecar_is_not_attached(importer, import_layout):
|
||||
select(func.count()).select_from(PostAttachment)
|
||||
).scalar_one()
|
||||
assert n == 0
|
||||
|
||||
|
||||
def test_mangled_filename_extension_is_sanitized(importer, import_layout):
|
||||
"""gallery-dl sometimes URL-encodes a query string into the basename
|
||||
(`...https___www.patreon.com_media-u_Z0F...`). Python's Path.suffix
|
||||
returns 50+ chars of base64-ish junk for those, which blows the
|
||||
PostAttachment.ext varchar(32) column. Operator-flagged 2026-05-25.
|
||||
The importer should record an empty ext rather than crash."""
|
||||
import_root, _ = import_layout
|
||||
f = (
|
||||
import_root / "Alice"
|
||||
/ "79507046_media_https___www.patreon.com_media-u_Z0FBQUFBQm5q"
|
||||
)
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_bytes(b"binary blob")
|
||||
r = importer.import_one(f)
|
||||
assert r.status == "attached"
|
||||
att = importer.session.execute(select(PostAttachment)).scalar_one()
|
||||
# Junk "extension" -> stored as empty string (not the 50-char garbage).
|
||||
assert att.ext == ""
|
||||
# original_filename is Text-typed so the full name survives intact.
|
||||
assert att.original_filename.endswith("_Z0FBQUFBQm5q")
|
||||
|
||||
@@ -64,9 +64,13 @@ def test_deep_rederives_phash_and_provenance(db_sync, import_layout):
|
||||
|
||||
deep = _mk(db_sync, import_layout, deep=True)
|
||||
r2 = deep.import_one(src)
|
||||
assert r2.status == "skipped"
|
||||
assert "deep" in (r2.error or "")
|
||||
# Outcome flipped from "skipped+duplicate_hash" to "refreshed" 2026-05-25
|
||||
# so the UI can surface deep scan's actual work instead of showing it as
|
||||
# a no-op. See ImportResult.status comment + _deep_rederive docstring.
|
||||
assert r2.status == "refreshed"
|
||||
assert r2.image_id == rec.id
|
||||
assert r2.skip_reason is None
|
||||
assert r2.error is None
|
||||
|
||||
db_sync.expire_all()
|
||||
rec2 = db_sync.get(ImageRecord, rec.id)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Race-safe ImageProvenance insert in Importer._apply_sidecar.
|
||||
|
||||
Operator-flagged 2026-05-26: the prior SELECT-then-INSERT pattern lost a
|
||||
race when two workers ran _apply_sidecar on the same (image, post) pair
|
||||
(plausibly seeded when the 5-min recovery sweep re-enqueued a still-running
|
||||
long import). Duplicates then broke .scalar_one_or_none() on every later
|
||||
deep-scan rederive (MultipleResultsFound). Alembic 0021 added
|
||||
uq_image_provenance_image_post; the importer's new savepoint+IntegrityError
|
||||
recovery path now trips on collision and gracefully recovers.
|
||||
|
||||
Tests cover:
|
||||
- idempotent: re-running _apply_sidecar via _deep_rederive produces
|
||||
exactly one ImageProvenance row.
|
||||
- IntegrityError recovery: pre-seed a provenance row, force the first
|
||||
SELECT to return None (simulating the race window where two workers
|
||||
both observed no row), call _apply_sidecar — the savepoint INSERT
|
||||
trips uq_image_provenance_image_post, gets rolled back, no exception
|
||||
escapes, still exactly one row.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from backend.app.models import (
|
||||
ImageProvenance,
|
||||
ImageRecord,
|
||||
ImportSettings,
|
||||
Source,
|
||||
)
|
||||
from backend.app.services.importer import Importer
|
||||
from backend.app.services.thumbnailer import Thumbnailer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def import_layout(tmp_path):
|
||||
import_root = tmp_path / "import"
|
||||
images_root = tmp_path / "images"
|
||||
import_root.mkdir()
|
||||
images_root.mkdir()
|
||||
return import_root, images_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def importer(db_sync, import_layout):
|
||||
import_root, images_root = import_layout
|
||||
settings = db_sync.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
).scalar_one()
|
||||
return Importer(
|
||||
session=db_sync,
|
||||
images_root=images_root,
|
||||
import_root=import_root,
|
||||
thumbnailer=Thumbnailer(images_root=images_root),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deep_importer(db_sync, import_layout):
|
||||
import_root, images_root = import_layout
|
||||
settings = db_sync.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
).scalar_one()
|
||||
return Importer(
|
||||
session=db_sync,
|
||||
images_root=images_root,
|
||||
import_root=import_root,
|
||||
thumbnailer=Thumbnailer(images_root=images_root),
|
||||
settings=settings,
|
||||
deep=True,
|
||||
)
|
||||
|
||||
|
||||
def _split(path: Path, orient, size=(256, 256)):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
w, h = size
|
||||
im = Image.new("L", size, 0)
|
||||
px = im.load()
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if (x / w if orient == "v" else y / h) >= 0.5:
|
||||
px[x, y] = 255
|
||||
im.convert("RGB").save(path, "JPEG")
|
||||
|
||||
|
||||
def _sidecar(media: Path, payload: dict):
|
||||
media.with_suffix(".json").write_text(json.dumps(payload))
|
||||
|
||||
|
||||
def test_apply_sidecar_idempotent_on_deep_rederive(
|
||||
importer, deep_importer, import_layout,
|
||||
):
|
||||
"""Normal-flow path: deep rederive on an already-imported image finds
|
||||
the existing provenance row via .scalar_one_or_none() and skips the
|
||||
insert. Exactly one ImageProvenance row after both runs."""
|
||||
import_root, _ = import_layout
|
||||
m = import_root / "Alice" / "a.jpg"
|
||||
_split(m, "v")
|
||||
_sidecar(m, {
|
||||
"category": "patreon", "id": 555,
|
||||
"url": "https://patreon.com/posts/555", "title": "Set 1",
|
||||
})
|
||||
r = importer.import_one(m)
|
||||
assert r.status == "imported"
|
||||
|
||||
# Re-import via deep mode — sha matches → _deep_rederive → _apply_sidecar.
|
||||
r2 = deep_importer.import_one(m)
|
||||
assert r2.status == "refreshed"
|
||||
|
||||
count = importer.session.execute(
|
||||
select(func.count()).select_from(ImageProvenance)
|
||||
).scalar_one()
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_apply_sidecar_recovers_from_integrity_error(
|
||||
importer, deep_importer, import_layout, db_sync, monkeypatch,
|
||||
):
|
||||
"""Race recovery: a row already exists for (image, post). We force the
|
||||
importer's existence-check SELECT to return None for one call, mimicking
|
||||
the race window where two workers both saw no row. The savepoint INSERT
|
||||
then trips uq_image_provenance_image_post; the helper rolls the
|
||||
savepoint back, no exception escapes, and the row count stays at 1.
|
||||
"""
|
||||
import_root, _ = import_layout
|
||||
m = import_root / "Bob" / "b.jpg"
|
||||
_split(m, "v")
|
||||
_sidecar(m, {
|
||||
"category": "patreon", "id": 777,
|
||||
"url": "https://patreon.com/posts/777", "title": "Set 2",
|
||||
})
|
||||
# First import lays the canonical provenance row.
|
||||
r = importer.import_one(m)
|
||||
assert r.status == "imported"
|
||||
rec = importer.session.get(ImageRecord, r.image_id)
|
||||
src = importer.session.execute(select(Source)).scalar_one()
|
||||
assert rec is not None
|
||||
assert src is not None
|
||||
|
||||
# Monkeypatch session.execute so the FIRST select inside _apply_sidecar's
|
||||
# existence-check returns a "no row" wrapper. Subsequent selects (e.g.
|
||||
# the find_or_create_source / find_or_create_post existence checks
|
||||
# earlier in _apply_sidecar) all run normally; we intercept only the
|
||||
# ImageProvenance lookup, identified by the SELECT's target columns
|
||||
# mentioning image_provenance.
|
||||
real_execute = db_sync.execute
|
||||
intercepted = [False]
|
||||
|
||||
def _intercepting_execute(stmt, *args, **kwargs):
|
||||
text = str(stmt)
|
||||
if (
|
||||
not intercepted[0]
|
||||
and "image_provenance" in text
|
||||
and "image_record_id" in text
|
||||
and "post_id" in text
|
||||
):
|
||||
intercepted[0] = True
|
||||
|
||||
class _ForcedMiss:
|
||||
def scalar_one_or_none(self):
|
||||
return None
|
||||
return _ForcedMiss()
|
||||
return real_execute(stmt, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(db_sync, "execute", _intercepting_execute)
|
||||
|
||||
# Re-import via deep mode → _deep_rederive → _apply_sidecar. With the
|
||||
# provenance-SELECT forced to miss, the helper will attempt the INSERT,
|
||||
# trip uq_image_provenance_image_post, catch IntegrityError, and recover.
|
||||
r2 = deep_importer.import_one(m)
|
||||
assert r2.status == "refreshed"
|
||||
|
||||
# Lift the intercept, then verify the row count.
|
||||
monkeypatch.undo()
|
||||
count = db_sync.execute(
|
||||
select(func.count()).select_from(ImageProvenance)
|
||||
).scalar_one()
|
||||
assert count == 1
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Tests for Importer._find_or_create_source / _find_or_create_post —
|
||||
the race-safe savepoint-based helpers that replaced the previous
|
||||
check-then-insert pattern.
|
||||
|
||||
Operator-flagged 2026-05-26: concurrent workers processing different
|
||||
files in the same post both found no existing Source row, then both
|
||||
INSERTed, tripping uq_source_artist_platform_url and poisoning the
|
||||
session with `psycopg.errors.UniqueViolation`. The new helpers wrap
|
||||
the INSERT in a savepoint and recover from IntegrityError by
|
||||
re-selecting the row that the concurrent op committed.
|
||||
|
||||
Tests cover:
|
||||
- idempotent return: same (artist_id, platform, url) → same Source row
|
||||
- idempotent return for Post: same (source_id, external_post_id) → same Post
|
||||
- IntegrityError recovery: monkeypatched flush raises once, helper
|
||||
finds the row a concurrent op committed
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from backend.app.models import Artist, ImportSettings, Post, Source
|
||||
from backend.app.services.importer import Importer
|
||||
from backend.app.services.thumbnailer import Thumbnailer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def importer(db_sync, tmp_path):
|
||||
import_root = tmp_path / "import"
|
||||
images_root = tmp_path / "images"
|
||||
import_root.mkdir()
|
||||
images_root.mkdir()
|
||||
settings = db_sync.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
).scalar_one()
|
||||
return Importer(
|
||||
session=db_sync,
|
||||
images_root=images_root,
|
||||
import_root=import_root,
|
||||
thumbnailer=Thumbnailer(images_root=images_root),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def artist_row(db_sync):
|
||||
a = Artist(name="TestArtist", slug="testartist")
|
||||
db_sync.add(a)
|
||||
db_sync.flush()
|
||||
return a
|
||||
|
||||
|
||||
def test_find_or_create_source_creates_then_returns_existing(importer, artist_row):
|
||||
s1 = importer._find_or_create_source(
|
||||
artist_id=artist_row.id, platform="patreon",
|
||||
url="https://www.patreon.com/posts/test-1",
|
||||
)
|
||||
s2 = importer._find_or_create_source(
|
||||
artist_id=artist_row.id, platform="patreon",
|
||||
url="https://www.patreon.com/posts/test-1",
|
||||
)
|
||||
assert s1.id == s2.id
|
||||
|
||||
|
||||
def test_find_or_create_source_distinct_urls_yield_distinct_rows(
|
||||
importer, artist_row,
|
||||
):
|
||||
a = importer._find_or_create_source(
|
||||
artist_id=artist_row.id, platform="patreon",
|
||||
url="https://www.patreon.com/posts/a",
|
||||
)
|
||||
b = importer._find_or_create_source(
|
||||
artist_id=artist_row.id, platform="patreon",
|
||||
url="https://www.patreon.com/posts/b",
|
||||
)
|
||||
assert a.id != b.id
|
||||
|
||||
|
||||
def test_find_or_create_post_idempotent(importer, artist_row, db_sync):
|
||||
src = importer._find_or_create_source(
|
||||
artist_id=artist_row.id, platform="patreon",
|
||||
url="https://www.patreon.com/posts/post-test",
|
||||
)
|
||||
p1 = importer._find_or_create_post(
|
||||
source_id=src.id, external_post_id="ext-001",
|
||||
)
|
||||
p2 = importer._find_or_create_post(
|
||||
source_id=src.id, external_post_id="ext-001",
|
||||
)
|
||||
assert p1.id == p2.id
|
||||
|
||||
|
||||
def test_find_or_create_source_recovers_from_integrity_error(
|
||||
importer, artist_row, db_sync, monkeypatch,
|
||||
):
|
||||
"""Simulate the race: another worker has already inserted a Source row
|
||||
matching our (artist_id, platform, url) just before our flush would
|
||||
have. Our flush raises IntegrityError; the helper rolls back the
|
||||
savepoint and re-selects, returning the row the concurrent op created.
|
||||
"""
|
||||
canonical_url = "https://www.patreon.com/posts/race-141226276"
|
||||
pre_existing = Source(
|
||||
artist_id=artist_row.id, platform="patreon", url=canonical_url,
|
||||
)
|
||||
db_sync.add(pre_existing)
|
||||
db_sync.flush()
|
||||
|
||||
# Force a fresh select within the helper to MISS the existing row by
|
||||
# detaching it from the identity map; SQLAlchemy's first-level cache
|
||||
# would otherwise return the pre_existing row immediately.
|
||||
# Easier: monkeypatch the FIRST select inside the helper to return
|
||||
# None on first call, real result on subsequent. We do that by
|
||||
# patching session.execute with a single-shot wrapper.
|
||||
real_execute = db_sync.execute
|
||||
skip_count = [0]
|
||||
|
||||
def execute_with_first_select_miss(stmt, *args, **kwargs):
|
||||
# Strip-down heuristic: the first SELECT issued by the helper is
|
||||
# the existence check. Force it to return a "no row" result.
|
||||
result = real_execute(stmt, *args, **kwargs)
|
||||
if skip_count[0] == 0:
|
||||
skip_count[0] += 1
|
||||
# Wrap result so .scalar_one_or_none() returns None for this
|
||||
# one call, then unwrap on subsequent uses.
|
||||
class _ForcedMiss:
|
||||
def scalar_one_or_none(self):
|
||||
return None
|
||||
|
||||
def scalar_one(self):
|
||||
return result.scalar_one()
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(result, name)
|
||||
return _ForcedMiss()
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(db_sync, "execute", execute_with_first_select_miss)
|
||||
|
||||
recovered = importer._find_or_create_source(
|
||||
artist_id=artist_row.id, platform="patreon", url=canonical_url,
|
||||
)
|
||||
assert recovered.id == pre_existing.id
|
||||
|
||||
|
||||
def test_source_for_sidecar_reuses_existing_subscription(
|
||||
importer, artist_row, db_sync,
|
||||
):
|
||||
"""The filesystem-import sidecar resolver should attach to whatever
|
||||
Source already exists for (artist, platform) — the canonical subscription
|
||||
Source — regardless of its URL. Without this, every imported post
|
||||
spawned its own Source row.
|
||||
"""
|
||||
canonical = Source(
|
||||
artist_id=artist_row.id, platform="patreon",
|
||||
url="https://www.patreon.com/cw/testartist", enabled=True,
|
||||
)
|
||||
db_sync.add(canonical)
|
||||
db_sync.flush()
|
||||
|
||||
resolved = importer._source_for_sidecar(
|
||||
artist_id=artist_row.id, platform="patreon",
|
||||
artist_slug=artist_row.slug,
|
||||
)
|
||||
assert resolved.id == canonical.id
|
||||
|
||||
|
||||
def test_source_for_sidecar_creates_synthetic_anchor_when_none_exists(
|
||||
importer, artist_row, db_sync,
|
||||
):
|
||||
"""No subscription Source for this (artist, platform) yet. The helper
|
||||
creates one synthetic anchor (enabled=False, url='sidecar:<plat>:<slug>')
|
||||
so subsequent imports reuse it instead of spawning per-post Sources.
|
||||
"""
|
||||
resolved = importer._source_for_sidecar(
|
||||
artist_id=artist_row.id, platform="pixiv",
|
||||
artist_slug=artist_row.slug,
|
||||
)
|
||||
assert resolved.url == f"sidecar:pixiv:{artist_row.slug}"
|
||||
assert resolved.enabled is False
|
||||
assert resolved.artist_id == artist_row.id
|
||||
assert resolved.platform == "pixiv"
|
||||
|
||||
# Second call returns the same row (no new Source spawned).
|
||||
again = importer._source_for_sidecar(
|
||||
artist_id=artist_row.id, platform="pixiv",
|
||||
artist_slug=artist_row.slug,
|
||||
)
|
||||
assert again.id == resolved.id
|
||||
|
||||
|
||||
def test_source_for_sidecar_distinct_platforms_distinct_anchors(
|
||||
importer, artist_row, db_sync,
|
||||
):
|
||||
"""One synthetic anchor per (artist, platform). Different platforms get
|
||||
different anchors even when no campaign Source exists for either.
|
||||
"""
|
||||
p = importer._source_for_sidecar(
|
||||
artist_id=artist_row.id, platform="patreon",
|
||||
artist_slug=artist_row.slug,
|
||||
)
|
||||
x = importer._source_for_sidecar(
|
||||
artist_id=artist_row.id, platform="pixiv",
|
||||
artist_slug=artist_row.slug,
|
||||
)
|
||||
assert p.id != x.id
|
||||
assert p.platform == "patreon"
|
||||
assert x.platform == "pixiv"
|
||||
@@ -241,3 +241,9 @@ def test_import_task_maps_superseded_to_complete_and_requeues():
|
||||
assert _map_result_to_status(
|
||||
ImportResult(status="failed", error="boom")
|
||||
) == ("failed", False)
|
||||
# Refreshed (deep scan): complete + no ML/thumb re-derive (pixels
|
||||
# unchanged). Added 2026-05-25 alongside ImportBatch.refreshed
|
||||
# counter so deep scan reports "X refreshed" instead of "no work".
|
||||
assert _map_result_to_status(
|
||||
ImportResult(status="refreshed", image_id=5)
|
||||
) == ("complete", False)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Deep scan re-queues already-completed ImportTasks.
|
||||
|
||||
Operator-flagged 2026-05-25: deep scan used to skip everything that
|
||||
already had a non-failed ImportTask row, making a deep re-scan a no-op
|
||||
when no new files were added. That defeated the entire point of deep
|
||||
scan (re-apply sidecar metadata to existing rows). The skip-set now
|
||||
splits by mode — quick keeps the old "any non-failed" semantics; deep
|
||||
skips ONLY actively-in-flight statuses.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from backend.app.models import ImportBatch, ImportSettings, ImportTask
|
||||
from backend.app.tasks.scan import scan_directory
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _img(path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (40, 40), (10, 200, 80)).save(path, "JPEG")
|
||||
|
||||
|
||||
def test_deep_scan_requeues_completed_task(db_sync, tmp_path, monkeypatch):
|
||||
"""Quick scan then deep scan of the same /import: the file completed
|
||||
in the first run should be re-enqueued by the deep run (different
|
||||
ImportTask id, same source_path)."""
|
||||
import_root = tmp_path / "import"
|
||||
settings = db_sync.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
).scalar_one()
|
||||
settings.import_scan_path = str(import_root)
|
||||
db_sync.commit()
|
||||
|
||||
src = import_root / "Mae" / "p.jpg"
|
||||
_img(src)
|
||||
|
||||
from backend.app import celery_app
|
||||
|
||||
celery_app.celery.conf.task_always_eager = False # explicit
|
||||
try:
|
||||
first_batch_id = scan_directory.run(triggered_by="manual", mode="quick")
|
||||
first_task = db_sync.execute(
|
||||
select(ImportTask).where(ImportTask.batch_id == first_batch_id)
|
||||
).scalar_one()
|
||||
# Simulate the worker having finished it.
|
||||
first_task.status = "complete"
|
||||
db_sync.commit()
|
||||
|
||||
# Now deep scan: the SAME file should get a NEW ImportTask row.
|
||||
second_batch_id = scan_directory.run(triggered_by="manual", mode="deep")
|
||||
assert second_batch_id != first_batch_id
|
||||
|
||||
new_tasks_in_second_batch = db_sync.execute(
|
||||
select(func.count())
|
||||
.select_from(ImportTask)
|
||||
.where(ImportTask.batch_id == second_batch_id)
|
||||
).scalar_one()
|
||||
assert new_tasks_in_second_batch == 1, (
|
||||
"deep scan did not re-queue the completed file"
|
||||
)
|
||||
|
||||
# And the second batch's task should be for the same source_path
|
||||
# as the first (proves it's a re-queue, not a different file).
|
||||
sp = db_sync.execute(
|
||||
select(ImportTask.source_path)
|
||||
.where(ImportTask.batch_id == second_batch_id)
|
||||
).scalar_one()
|
||||
assert sp == str(src)
|
||||
finally:
|
||||
celery_app.celery.conf.task_always_eager = False
|
||||
|
||||
|
||||
def test_quick_scan_does_not_requeue_completed_task(db_sync, tmp_path):
|
||||
"""The flip side: quick scan still keeps the old skip semantics —
|
||||
a file with a completed ImportTask row from a prior batch is NOT
|
||||
re-enqueued on a fresh quick scan."""
|
||||
import_root = tmp_path / "import"
|
||||
settings = db_sync.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
).scalar_one()
|
||||
settings.import_scan_path = str(import_root)
|
||||
db_sync.commit()
|
||||
|
||||
src = import_root / "Mae" / "q.jpg"
|
||||
_img(src)
|
||||
|
||||
first_batch_id = scan_directory.run(triggered_by="manual", mode="quick")
|
||||
first_task = db_sync.execute(
|
||||
select(ImportTask).where(ImportTask.batch_id == first_batch_id)
|
||||
).scalar_one()
|
||||
first_task.status = "complete"
|
||||
db_sync.commit()
|
||||
|
||||
second_batch_id = scan_directory.run(triggered_by="manual", mode="quick")
|
||||
second_batch_count = db_sync.execute(
|
||||
select(func.count())
|
||||
.select_from(ImportTask)
|
||||
.where(ImportTask.batch_id == second_batch_id)
|
||||
).scalar_one()
|
||||
assert second_batch_count == 0, (
|
||||
"quick scan re-queued an already-completed task — should have skipped"
|
||||
)
|
||||
|
||||
# And the second batch should self-finalize (files_seen=0).
|
||||
second_batch = db_sync.get(ImportBatch, second_batch_id)
|
||||
assert second_batch.status == "complete"
|
||||
@@ -23,6 +23,51 @@ def test_find_sidecar_none(tmp_path):
|
||||
assert find_sidecar(media) is None
|
||||
|
||||
|
||||
def test_find_sidecar_gallerydl_numbered_prefix(tmp_path):
|
||||
"""gallery-dl prefixes media filenames with NN_ for in-post ordering
|
||||
(e.g. `01_HOLLOW-ICHIGO.png`) but writes the post-level sidecar under
|
||||
the attachment stem WITHOUT the prefix (`HOLLOW-ICHIGO.json`).
|
||||
Confirmed against real Patreon downloads 2026-05-26 — operator's deep
|
||||
scan produced 24 refresh calls but 0 Posts because the unprefixed
|
||||
sidecar was invisible to find_sidecar."""
|
||||
media = tmp_path / "01_HOLLOW-ICHIGO.png"
|
||||
media.write_bytes(b"x")
|
||||
sc = tmp_path / "HOLLOW-ICHIGO.json"
|
||||
sc.write_text("{}")
|
||||
assert find_sidecar(media) == sc
|
||||
|
||||
|
||||
def test_find_sidecar_multidigit_prefix(tmp_path):
|
||||
"""Numbering prefix can be wider than 2 digits (`001_...`); the strip
|
||||
handles any \\d+_ form."""
|
||||
media = tmp_path / "001_mirko-sketch.png"
|
||||
media.write_bytes(b"x")
|
||||
sc = tmp_path / "mirko-sketch.json"
|
||||
sc.write_text("{}")
|
||||
assert find_sidecar(media) == sc
|
||||
|
||||
|
||||
def test_find_sidecar_prefers_attachment_level_over_post_level(tmp_path):
|
||||
"""If BOTH a per-attachment sidecar and a post-level sidecar exist,
|
||||
the attachment-level one wins (it's more specific)."""
|
||||
media = tmp_path / "01_image.png"
|
||||
media.write_bytes(b"x")
|
||||
per_attachment = tmp_path / "01_image.json"
|
||||
per_attachment.write_text('{"specific": true}')
|
||||
post_level = tmp_path / "image.json"
|
||||
post_level.write_text('{"specific": false}')
|
||||
assert find_sidecar(media) == per_attachment
|
||||
|
||||
|
||||
def test_find_sidecar_no_underscore_not_treated_as_prefix(tmp_path):
|
||||
"""`01.png` (just digits, no underscore-separated stem) shouldn't
|
||||
match. The regex requires NN_<something>."""
|
||||
media = tmp_path / "01.png"
|
||||
media.write_bytes(b"x")
|
||||
(tmp_path / ".json").write_text("{}") # would be matched only if buggy
|
||||
assert find_sidecar(media) is None
|
||||
|
||||
|
||||
def test_parse_empty_dict_all_none():
|
||||
sd = parse_sidecar({})
|
||||
assert isinstance(sd, SidecarData)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Tests for scan_library_for_rule Celery task.
|
||||
|
||||
Eager mode is used so the task runs synchronously in-test and we can
|
||||
assert state via column selects (post-DML ORM access banned per
|
||||
reference-async-coredml-test-assertions)."""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
import backend.app.tasks.library_audit # noqa: F401 — celery registration
|
||||
from backend.app import celery_app
|
||||
from backend.app.models import ImageRecord, LibraryAuditRun
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _mk_image(db_sync, tmp_path, *, mode, color, name):
|
||||
path = tmp_path / name
|
||||
Image.new(mode, (10, 10), color).save(path)
|
||||
# sha256 column is varchar(64) — pad/truncate a per-file pseudo hash
|
||||
# exactly to 64 chars. Each test fixture file must have a unique
|
||||
# sha256 (reference-image-record-path-unique peer constraint).
|
||||
sha = f"audit-{name}".ljust(64, "x")[:64]
|
||||
rec = ImageRecord(
|
||||
path=str(path),
|
||||
sha256=sha,
|
||||
size_bytes=path.stat().st_size,
|
||||
mime="image/png",
|
||||
width=10, height=10,
|
||||
origin="imported_filesystem",
|
||||
integrity_status="ok",
|
||||
)
|
||||
db_sync.add(rec)
|
||||
db_sync.flush()
|
||||
return rec, path
|
||||
|
||||
|
||||
def test_scan_library_for_rule_populates_matched_ids_for_transparency(
|
||||
db_sync, tmp_path, monkeypatch,
|
||||
):
|
||||
transparent_rec, _ = _mk_image(
|
||||
db_sync, tmp_path, mode="RGBA", color=(0, 0, 0, 0), name="trans.png",
|
||||
)
|
||||
opaque_rec, _ = _mk_image(
|
||||
db_sync, tmp_path, mode="RGBA", color=(200, 0, 0, 255), name="opaque.png",
|
||||
)
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.5},
|
||||
status="running", matched_ids=[],
|
||||
)
|
||||
db_sync.add(audit)
|
||||
db_sync.commit()
|
||||
audit_id = audit.id
|
||||
|
||||
monkeypatch.setattr(celery_app.celery.conf, "task_always_eager", True)
|
||||
from backend.app.tasks.library_audit import scan_library_for_rule
|
||||
scan_library_for_rule.run(audit_id)
|
||||
monkeypatch.setattr(celery_app.celery.conf, "task_always_eager", False)
|
||||
|
||||
matched = db_sync.execute(
|
||||
select(LibraryAuditRun.matched_ids).where(LibraryAuditRun.id == audit_id)
|
||||
).scalar_one()
|
||||
status = db_sync.execute(
|
||||
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit_id)
|
||||
).scalar_one()
|
||||
assert transparent_rec.id in matched
|
||||
assert opaque_rec.id not in matched
|
||||
assert status == "ready"
|
||||
|
||||
|
||||
def test_scan_library_for_rule_skips_missing_files_gracefully(
|
||||
db_sync, tmp_path, monkeypatch,
|
||||
):
|
||||
rec, path = _mk_image(
|
||||
db_sync, tmp_path, mode="RGBA", color=(0, 0, 0, 0), name="ghost.png",
|
||||
)
|
||||
path.unlink() # delete the file but leave the DB row
|
||||
audit = LibraryAuditRun(
|
||||
rule="transparency", params={"threshold": 0.5},
|
||||
status="running", matched_ids=[],
|
||||
)
|
||||
db_sync.add(audit)
|
||||
db_sync.commit()
|
||||
audit_id = audit.id
|
||||
|
||||
monkeypatch.setattr(celery_app.celery.conf, "task_always_eager", True)
|
||||
from backend.app.tasks.library_audit import scan_library_for_rule
|
||||
scan_library_for_rule.run(audit_id)
|
||||
monkeypatch.setattr(celery_app.celery.conf, "task_always_eager", False)
|
||||
|
||||
status = db_sync.execute(
|
||||
select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit_id)
|
||||
).scalar_one()
|
||||
matched = db_sync.execute(
|
||||
select(LibraryAuditRun.matched_ids).where(LibraryAuditRun.id == audit_id)
|
||||
).scalar_one()
|
||||
# Missing file is skipped (warning logged), audit completes successfully.
|
||||
assert status == "ready"
|
||||
assert rec.id not in matched
|
||||
@@ -23,3 +23,7 @@ def test_import_media_file_registered():
|
||||
|
||||
def test_generate_thumbnail_registered():
|
||||
assert "backend.app.tasks.thumbnail.generate_thumbnail" in celery.tasks
|
||||
|
||||
|
||||
def test_backfill_thumbnails_registered():
|
||||
assert "backend.app.tasks.thumbnail.backfill_thumbnails" in celery.tasks
|
||||
|
||||
Reference in New Issue
Block a user