Compare commits
37 Commits
v26.05.25.0
...
ext-1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| e766197d99 | |||
| 5587a76606 | |||
| 3872e1dda9 | |||
| 17e19081a2 | |||
| 9814f3dbaf | |||
| 770bcf3aa6 | |||
| 52d7905c43 | |||
| e6ededbe8e | |||
| c06cbc0abe | |||
| b214460fdb | |||
| ac39509a74 | |||
| 3531f373ee | |||
| 36cc0622cb | |||
| e50f92d900 | |||
| ba8d9b112d | |||
| c451061ca5 | |||
| ac55d0e8d8 | |||
| 47d760550d | |||
| 89a89e0ded | |||
| dc3bce7fc1 | |||
| f657582f30 | |||
| 111b952535 | |||
| 4e9aac2c05 | |||
| a0470b5f60 | |||
| b0bb7ae6cc | |||
| 1bbe478fd0 | |||
| 5666fd5ca5 | |||
| 2879ac6f2b | |||
| 3a359f6c5e | |||
| b6a917ac81 | |||
| c361032554 | |||
| 9f54efdedf | |||
| 6b1bb87647 | |||
| 68cffce322 | |||
| 52445eb501 | |||
| 3b3e7565fb | |||
| 9d5abb09f6 |
@@ -6,18 +6,183 @@ on:
|
|||||||
|
|
||||||
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
|
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
|
||||||
# - write:package, read:package (for docker push to git.fabledsword.com)
|
# - 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)
|
# - write:issue (for future issue-management automation)
|
||||||
# The injected GITHUB_TOKEN cannot be used — it lacks write:package.
|
# The injected GITHUB_TOKEN cannot be used — it lacks write:package.
|
||||||
|
|
||||||
jobs:
|
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
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
||||||
|
|
||||||
|
- name: Download cached signed XPI (cache hit)
|
||||||
|
if: steps.cache.outputs.cached == 'true'
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
mkdir -p extension/web-ext-artifacts
|
||||||
|
curl -sL -H "Authorization: token $TOKEN" \
|
||||||
|
-o "extension/web-ext-artifacts/fabledcurator-${{ steps.extver.outputs.version }}.xpi" \
|
||||||
|
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/assets/${{ steps.cache.outputs.asset_id }}"
|
||||||
|
ls -la extension/web-ext-artifacts/
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
|
||||||
|
- name: Upload XPI as Actions artifact (handoff to build-web)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: signed-extension
|
||||||
|
path: extension/web-ext-artifacts/fabledcurator-*.xpi
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
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 extension (main only)
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: signed-extension
|
||||||
|
path: extension/web-ext-artifacts/
|
||||||
|
|
||||||
|
- name: Place signed XPI in build context (main only)
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||||
|
XPI=$(ls extension/web-ext-artifacts/*.xpi | head -1)
|
||||||
|
mkdir -p frontend/public/extension
|
||||||
|
cp "$XPI" "frontend/public/extension/fabledcurator-$VERSION.xpi"
|
||||||
|
cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi"
|
||||||
|
ls -la frontend/public/extension/
|
||||||
|
|
||||||
- name: Determine tag
|
- name: Determine tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+196
-22
@@ -24,11 +24,26 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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: Install Python deps
|
- name: Install Python deps
|
||||||
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
||||||
# Dockerfile's RUFF_VERSION). Per FabledRulebook ci-runners.md, toolchain
|
# Dockerfile's RUFF_VERSION). Per FabledRulebook ci-runners.md, toolchain
|
||||||
# versions live on the runner image, not here.
|
# versions live on the runner image, not here.
|
||||||
run: pip install -r requirements.txt pytest pytest-asyncio
|
# uv: 5-10x faster wheel resolve than pip for cold caches.
|
||||||
|
# Falls back to pip install on uv-missing runners (older images).
|
||||||
|
run: |
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
||||||
|
else
|
||||||
|
pip install -r requirements.txt pytest pytest-asyncio
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Ruff lint
|
- name: Ruff lint
|
||||||
run: ruff check backend/ tests/ alembic/
|
run: ruff check backend/ tests/ alembic/
|
||||||
@@ -57,17 +72,28 @@ jobs:
|
|||||||
- run: npm run test:unit
|
- run: npm run test:unit
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
integration:
|
# Integration suite split into THREE parallel shards (2026-05-25, runner
|
||||||
# This act_runner (swarm-runner v0.6.1) puts service containers on the
|
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service
|
||||||
# default bridge with NO service-name DNS, and publishing fixed host
|
# set and runs alembic + a disjoint subset of integration tests. Shards
|
||||||
# ports collides with the operator's running docker-compose dev stack on
|
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
|
||||||
# the same shared daemon. Workaround: publish NO host ports, and reach
|
# stays single-threaded per shard but multiple shards run in parallel
|
||||||
# each service by its bridge IP — discovered at runtime via the mounted
|
# wall-clock. Approximate split — rebalance once --durations=15 output
|
||||||
# docker socket (the ci-python image ships /usr/bin/docker). Default-bridge
|
# reveals which shard is the long pole.
|
||||||
# containers can talk by IP (only embedded DNS is missing), so IP
|
#
|
||||||
# addressing is reliable here. Everything runs in ONE step so resolved
|
# Each shard's docker-ps filter uses its own unique job name to scope
|
||||||
# values don't depend on cross-step env passing. Pattern documented in
|
# service-container resolution. act_runner appears to strip underscores
|
||||||
# FabledRulebook/forgejo.md "CI philosophy".
|
# 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
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -98,15 +124,21 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Cache pip wheels
|
||||||
- name: Integration suite (resolve service IPs, migrate, test)
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pip
|
||||||
|
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||||
|
restore-keys: |
|
||||||
|
pip-${{ runner.os }}-py314-
|
||||||
|
- name: API integration shard (resolve service IPs, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
# Scope to THIS job's service containers (act_runner names them
|
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||||
# ...JOB-integration...); the operator's compose stack uses the
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
# same images but different names, so it won't match.
|
echo "=== end landscape ==="
|
||||||
PG=$(docker ps --filter "name=JOB-integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||||
RD=$(docker ps --filter "name=JOB-integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||||
test -n "$PG" && test -n "$RD"
|
test -n "$PG" && test -n "$RD"
|
||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||||
@@ -114,11 +146,153 @@ jobs:
|
|||||||
export DB_HOST="$PG_IP"
|
export DB_HOST="$PG_IP"
|
||||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||||
export CELERY_RESULT_BACKEND="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
|
for i in $(seq 1 60); do
|
||||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
pip install -r requirements.txt pytest pytest-asyncio
|
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
|
alembic upgrade head
|
||||||
pytest tests/ -v -m integration
|
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
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
paths: ['extension/**']
|
paths:
|
||||||
|
- 'extension/**'
|
||||||
|
- '.forgejo/workflows/extension.yml'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'extension/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -16,44 +27,3 @@ jobs:
|
|||||||
run: cd extension && npm install --no-save --no-audit --no-fund
|
run: cd extension && npm install --no-save --no-audit --no-fund
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: cd extension && npm run 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")
|
||||||
@@ -42,6 +42,8 @@ async def scroll():
|
|||||||
"width": i.width,
|
"width": i.width,
|
||||||
"height": i.height,
|
"height": i.height,
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": i.created_at.isoformat(),
|
||||||
|
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
||||||
|
"effective_date": i.effective_date.isoformat(),
|
||||||
"thumbnail_url": i.thumbnail_url,
|
"thumbnail_url": i.thumbnail_url,
|
||||||
"artist": i.artist,
|
"artist": i.artist,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ async def status():
|
|||||||
if active:
|
if active:
|
||||||
payload["active_batch"] = {
|
payload["active_batch"] = {
|
||||||
"id": active.id,
|
"id": active.id,
|
||||||
|
"source_path": active.source_path,
|
||||||
|
"scan_mode": active.scan_mode,
|
||||||
"total_files": active.total_files,
|
"total_files": active.total_files,
|
||||||
"imported": active.imported,
|
"imported": active.imported,
|
||||||
"skipped": active.skipped,
|
"skipped": active.skipped,
|
||||||
"failed": active.failed,
|
"failed": active.failed,
|
||||||
|
"refreshed": active.refreshed,
|
||||||
"started_at": active.started_at.isoformat(),
|
"started_at": active.started_at.isoformat(),
|
||||||
}
|
}
|
||||||
return jsonify(payload)
|
return jsonify(payload)
|
||||||
@@ -120,6 +123,83 @@ async def retry_failed():
|
|||||||
return jsonify({"retried": len(failed_ids)})
|
return jsonify({"retried": len(failed_ids)})
|
||||||
|
|
||||||
|
|
||||||
|
@import_admin_bp.route("/clear-stuck", methods=["POST"])
|
||||||
|
async def clear_stuck():
|
||||||
|
"""Force any non-terminal ImportTask (status in pending/queued/
|
||||||
|
processing) to 'failed' AND finalize any ImportBatch that ends up
|
||||||
|
with no active children. Escape hatch for the operator when the
|
||||||
|
automatic recover_interrupted_tasks sweep keeps re-queueing the
|
||||||
|
same stuck row forever (e.g., underlying file is genuinely broken
|
||||||
|
and the import keeps OSError-looping at PIL load).
|
||||||
|
|
||||||
|
Idempotent + non-destructive: rows survive as 'failed' so the
|
||||||
|
Retry-Failed button can re-attempt them once whatever was broken
|
||||||
|
is fixed. Banked 2026-05-25 — operator hit 3 large PNGs that
|
||||||
|
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"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).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"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finalize any 'running' ImportBatch that no longer has any
|
||||||
|
# active children. The "Scanning..." banner is driven by
|
||||||
|
# /api/import/status finding a running batch; left untouched,
|
||||||
|
# it would persist forever after the stuck-task clear.
|
||||||
|
running_batches = (
|
||||||
|
await session.execute(
|
||||||
|
select(ImportBatch.id).where(ImportBatch.status == "running")
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
finalized_batches = 0
|
||||||
|
for batch_id in running_batches:
|
||||||
|
still_active = (
|
||||||
|
await session.execute(
|
||||||
|
select(ImportTask.id)
|
||||||
|
.where(ImportTask.batch_id == batch_id)
|
||||||
|
.where(ImportTask.status.in_(
|
||||||
|
["pending", "queued", "processing"]
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if still_active is None:
|
||||||
|
await session.execute(
|
||||||
|
update(ImportBatch)
|
||||||
|
.where(ImportBatch.id == batch_id)
|
||||||
|
.values(
|
||||||
|
status="complete",
|
||||||
|
finished_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finalized_batches += 1
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"tasks_failed": len(stuck_ids),
|
||||||
|
"batches_finalized": finalized_batches,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@import_admin_bp.route("/clear-completed", methods=["POST"])
|
@import_admin_bp.route("/clear-completed", methods=["POST"])
|
||||||
async def clear_completed():
|
async def clear_completed():
|
||||||
body = await request.get_json(silent=True) or {}
|
body = await request.get_json(silent=True) or {}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ class ImportBatch(Base):
|
|||||||
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
failed: 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)
|
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)
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
|
||||||
# running | complete | cancelled
|
# running | complete | cancelled
|
||||||
|
|||||||
@@ -25,12 +25,31 @@ def _snapshot(repo_id: str, dest: Path, allow_patterns: list[str] | None) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def ensure_camie() -> None:
|
def ensure_camie() -> None:
|
||||||
|
"""Fetch Camie v2 weights + metadata.
|
||||||
|
|
||||||
|
v2 layout (HuggingFace Camais03/camie-tagger-v2): the ONNX file is
|
||||||
|
named camie-tagger-v2.onnx (not model.onnx) and tags ship inside
|
||||||
|
camie-tagger-v2-metadata.json (not selected_tags.csv). Both at root.
|
||||||
|
The repo also contains app/, game/, training/, images/ subdirs full
|
||||||
|
of setup/demo files we don't need — allow_patterns scopes the fetch
|
||||||
|
to just the inference essentials (~790 MB instead of ~2 GB).
|
||||||
|
"""
|
||||||
dest = MODEL_ROOT / "camie"
|
dest = MODEL_ROOT / "camie"
|
||||||
if (dest / "model.onnx").is_file() and (dest / "selected_tags.csv").is_file():
|
model_file = dest / "camie-tagger-v2.onnx"
|
||||||
|
meta_file = dest / "camie-tagger-v2-metadata.json"
|
||||||
|
if model_file.is_file() and meta_file.is_file():
|
||||||
print(f"[download_models] Camie present at {dest}")
|
print(f"[download_models] Camie present at {dest}")
|
||||||
return
|
return
|
||||||
print(f"[download_models] Fetching {CAMIE_REPO} -> {dest}")
|
print(f"[download_models] Fetching {CAMIE_REPO} -> {dest}")
|
||||||
_snapshot(CAMIE_REPO, dest, ["model.onnx", "selected_tags.csv", "*.json"])
|
_snapshot(
|
||||||
|
CAMIE_REPO, dest,
|
||||||
|
[
|
||||||
|
"camie-tagger-v2.onnx",
|
||||||
|
"camie-tagger-v2-metadata.json",
|
||||||
|
"config.json",
|
||||||
|
"config.yaml",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_siglip() -> None:
|
def ensure_siglip() -> None:
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
"""Cursor-paginated gallery queries.
|
"""Cursor-paginated gallery queries.
|
||||||
|
|
||||||
Cursor format: opaque base64-encoded "<iso8601_created_at>:<image_id>".
|
Cursor format: opaque base64-encoded "<iso8601_effective_date>:<image_id>".
|
||||||
Pagination key is (created_at DESC, id DESC) so we don't drift when new
|
|
||||||
imports arrive between page loads. Decoding rejects malformed cursors with
|
Pagination key is (effective_date DESC, id DESC) where effective_date is
|
||||||
a ValueError; the API layer translates that to HTTP 400.
|
COALESCE(post.post_date, image_record.created_at) so the gallery surfaces
|
||||||
|
images by ORIGINAL publish date when known, falling back to FC's scan
|
||||||
|
date. Important for migrated content: ~57k IR images scanned in a single
|
||||||
|
week would otherwise all share the same created_at and pile up in one
|
||||||
|
month bucket. The effective_date spreads them across the years they
|
||||||
|
were originally published.
|
||||||
|
|
||||||
|
Decoding rejects malformed cursors with a ValueError; the API layer
|
||||||
|
translates that to HTTP 400.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import and_, exists, func, or_, select
|
from sqlalchemy import Select, and_, exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models import Artist, ImageProvenance, ImageRecord, Source, Tag
|
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
|
|
||||||
CURSOR_SEPARATOR = "|"
|
CURSOR_SEPARATOR = "|"
|
||||||
|
|
||||||
|
|
||||||
def encode_cursor(created_at: datetime, image_id: int) -> str:
|
def encode_cursor(effective_date: datetime, image_id: int) -> str:
|
||||||
raw = f"{created_at.isoformat()}{CURSOR_SEPARATOR}{image_id}"
|
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
|
||||||
return base64.urlsafe_b64encode(raw.encode()).decode()
|
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +41,26 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
|||||||
raise ValueError(f"invalid cursor: {cursor!r}") from exc
|
raise ValueError(f"invalid cursor: {cursor!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_date_col():
|
||||||
|
"""SQL expression: COALESCE(post.post_date, image_record.created_at).
|
||||||
|
|
||||||
|
Used as the canonical sort/group/filter key across the gallery so
|
||||||
|
images backfilled with primary_post_id (e.g. via tag_apply phase 4)
|
||||||
|
surface at their original publish date, not their FC import date.
|
||||||
|
Images without a Post (or with Post.post_date NULL) fall back to
|
||||||
|
image_record.created_at and still order coherently against
|
||||||
|
post-attached ones.
|
||||||
|
"""
|
||||||
|
return func.coalesce(Post.post_date, ImageRecord.created_at)
|
||||||
|
|
||||||
|
|
||||||
|
def _outer_join_primary_post(stmt: Select) -> Select:
|
||||||
|
"""LEFT JOIN Post on ImageRecord.primary_post_id so the COALESCE
|
||||||
|
above sees Post.post_date when available. Images without a post
|
||||||
|
survive the join as NULL on the Post side; COALESCE handles it."""
|
||||||
|
return stmt.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GalleryImage:
|
class GalleryImage:
|
||||||
id: int
|
id: int
|
||||||
@@ -41,7 +69,9 @@ class GalleryImage:
|
|||||||
mime: str
|
mime: str
|
||||||
width: int | None
|
width: int | None
|
||||||
height: int | None
|
height: int | None
|
||||||
created_at: datetime
|
created_at: datetime # FC's row-insert time
|
||||||
|
effective_date: datetime # COALESCE(post.post_date, created_at)
|
||||||
|
posted_at: datetime | None # post.post_date if known, else None
|
||||||
thumbnail_url: str
|
thumbnail_url: str
|
||||||
artist: dict | None = None
|
artist: dict | None = None
|
||||||
|
|
||||||
@@ -78,7 +108,7 @@ def _require_single_filter(tag_id, post_id, artist_id) -> None:
|
|||||||
def _provenance_clause(post_id, artist_id):
|
def _provenance_clause(post_id, artist_id):
|
||||||
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
||||||
matching provenance rows is returned exactly once and the
|
matching provenance rows is returned exactly once and the
|
||||||
(created_at DESC, id DESC) cursor ordering is unaffected."""
|
(effective_date DESC, id DESC) cursor ordering is unaffected."""
|
||||||
if post_id is not None:
|
if post_id is not None:
|
||||||
return exists().where(
|
return exists().where(
|
||||||
ImageProvenance.image_record_id == ImageRecord.id,
|
ImageProvenance.image_record_id == ImageRecord.id,
|
||||||
@@ -125,7 +155,9 @@ class GalleryService:
|
|||||||
raise ValueError("limit must be between 1 and 200")
|
raise ValueError("limit must be between 1 and 200")
|
||||||
_require_single_filter(tag_id, post_id, artist_id)
|
_require_single_filter(tag_id, post_id, artist_id)
|
||||||
|
|
||||||
stmt = select(ImageRecord)
|
eff = _effective_date_col()
|
||||||
|
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||||
|
stmt = _outer_join_primary_post(stmt)
|
||||||
if tag_id is not None:
|
if tag_id is not None:
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||||
image_tag.c.tag_id == tag_id
|
image_tag.c.tag_id == tag_id
|
||||||
@@ -138,34 +170,38 @@ class GalleryService:
|
|||||||
cur_ts, cur_id = decode_cursor(cursor)
|
cur_ts, cur_id = decode_cursor(cursor)
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
ImageRecord.created_at < cur_ts,
|
eff < cur_ts,
|
||||||
and_(ImageRecord.created_at == cur_ts, ImageRecord.id < cur_id),
|
and_(eff == cur_ts, ImageRecord.id < cur_id),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
stmt = stmt.order_by(ImageRecord.created_at.desc(), ImageRecord.id.desc()).limit(limit + 1)
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1)
|
||||||
rows = (await self.session.execute(stmt)).scalars().all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
if len(rows) > limit:
|
if len(rows) > limit:
|
||||||
last = rows[limit - 1]
|
last_record, _last_posted_at, last_eff = rows[limit - 1]
|
||||||
next_cursor = encode_cursor(last.created_at, last.id)
|
next_cursor = encode_cursor(last_eff, last_record.id)
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
|
|
||||||
artists = await _artists_for(self.session, [r.id for r in rows])
|
artists = await _artists_for(
|
||||||
|
self.session, [r[0].id for r in rows]
|
||||||
|
)
|
||||||
images = [
|
images = [
|
||||||
GalleryImage(
|
GalleryImage(
|
||||||
id=r.id,
|
id=record.id,
|
||||||
path=r.path,
|
path=record.path,
|
||||||
sha256=r.sha256,
|
sha256=record.sha256,
|
||||||
mime=r.mime,
|
mime=record.mime,
|
||||||
width=r.width,
|
width=record.width,
|
||||||
height=r.height,
|
height=record.height,
|
||||||
created_at=r.created_at,
|
created_at=record.created_at,
|
||||||
thumbnail_url=thumbnail_url(r.sha256, r.mime),
|
effective_date=eff_date,
|
||||||
artist=artists.get(r.id),
|
posted_at=posted_at,
|
||||||
|
thumbnail_url=thumbnail_url(record.sha256, record.mime),
|
||||||
|
artist=artists.get(record.id),
|
||||||
)
|
)
|
||||||
for r in rows
|
for record, posted_at, eff_date in rows
|
||||||
]
|
]
|
||||||
return GalleryPage(
|
return GalleryPage(
|
||||||
images=images,
|
images=images,
|
||||||
@@ -179,11 +215,13 @@ class GalleryService:
|
|||||||
post_id: int | None = None,
|
post_id: int | None = None,
|
||||||
artist_id: int | None = None,
|
artist_id: int | None = None,
|
||||||
) -> list[TimelineBucket]:
|
) -> list[TimelineBucket]:
|
||||||
year_col = func.date_part("year", ImageRecord.created_at).label("yr")
|
eff = _effective_date_col()
|
||||||
month_col = func.date_part("month", ImageRecord.created_at).label("mo")
|
year_col = func.date_part("year", eff).label("yr")
|
||||||
|
month_col = func.date_part("month", eff).label("mo")
|
||||||
stmt = select(
|
stmt = select(
|
||||||
year_col, month_col, func.count(ImageRecord.id).label("cnt")
|
year_col, month_col, func.count(ImageRecord.id).label("cnt")
|
||||||
)
|
)
|
||||||
|
stmt = _outer_join_primary_post(stmt)
|
||||||
_require_single_filter(tag_id, post_id, artist_id)
|
_require_single_filter(tag_id, post_id, artist_id)
|
||||||
if tag_id is not None:
|
if tag_id is not None:
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||||
@@ -201,14 +239,17 @@ class GalleryService:
|
|||||||
post_id: int | None = None, artist_id: int | None = None,
|
post_id: int | None = None, artist_id: int | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Returns a cursor that, when passed to scroll(), positions at the
|
"""Returns a cursor that, when passed to scroll(), positions at the
|
||||||
first image of the given year-month. None if the bucket is empty.
|
first image of the given year-month (by effective_date, not
|
||||||
|
created_at). None if the bucket is empty.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import extract
|
from sqlalchemy import extract
|
||||||
|
|
||||||
stmt = select(ImageRecord).where(
|
eff = _effective_date_col()
|
||||||
extract("year", ImageRecord.created_at) == year,
|
stmt = select(ImageRecord, eff.label("eff")).where(
|
||||||
extract("month", ImageRecord.created_at) == month,
|
extract("year", eff) == year,
|
||||||
|
extract("month", eff) == month,
|
||||||
)
|
)
|
||||||
|
stmt = _outer_join_primary_post(stmt)
|
||||||
_require_single_filter(tag_id, post_id, artist_id)
|
_require_single_filter(tag_id, post_id, artist_id)
|
||||||
if tag_id is not None:
|
if tag_id is not None:
|
||||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||||
@@ -217,13 +258,14 @@ class GalleryService:
|
|||||||
prov = _provenance_clause(post_id, artist_id)
|
prov = _provenance_clause(post_id, artist_id)
|
||||||
if prov is not None:
|
if prov is not None:
|
||||||
stmt = stmt.where(prov)
|
stmt = stmt.where(prov)
|
||||||
stmt = stmt.order_by(ImageRecord.created_at.desc(), ImageRecord.id.desc()).limit(1)
|
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
|
||||||
first = (await self.session.execute(stmt)).scalar_one_or_none()
|
first = (await self.session.execute(stmt)).first()
|
||||||
if first is None:
|
if first is None:
|
||||||
return None
|
return None
|
||||||
|
record, eff_date = first
|
||||||
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
|
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
|
||||||
# is the first result in the next scroll().
|
# is the first result in the next scroll().
|
||||||
return encode_cursor(first.created_at, first.id + 1)
|
return encode_cursor(eff_date, record.id + 1)
|
||||||
|
|
||||||
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||||
record = await self.session.get(ImageRecord, image_id)
|
record = await self.session.get(ImageRecord, image_id)
|
||||||
@@ -236,6 +278,13 @@ class GalleryService:
|
|||||||
.order_by(Tag.kind.asc(), Tag.name.asc())
|
.order_by(Tag.kind.asc(), Tag.name.asc())
|
||||||
)
|
)
|
||||||
tags = (await self.session.execute(tag_stmt)).scalars().all()
|
tags = (await self.session.execute(tag_stmt)).scalars().all()
|
||||||
|
# Fetch the canonical post.post_date for this image (if any) so
|
||||||
|
# the modal can show "Posted on <date>" alongside import date.
|
||||||
|
posted_at = None
|
||||||
|
if record.primary_post_id is not None:
|
||||||
|
posted_at = (await self.session.execute(
|
||||||
|
select(Post.post_date).where(Post.id == record.primary_post_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
neighbors = await self._neighbors(record)
|
neighbors = await self._neighbors(record)
|
||||||
# Direct artist FK — used by the modal's ProvenancePanel as a
|
# Direct artist FK — used by the modal's ProvenancePanel as a
|
||||||
# fallback when ImageProvenance is empty (i.e., filesystem-
|
# fallback when ImageProvenance is empty (i.e., filesystem-
|
||||||
@@ -256,6 +305,7 @@ class GalleryService:
|
|||||||
"size_bytes": record.size_bytes,
|
"size_bytes": record.size_bytes,
|
||||||
"integrity_status": record.integrity_status,
|
"integrity_status": record.integrity_status,
|
||||||
"created_at": record.created_at.isoformat(),
|
"created_at": record.created_at.isoformat(),
|
||||||
|
"posted_at": posted_at.isoformat() if posted_at else None,
|
||||||
"thumbnail_url": thumbnail_url(record.sha256, record.mime),
|
"thumbnail_url": thumbnail_url(record.sha256, record.mime),
|
||||||
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
|
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
|
||||||
"artist": (
|
"artist": (
|
||||||
@@ -275,34 +325,41 @@ class GalleryService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def _neighbors(self, record: ImageRecord) -> dict:
|
async def _neighbors(self, record: ImageRecord) -> dict:
|
||||||
prev_stmt = (
|
# Compute the boundary image's effective_date in Python (one query
|
||||||
select(ImageRecord.id)
|
# below + the SELECT we already have on `record`) and use it for
|
||||||
.where(
|
# the neighbor comparison. Cheaper than re-deriving in SQL via
|
||||||
|
# correlated subquery.
|
||||||
|
boundary_eff = record.created_at
|
||||||
|
if record.primary_post_id is not None:
|
||||||
|
post_date = (await self.session.execute(
|
||||||
|
select(Post.post_date).where(Post.id == record.primary_post_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if post_date is not None:
|
||||||
|
boundary_eff = post_date
|
||||||
|
|
||||||
|
eff = _effective_date_col()
|
||||||
|
prev_stmt = _outer_join_primary_post(
|
||||||
|
select(ImageRecord.id).where(
|
||||||
or_(
|
or_(
|
||||||
ImageRecord.created_at > record.created_at,
|
eff > boundary_eff,
|
||||||
and_(
|
and_(
|
||||||
ImageRecord.created_at == record.created_at,
|
eff == boundary_eff,
|
||||||
ImageRecord.id > record.id,
|
ImageRecord.id > record.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(ImageRecord.created_at.asc(), ImageRecord.id.asc())
|
).order_by(eff.asc(), ImageRecord.id.asc()).limit(1)
|
||||||
.limit(1)
|
next_stmt = _outer_join_primary_post(
|
||||||
)
|
select(ImageRecord.id).where(
|
||||||
next_stmt = (
|
|
||||||
select(ImageRecord.id)
|
|
||||||
.where(
|
|
||||||
or_(
|
or_(
|
||||||
ImageRecord.created_at < record.created_at,
|
eff < boundary_eff,
|
||||||
and_(
|
and_(
|
||||||
ImageRecord.created_at == record.created_at,
|
eff == boundary_eff,
|
||||||
ImageRecord.id < record.id,
|
ImageRecord.id < record.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(ImageRecord.created_at.desc(), ImageRecord.id.desc())
|
).order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
prev_id = (await self.session.execute(prev_stmt)).scalar_one_or_none()
|
prev_id = (await self.session.execute(prev_stmt)).scalar_one_or_none()
|
||||||
next_id = (await self.session.execute(next_stmt)).scalar_one_or_none()
|
next_id = (await self.session.execute(next_stmt)).scalar_one_or_none()
|
||||||
return {"prev_id": prev_id, "next_id": next_id}
|
return {"prev_id": prev_id, "next_id": next_id}
|
||||||
@@ -311,9 +368,11 @@ class GalleryService:
|
|||||||
def _group_by_year_month(
|
def _group_by_year_month(
|
||||||
images: list[GalleryImage],
|
images: list[GalleryImage],
|
||||||
) -> list[tuple[int, int, list[int]]]:
|
) -> list[tuple[int, int, list[int]]]:
|
||||||
|
"""Group by effective_date's year/month so migrated content surfaces
|
||||||
|
in the publish-date buckets, not the FC-scan-date bucket."""
|
||||||
groups: list[tuple[int, int, list[int]]] = []
|
groups: list[tuple[int, int, list[int]]] = []
|
||||||
for img in images:
|
for img in images:
|
||||||
y, m = img.created_at.year, img.created_at.month
|
y, m = img.effective_date.year, img.effective_date.month
|
||||||
if groups and groups[-1][0] == y and groups[-1][1] == m:
|
if groups and groups[-1][0] == y and groups[-1][1] == m:
|
||||||
groups[-1][2].append(img.id)
|
groups[-1][2].append(img.id)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -51,7 +51,14 @@ class SkipReason(StrEnum):
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ImportResult:
|
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
|
image_id: int | None = None
|
||||||
skip_reason: SkipReason | None = None
|
skip_reason: SkipReason | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
@@ -71,6 +78,31 @@ def is_video(path: Path) -> bool:
|
|||||||
return path.suffix.lower() in VIDEO_EXTS
|
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:
|
def _mime_for(path: Path) -> str:
|
||||||
suffix = path.suffix.lower()
|
suffix = path.suffix.lower()
|
||||||
image_mimes = {
|
image_mimes = {
|
||||||
@@ -126,6 +158,49 @@ class Importer:
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.deep = deep
|
self.deep = deep
|
||||||
self.attachments = AttachmentStore(images_root)
|
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 import_one(self, source: Path) -> ImportResult:
|
def import_one(self, source: Path) -> ImportResult:
|
||||||
"""Dispatch by kind. Media → normal pipeline. Archive → extract
|
"""Dispatch by kind. Media → normal pipeline. Archive → extract
|
||||||
@@ -209,7 +284,7 @@ class Importer:
|
|||||||
sha256=sha,
|
sha256=sha,
|
||||||
path=stored,
|
path=stored,
|
||||||
original_filename=source.name,
|
original_filename=source.name,
|
||||||
ext=source.suffix.lower(),
|
ext=_safe_ext(source),
|
||||||
mime=_mime_for(source),
|
mime=_mime_for(source),
|
||||||
size_bytes=source.stat().st_size,
|
size_bytes=source.stat().st_size,
|
||||||
))
|
))
|
||||||
@@ -280,7 +355,18 @@ class Importer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.settings.skip_transparent and has_alpha:
|
if self.settings.skip_transparent and has_alpha:
|
||||||
pct = self._transparency_pct(source)
|
try:
|
||||||
|
pct = self._transparency_pct(source)
|
||||||
|
except OSError as exc:
|
||||||
|
# PIL.verify() at line 263 only validates header structure;
|
||||||
|
# truncated/corrupt pixel data only surfaces when load()
|
||||||
|
# actually decodes (here via getchannel('A')). Convert to
|
||||||
|
# invalid_image skip so the Celery autoretry loop doesn't
|
||||||
|
# bounce the same broken file forever.
|
||||||
|
return ImportResult(
|
||||||
|
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||||
|
error=f"PIL load failed during transparency check: {exc}",
|
||||||
|
)
|
||||||
if pct >= self.settings.transparency_threshold:
|
if pct >= self.settings.transparency_threshold:
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
status="skipped", skip_reason=SkipReason.too_transparent,
|
status="skipped", skip_reason=SkipReason.too_transparent,
|
||||||
@@ -302,21 +388,18 @@ class Importer:
|
|||||||
# Perceptual near-dup (images only; videos keep phash NULL).
|
# Perceptual near-dup (images only; videos keep phash NULL).
|
||||||
phash = None
|
phash = None
|
||||||
if not is_video(source):
|
if not is_video(source):
|
||||||
with Image.open(source) as im:
|
try:
|
||||||
phash = compute_phash(im)
|
with Image.open(source) as im:
|
||||||
|
phash = compute_phash(im)
|
||||||
|
except OSError as exc:
|
||||||
|
# Same rationale as the transparency-check guard above:
|
||||||
|
# broken-pixel-data files pass verify() but blow up here.
|
||||||
|
return ImportResult(
|
||||||
|
status="skipped", skip_reason=SkipReason.invalid_image,
|
||||||
|
error=f"PIL load failed during phash compute: {exc}",
|
||||||
|
)
|
||||||
if phash is not None:
|
if phash is not None:
|
||||||
cand_rows = self.session.execute(
|
candidates = self._phash_candidates_cache()
|
||||||
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
|
|
||||||
]
|
|
||||||
rel, match_id = find_similar(
|
rel, match_id = find_similar(
|
||||||
phash, width or 0, height or 0,
|
phash, width or 0, height or 0,
|
||||||
candidates, self.settings.phash_threshold,
|
candidates, self.settings.phash_threshold,
|
||||||
@@ -350,6 +433,7 @@ class Importer:
|
|||||||
)
|
)
|
||||||
self.session.add(record)
|
self.session.add(record)
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
|
self._phash_cache_append(phash, width, height, record.id)
|
||||||
|
|
||||||
# Folder→artist (anchored to attribution_path).
|
# Folder→artist (anchored to attribution_path).
|
||||||
artist = None
|
artist = None
|
||||||
@@ -373,13 +457,27 @@ class Importer:
|
|||||||
) -> ImportResult:
|
) -> ImportResult:
|
||||||
"""Deep scan: backfill phash/provenance/artist on an
|
"""Deep scan: backfill phash/provenance/artist on an
|
||||||
already-imported record. METADATA ONLY — never re-runs the pHash
|
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):
|
if existing.phash is None and not is_video(source):
|
||||||
try:
|
try:
|
||||||
with Image.open(source) as im:
|
with Image.open(source) as im:
|
||||||
ph = compute_phash(im)
|
ph = compute_phash(im)
|
||||||
if ph is not None:
|
if ph is not None:
|
||||||
existing.phash = ph
|
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:
|
except Exception as exc:
|
||||||
log.warning("deep rephash failed for %s: %s", source, exc)
|
log.warning("deep rephash failed for %s: %s", source, exc)
|
||||||
|
|
||||||
@@ -392,10 +490,7 @@ class Importer:
|
|||||||
|
|
||||||
self._apply_sidecar(existing, attribution_path, artist)
|
self._apply_sidecar(existing, attribution_path, artist)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
return ImportResult(
|
return ImportResult(status="refreshed", image_id=existing.id)
|
||||||
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
|
||||||
image_id=existing.id, error="deep: re-derived",
|
|
||||||
)
|
|
||||||
|
|
||||||
def attach_in_place(
|
def attach_in_place(
|
||||||
self,
|
self,
|
||||||
@@ -479,18 +574,7 @@ class Importer:
|
|||||||
except Exception:
|
except Exception:
|
||||||
phash = None
|
phash = None
|
||||||
if phash is not None:
|
if phash is not None:
|
||||||
cand_rows = self.session.execute(
|
candidates = self._phash_candidates_cache()
|
||||||
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
|
|
||||||
]
|
|
||||||
rel, match_id = find_similar(
|
rel, match_id = find_similar(
|
||||||
phash, width or 0, height or 0,
|
phash, width or 0, height or 0,
|
||||||
candidates, self.settings.phash_threshold,
|
candidates, self.settings.phash_threshold,
|
||||||
@@ -525,6 +609,7 @@ class Importer:
|
|||||||
record.artist_id = artist.id
|
record.artist_id = artist.id
|
||||||
self.session.add(record)
|
self.session.add(record)
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
|
self._phash_cache_append(phash, width, height, record.id)
|
||||||
|
|
||||||
# Sidecar provenance (best-effort). When `source` is passed, link
|
# Sidecar provenance (best-effort). When `source` is passed, link
|
||||||
# the post to that subscription Source instead of creating a new
|
# the post to that subscription Source instead of creating a new
|
||||||
@@ -703,6 +788,15 @@ class Importer:
|
|||||||
row id (so tags/series/curation stay attached). ML is cleared so
|
row id (so tags/series/curation stay attached). ML is cleared so
|
||||||
the import task re-derives it on the new pixels.
|
the import task re-derives it on the new pixels.
|
||||||
|
|
||||||
|
After the file swap, the new file's adjacent gallery-dl sidecar
|
||||||
|
(if any) is applied via _apply_sidecar — operator-flagged
|
||||||
|
2026-05-25: scanning a GS download dir with smaller IR-migrated
|
||||||
|
images on the receiving end used to swap files but lose the GS
|
||||||
|
sidecar's post metadata entirely. _apply_sidecar is additive
|
||||||
|
(find-or-create Post / Source / ImageProvenance, NULL-only
|
||||||
|
primary_post_id update) so any pre-existing Post linkage
|
||||||
|
survives untouched.
|
||||||
|
|
||||||
If `new_path` is provided, `source` is assumed to ALREADY be at
|
If `new_path` is provided, `source` is assumed to ALREADY be at
|
||||||
that path (FC-3c attach_in_place case) — skip the copy step.
|
that path (FC-3c attach_in_place case) — skip the copy step.
|
||||||
Otherwise the file is copied via _copy_to_library."""
|
Otherwise the file is copied via _copy_to_library."""
|
||||||
@@ -731,6 +825,26 @@ class Importer:
|
|||||||
# created_at intentionally preserved; updated_at auto-bumps.
|
# created_at intentionally preserved; updated_at auto-bumps.
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
# 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
|
||||||
|
# existing row has none, and is internally guarded against
|
||||||
|
# missing-or-malformed sidecars (silent return).
|
||||||
|
try:
|
||||||
|
self._apply_sidecar(existing, source, None)
|
||||||
|
except Exception as exc:
|
||||||
|
# Don't unwind the supersede DB swap if sidecar parsing
|
||||||
|
# blows up unexpectedly — the file replacement is the
|
||||||
|
# critical operation, sidecar is enrichment.
|
||||||
|
log.warning(
|
||||||
|
"sidecar enrichment failed during supersede of "
|
||||||
|
"image_record.id=%s from %s: %s",
|
||||||
|
existing.id, source, exc,
|
||||||
|
)
|
||||||
|
|
||||||
for stale in (old_path, old_thumb):
|
for stale in (old_path, old_thumb):
|
||||||
if not stale or stale == str(dest):
|
if not stale or stale == str(dest):
|
||||||
|
|||||||
@@ -37,13 +37,25 @@ from ...utils.slug import slugify
|
|||||||
from .ir_ingest import manifest_path
|
from .ir_ingest import manifest_path
|
||||||
|
|
||||||
# Per-platform artist-profile URL — used as Source.url when restoring
|
# Per-platform artist-profile URL — used as Source.url when restoring
|
||||||
# IR PostMetadata into FC. Keep this table in sync with
|
# IR PostMetadata into FC. Must cover every platform that
|
||||||
# backend/app/services/extension_service.py:_PLATFORM_PATTERNS and
|
# backend/app/services/extension_service.py:_PLATFORM_PATTERNS
|
||||||
# extension/lib/platforms.js.
|
# recognizes; an entry missing here silently drops ALL PostMetadata for
|
||||||
|
# that platform during phase 4 (operator hit this 2026-05-25:
|
||||||
|
# DeviantArt + Pixiv posts in the IR migration produced empty
|
||||||
|
# ImageProvenance because they fell through this table).
|
||||||
|
#
|
||||||
|
# Pixiv caveat: the real profile URL takes a numeric user_id
|
||||||
|
# (https://www.pixiv.net/users/12345), but IR's PostMetadata.artist
|
||||||
|
# stores the display name not the id. We use the slugified name here
|
||||||
|
# so we preserve the artist→post→image linkage; the resulting Source.url
|
||||||
|
# won't resolve in a browser and the operator may want to manually fix
|
||||||
|
# it via Settings → Subscriptions once the migration lands.
|
||||||
_PLATFORM_PROFILE_URL = {
|
_PLATFORM_PROFILE_URL = {
|
||||||
"patreon": "https://www.patreon.com/{slug}",
|
"patreon": "https://www.patreon.com/{slug}",
|
||||||
"subscribestar": "https://www.subscribestar.com/{slug}",
|
"subscribestar": "https://www.subscribestar.com/{slug}",
|
||||||
"hentaifoundry": "https://www.hentai-foundry.com/user/{slug}",
|
"hentaifoundry": "https://www.hentai-foundry.com/user/{slug}",
|
||||||
|
"deviantart": "https://www.deviantart.com/{slug}",
|
||||||
|
"pixiv": "https://www.pixiv.net/users/{slug}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +122,14 @@ async def _ensure_provenance(
|
|||||||
db: AsyncSession, *,
|
db: AsyncSession, *,
|
||||||
image_id: int, post_id: int, source_id: int, dry_run: bool,
|
image_id: int, post_id: int, source_id: int, dry_run: bool,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Returns True if a new ImageProvenance row was inserted."""
|
"""Returns True if a new ImageProvenance row was inserted.
|
||||||
|
|
||||||
|
Also sets ImageRecord.primary_post_id to this post if the image
|
||||||
|
doesn't already have one — preserves any primary_post_id already
|
||||||
|
assigned at download time by the importer (don't clobber). This is
|
||||||
|
the linkage gallery_service.py uses to surface Post.post_date as
|
||||||
|
the image's effective date for sort/group/jump/neighbor nav.
|
||||||
|
"""
|
||||||
existing = (await db.execute(
|
existing = (await db.execute(
|
||||||
select(ImageProvenance.id).where(
|
select(ImageProvenance.id).where(
|
||||||
ImageProvenance.image_record_id == image_id,
|
ImageProvenance.image_record_id == image_id,
|
||||||
@@ -118,6 +137,18 @@ async def _ensure_provenance(
|
|||||||
ImageProvenance.source_id == source_id,
|
ImageProvenance.source_id == source_id,
|
||||||
)
|
)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
# Whether-or-not the provenance row already exists, ensure the
|
||||||
|
# image's primary_post_id is set so the gallery date-coalesce works.
|
||||||
|
# Idempotent: only writes when currently NULL.
|
||||||
|
if not dry_run:
|
||||||
|
await db.execute(
|
||||||
|
ImageRecord.__table__.update()
|
||||||
|
.where(ImageRecord.id == image_id)
|
||||||
|
.where(ImageRecord.primary_post_id.is_(None))
|
||||||
|
.values(primary_post_id=post_id)
|
||||||
|
)
|
||||||
|
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return False
|
return False
|
||||||
if dry_run:
|
if dry_run:
|
||||||
|
|||||||
@@ -34,10 +34,21 @@ class Embedder:
|
|||||||
if self._model is not None:
|
if self._model is not None:
|
||||||
return
|
return
|
||||||
import torch
|
import torch
|
||||||
from transformers import AutoModel, AutoProcessor
|
from transformers import AutoModel, SiglipImageProcessor
|
||||||
|
|
||||||
self._torch = torch
|
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 = AutoModel.from_pretrained(str(self._model_dir))
|
||||||
self._model.eval()
|
self._model.eval()
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ CPU-only, single-image at a time. Loaded lazily inside the ml-worker
|
|||||||
process; NOT thread-safe — the ml queue worker must run --concurrency=1
|
process; NOT thread-safe — the ml queue worker must run --concurrency=1
|
||||||
(set by the FC-1 entrypoint).
|
(set by the FC-1 entrypoint).
|
||||||
|
|
||||||
Camie's selected_tags.csv columns: tag_id,name,category,count
|
v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
|
||||||
where category is a string: general|character|copyright|artist|meta|rating|year
|
camie-tagger-v2.onnx (789 MB) + camie-tagger-v2-metadata.json (7.77 MB)
|
||||||
(unlike WD14's integer Danbooru category ids).
|
+ config.json. Tags ship as nested JSON, not CSV. Preprocessing and
|
||||||
|
output handling follow the published onnx_inference.py reference:
|
||||||
|
ImageNet normalize, NCHW layout, sigmoid on refined logits (output[1]).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import json
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -28,6 +30,8 @@ ImageFile.LOAD_TRUNCATED_IMAGES = True
|
|||||||
|
|
||||||
MODEL_NAME = os.environ.get("CAMIE_MODEL_NAME", "camie-tagger-v2")
|
MODEL_NAME = os.environ.get("CAMIE_MODEL_NAME", "camie-tagger-v2")
|
||||||
_MODEL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "camie"
|
_MODEL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "camie"
|
||||||
|
_MODEL_FILE = f"{MODEL_NAME}.onnx"
|
||||||
|
_METADATA_FILE = f"{MODEL_NAME}-metadata.json"
|
||||||
|
|
||||||
# Below this confidence, predictions aren't stored (keeps the JSON compact).
|
# Below this confidence, predictions aren't stored (keeps the JSON compact).
|
||||||
STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05"))
|
STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05"))
|
||||||
@@ -39,6 +43,12 @@ STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05"))
|
|||||||
# stored at STORE_FLOOR but artist never surfaces.
|
# stored at STORE_FLOOR but artist never surfaces.
|
||||||
SURFACED_CATEGORIES = {"character", "copyright", "general"}
|
SURFACED_CATEGORIES = {"character", "copyright", "general"}
|
||||||
|
|
||||||
|
# ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
|
||||||
|
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||||
|
_IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
||||||
|
# Square-pad color ≈ ImageNet mean × 255 (matches reference inference).
|
||||||
|
_PAD_COLOR = (124, 116, 104)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TagPrediction:
|
class TagPrediction:
|
||||||
@@ -51,34 +61,48 @@ class Tagger:
|
|||||||
def __init__(self, model_dir: Path | None = None):
|
def __init__(self, model_dir: Path | None = None):
|
||||||
self._model_dir = model_dir or _MODEL_DIR
|
self._model_dir = model_dir or _MODEL_DIR
|
||||||
self._session = None # onnxruntime.InferenceSession once load()ed
|
self._session = None # onnxruntime.InferenceSession once load()ed
|
||||||
self._tag_meta: list[dict] | None = None
|
self._tag_names: list[str] | None = None
|
||||||
|
self._tag_categories: list[str] | None = None
|
||||||
self._input_name: str | None = None
|
self._input_name: str | None = None
|
||||||
self._output_name: str | None = None
|
self._input_size: int = 512
|
||||||
self._input_size: int = 448
|
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
if self._session is not None:
|
if self._session is not None:
|
||||||
return
|
return
|
||||||
model_path = self._model_dir / "model.onnx"
|
model_path = self._model_dir / _MODEL_FILE
|
||||||
tags_path = self._model_dir / "selected_tags.csv"
|
meta_path = self._model_dir / _METADATA_FILE
|
||||||
if not model_path.is_file():
|
if not model_path.is_file():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Camie model.onnx missing at {model_path}. "
|
f"Camie {_MODEL_FILE} missing at {model_path}. "
|
||||||
f"Populate /models via the ml-worker downloader."
|
f"Populate /models via the ml-worker downloader."
|
||||||
)
|
)
|
||||||
if not tags_path.is_file():
|
if not meta_path.is_file():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Camie selected_tags.csv missing at {tags_path}. "
|
f"Camie {_METADATA_FILE} missing at {meta_path}. "
|
||||||
f"Populate /models via the ml-worker downloader."
|
f"Populate /models via the ml-worker downloader."
|
||||||
)
|
)
|
||||||
|
|
||||||
tag_meta: list[dict] = []
|
with open(meta_path) as f:
|
||||||
with open(tags_path, newline="") as f:
|
metadata = json.load(f)
|
||||||
reader = csv.DictReader(f)
|
|
||||||
for row in reader:
|
# Per Camie v2 onnx_inference.py: idx_to_tag is keyed by str(idx);
|
||||||
tag_meta.append(
|
# tag_to_category maps tag_name -> category. Project to two parallel
|
||||||
{"name": row["name"], "category": row["category"]}
|
# lists indexed by output position for O(1) lookup in the hot path.
|
||||||
)
|
ds = metadata["dataset_info"]
|
||||||
|
idx_to_tag = ds["tag_mapping"]["idx_to_tag"]
|
||||||
|
tag_to_category = ds["tag_mapping"]["tag_to_category"]
|
||||||
|
total = ds["total_tags"]
|
||||||
|
names: list[str] = []
|
||||||
|
cats: list[str] = []
|
||||||
|
for i in range(total):
|
||||||
|
name = idx_to_tag.get(str(i), f"unknown-{i}")
|
||||||
|
names.append(name)
|
||||||
|
cats.append(tag_to_category.get(name, "general"))
|
||||||
|
|
||||||
|
# Input size from metadata; fall back to 512 (the v2 default).
|
||||||
|
self._input_size = int(
|
||||||
|
metadata.get("model_info", {}).get("img_size", 512)
|
||||||
|
)
|
||||||
|
|
||||||
# Lazy import — kept after the file-existence checks so the
|
# Lazy import — kept after the file-existence checks so the
|
||||||
# missing-model RuntimeError still fires first in environments
|
# missing-model RuntimeError still fires first in environments
|
||||||
@@ -89,51 +113,65 @@ class Tagger:
|
|||||||
str(model_path), providers=["CPUExecutionProvider"]
|
str(model_path), providers=["CPUExecutionProvider"]
|
||||||
)
|
)
|
||||||
self._input_name = session.get_inputs()[0].name
|
self._input_name = session.get_inputs()[0].name
|
||||||
self._output_name = session.get_outputs()[0].name
|
|
||||||
input_shape = session.get_inputs()[0].shape
|
|
||||||
for dim in input_shape:
|
|
||||||
if isinstance(dim, int) and dim > 1:
|
|
||||||
self._input_size = dim
|
|
||||||
break
|
|
||||||
# Assign sentinels last so a partial load isn't observable.
|
# Assign sentinels last so a partial load isn't observable.
|
||||||
self._tag_meta = tag_meta
|
self._tag_names = names
|
||||||
|
self._tag_categories = cats
|
||||||
self._session = session
|
self._session = session
|
||||||
|
|
||||||
def _preprocess(self, image_path: Path) -> np.ndarray:
|
def _preprocess(self, image_path: Path) -> np.ndarray:
|
||||||
img = Image.open(image_path)
|
img = Image.open(image_path)
|
||||||
# Camie handles RGBA natively but we still composite onto white so
|
# Composite RGBA onto neutral so transparency doesn't bias the model.
|
||||||
# transparency doesn't bias the model (same as IR's WD14 path).
|
if img.mode == "RGBA":
|
||||||
if img.mode != "RGBA":
|
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
||||||
img = img.convert("RGBA")
|
bg.paste(img, mask=img.split()[3])
|
||||||
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
img = bg.convert("RGB")
|
||||||
bg.paste(img, mask=img.split()[3])
|
elif img.mode != "RGB":
|
||||||
img = bg.convert("RGB")
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# Pad to square with ImageNet-mean color, then bicubic resize.
|
||||||
w, h = img.size
|
w, h = img.size
|
||||||
side = max(w, h)
|
side = max(w, h)
|
||||||
square = Image.new("RGB", (side, side), (255, 255, 255))
|
square = Image.new("RGB", (side, side), _PAD_COLOR)
|
||||||
square.paste(img, ((side - w) // 2, (side - h) // 2))
|
square.paste(img, ((side - w) // 2, (side - h) // 2))
|
||||||
square = square.resize(
|
square = square.resize(
|
||||||
(self._input_size, self._input_size), Image.BICUBIC
|
(self._input_size, self._input_size), Image.BICUBIC
|
||||||
)
|
)
|
||||||
arr = np.array(square, dtype=np.float32)
|
|
||||||
return arr[np.newaxis, :, :, :] # NHWC
|
arr = np.array(square, dtype=np.float32) / 255.0 # HWC, [0,1]
|
||||||
|
arr = (arr - _IMAGENET_MEAN) / _IMAGENET_STD # ImageNet normalize
|
||||||
|
arr = arr.transpose(2, 0, 1) # HWC -> CHW
|
||||||
|
return arr[np.newaxis, :, :, :] # NCHW
|
||||||
|
|
||||||
def infer(self, image_path: Path) -> dict[str, TagPrediction]:
|
def infer(self, image_path: Path) -> dict[str, TagPrediction]:
|
||||||
"""Run Camie on one image. Returns {name: TagPrediction}, only
|
"""Run Camie v2 on one image. Returns {name: TagPrediction} with
|
||||||
entries with confidence >= STORE_FLOOR (across all categories —
|
confidence >= STORE_FLOOR (across all categories — the suggestion
|
||||||
the suggestion service does category filtering later)."""
|
service does category filtering later).
|
||||||
|
|
||||||
|
v2 emits multiple outputs; we use the refined predictions
|
||||||
|
(output[1] per onnx_inference.py). Sigmoid is applied to raw
|
||||||
|
logits to produce [0,1] confidence scores.
|
||||||
|
"""
|
||||||
self.load()
|
self.load()
|
||||||
x = self._preprocess(image_path)
|
x = self._preprocess(image_path)
|
||||||
out = self._session.run([self._output_name], {self._input_name: x})[0][0]
|
outputs = self._session.run(None, {self._input_name: x})
|
||||||
|
# Refined predictions if present (v2 emits initial + refined),
|
||||||
|
# fall back to initial for single-output forks.
|
||||||
|
logits = outputs[1] if len(outputs) > 1 else outputs[0]
|
||||||
|
# Squeeze batch dim, apply sigmoid.
|
||||||
|
probs = 1.0 / (1.0 + np.exp(-logits[0]))
|
||||||
results: dict[str, TagPrediction] = {}
|
results: dict[str, TagPrediction] = {}
|
||||||
for idx, score in enumerate(out):
|
names = self._tag_names
|
||||||
|
cats = self._tag_categories
|
||||||
|
for idx, score in enumerate(probs):
|
||||||
conf = float(score)
|
conf = float(score)
|
||||||
if conf < STORE_FLOOR:
|
if conf < STORE_FLOOR:
|
||||||
continue
|
continue
|
||||||
meta = self._tag_meta[idx]
|
if idx >= len(names):
|
||||||
results[meta["name"]] = TagPrediction(
|
# Output longer than metadata declared — shouldn't happen but
|
||||||
name=meta["name"], category=meta["category"], confidence=conf
|
# don't crash the import pipeline if v2 metadata desynchronizes.
|
||||||
|
continue
|
||||||
|
results[names[idx]] = TagPrediction(
|
||||||
|
name=names[idx], category=cats[idx], confidence=conf
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ IMAGES_ROOT = Path("/images")
|
|||||||
def _map_result_to_status(result):
|
def _map_result_to_status(result):
|
||||||
"""(ImportTask.status, should_requeue_ml_and_thumb) for an ImportResult.
|
"""(ImportTask.status, should_requeue_ml_and_thumb) for an ImportResult.
|
||||||
'superseded' = the kept row's file/ML changed → complete + re-derive.
|
'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"):
|
if result.status in ("imported", "superseded"):
|
||||||
return ("complete", True)
|
return ("complete", True)
|
||||||
if result.status == "attached":
|
if result.status in ("attached", "refreshed"):
|
||||||
return ("complete", False)
|
return ("complete", False)
|
||||||
if result.status == "skipped":
|
if result.status == "skipped":
|
||||||
return ("skipped", False)
|
return ("skipped", False)
|
||||||
@@ -138,6 +140,15 @@ def _do_import(session, task, import_task_id: int) -> dict:
|
|||||||
task.result_image_id = result.image_id
|
task.result_image_id = result.image_id
|
||||||
counter_col_name = "imported"
|
counter_col_name = "imported"
|
||||||
counter_col = ImportBatch.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":
|
elif result.status == "attached":
|
||||||
task.status = "complete"
|
task.status = "complete"
|
||||||
counter_col_name = "attachments"
|
counter_col_name = "attachments"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ._sync_engine import sync_session_factory as _sync_session_factory
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
STUCK_THRESHOLD_MINUTES = 5
|
STUCK_THRESHOLD_MINUTES = 5
|
||||||
|
ORPHAN_PENDING_THRESHOLD_MINUTES = 30
|
||||||
OLD_TASK_DAYS = 7
|
OLD_TASK_DAYS = 7
|
||||||
PHASH_PAGE = 500
|
PHASH_PAGE = 500
|
||||||
VERIFY_PAGE = 200
|
VERIFY_PAGE = 200
|
||||||
@@ -26,40 +27,77 @@ TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
|
|||||||
|
|
||||||
@celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks")
|
@celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks")
|
||||||
def recover_interrupted_tasks() -> int:
|
def recover_interrupted_tasks() -> int:
|
||||||
"""Find ImportTask rows stuck in 'processing' for >5 min and re-queue them.
|
"""Recover stuck ImportTask rows. Two distinct stuck states:
|
||||||
|
|
||||||
Why 5 min: import_media_file is sub-second for the vast majority of
|
1. 'processing' > 5 min — worker crash mid-import. Re-queue via
|
||||||
files; even a large-video transcode caps at the per-task soft_time_limit
|
.delay() and let the import retry. Was 30 min historically;
|
||||||
(5 min) defined on the task itself. Anything still 'processing' after
|
tightened 2026-05-24 after operator hit a 2224-row zombie pile.
|
||||||
that window is a confirmed crash (worker died, DB disconnect mid-flush,
|
import_media_file is sub-second for the vast majority of files and
|
||||||
OOM) and must be recycled. Was 30 min historically; tightened
|
capped at the per-task soft_time_limit (5 min), so anything still
|
||||||
2026-05-24 after operator hit a 2224-row zombie pile during the IR
|
'processing' after that window is a confirmed crash.
|
||||||
migration scan.
|
|
||||||
|
2. 'pending' or 'queued' > 30 min — enqueue-phase crash. scan_directory
|
||||||
|
creates rows with status='pending' (commit), then in a second pass
|
||||||
|
transitions to 'queued' and calls .delay() (commit). If the scanner
|
||||||
|
crashes between those two commits, rows are orphaned in 'pending'
|
||||||
|
(never enqueued) with no recovery path — invisible to the
|
||||||
|
'processing' sweep above. Flagged 2026-05-25 by operator hitting a
|
||||||
|
5490-row orphan pile. Flip these to 'failed' (not re-enqueue) so
|
||||||
|
the operator drains them via /api/import/retry-failed at their own
|
||||||
|
pace; bulk-re-enqueueing 5000+ rows would thundering-herd the
|
||||||
|
import worker.
|
||||||
|
|
||||||
|
Returns total rows touched (recovered + marked failed).
|
||||||
"""
|
"""
|
||||||
SessionLocal = _sync_session_factory()
|
SessionLocal = _sync_session_factory()
|
||||||
cutoff = datetime.now(UTC) - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
|
now = datetime.now(UTC)
|
||||||
|
processing_cutoff = now - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
|
||||||
|
orphan_cutoff = now - timedelta(minutes=ORPHAN_PENDING_THRESHOLD_MINUTES)
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
stuck_ids = session.execute(
|
stuck_ids = session.execute(
|
||||||
select(ImportTask.id)
|
select(ImportTask.id)
|
||||||
.where(ImportTask.status == "processing")
|
.where(ImportTask.status == "processing")
|
||||||
.where(ImportTask.started_at < cutoff)
|
.where(ImportTask.started_at < processing_cutoff)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
||||||
if not stuck_ids:
|
orphan_ids = session.execute(
|
||||||
|
select(ImportTask.id)
|
||||||
|
.where(ImportTask.status.in_(["pending", "queued"]))
|
||||||
|
.where(ImportTask.created_at < orphan_cutoff)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
if not stuck_ids and not orphan_ids:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
session.execute(
|
if stuck_ids:
|
||||||
update(ImportTask)
|
session.execute(
|
||||||
.where(ImportTask.id.in_(stuck_ids))
|
update(ImportTask)
|
||||||
.values(status="queued", started_at=None, error="recovered from stuck state")
|
.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"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
from .import_file import import_media_file
|
if stuck_ids:
|
||||||
for tid in stuck_ids:
|
from .import_file import import_media_file
|
||||||
import_media_file.delay(tid)
|
for tid in stuck_ids:
|
||||||
|
import_media_file.delay(tid)
|
||||||
|
|
||||||
return len(stuck_ids)
|
return len(stuck_ids) + len(orphan_ids)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_tasks")
|
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_tasks")
|
||||||
|
|||||||
@@ -59,16 +59,25 @@ def scan_directory(self, triggered_by: str = "manual",
|
|||||||
session.flush()
|
session.flush()
|
||||||
batch_id = batch.id
|
batch_id = batch.id
|
||||||
|
|
||||||
# Skip-set: any source_path that already has a non-failed ImportTask
|
# Skip-set behavior splits by mode (operator-flagged 2026-05-25):
|
||||||
# row. Re-running scan_directory must not re-enqueue files the
|
#
|
||||||
# importer has already handled (or is currently handling); doing so
|
# quick: any non-failed prior ImportTask (active OR finished) is
|
||||||
# creates duplicate work and inflates the queue. Failed prior tasks
|
# skipped — quick scan only does new-file enqueue, so re-touching
|
||||||
# are eligible for retry.
|
# 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(
|
non_failed_existing = set(session.execute(
|
||||||
select(ImportTask.source_path).where(
|
select(ImportTask.source_path).where(
|
||||||
ImportTask.status.in_(
|
ImportTask.status.in_(skip_statuses),
|
||||||
["pending", "queued", "processing", "complete", "skipped"]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
).scalars().all())
|
).scalars().all())
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "FabledCurator",
|
"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.",
|
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||||
|
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledcurator-extension",
|
"name": "fabledcurator-extension",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Firefox extension for FabledCurator",
|
"description": "Firefox extension for FabledCurator",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "web-ext lint --source-dir=.",
|
"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=. --firefox=firefox",
|
"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=. --overwrite-dest",
|
"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=. --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
"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": {
|
"devDependencies": {
|
||||||
"web-ext": "^8.0.0"
|
"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',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -38,9 +38,18 @@ function onCardClick() {
|
|||||||
.fc-artistcard { cursor: pointer; }
|
.fc-artistcard { cursor: pointer; }
|
||||||
.fc-artistcard__previews {
|
.fc-artistcard__previews {
|
||||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 2px; aspect-ratio: 3 / 1; background: rgb(var(--v-theme-surface-light));
|
gap: 2px; aspect-ratio: 3 / 1;
|
||||||
|
/* Explicit floor + ceiling so tall source images can't escape the
|
||||||
|
preview slot even on browsers where aspect-ratio doesn't compute. */
|
||||||
|
min-height: 150px; max-height: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
}
|
||||||
|
.fc-artistcard__previews img {
|
||||||
|
display: block;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: cover; object-position: center;
|
||||||
}
|
}
|
||||||
.fc-artistcard__previews img { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
.fc-artistcard__noimg {
|
.fc-artistcard__noimg {
|
||||||
grid-column: 1 / -1; display: flex; align-items: center;
|
grid-column: 1 / -1; display: flex; align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -106,9 +106,19 @@ function submit() {
|
|||||||
.fc-tagcard { cursor: pointer; }
|
.fc-tagcard { cursor: pointer; }
|
||||||
.fc-tagcard__previews {
|
.fc-tagcard__previews {
|
||||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 2px; aspect-ratio: 3 / 1; background: rgb(var(--v-theme-surface-light));
|
gap: 2px; aspect-ratio: 3 / 1;
|
||||||
|
/* Explicit floor + ceiling so tall source images can't escape the
|
||||||
|
preview slot even on browsers where aspect-ratio doesn't compute
|
||||||
|
(older Safari, embedded webviews). */
|
||||||
|
min-height: 150px; max-height: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
}
|
||||||
|
.fc-tagcard__previews img {
|
||||||
|
display: block;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: cover; object-position: center;
|
||||||
}
|
}
|
||||||
.fc-tagcard__previews img { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
.fc-tagcard__noimg {
|
.fc-tagcard__noimg {
|
||||||
grid-column: 1 / -1; display: flex; align-items: center;
|
grid-column: 1 / -1; display: flex; align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
>
|
>
|
||||||
Retry failed
|
Retry failed
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
variant="text" rounded="pill" size="small" color="warning"
|
||||||
|
:disabled="!hasStuck" @click="onClearStuckOpen"
|
||||||
|
>
|
||||||
|
Clear stuck…
|
||||||
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="text" rounded="pill" size="small" color="error"
|
variant="text" rounded="pill" size="small" color="error"
|
||||||
@click="onClearOpen"
|
@click="onClearOpen"
|
||||||
@@ -69,6 +75,31 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="clearStuckDialog" max-width="480">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Clear stuck tasks</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||||
|
Force every <strong>pending / queued / processing</strong> task to
|
||||||
|
<strong>failed</strong> and finalize any active batch that
|
||||||
|
has no remaining work. Use this when the automatic recovery
|
||||||
|
sweep keeps re-queueing the same row (e.g., corrupt file in
|
||||||
|
an autoretry loop, or worker model missing).
|
||||||
|
</v-alert>
|
||||||
|
<p class="text-body-2">
|
||||||
|
Tasks remain in the database with status=<code>failed</code>;
|
||||||
|
click <em>Retry failed</em> once the underlying cause is
|
||||||
|
resolved to re-queue them.
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn @click="clearStuckDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="warning" rounded="pill" @click="onClearStuckConfirm">Clear stuck</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -80,6 +111,7 @@ const store = useImportStore()
|
|||||||
const statusFilter = ref(null)
|
const statusFilter = ref(null)
|
||||||
const clearDialog = ref(false)
|
const clearDialog = ref(false)
|
||||||
const clearAgeDays = ref(7)
|
const clearAgeDays = ref(7)
|
||||||
|
const clearStuckDialog = ref(false)
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ title: 'All', value: null },
|
{ title: 'All', value: null },
|
||||||
@@ -100,6 +132,9 @@ const headers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const hasFailed = computed(() => store.tasks.some(t => t.status === 'failed'))
|
const hasFailed = computed(() => store.tasks.some(t => t.status === 'failed'))
|
||||||
|
const hasStuck = computed(() => store.tasks.some(
|
||||||
|
t => t.status === 'pending' || t.status === 'queued' || t.status === 'processing'
|
||||||
|
))
|
||||||
|
|
||||||
function statusColor(s) {
|
function statusColor(s) {
|
||||||
return {
|
return {
|
||||||
@@ -138,4 +173,9 @@ async function onClearConfirm() {
|
|||||||
await store.clearCompleted(clearAgeDays.value)
|
await store.clearCompleted(clearAgeDays.value)
|
||||||
clearDialog.value = false
|
clearDialog.value = false
|
||||||
}
|
}
|
||||||
|
function onClearStuckOpen() { clearStuckDialog.value = true }
|
||||||
|
async function onClearStuckConfirm() {
|
||||||
|
await store.clearStuck()
|
||||||
|
clearStuckDialog.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,31 +2,71 @@
|
|||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>Trigger scan</v-card-title>
|
<v-card-title>Trigger scan</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div v-if="store.activeBatch" class="d-flex align-center" style="gap: 12px;">
|
<div v-if="store.activeBatch" class="d-flex align-center mb-3" style="gap: 12px;">
|
||||||
<v-progress-circular
|
<v-progress-circular
|
||||||
indeterminate color="accent" size="20"
|
indeterminate color="accent" size="20"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
Scanning {{ store.activeBatch.source_path }} —
|
{{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }}
|
||||||
|
{{ store.activeBatch.source_path || '/import' }} —
|
||||||
imported {{ store.activeBatch.imported }},
|
imported {{ store.activeBatch.imported }},
|
||||||
|
<template v-if="store.activeBatch.scan_mode === 'deep'">
|
||||||
|
refreshed {{ store.activeBatch.refreshed || 0 }},
|
||||||
|
</template>
|
||||||
skipped {{ store.activeBatch.skipped }},
|
skipped {{ store.activeBatch.skipped }},
|
||||||
failed {{ store.activeBatch.failed }} /
|
failed {{ store.activeBatch.failed }} /
|
||||||
{{ store.activeBatch.total_files }} files
|
{{ store.activeBatch.total_files }} files
|
||||||
</span>
|
</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text" rounded="pill" size="small" color="warning"
|
||||||
|
:loading="clearing" @click="onClearStuck"
|
||||||
|
>
|
||||||
|
Clear stuck
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
|
||||||
<p class="text-body-2 mb-3">
|
<p class="text-body-2 mb-3">
|
||||||
Run a quick scan of the import directory. Deep scan (pHash dedup,
|
<span v-if="!store.activeBatch">
|
||||||
archives) lands in FC-2d.
|
<strong>Quick scan</strong> walks <code>/import</code> and enqueues
|
||||||
</p>
|
new files only.
|
||||||
<v-btn color="primary" rounded="pill" @click="trigger" :loading="busy">
|
<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
|
||||||
|
<em>Clear stuck</em> above if it has been wedged with no
|
||||||
|
measurable progress.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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>
|
<v-icon start>mdi-magnify-scan</v-icon>
|
||||||
Quick scan
|
Quick scan
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
|
<v-btn
|
||||||
{{ store.triggerError }}
|
color="secondary" rounded="pill" variant="tonal"
|
||||||
</v-alert>
|
:disabled="!!store.activeBatch"
|
||||||
|
:loading="busy === 'deep'"
|
||||||
|
@click="trigger('deep')"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-magnify-plus-outline</v-icon>
|
||||||
|
Deep scan
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
|
||||||
|
{{ store.triggerError }}
|
||||||
|
</v-alert>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,10 +76,22 @@ import { ref } from 'vue'
|
|||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
|
||||||
const store = useImportStore()
|
const store = useImportStore()
|
||||||
const busy = ref(false)
|
const busy = ref(null)
|
||||||
|
const clearing = ref(false)
|
||||||
|
|
||||||
async function trigger() {
|
async function trigger(mode) {
|
||||||
busy.value = true
|
busy.value = mode
|
||||||
try { await store.triggerScan() } catch {} finally { busy.value = false }
|
try { await store.triggerScan(mode) } catch {} finally { busy.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearStuck() {
|
||||||
|
clearing.value = true
|
||||||
|
try {
|
||||||
|
await store.clearStuck()
|
||||||
|
} catch {
|
||||||
|
// store surfaces error via triggerError if needed
|
||||||
|
} finally {
|
||||||
|
clearing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
<AliasTable class="mt-4" />
|
<AliasTable class="mt-4" />
|
||||||
<BackupCard class="mt-6" />
|
<BackupCard class="mt-6" />
|
||||||
<TagMaintenanceCard class="mt-6" />
|
<TagMaintenanceCard class="mt-6" />
|
||||||
<BrowserExtensionCard class="mt-6" />
|
|
||||||
<LegacyMigrationCard class="mt-6" />
|
<LegacyMigrationCard class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,7 +26,6 @@ import AllowlistTable from './AllowlistTable.vue'
|
|||||||
import AliasTable from './AliasTable.vue'
|
import AliasTable from './AliasTable.vue'
|
||||||
import BackupCard from './BackupCard.vue'
|
import BackupCard from './BackupCard.vue'
|
||||||
import TagMaintenanceCard from './TagMaintenanceCard.vue'
|
import TagMaintenanceCard from './TagMaintenanceCard.vue'
|
||||||
import BrowserExtensionCard from './BrowserExtensionCard.vue'
|
|
||||||
import LegacyMigrationCard from './LegacyMigrationCard.vue'
|
import LegacyMigrationCard from './LegacyMigrationCard.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -52,11 +52,58 @@ 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
|
triggerError.value = null
|
||||||
try {
|
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).
|
||||||
|
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()
|
await refreshStatus()
|
||||||
|
// 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()
|
||||||
|
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: `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) {
|
} catch (e) {
|
||||||
triggerError.value = e.message
|
triggerError.value = e.message
|
||||||
window.__fcToast?.({ text: `Scan failed: ${e.message}`, type: 'error' })
|
window.__fcToast?.({ text: `Scan failed: ${e.message}`, type: 'error' })
|
||||||
@@ -92,6 +139,13 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
await loadTasks(true)
|
await loadTasks(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearStuck() {
|
||||||
|
const body = await api.post('/api/import/clear-stuck')
|
||||||
|
await loadTasks(true)
|
||||||
|
await refreshStatus()
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
const hasMore = computed(() => tasksNextCursor.value !== null)
|
const hasMore = computed(() => tasksNextCursor.value !== null)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +155,6 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
triggerError,
|
triggerError,
|
||||||
loadSettings, patchSettings,
|
loadSettings, patchSettings,
|
||||||
refreshStatus, triggerScan,
|
refreshStatus, triggerScan,
|
||||||
loadTasks, setStatusFilter, retryFailed, clearCompleted
|
loadTasks, setStatusFilter, retryFailed, clearCompleted, clearStuck
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,69 +45,92 @@
|
|||||||
>Credential health · FC-3b</v-chip>
|
>Credential health · FC-3b</v-chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="store.overview.cooccurring_tags.length" class="fc-artist__sec">
|
<!-- Tabs split (2026-05-25): Settings was previously slotted at the
|
||||||
<h2 class="fc-h2">Frequent tags</h2>
|
bottom of the page after the infinite-scroll image grid, which
|
||||||
<div class="fc-artist__tags">
|
made it effectively unreachable for any artist with more than
|
||||||
<v-chip
|
a couple of pages of content. The Settings tab now hosts
|
||||||
v-for="t in store.overview.cooccurring_tags" :key="t.id"
|
destructive admin actions (artist+content cascade-delete) and
|
||||||
size="small" @click="openTag(t.id)"
|
any future per-artist management UI. v-tabs is `position:
|
||||||
>{{ t.name }} <span class="fc-artist__tagc">{{ t.count }}</span></v-chip>
|
sticky; top: 64px` (under the 64px AppShell TopNav) so it
|
||||||
</div>
|
stays parked while the gallery scrolls. -->
|
||||||
</section>
|
<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="settings">Settings</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
<section v-if="store.overview.activity.length" class="fc-artist__sec">
|
<v-window v-model="tab">
|
||||||
<h2 class="fc-h2">Activity</h2>
|
<v-window-item value="overview">
|
||||||
<svg class="fc-artist__spark" :viewBox="`0 0 ${sparkW} ${sparkH}`"
|
<section v-if="store.overview.cooccurring_tags.length" class="fc-artist__sec">
|
||||||
preserveAspectRatio="none" role="img" aria-label="posts over time">
|
<h2 class="fc-h2">Frequent tags</h2>
|
||||||
<polyline :points="sparkPoints" fill="none"
|
<div class="fc-artist__tags">
|
||||||
stroke="rgb(var(--v-theme-accent))" stroke-width="2" />
|
<v-chip
|
||||||
</svg>
|
v-for="t in store.overview.cooccurring_tags" :key="t.id"
|
||||||
</section>
|
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.sources.length" class="fc-artist__sec">
|
<section v-if="store.overview.activity.length" class="fc-artist__sec">
|
||||||
<div class="fc-artist__sec-head">
|
<h2 class="fc-h2">Activity</h2>
|
||||||
<h2 class="fc-h2">Sources</h2>
|
<svg class="fc-artist__spark" :viewBox="`0 0 ${sparkW} ${sparkH}`"
|
||||||
<RouterLink
|
preserveAspectRatio="none" role="img" aria-label="posts over time">
|
||||||
:to="`/subscriptions?artist_id=${store.overview.id}`"
|
<polyline :points="sparkPoints" fill="none"
|
||||||
class="fc-artist__manage"
|
stroke="rgb(var(--v-theme-accent))" stroke-width="2" />
|
||||||
>Manage subscriptions →</RouterLink>
|
</svg>
|
||||||
</div>
|
</section>
|
||||||
<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">
|
<section v-if="store.overview.sources.length" class="fc-artist__sec">
|
||||||
<h2 class="fc-h2">Images</h2>
|
<div class="fc-artist__sec-head">
|
||||||
<MasonryGrid
|
<h2 class="fc-h2">Sources</h2>
|
||||||
:items="store.images"
|
<RouterLink
|
||||||
:loading="store.imagesLoading"
|
:to="`/subscriptions?artist_id=${store.overview.id}`"
|
||||||
:has-more="store.hasMoreImages"
|
class="fc-artist__manage"
|
||||||
@load-more="store.loadMoreImages(slug)"
|
>Manage subscriptions →</RouterLink>
|
||||||
@open="openImage"
|
</div>
|
||||||
/>
|
<v-table density="compact">
|
||||||
</section>
|
<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>
|
||||||
|
|
||||||
<ArtistDangerZone
|
<section class="fc-artist__sec">
|
||||||
:slug="slug"
|
<h2 class="fc-h2">Images</h2>
|
||||||
:artist-id="store.overview.id"
|
<MasonryGrid
|
||||||
:artist-name="store.overview.name"
|
:items="store.images"
|
||||||
/>
|
:loading="store.imagesLoading"
|
||||||
|
:has-more="store.hasMoreImages"
|
||||||
|
@load-more="store.loadMoreImages(slug)"
|
||||||
|
@open="openImage"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</v-window-item>
|
||||||
|
|
||||||
|
<v-window-item value="settings">
|
||||||
|
<ArtistDangerZone
|
||||||
|
:slug="slug"
|
||||||
|
:artist-id="store.overview.id"
|
||||||
|
:artist-name="store.overview.name"
|
||||||
|
/>
|
||||||
|
</v-window-item>
|
||||||
|
</v-window>
|
||||||
</template>
|
</template>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import { useArtistStore } from '../stores/artist.js'
|
import { useArtistStore } from '../stores/artist.js'
|
||||||
import { useModalStore } from '../stores/modal.js'
|
import { useModalStore } from '../stores/modal.js'
|
||||||
@@ -120,8 +143,18 @@ const store = useArtistStore()
|
|||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
|
|
||||||
const slug = computed(() => route.params.slug)
|
const slug = computed(() => route.params.slug)
|
||||||
|
// Per-artist tab — defaults to Overview. Settings tab hosts destructive
|
||||||
|
// admin actions (DangerZone). Switching artists resets to Overview so the
|
||||||
|
// destructive surface isn't re-shown by accident when navigating between
|
||||||
|
// artists.
|
||||||
|
const tab = ref('overview')
|
||||||
|
|
||||||
watch(slug, (s) => { if (s) store.load(s) }, { immediate: true })
|
watch(slug, (s) => {
|
||||||
|
if (s) {
|
||||||
|
store.load(s)
|
||||||
|
tab.value = 'overview'
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const dateRange = computed(() => {
|
const dateRange = computed(() => {
|
||||||
const r = store.overview?.date_range
|
const r = store.overview?.date_range
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ onUnmounted(() => observer && observer.disconnect())
|
|||||||
}
|
}
|
||||||
.fc-artists__grid {
|
.fc-artists__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.fc-artists__sentinel {
|
.fc-artists__sentinel {
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<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="overview">Overview</v-tab>
|
||||||
<v-tab value="activity">Activity</v-tab>
|
<v-tab value="activity">Activity</v-tab>
|
||||||
<v-tab value="import">Import</v-tab>
|
<v-tab value="import">Import</v-tab>
|
||||||
@@ -21,6 +31,11 @@
|
|||||||
{{ system.stats.tasks.pending + system.stats.tasks.queued }} import task(s) pending.
|
{{ 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-btn variant="text" size="small" @click="tab = 'import'">Go to Import tab</v-btn>
|
||||||
</v-alert>
|
</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>
|
||||||
|
|
||||||
<v-window-item value="activity">
|
<v-window-item value="activity">
|
||||||
@@ -28,11 +43,14 @@
|
|||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
<v-window-item value="import">
|
<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 />
|
<ImportTriggerPanel />
|
||||||
<v-divider class="my-6" />
|
<v-divider class="my-6" />
|
||||||
<ImportFiltersForm />
|
|
||||||
<v-divider class="my-6" />
|
|
||||||
<ImportTaskList />
|
<ImportTaskList />
|
||||||
|
<v-divider class="my-6" />
|
||||||
|
<ImportFiltersForm />
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
<v-window-item value="maintenance">
|
<v-window-item value="maintenance">
|
||||||
@@ -49,6 +67,7 @@ import { useImportStore } from '../stores/import.js'
|
|||||||
import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
|
import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
|
||||||
import SystemActivitySummary from '../components/settings/SystemActivitySummary.vue'
|
import SystemActivitySummary from '../components/settings/SystemActivitySummary.vue'
|
||||||
import SystemActivityTab from '../components/settings/SystemActivityTab.vue'
|
import SystemActivityTab from '../components/settings/SystemActivityTab.vue'
|
||||||
|
import BrowserExtensionCard from '../components/settings/BrowserExtensionCard.vue'
|
||||||
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
|
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
|
||||||
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
|
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
|
||||||
import ImportTaskList from '../components/settings/ImportTaskList.vue'
|
import ImportTaskList from '../components/settings/ImportTaskList.vue'
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ async function onDeleteTagConfirm() {
|
|||||||
}
|
}
|
||||||
.fc-tags__grid {
|
.fc-tags__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.fc-tags__sentinel {
|
.fc-tags__sentinel {
|
||||||
|
|||||||
@@ -86,6 +86,61 @@ async def test_clear_completed(client, db):
|
|||||||
assert body["deleted"] == 1
|
assert body["deleted"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_stuck_fails_non_terminal_and_finalizes_orphan_batch(client, db):
|
||||||
|
"""Operator-flagged 2026-05-25: 3 large PNGs got stuck in 'processing'
|
||||||
|
for 2 days, the active ImportBatch never finalized, and the UI's
|
||||||
|
'Scanning...' banner persisted with 0/0 files. /api/import/clear-stuck
|
||||||
|
is the escape hatch to break the autoretry loop manually."""
|
||||||
|
from sqlalchemy import select as _select
|
||||||
|
|
||||||
|
batch = ImportBatch(triggered_by="manual", source_path="/import", scan_mode="quick")
|
||||||
|
db.add(batch)
|
||||||
|
await db.flush()
|
||||||
|
# Three stuck rows in mixed non-terminal states.
|
||||||
|
db.add(ImportTask(
|
||||||
|
batch_id=batch.id, source_path="/p1", task_type="media", status="processing",
|
||||||
|
))
|
||||||
|
db.add(ImportTask(
|
||||||
|
batch_id=batch.id, source_path="/p2", task_type="media", status="queued",
|
||||||
|
))
|
||||||
|
db.add(ImportTask(
|
||||||
|
batch_id=batch.id, source_path="/p3", task_type="media", status="pending",
|
||||||
|
))
|
||||||
|
# One already-complete row should be untouched.
|
||||||
|
db.add(ImportTask(
|
||||||
|
batch_id=batch.id, source_path="/done", task_type="media",
|
||||||
|
status="complete", finished_at=datetime.now(UTC),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
resp = await client.post("/api/import/clear-stuck")
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert body["tasks_failed"] == 3
|
||||||
|
assert body["batches_finalized"] == 1
|
||||||
|
|
||||||
|
statuses = {
|
||||||
|
row.status for row in
|
||||||
|
(await db.execute(_select(ImportTask).where(ImportTask.batch_id == batch.id)))
|
||||||
|
.scalars().all()
|
||||||
|
}
|
||||||
|
assert statuses == {"failed", "complete"}
|
||||||
|
|
||||||
|
batch_status = (await db.execute(
|
||||||
|
_select(ImportBatch.status).where(ImportBatch.id == batch.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert batch_status == "complete"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_stuck_no_op_when_nothing_stuck(client, db):
|
||||||
|
resp = await client.post("/api/import/clear-stuck")
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert body == {"tasks_failed": 0, "batches_finalized": 0}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_trigger_accepts_deep(client, monkeypatch):
|
async def test_trigger_accepts_deep(client, monkeypatch):
|
||||||
# Stub the task dispatch — assert the API accepts 'deep' and forwards
|
# Stub the task dispatch — assert the API accepts 'deep' and forwards
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ from backend.app.scripts import download_models as dm
|
|||||||
|
|
||||||
|
|
||||||
def test_ensure_camie_skips_when_present(tmp_path, monkeypatch):
|
def test_ensure_camie_skips_when_present(tmp_path, monkeypatch):
|
||||||
|
"""v2 layout (HF Camais03/camie-tagger-v2): the ONNX file is named
|
||||||
|
camie-tagger-v2.onnx (not model.onnx) and tags ship inside
|
||||||
|
camie-tagger-v2-metadata.json (not selected_tags.csv). Both at root.
|
||||||
|
Updated 2026-05-25 after the actual repo layout was confirmed via
|
||||||
|
WebFetch — the old assertion pinned the v1 filenames."""
|
||||||
monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
|
monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
|
||||||
camie = tmp_path / "camie"
|
camie = tmp_path / "camie"
|
||||||
camie.mkdir(parents=True)
|
camie.mkdir(parents=True)
|
||||||
(camie / "model.onnx").write_bytes(b"x")
|
(camie / "camie-tagger-v2.onnx").write_bytes(b"x")
|
||||||
(camie / "selected_tags.csv").write_text("tag_id,name,category,count\n")
|
(camie / "camie-tagger-v2-metadata.json").write_text("{}")
|
||||||
with patch.object(dm, "_snapshot") as snap:
|
with patch.object(dm, "_snapshot") as snap:
|
||||||
dm.ensure_camie()
|
dm.ensure_camie()
|
||||||
snap.assert_not_called()
|
snap.assert_not_called()
|
||||||
|
|||||||
@@ -133,3 +133,135 @@ async def test_get_image_with_tags_includes_integrity_status(db):
|
|||||||
svc = GalleryService(db)
|
svc = GalleryService(db)
|
||||||
payload = await svc.get_image_with_tags(img.id)
|
payload = await svc.get_image_with_tags(img.id)
|
||||||
assert payload["integrity_status"] == "ok"
|
assert payload["integrity_status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_image_with_post(
|
||||||
|
db, *, sha: str, image_created_at, post_date, artist_name="test-artist",
|
||||||
|
platform="patreon", external_post_id="42",
|
||||||
|
):
|
||||||
|
"""Helper: seed an Artist + Source + Post and one ImageRecord whose
|
||||||
|
primary_post_id points at that Post. Used for date-coalesce tests."""
|
||||||
|
from backend.app.models import Artist, Post, Source
|
||||||
|
artist = Artist(name=artist_name, slug=artist_name.lower().replace(" ", "-"))
|
||||||
|
db.add(artist)
|
||||||
|
await db.flush()
|
||||||
|
source = Source(
|
||||||
|
artist_id=artist.id, platform=platform,
|
||||||
|
url=f"https://www.{platform}.com/{artist.slug}",
|
||||||
|
)
|
||||||
|
db.add(source)
|
||||||
|
await db.flush()
|
||||||
|
post = Post(
|
||||||
|
source_id=source.id, external_post_id=external_post_id,
|
||||||
|
post_title="A Post", post_date=post_date,
|
||||||
|
)
|
||||||
|
db.add(post)
|
||||||
|
await db.flush()
|
||||||
|
img = ImageRecord(
|
||||||
|
path=f"/images/test/{sha[:8]}.jpg",
|
||||||
|
sha256=sha, size_bytes=1000, mime="image/jpeg",
|
||||||
|
width=100, height=100,
|
||||||
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
|
primary_post_id=post.id,
|
||||||
|
)
|
||||||
|
img.created_at = image_created_at
|
||||||
|
db.add(img)
|
||||||
|
await db.flush()
|
||||||
|
return img, post
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_sorts_by_post_date_when_available(db):
|
||||||
|
"""Operator-flagged 2026-05-25: ~57k IR images all imported in the
|
||||||
|
same week sort by image.created_at and pile up in one month bucket.
|
||||||
|
Once primary_post_id is wired (via tag_apply phase 4), the gallery
|
||||||
|
should sort by Post.post_date instead, spreading them across the
|
||||||
|
actual publish years."""
|
||||||
|
base_import = _now()
|
||||||
|
# Image A: imported NOW, but post was made 2 years ago.
|
||||||
|
img_a, _ = await _seed_image_with_post(
|
||||||
|
db, sha="a" * 64,
|
||||||
|
image_created_at=base_import,
|
||||||
|
post_date=base_import - timedelta(days=730),
|
||||||
|
artist_name="Aria", external_post_id="A-1",
|
||||||
|
)
|
||||||
|
# Image B: imported NOW (1 min later), post made YESTERDAY.
|
||||||
|
img_b, _ = await _seed_image_with_post(
|
||||||
|
db, sha="b" * 64,
|
||||||
|
image_created_at=base_import - timedelta(minutes=1),
|
||||||
|
post_date=base_import - timedelta(days=1),
|
||||||
|
artist_name="Bea", external_post_id="B-1",
|
||||||
|
)
|
||||||
|
# Image C: filesystem-imported, no primary_post_id, created 5 days ago.
|
||||||
|
img_c = ImageRecord(
|
||||||
|
path="/images/test/c.jpg", sha256="c" * 64,
|
||||||
|
size_bytes=1000, mime="image/jpeg",
|
||||||
|
width=100, height=100,
|
||||||
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
|
)
|
||||||
|
img_c.created_at = base_import - timedelta(days=5)
|
||||||
|
db.add(img_c)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
svc = GalleryService(db)
|
||||||
|
page = await svc.scroll(cursor=None, limit=10)
|
||||||
|
# Effective-date order: B (yesterday) > C (5 days ago) > A (2 years ago)
|
||||||
|
assert [i.id for i in page.images] == [img_b.id, img_c.id, img_a.id]
|
||||||
|
|
||||||
|
# API exposes both fields explicitly so the UI can show "Posted X / Imported Y".
|
||||||
|
a_payload = next(i for i in page.images if i.id == img_a.id)
|
||||||
|
assert a_payload.posted_at is not None
|
||||||
|
assert a_payload.posted_at < a_payload.created_at
|
||||||
|
c_payload = next(i for i in page.images if i.id == img_c.id)
|
||||||
|
assert c_payload.posted_at is None
|
||||||
|
assert c_payload.effective_date == c_payload.created_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeline_buckets_use_post_date_when_available(db):
|
||||||
|
"""Timeline group-by must follow the same effective_date rule so the
|
||||||
|
UI's year/month navigation surfaces publish-date buckets, not the
|
||||||
|
single FC-scan bucket all migrated images share."""
|
||||||
|
base = datetime(2026, 6, 15, 12, 0, tzinfo=UTC)
|
||||||
|
await _seed_image_with_post(
|
||||||
|
db, sha="1" * 64,
|
||||||
|
image_created_at=base,
|
||||||
|
post_date=datetime(2024, 3, 10, tzinfo=UTC),
|
||||||
|
artist_name="Carl", external_post_id="C-1",
|
||||||
|
)
|
||||||
|
await _seed_image_with_post(
|
||||||
|
db, sha="2" * 64,
|
||||||
|
image_created_at=base,
|
||||||
|
post_date=datetime(2024, 3, 11, tzinfo=UTC),
|
||||||
|
artist_name="Dee", external_post_id="D-1",
|
||||||
|
)
|
||||||
|
await _seed_image_with_post(
|
||||||
|
db, sha="3" * 64,
|
||||||
|
image_created_at=base,
|
||||||
|
post_date=datetime(2025, 9, 1, tzinfo=UTC),
|
||||||
|
artist_name="Eli", external_post_id="E-1",
|
||||||
|
)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
buckets = await svc.timeline()
|
||||||
|
bucket_keys = {(b.year, b.month, b.count) for b in buckets}
|
||||||
|
# Two posts in 2024-03, one in 2025-09 — even though all imported in 2026-06.
|
||||||
|
assert (2024, 3, 2) in bucket_keys
|
||||||
|
assert (2025, 9, 1) in bucket_keys
|
||||||
|
# The FC-import bucket should NOT appear since all 3 images have post_date.
|
||||||
|
assert not any(b.year == 2026 and b.month == 6 for b in buckets)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_image_with_tags_includes_posted_at_when_present(db):
|
||||||
|
base = _now()
|
||||||
|
img, _ = await _seed_image_with_post(
|
||||||
|
db, sha="f" * 64,
|
||||||
|
image_created_at=base,
|
||||||
|
post_date=base - timedelta(days=365),
|
||||||
|
artist_name="Fred", external_post_id="F-1",
|
||||||
|
)
|
||||||
|
svc = GalleryService(db)
|
||||||
|
payload = await svc.get_image_with_tags(img.id)
|
||||||
|
assert payload["posted_at"] is not None
|
||||||
|
# Image's own created_at is still surfaced separately.
|
||||||
|
assert payload["created_at"] != payload["posted_at"]
|
||||||
|
|||||||
@@ -134,3 +134,58 @@ def test_root_level_file_has_no_artist(importer, import_layout):
|
|||||||
importer.import_one(src)
|
importer.import_one(src)
|
||||||
artists = importer.session.execute(select(Artist)).scalars().all()
|
artists = importer.session.execute(select(Artist)).scalars().all()
|
||||||
assert artists == []
|
assert artists == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pil_load_oserror_in_transparency_check_skips_not_raises(
|
||||||
|
importer, import_layout, monkeypatch,
|
||||||
|
):
|
||||||
|
"""PIL.verify() only validates header structure — broken pixel data
|
||||||
|
only surfaces when load() actually decodes. The importer must catch
|
||||||
|
the OSError and return a skipped: invalid_image result so the Celery
|
||||||
|
autoretry loop doesn't bounce the same broken file forever.
|
||||||
|
Operator hit this 2026-05-25 with a corrupt JPEG in the IR set."""
|
||||||
|
import_root, _ = import_layout
|
||||||
|
src = import_root / "Bob" / "corrupt.png"
|
||||||
|
# Make a real RGBA PNG so the has_alpha path engages.
|
||||||
|
_make_png_rgba(src, (100, 100), alpha=128)
|
||||||
|
|
||||||
|
importer.settings.skip_transparent = True
|
||||||
|
importer.settings.transparency_threshold = 0.5
|
||||||
|
|
||||||
|
# Force the next _transparency_pct call to raise as if PIL's load()
|
||||||
|
# blew up on truncated pixel data.
|
||||||
|
def _boom(_self, _src):
|
||||||
|
raise OSError("broken data stream when reading image file")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(importer), "_transparency_pct", _boom,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = importer.import_one(src)
|
||||||
|
assert result.status == "skipped"
|
||||||
|
assert result.skip_reason == SkipReason.invalid_image
|
||||||
|
assert "transparency check" in (result.error or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pil_load_oserror_in_phash_compute_skips_not_raises(
|
||||||
|
importer, import_layout, monkeypatch,
|
||||||
|
):
|
||||||
|
"""Same shape as the transparency-check guard, but for the phash
|
||||||
|
compute block — the OTHER place PIL.load() runs implicitly during
|
||||||
|
the dedup pipeline."""
|
||||||
|
import_root, _ = import_layout
|
||||||
|
src = import_root / "Carol" / "corrupt.jpg"
|
||||||
|
_make_jpeg(src)
|
||||||
|
|
||||||
|
# Disable transparency check so we reach the phash compute block.
|
||||||
|
importer.settings.skip_transparent = False
|
||||||
|
|
||||||
|
from backend.app.services import importer as importer_module
|
||||||
|
|
||||||
|
def _boom(_im):
|
||||||
|
raise OSError("broken data stream when reading image file")
|
||||||
|
monkeypatch.setattr(importer_module, "compute_phash", _boom)
|
||||||
|
|
||||||
|
result = importer.import_one(src)
|
||||||
|
assert result.status == "skipped"
|
||||||
|
assert result.skip_reason == SkipReason.invalid_image
|
||||||
|
assert "phash compute" in (result.error or "")
|
||||||
|
|||||||
@@ -59,3 +59,25 @@ def test_json_sidecar_is_not_attached(importer, import_layout):
|
|||||||
select(func.count()).select_from(PostAttachment)
|
select(func.count()).select_from(PostAttachment)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert n == 0
|
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)
|
deep = _mk(db_sync, import_layout, deep=True)
|
||||||
r2 = deep.import_one(src)
|
r2 = deep.import_one(src)
|
||||||
assert r2.status == "skipped"
|
# Outcome flipped from "skipped+duplicate_hash" to "refreshed" 2026-05-25
|
||||||
assert "deep" in (r2.error or "")
|
# 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.image_id == rec.id
|
||||||
|
assert r2.skip_reason is None
|
||||||
|
assert r2.error is None
|
||||||
|
|
||||||
db_sync.expire_all()
|
db_sync.expire_all()
|
||||||
rec2 = db_sync.get(ImageRecord, rec.id)
|
rec2 = db_sync.get(ImageRecord, rec.id)
|
||||||
|
|||||||
@@ -68,6 +68,102 @@ def test_recover_interrupted_only_old(db_sync, monkeypatch):
|
|||||||
assert dispatched == [stale.id]
|
assert dispatched == [stale.id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_interrupted_sweeps_pending_orphans_to_failed(db_sync, monkeypatch):
|
||||||
|
"""A scan that creates ImportTask rows but crashes before the second
|
||||||
|
pass (transition to 'queued' + .delay()) leaves rows orphaned at
|
||||||
|
status='pending'. The sweep flips them to 'failed' so the operator
|
||||||
|
can drain via /api/import/retry-failed without thundering-herding.
|
||||||
|
Banked 2026-05-25 after operator hit 5490 stuck pending rows.
|
||||||
|
"""
|
||||||
|
from backend.app.tasks import import_file
|
||||||
|
monkeypatch.setattr(import_file.import_media_file, "delay", lambda *_: None)
|
||||||
|
|
||||||
|
batch_id = _make_batch(db_sync)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
fresh_pending = ImportTask(
|
||||||
|
batch_id=batch_id, source_path="/import/fresh.jpg", task_type="media",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_sync.add(fresh_pending)
|
||||||
|
db_sync.flush()
|
||||||
|
# created_at defaults to now() server-side; fresh row stays untouched.
|
||||||
|
|
||||||
|
# Two stale rows simulating the orphan pile: one 'pending', one
|
||||||
|
# 'queued' (scanner crashed AFTER transitioning some rows but
|
||||||
|
# before all). Both should sweep.
|
||||||
|
stale_pending = ImportTask(
|
||||||
|
batch_id=batch_id, source_path="/import/stale1.jpg", task_type="media",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
stale_queued = ImportTask(
|
||||||
|
batch_id=batch_id, source_path="/import/stale2.jpg", task_type="media",
|
||||||
|
status="queued",
|
||||||
|
)
|
||||||
|
db_sync.add_all([stale_pending, stale_queued])
|
||||||
|
db_sync.flush()
|
||||||
|
# Backdate created_at past the orphan cutoff (30 min).
|
||||||
|
from sqlalchemy import update as _upd
|
||||||
|
db_sync.execute(
|
||||||
|
_upd(ImportTask)
|
||||||
|
.where(ImportTask.id.in_([stale_pending.id, stale_queued.id]))
|
||||||
|
.values(created_at=now - timedelta(hours=2))
|
||||||
|
)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
from backend.app.tasks.maintenance import recover_interrupted_tasks
|
||||||
|
touched = recover_interrupted_tasks.apply().get()
|
||||||
|
assert touched == 2
|
||||||
|
|
||||||
|
db_sync.refresh(fresh_pending)
|
||||||
|
db_sync.refresh(stale_pending)
|
||||||
|
db_sync.refresh(stale_queued)
|
||||||
|
assert fresh_pending.status == "pending" # fresh row untouched
|
||||||
|
assert stale_pending.status == "failed"
|
||||||
|
assert stale_queued.status == "failed"
|
||||||
|
assert "orphan" in (stale_pending.error or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_interrupted_handles_both_stuck_and_orphans(db_sync, monkeypatch):
|
||||||
|
"""One sweep tick handles both 'processing' crashes AND
|
||||||
|
'pending'/'queued' orphans in a single pass."""
|
||||||
|
from backend.app.tasks import import_file
|
||||||
|
dispatched: list[int] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
import_file.import_media_file, "delay", dispatched.append
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_id = _make_batch(db_sync)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
stuck = ImportTask(
|
||||||
|
batch_id=batch_id, source_path="/import/stuck.jpg", task_type="media",
|
||||||
|
status="processing", started_at=now - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
orphan = ImportTask(
|
||||||
|
batch_id=batch_id, source_path="/import/orphan.jpg", task_type="media",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_sync.add_all([stuck, orphan])
|
||||||
|
db_sync.flush()
|
||||||
|
from sqlalchemy import update as _upd
|
||||||
|
db_sync.execute(
|
||||||
|
_upd(ImportTask).where(ImportTask.id == orphan.id)
|
||||||
|
.values(created_at=now - timedelta(hours=2))
|
||||||
|
)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
from backend.app.tasks.maintenance import recover_interrupted_tasks
|
||||||
|
touched = recover_interrupted_tasks.apply().get()
|
||||||
|
assert touched == 2
|
||||||
|
|
||||||
|
db_sync.refresh(stuck)
|
||||||
|
db_sync.refresh(orphan)
|
||||||
|
assert stuck.status == "queued"
|
||||||
|
assert orphan.status == "failed"
|
||||||
|
assert dispatched == [stuck.id] # stuck rows re-enqueue; orphans don't
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_old_deletes_finished_old(db_sync):
|
def test_cleanup_old_deletes_finished_old(db_sync):
|
||||||
batch_id = _make_batch(db_sync)
|
batch_id = _make_batch(db_sync)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|||||||
@@ -40,5 +40,8 @@ def test_get_tagger_singleton():
|
|||||||
|
|
||||||
def test_load_raises_when_model_missing(tmp_path):
|
def test_load_raises_when_model_missing(tmp_path):
|
||||||
t = Tagger(model_dir=tmp_path / "nonexistent")
|
t = Tagger(model_dir=tmp_path / "nonexistent")
|
||||||
with pytest.raises(RuntimeError, match="model.onnx missing"):
|
# Match the trailing "missing at <path>" rather than the specific
|
||||||
|
# filename, so a future model-version bump (camie-tagger-v3.onnx, etc.)
|
||||||
|
# doesn't bounce this test.
|
||||||
|
with pytest.raises(RuntimeError, match=r"\.onnx missing at "):
|
||||||
t.load()
|
t.load()
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import pytest
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from backend.app.models import ImageRecord, ImportSettings, Tag, TagKind
|
from backend.app.models import (
|
||||||
|
ImageProvenance,
|
||||||
|
ImageRecord,
|
||||||
|
ImportSettings,
|
||||||
|
Post,
|
||||||
|
Source,
|
||||||
|
Tag,
|
||||||
|
TagKind,
|
||||||
|
)
|
||||||
from backend.app.models.tag import image_tag
|
from backend.app.models.tag import image_tag
|
||||||
from backend.app.services.importer import Importer, SkipReason
|
from backend.app.services.importer import Importer, SkipReason
|
||||||
from backend.app.services.thumbnailer import Thumbnailer
|
from backend.app.services.thumbnailer import Thumbnailer
|
||||||
@@ -141,6 +149,67 @@ def test_smaller_existing_is_superseded(importer, import_layout):
|
|||||||
assert Path(row.path).exists()
|
assert Path(row.path).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_supersede_applies_new_file_sidecar(importer, import_layout):
|
||||||
|
"""Operator-flagged 2026-05-25: scanning the GS download dir should
|
||||||
|
supersede smaller IR-migrated images AND wire up the GS sidecar's
|
||||||
|
Post/Source/ImageProvenance. Previously _supersede swapped the file
|
||||||
|
but ignored the sidecar entirely."""
|
||||||
|
import json
|
||||||
|
import_root, _ = import_layout
|
||||||
|
|
||||||
|
# Stage 1: a small, sidecar-less image (the "IR migration" precondition).
|
||||||
|
small = import_root / "ir-migration-folder" / "small.png"
|
||||||
|
_write(small, (60, 130, 200), (200, 200))
|
||||||
|
r1 = importer.import_one(small)
|
||||||
|
assert r1.status == "imported"
|
||||||
|
eid = r1.image_id
|
||||||
|
|
||||||
|
# Stage 2: a larger version of the same image (same phash) WITH a
|
||||||
|
# gallery-dl JSON sidecar adjacent. Live in a separate folder to
|
||||||
|
# simulate the GS download dir.
|
||||||
|
big = import_root / "Maewix" / "patreon" / "01_big.png"
|
||||||
|
_write(big, (60, 130, 200), (900, 900))
|
||||||
|
sidecar_path = big.with_suffix(big.suffix + ".json")
|
||||||
|
sidecar_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sidecar_path.write_text(json.dumps({
|
||||||
|
"category": "patreon",
|
||||||
|
"id": 555,
|
||||||
|
"url": "https://www.patreon.com/posts/555",
|
||||||
|
"title": "Set 1",
|
||||||
|
"content": "<p>The big version</p>",
|
||||||
|
"page_count": 1,
|
||||||
|
"published_at": "2025-08-01T00:00:00+00:00",
|
||||||
|
"artist": "Maewix",
|
||||||
|
}))
|
||||||
|
|
||||||
|
r2 = importer.import_one(big)
|
||||||
|
assert r2.status == "superseded"
|
||||||
|
assert r2.image_id == eid
|
||||||
|
|
||||||
|
importer.session.expire_all()
|
||||||
|
# Row preserved, file replaced, sidecar metadata wired up.
|
||||||
|
row = importer.session.get(ImageRecord, eid)
|
||||||
|
assert row.width == 900 and row.height == 900
|
||||||
|
|
||||||
|
post = importer.session.execute(
|
||||||
|
select(Post).where(Post.external_post_id == "555")
|
||||||
|
).scalar_one()
|
||||||
|
assert post.post_title == "Set 1"
|
||||||
|
assert "big version" in (post.description or "")
|
||||||
|
|
||||||
|
source = importer.session.execute(
|
||||||
|
select(Source).where(Source.id == post.source_id)
|
||||||
|
).scalar_one()
|
||||||
|
assert source.platform == "patreon"
|
||||||
|
|
||||||
|
prov_count = importer.session.execute(
|
||||||
|
select(func.count(ImageProvenance.id))
|
||||||
|
.where(ImageProvenance.image_record_id == eid)
|
||||||
|
.where(ImageProvenance.post_id == post.id)
|
||||||
|
).scalar_one()
|
||||||
|
assert prov_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_threshold_controls_match(importer, import_layout):
|
def test_threshold_controls_match(importer, import_layout):
|
||||||
# Structurally distinct images (orthogonal splits) are far apart in
|
# Structurally distinct images (orthogonal splits) are far apart in
|
||||||
# phash space, so a tight threshold keeps them independent rather than
|
# phash space, so a tight threshold keeps them independent rather than
|
||||||
@@ -172,3 +241,9 @@ def test_import_task_maps_superseded_to_complete_and_requeues():
|
|||||||
assert _map_result_to_status(
|
assert _map_result_to_status(
|
||||||
ImportResult(status="failed", error="boom")
|
ImportResult(status="failed", error="boom")
|
||||||
) == ("failed", False)
|
) == ("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"
|
||||||
@@ -227,6 +227,67 @@ async def test_image_posts_creates_source_post_provenance(db, tmp_path):
|
|||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
assert prov_count == 1
|
assert prov_count == 1
|
||||||
|
|
||||||
|
# Phase 4 must also set ImageRecord.primary_post_id so the gallery's
|
||||||
|
# effective_date COALESCE can surface Post.post_date. Operator-flagged
|
||||||
|
# 2026-05-25: without this, IR-migrated images keep sorting by FC's
|
||||||
|
# scan date instead of the original publish date.
|
||||||
|
primary_post_id = (await db.execute(
|
||||||
|
select(ImageRecord.primary_post_id).where(ImageRecord.id == img_id)
|
||||||
|
)).scalar_one()
|
||||||
|
canonical_post_id = (await db.execute(
|
||||||
|
select(Post.id).where(Post.external_post_id == "10001")
|
||||||
|
)).scalar_one()
|
||||||
|
assert primary_post_id == canonical_post_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_posts_primary_post_id_not_clobbered(db, tmp_path):
|
||||||
|
"""If the importer already set primary_post_id (e.g. a downloaded
|
||||||
|
image with a known provenance), phase 4 must NOT overwrite it when
|
||||||
|
re-running tag_apply against the IR migration. The existing
|
||||||
|
download-time linkage is the source of truth."""
|
||||||
|
sha = "9" * 64
|
||||||
|
await _seed_image(db, sha, suffix="9")
|
||||||
|
# Pre-set primary_post_id to a sentinel Post so we can detect a clobber.
|
||||||
|
img_id = (await db.execute(
|
||||||
|
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
|
||||||
|
)).scalar_one()
|
||||||
|
# Build an existing Source + Post for the sentinel.
|
||||||
|
art = Artist(name="Pre-existing", slug="pre-existing")
|
||||||
|
db.add(art)
|
||||||
|
await db.flush()
|
||||||
|
src = Source(
|
||||||
|
artist_id=art.id, platform="patreon",
|
||||||
|
url="https://www.patreon.com/pre-existing",
|
||||||
|
)
|
||||||
|
db.add(src)
|
||||||
|
await db.flush()
|
||||||
|
sentinel_post = Post(
|
||||||
|
source_id=src.id, external_post_id="sentinel-99",
|
||||||
|
post_title="Pre-existing",
|
||||||
|
)
|
||||||
|
db.add(sentinel_post)
|
||||||
|
await db.flush()
|
||||||
|
await db.execute(
|
||||||
|
ImageRecord.__table__.update()
|
||||||
|
.where(ImageRecord.id == img_id)
|
||||||
|
.values(primary_post_id=sentinel_post.id)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
_write_manifest(tmp_path, image_posts=[
|
||||||
|
{**_POST_ENTRY, "image_sha256s": [sha]},
|
||||||
|
])
|
||||||
|
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
|
||||||
|
|
||||||
|
# The migration created a NEW Post (external_post_id="10001") and a
|
||||||
|
# new ImageProvenance, but primary_post_id must still point at the
|
||||||
|
# original sentinel.
|
||||||
|
primary_post_id = (await db.execute(
|
||||||
|
select(ImageRecord.primary_post_id).where(ImageRecord.id == img_id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert primary_post_id == sentinel_post.id
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_image_posts_idempotent_on_rerun(db, tmp_path):
|
async def test_image_posts_idempotent_on_rerun(db, tmp_path):
|
||||||
@@ -281,6 +342,42 @@ async def test_image_posts_unknown_platform_skipped(db, tmp_path):
|
|||||||
assert src_count == 0
|
assert src_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("platform,expected_url", [
|
||||||
|
("deviantart", "https://www.deviantart.com/maewix"),
|
||||||
|
("pixiv", "https://www.pixiv.net/users/maewix"),
|
||||||
|
])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_posts_extended_platforms_create_source(
|
||||||
|
db, tmp_path, platform, expected_url,
|
||||||
|
):
|
||||||
|
"""Regression for 2026-05-25 operator-reported bug: phase 4's
|
||||||
|
_PLATFORM_PROFILE_URL had only patreon/subscribestar/hentaifoundry,
|
||||||
|
silently dropping deviantart + pixiv PostMetadata from the IR migration."""
|
||||||
|
sha = f"{platform[0]}" * 64
|
||||||
|
await _seed_image(db, sha, suffix=f"_{platform}")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
_write_manifest(tmp_path, image_posts=[
|
||||||
|
{**_POST_ENTRY, "platform": platform, "image_sha256s": [sha]},
|
||||||
|
])
|
||||||
|
|
||||||
|
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
|
||||||
|
assert result["counts"]["rows_inserted"] >= 1
|
||||||
|
|
||||||
|
src_url = (await db.execute(
|
||||||
|
select(Source.url).where(Source.platform == platform)
|
||||||
|
)).scalar_one()
|
||||||
|
assert src_url == expected_url
|
||||||
|
|
||||||
|
# ImageProvenance row was created.
|
||||||
|
prov_count = (await db.execute(
|
||||||
|
select(func.count(ImageProvenance.id))
|
||||||
|
.join(Source, Source.id == ImageProvenance.source_id)
|
||||||
|
.where(Source.platform == platform)
|
||||||
|
)).scalar_one()
|
||||||
|
assert prov_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_image_posts_dry_run_makes_no_writes(db, tmp_path):
|
async def test_image_posts_dry_run_makes_no_writes(db, tmp_path):
|
||||||
sha = "2" * 64
|
sha = "2" * 64
|
||||||
|
|||||||
Reference in New Issue
Block a user