Merge pull request 'dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf' (#62) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m0s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m0s
This commit was merged in pull request #62.
This commit is contained in:
+30
-149
@@ -92,28 +92,25 @@ jobs:
|
||||
- run: npm run test:unit
|
||||
- run: npm run build
|
||||
|
||||
# Integration suite split into THREE parallel shards (2026-05-25, runner
|
||||
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service
|
||||
# set and runs alembic + a disjoint subset of integration tests. Shards
|
||||
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
|
||||
# stays single-threaded per shard but multiple shards run in parallel
|
||||
# wall-clock. Approximate split — rebalance once --durations=15 output
|
||||
# reveals which shard is the long pole.
|
||||
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
|
||||
# The shards existed to parallelize ~8.5min of integration tests; once the
|
||||
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
|
||||
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
|
||||
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
|
||||
# runner slots for no wall-clock gain. One job now: spin up once, install
|
||||
# once, migrate once, run every integration test.
|
||||
#
|
||||
# Each shard's docker-ps filter uses its own unique job name to scope
|
||||
# service-container resolution. act_runner appears to strip underscores
|
||||
# from job names when building container labels — `int_api` yielded
|
||||
# zero matches on 2026-05-25 — so shards use no-separator names
|
||||
# (`intapi`, `intimp`, `intcore`) instead. Each step prints
|
||||
# `docker ps -a` first so a future naming-convention shift surfaces in
|
||||
# the log without another guess-and-push cycle.
|
||||
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
|
||||
# containers by job name. act_runner strips underscores from job names when
|
||||
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
|
||||
# stays separator-free (`integration`). The step prints `docker ps -a` first
|
||||
# so a future naming-convention shift surfaces in the log without a
|
||||
# guess-and-push cycle.
|
||||
#
|
||||
# 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:
|
||||
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
|
||||
# per ci-requirements.md, FC is the only Python consumer of that image and the
|
||||
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
|
||||
integration:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -144,14 +141,14 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: API integration shard (resolve service IPs, migrate, test)
|
||||
- name: Integration suite (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=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
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")
|
||||
@@ -168,130 +165,14 @@ jobs:
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
# Relax durability on the throwaway CI Postgres so the per-test
|
||||
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
|
||||
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
|
||||
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
|
||||
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
|
||||
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
|
||||
# surprise can't red the job; fabledcurator is the postgres image's
|
||||
# bootstrap superuser.
|
||||
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
|
||||
alembic upgrade head
|
||||
pytest tests/test_api_*.py -v -m integration --durations=15
|
||||
|
||||
intimp:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
DB_USER: fabledcurator
|
||||
DB_PASSWORD: ci_integration
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: fabledcurator_test
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: fabledcurator
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: fabledcurator_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U fabledcurator"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Importer integration shard (resolve service IPs, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
echo "=== end landscape ==="
|
||||
PG=$(docker ps --filter "name=intimp" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intimp" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
test -n "$PG" && test -n "$RD"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||
test -n "$PG_IP" && test -n "$RD_IP"
|
||||
export DB_HOST="$PG_IP"
|
||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
||||
for i in $(seq 1 60); do
|
||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
alembic upgrade head
|
||||
pytest tests/test_importer*.py tests/test_import_*.py tests/test_migration_*.py tests/test_phash_*.py tests/test_sidecar_*.py tests/test_scan_*.py tests/test_archive_extractor.py tests/test_backfill_phash.py -v -m integration --durations=15
|
||||
|
||||
intcore:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
DB_USER: fabledcurator
|
||||
DB_PASSWORD: ci_integration
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: fabledcurator_test
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: fabledcurator
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: fabledcurator_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U fabledcurator"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
echo "=== end landscape ==="
|
||||
PG=$(docker ps --filter "name=intcore" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intcore" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
test -n "$PG" && test -n "$RD"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||
test -n "$PG_IP" && test -n "$RD_IP"
|
||||
export DB_HOST="$PG_IP"
|
||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
||||
for i in $(seq 1 60); do
|
||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
alembic upgrade head
|
||||
pytest tests/ -v -m integration --durations=15 \
|
||||
--ignore-glob='tests/test_api_*.py' \
|
||||
--ignore-glob='tests/test_importer*.py' \
|
||||
--ignore-glob='tests/test_import_*.py' \
|
||||
--ignore-glob='tests/test_migration_*.py' \
|
||||
--ignore-glob='tests/test_phash_*.py' \
|
||||
--ignore-glob='tests/test_sidecar_*.py' \
|
||||
--ignore-glob='tests/test_scan_*.py' \
|
||||
--ignore-glob='tests/test_archive_extractor.py' \
|
||||
--ignore-glob='tests/test_backfill_phash.py'
|
||||
pytest tests/ -v -m integration --durations=15
|
||||
|
||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import Artist
|
||||
@@ -222,3 +222,49 @@ async def tags_purge_legacy():
|
||||
lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run)
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
|
||||
async def db_stats():
|
||||
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
||||
so the operator can see when a VACUUM is worth running."""
|
||||
from ..tasks.maintenance import VACUUM_TABLES
|
||||
|
||||
wanted = set(VACUUM_TABLES)
|
||||
async with get_session() as session:
|
||||
rows = (await session.execute(text(
|
||||
"SELECT relname, n_live_tup, n_dead_tup, last_vacuum, "
|
||||
"last_autovacuum, last_analyze FROM pg_stat_user_tables"
|
||||
))).all()
|
||||
|
||||
def _iso(v):
|
||||
return v.isoformat() if v is not None else None
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
if r.relname not in wanted:
|
||||
continue
|
||||
live = r.n_live_tup or 0
|
||||
dead = r.n_dead_tup or 0
|
||||
total = live + dead
|
||||
out.append({
|
||||
"table": r.relname,
|
||||
"live": live,
|
||||
"dead": dead,
|
||||
"dead_pct": round(100 * dead / total, 1) if total else 0.0,
|
||||
"last_vacuum": _iso(r.last_vacuum),
|
||||
"last_autovacuum": _iso(r.last_autovacuum),
|
||||
"last_analyze": _iso(r.last_analyze),
|
||||
})
|
||||
out.sort(key=lambda t: t["dead"], reverse=True)
|
||||
return jsonify({"tables": out})
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/vacuum", methods=["POST"])
|
||||
async def trigger_vacuum():
|
||||
"""Operator-triggered VACUUM (ANALYZE) over the high-churn tables — the
|
||||
same maintenance-queue task the weekly Beat schedule runs."""
|
||||
from ..tasks.maintenance import vacuum_analyze
|
||||
|
||||
vacuum_analyze.delay()
|
||||
return jsonify({"status": "queued"}), 202
|
||||
|
||||
+64
-19
@@ -1,4 +1,6 @@
|
||||
"""Gallery API: cursor scroll, timeline, jump, image detail."""
|
||||
"""Gallery API: cursor scroll, timeline, jump, image detail, facets."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
@@ -8,11 +10,24 @@ from ..services.gallery_service import GalleryService
|
||||
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
||||
|
||||
|
||||
def _parse_date(raw):
|
||||
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
|
||||
Raises ValueError (→ 400) on a malformed value."""
|
||||
if not raw:
|
||||
return None
|
||||
return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _parse_filters():
|
||||
"""Parse the composable gallery filters from query args. Raises
|
||||
ValueError (→ 400) on malformed ids. `tag_id` accepts a single id or a
|
||||
comma-separated list (AND); `media` is image|video; `sort` is
|
||||
newest|oldest."""
|
||||
"""Parse the composable gallery filters from query args, returning
|
||||
``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
|
||||
|
||||
`tag_id` accepts a single id or a comma-separated list (AND); `media` is
|
||||
image|video; `sort` is newest|oldest; `platform` selects one platform
|
||||
(or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are boolean
|
||||
flags; `date_from`/`date_to` are inclusive calendar-day bounds (date_to is
|
||||
widened by a day so the whole day is covered by the service's half-open
|
||||
`< date_to`)."""
|
||||
tag_raw = request.args.get("tag_id")
|
||||
tag_ids = (
|
||||
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
|
||||
@@ -25,7 +40,20 @@ def _parse_filters():
|
||||
media_type = media if media in ("image", "video") else None
|
||||
sort = request.args.get("sort")
|
||||
sort = sort if sort in ("newest", "oldest") else "newest"
|
||||
return tag_ids, post_id, artist_id, media_type, sort
|
||||
platform = request.args.get("platform") or None
|
||||
untagged = request.args.get("untagged") in ("1", "true", "yes")
|
||||
no_artist = request.args.get("no_artist") in ("1", "true", "yes")
|
||||
date_from = _parse_date(request.args.get("date_from"))
|
||||
date_to = _parse_date(request.args.get("date_to"))
|
||||
if date_to is not None:
|
||||
date_to += timedelta(days=1) # inclusive of the date_to calendar day
|
||||
filters = {
|
||||
"tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id,
|
||||
"media_type": media_type, "platform": platform,
|
||||
"untagged": untagged, "no_artist": no_artist,
|
||||
"date_from": date_from, "date_to": date_to,
|
||||
}
|
||||
return filters, sort
|
||||
|
||||
|
||||
@gallery_bp.route("/scroll", methods=["GET"])
|
||||
@@ -33,7 +61,7 @@ async def scroll():
|
||||
cursor = request.args.get("cursor") or None
|
||||
try:
|
||||
limit = int(request.args.get("limit", "50"))
|
||||
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
|
||||
filters, sort = _parse_filters()
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid filter or limit parameter"}), 400
|
||||
|
||||
@@ -41,9 +69,7 @@ async def scroll():
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
page = await svc.scroll(
|
||||
cursor=cursor, limit=limit, tag_ids=tag_ids,
|
||||
post_id=post_id, artist_id=artist_id,
|
||||
media_type=media_type, sort=sort,
|
||||
cursor=cursor, limit=limit, sort=sort, **filters,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
@@ -75,16 +101,13 @@ async def scroll():
|
||||
@gallery_bp.route("/timeline", methods=["GET"])
|
||||
async def timeline():
|
||||
try:
|
||||
tag_ids, post_id, artist_id, media_type, _sort = _parse_filters()
|
||||
filters, _sort = _parse_filters()
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid filter parameter"}), 400
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
buckets = await svc.timeline(
|
||||
tag_ids=tag_ids, post_id=post_id, artist_id=artist_id,
|
||||
media_type=media_type,
|
||||
)
|
||||
buckets = await svc.timeline(**filters)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(
|
||||
@@ -92,21 +115,43 @@ async def timeline():
|
||||
)
|
||||
|
||||
|
||||
@gallery_bp.route("/facets", methods=["GET"])
|
||||
async def facets():
|
||||
try:
|
||||
filters, _sort = _parse_filters()
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid filter parameter"}), 400
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
f = await svc.facets(**filters)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"total": f.total,
|
||||
"platforms": f.platforms,
|
||||
"untagged": f.untagged,
|
||||
"no_artist": f.no_artist,
|
||||
"date_min": f.date_min.isoformat() if f.date_min else None,
|
||||
"date_max": f.date_max.isoformat() if f.date_max else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@gallery_bp.route("/jump", methods=["GET"])
|
||||
async def jump():
|
||||
try:
|
||||
year = int(request.args["year"])
|
||||
month = int(request.args["month"])
|
||||
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
|
||||
filters, sort = _parse_filters()
|
||||
except (KeyError, ValueError):
|
||||
return jsonify({"error": "year and month query params required"}), 400
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
cursor = await svc.jump_cursor(
|
||||
year=year, month=month, tag_ids=tag_ids,
|
||||
post_id=post_id, artist_id=artist_id,
|
||||
media_type=media_type, sort=sort,
|
||||
year=year, month=month, sort=sort, **filters,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@@ -97,6 +97,10 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.maintenance.prune_task_runs",
|
||||
"schedule": 86400.0, # daily
|
||||
},
|
||||
"vacuum-analyze": {
|
||||
"task": "backend.app.tasks.maintenance.vacuum_analyze",
|
||||
"schedule": 604800.0, # weekly — reclaim dead-tuple bloat + refresh stats
|
||||
},
|
||||
"fc3h-backup-db-nightly": {
|
||||
"task": "backend.app.tasks.backup.backup_db_nightly",
|
||||
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
|
||||
|
||||
@@ -18,15 +18,22 @@ import base64
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Select, and_, exists, func, or_, select
|
||||
from sqlalchemy import Select, and_, distinct, exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from ..models import Artist, ImageProvenance, ImageRecord, Post, Tag
|
||||
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
|
||||
from ..models.tag import image_tag
|
||||
|
||||
CURSOR_SEPARATOR = "|"
|
||||
|
||||
# Reserved `platform` filter value selecting images with NO platformed
|
||||
# provenance (filesystem imports). Returned by facets() as a null-valued
|
||||
# bucket; the frontend maps that null back to this sentinel in the URL so the
|
||||
# bucket is selectable. Underscore-wrapped so it can't collide with a real
|
||||
# gallery-dl platform name (patreon/pixiv/...).
|
||||
UNSOURCED_PLATFORM = "__unsourced__"
|
||||
|
||||
|
||||
def encode_cursor(effective_date: datetime, image_id: int) -> str:
|
||||
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
|
||||
@@ -92,6 +99,16 @@ class TimelineBucket:
|
||||
count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GalleryFacets:
|
||||
total: int # images matching the FULL active filter
|
||||
platforms: list[dict] # [{"value": str|None, "count": int}], null = unsourced
|
||||
untagged: int # how many the Untagged flag would isolate
|
||||
no_artist: int # how many the No-artist flag would isolate
|
||||
date_min: datetime | None
|
||||
date_max: datetime | None
|
||||
|
||||
|
||||
def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str:
|
||||
"""Return the URL to fetch a thumbnail.
|
||||
|
||||
@@ -128,15 +145,27 @@ def _require_single_filter(tag_ids, post_id, artist_id) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type):
|
||||
"""Apply the composable gallery filters to a statement already joined
|
||||
to Post via _outer_join_primary_post.
|
||||
def _apply_scope(
|
||||
stmt, *, tag_ids, post_id, artist_id, media_type,
|
||||
platform=None, untagged=False, no_artist=False,
|
||||
date_from=None, date_to=None,
|
||||
):
|
||||
"""Apply the composable gallery filters to a statement.
|
||||
|
||||
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag
|
||||
(AND), which avoids the row-multiplication a multi-join would cause.
|
||||
All clauses are correlated EXISTS / scalar predicates on ImageRecord, so
|
||||
they AND together without row-multiplication and don't require any join to
|
||||
be present on `stmt` (the artist/platform paths alias Post/Source inside
|
||||
their own EXISTS).
|
||||
|
||||
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag.
|
||||
- post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
|
||||
by _require_single_filter).
|
||||
- media_type: 'image' | 'video' narrows by mime prefix.
|
||||
- platform: EXISTS a provenance→source with that platform; the
|
||||
UNSOURCED_PLATFORM sentinel inverts it (NO platformed provenance).
|
||||
- untagged: NOT EXISTS any image_tag row.
|
||||
- no_artist: ImageRecord.artist_id IS NULL.
|
||||
- date_from / date_to: half-open [from, to) bounds on effective_date.
|
||||
"""
|
||||
for tid in tag_ids or []:
|
||||
stmt = stmt.where(
|
||||
@@ -152,9 +181,39 @@ def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type):
|
||||
stmt = stmt.where(ImageRecord.mime.like("image/%"))
|
||||
elif media_type == "video":
|
||||
stmt = stmt.where(ImageRecord.mime.like("video/%"))
|
||||
if platform is not None:
|
||||
stmt = stmt.where(_platform_clause(platform))
|
||||
if untagged:
|
||||
stmt = stmt.where(
|
||||
~exists().where(image_tag.c.image_record_id == ImageRecord.id)
|
||||
)
|
||||
if no_artist:
|
||||
stmt = stmt.where(ImageRecord.artist_id.is_(None))
|
||||
eff = _effective_date_col()
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(eff >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(eff < date_to)
|
||||
return stmt
|
||||
|
||||
|
||||
def _platform_clause(platform):
|
||||
"""Correlated EXISTS on a provenance row whose Source carries `platform`.
|
||||
The UNSOURCED_PLATFORM sentinel inverts to NOT EXISTS(any sourced
|
||||
provenance) — i.e. filesystem-imported content with no platform."""
|
||||
src = aliased(Source)
|
||||
if platform == UNSOURCED_PLATFORM:
|
||||
return ~exists().where(
|
||||
ImageProvenance.image_record_id == ImageRecord.id,
|
||||
ImageProvenance.source_id == src.id,
|
||||
)
|
||||
return exists().where(
|
||||
ImageProvenance.image_record_id == ImageRecord.id,
|
||||
ImageProvenance.source_id == src.id,
|
||||
src.platform == platform,
|
||||
)
|
||||
|
||||
|
||||
def _provenance_clause(post_id, artist_id):
|
||||
"""Correlated EXISTS clause (NOT a join) so an image with multiple
|
||||
matching provenance rows is returned exactly once and the
|
||||
@@ -215,6 +274,11 @@ class GalleryService:
|
||||
artist_id: int | None = None,
|
||||
media_type: str | None = None,
|
||||
sort: str = "newest",
|
||||
platform: str | None = None,
|
||||
untagged: bool = False,
|
||||
no_artist: bool = False,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
) -> GalleryPage:
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
@@ -226,6 +290,8 @@ class GalleryService:
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
|
||||
descending = sort != "oldest"
|
||||
@@ -286,6 +352,11 @@ class GalleryService:
|
||||
post_id: int | None = None,
|
||||
artist_id: int | None = None,
|
||||
media_type: str | None = None,
|
||||
platform: str | None = None,
|
||||
untagged: bool = False,
|
||||
no_artist: bool = False,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
) -> list[TimelineBucket]:
|
||||
eff = _effective_date_col()
|
||||
year_col = func.date_part("year", eff).label("yr")
|
||||
@@ -298,6 +369,8 @@ class GalleryService:
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
@@ -307,6 +380,9 @@ class GalleryService:
|
||||
self, year: int, month: int, tag_ids: list[int] | None = None,
|
||||
post_id: int | None = None, artist_id: int | None = None,
|
||||
media_type: str | None = None, sort: str = "newest",
|
||||
platform: str | None = None, untagged: bool = False,
|
||||
no_artist: bool = False, date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Returns a cursor that, when passed to scroll() with the same sort,
|
||||
positions at the first image of the given year-month. None if the
|
||||
@@ -324,6 +400,8 @@ class GalleryService:
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=post_id,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
descending = sort != "oldest"
|
||||
if descending:
|
||||
@@ -339,6 +417,94 @@ class GalleryService:
|
||||
boundary = record.id + 1 if descending else record.id - 1
|
||||
return encode_cursor(eff_date, boundary)
|
||||
|
||||
async def facets(
|
||||
self, *, tag_ids: list[int] | None = None,
|
||||
post_id: int | None = None, artist_id: int | None = None,
|
||||
media_type: str | None = None, platform: str | None = None,
|
||||
untagged: bool = False, no_artist: bool = False,
|
||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
) -> GalleryFacets:
|
||||
"""Live facet counts scoped to the current filter. Each facet GROUP is
|
||||
computed with all OTHER active filters applied but its OWN selection
|
||||
ignored ("minus-self"), so sibling options stay visible/switchable.
|
||||
No outer join is needed — every clause is a correlated EXISTS or a
|
||||
column predicate on ImageRecord.
|
||||
"""
|
||||
_require_single_filter(tag_ids, post_id, artist_id)
|
||||
common = {
|
||||
"tag_ids": tag_ids, "post_id": post_id,
|
||||
"artist_id": artist_id, "media_type": media_type,
|
||||
}
|
||||
|
||||
# total — the full active filter (the headline result count).
|
||||
total = (await self.session.execute(
|
||||
_apply_scope(
|
||||
select(func.count(ImageRecord.id)), **common,
|
||||
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
)).scalar_one()
|
||||
|
||||
# platforms — scope minus the platform selection. Inner-join
|
||||
# provenance→source and COUNT(DISTINCT image) per platform (a
|
||||
# cross-posted image counts under each of its platforms).
|
||||
plat_scope = {
|
||||
**common, "untagged": untagged, "no_artist": no_artist,
|
||||
"date_from": date_from, "date_to": date_to,
|
||||
}
|
||||
src = aliased(Source)
|
||||
plat_stmt = (
|
||||
select(src.platform, func.count(distinct(ImageRecord.id)))
|
||||
.select_from(ImageRecord)
|
||||
.join(ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id)
|
||||
.join(src, src.id == ImageProvenance.source_id)
|
||||
)
|
||||
plat_stmt = _apply_scope(plat_stmt, **plat_scope).group_by(src.platform)
|
||||
platforms = [
|
||||
{"value": p, "count": c}
|
||||
for p, c in (await self.session.execute(plat_stmt)).all()
|
||||
]
|
||||
# Unsourced (filesystem) bucket — same minus-platform scope.
|
||||
unsourced = (await self.session.execute(
|
||||
_apply_scope(
|
||||
select(func.count(ImageRecord.id)), **plat_scope,
|
||||
platform=UNSOURCED_PLATFORM,
|
||||
)
|
||||
)).scalar_one()
|
||||
if unsourced:
|
||||
platforms.append({"value": None, "count": unsourced})
|
||||
|
||||
# curation flags — each minus its OWN flag.
|
||||
untagged_count = (await self.session.execute(
|
||||
_apply_scope(
|
||||
select(func.count(ImageRecord.id)), **common,
|
||||
platform=platform, no_artist=no_artist,
|
||||
date_from=date_from, date_to=date_to, untagged=True,
|
||||
)
|
||||
)).scalar_one()
|
||||
no_artist_count = (await self.session.execute(
|
||||
_apply_scope(
|
||||
select(func.count(ImageRecord.id)), **common,
|
||||
platform=platform, untagged=untagged,
|
||||
date_from=date_from, date_to=date_to, no_artist=True,
|
||||
)
|
||||
)).scalar_one()
|
||||
|
||||
# date bounds — scope minus the date params (those drive the picker).
|
||||
eff = _effective_date_col()
|
||||
dmin, dmax = (await self.session.execute(
|
||||
_apply_scope(
|
||||
select(func.min(eff), func.max(eff)), **common,
|
||||
platform=platform, untagged=untagged, no_artist=no_artist,
|
||||
)
|
||||
)).one()
|
||||
|
||||
return GalleryFacets(
|
||||
total=total, platforms=platforms,
|
||||
untagged=untagged_count, no_artist=no_artist_count,
|
||||
date_min=dmin, date_max=dmax,
|
||||
)
|
||||
|
||||
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||
record = await self.session.get(ImageRecord, image_id)
|
||||
if record is None:
|
||||
|
||||
@@ -34,3 +34,10 @@ def sync_session_factory():
|
||||
)
|
||||
_SESSIONMAKER = sessionmaker(_ENGINE, expire_on_commit=False)
|
||||
return _SESSIONMAKER
|
||||
|
||||
|
||||
def get_sync_engine():
|
||||
"""The process-wide sync Engine — for raw work that needs a connection
|
||||
directly (e.g. AUTOCOMMIT VACUUM, which can't run inside a transaction)."""
|
||||
sync_session_factory() # ensure _ENGINE is initialized
|
||||
return _ENGINE
|
||||
|
||||
@@ -22,10 +22,27 @@ from ..models import (
|
||||
TaskRun,
|
||||
)
|
||||
from ..utils.phash import compute_phash
|
||||
from ._sync_engine import get_sync_engine
|
||||
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# High-churn tables whose dead-tuple bloat matters: the TABLESAMPLE showcase
|
||||
# reads physical blocks (bloat slows it directly), and the periodic
|
||||
# prune/backfill/recovery tasks generate dead tuples faster than autovacuum
|
||||
# always keeps up with. VACUUM reclaims them; ANALYZE refreshes planner stats.
|
||||
# Allowlist ONLY — names are interpolated into VACUUM, so they must never come
|
||||
# from request input.
|
||||
VACUUM_TABLES = (
|
||||
"image_record",
|
||||
"image_provenance",
|
||||
"post_attachment",
|
||||
"download_event",
|
||||
"task_run",
|
||||
"import_task",
|
||||
"import_batch",
|
||||
)
|
||||
|
||||
STUCK_THRESHOLD_MINUTES = 5
|
||||
# Archive ImportTasks run the per-member pipeline inline for every
|
||||
# member (import_archive_file: soft=30min/hard=35min). The ImportTask
|
||||
@@ -761,3 +778,20 @@ def cleanup_old_download_events() -> int:
|
||||
)
|
||||
session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.vacuum_analyze")
|
||||
def vacuum_analyze() -> dict:
|
||||
"""Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to
|
||||
reclaim dead-tuple bloat and refresh planner statistics. VACUUM cannot run
|
||||
inside a transaction block, so it runs on an AUTOCOMMIT connection.
|
||||
Scheduled weekly; also operator-triggerable from Settings → Maintenance.
|
||||
"""
|
||||
engine = get_sync_engine()
|
||||
done = []
|
||||
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
|
||||
for table in VACUUM_TABLES:
|
||||
conn.exec_driver_sql(f"VACUUM (ANALYZE) {table}")
|
||||
done.append(table)
|
||||
log.info("vacuum_analyze complete: %s", done)
|
||||
return {"vacuumed": done}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
v-for="item in col" :key="item.id"
|
||||
class="fc-masonry__item"
|
||||
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
|
||||
:style="itemStyle(item)"
|
||||
type="button"
|
||||
@click="$emit('open', item.id)"
|
||||
>
|
||||
@@ -66,12 +65,6 @@ function shouldAnimate(item) {
|
||||
return idx !== undefined && idx >= props.animateFromIndex
|
||||
}
|
||||
|
||||
function itemStyle(item) {
|
||||
if (!shouldAnimate(item)) return {}
|
||||
const idx = idxById.value.get(item.id) - props.animateFromIndex
|
||||
return { '--stagger-index': idx }
|
||||
}
|
||||
|
||||
function aspectStyle(item) {
|
||||
const w = Number(item.width)
|
||||
const h = Number(item.height)
|
||||
@@ -117,12 +110,15 @@ useInfiniteScroll(sentinelEl, () => {
|
||||
.fc-masonry__end { text-align: center; padding: 32px 0; }
|
||||
|
||||
/* Cascade entry: each tile flips up out of a backward tilt and settles
|
||||
into place, one at a time — more pronounced than a plain fade so the
|
||||
showcase reads as an "experience" (operator-flagged 2026-05-28). The
|
||||
`both` fill holds the hidden/tilted 0% state until each tile's staggered
|
||||
turn; the cubic-bezier overshoots slightly past flat then settles.
|
||||
Honors prefers-reduced-motion. Tunables: tilt (-28deg), stagger (70ms),
|
||||
duration (0.6s). */
|
||||
into place — more pronounced than a plain fade so the showcase reads as an
|
||||
"experience" (operator-flagged 2026-05-28). The reveal is paced entirely by
|
||||
the store, which pushes one fully-decoded item at a time (showcase.js); each
|
||||
tile therefore animates the instant it mounts, with NO per-index CSS delay —
|
||||
the old `animation-delay: index×70ms` compounded on top of the insert
|
||||
cadence and made the cascade drag and desync as it grew (operator-flagged
|
||||
2026-06-04). The `both` fill holds the hidden/tilted 0% state until mount;
|
||||
the cubic-bezier overshoots slightly past flat then settles. Honors
|
||||
prefers-reduced-motion. Tunables: tilt (-28deg), duration (0.6s). */
|
||||
@keyframes fc-masonry-item-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@@ -138,7 +134,6 @@ useInfiniteScroll(sentinelEl, () => {
|
||||
transform-origin: center top;
|
||||
backface-visibility: hidden;
|
||||
animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both;
|
||||
animation-delay: calc(var(--stagger-index, 0) * 70ms);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-masonry__item--anim {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="fc-facets">
|
||||
<v-progress-linear
|
||||
v-if="store.facetsLoading" indeterminate color="accent"
|
||||
class="fc-facets__bar" height="2"
|
||||
/>
|
||||
|
||||
<div class="fc-facets__group">
|
||||
<span class="fc-facets__label">Platform</span>
|
||||
<div class="fc-facets__chips">
|
||||
<v-chip
|
||||
v-for="p in platformOptions" :key="p.key"
|
||||
size="small" label
|
||||
:variant="p.key === store.filter.platform ? 'flat' : 'tonal'"
|
||||
:color="p.key === store.filter.platform ? 'accent' : undefined"
|
||||
@click="selectPlatform(p.key)"
|
||||
>{{ p.label }}<span class="fc-facets__count">{{ p.count }}</span></v-chip>
|
||||
<span v-if="!platformOptions.length" class="fc-facets__empty">none</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-facets__group">
|
||||
<span class="fc-facets__label">Curation</span>
|
||||
<div class="fc-facets__chips">
|
||||
<v-chip
|
||||
size="small" label
|
||||
:variant="store.filter.untagged ? 'flat' : 'tonal'"
|
||||
:color="store.filter.untagged ? 'accent' : undefined"
|
||||
@click="toggleFlag('untagged')"
|
||||
>Untagged<span class="fc-facets__count">{{ facetCount('untagged') }}</span></v-chip>
|
||||
<v-chip
|
||||
size="small" label
|
||||
:variant="store.filter.no_artist ? 'flat' : 'tonal'"
|
||||
:color="store.filter.no_artist ? 'accent' : undefined"
|
||||
@click="toggleFlag('no_artist')"
|
||||
>No artist<span class="fc-facets__count">{{ facetCount('no_artist') }}</span></v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-facets__group">
|
||||
<span class="fc-facets__label">Date</span>
|
||||
<v-text-field
|
||||
type="date" density="compact" hide-details variant="outlined"
|
||||
:model-value="store.filter.date_from" :min="dateMin" :max="dateMax"
|
||||
class="fc-facets__date"
|
||||
@update:model-value="setDate('date_from', $event)"
|
||||
/>
|
||||
<span class="fc-facets__dash">–</span>
|
||||
<v-text-field
|
||||
type="date" density="compact" hide-details variant="outlined"
|
||||
:model-value="store.filter.date_to" :min="dateMin" :max="dateMax"
|
||||
class="fc-facets__date"
|
||||
@update:model-value="setDate('date_to', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
|
||||
|
||||
// Mirrors backend gallery_service.UNSOURCED_PLATFORM — the sentinel that
|
||||
// selects filesystem-imported content (the null/"no platform" bucket).
|
||||
const UNSOURCED = '__unsourced__'
|
||||
|
||||
const store = useGalleryStore()
|
||||
const router = useRouter()
|
||||
|
||||
const platformOptions = computed(() =>
|
||||
(store.facets?.platforms || []).map((p) => ({
|
||||
key: p.value === null ? UNSOURCED : p.value,
|
||||
label: p.value === null ? 'No platform' : p.value,
|
||||
count: p.count,
|
||||
}))
|
||||
)
|
||||
|
||||
function facetCount(flag) {
|
||||
const v = store.facets?.[flag]
|
||||
return v == null ? '–' : v
|
||||
}
|
||||
|
||||
const dateMin = computed(() => (store.facets?.date_min || '').slice(0, 10) || undefined)
|
||||
const dateMax = computed(() => (store.facets?.date_max || '').slice(0, 10) || undefined)
|
||||
|
||||
// Single write path, shared format with the bar: clone → patch → URL. The
|
||||
// route watcher in GalleryView reloads the store (and our filter-watch below
|
||||
// refetches the facet counts for the new scope).
|
||||
function pushPatch(patch) {
|
||||
const n = cloneFilter(store.filter)
|
||||
Object.assign(n, patch)
|
||||
router.push({ name: 'gallery', query: filterToQuery(n) })
|
||||
}
|
||||
|
||||
function selectPlatform(key) {
|
||||
// Single-select, click-active-to-clear (the platform param is one value).
|
||||
pushPatch({ platform: store.filter.platform === key ? null : key })
|
||||
}
|
||||
function toggleFlag(name) {
|
||||
pushPatch({ [name]: !store.filter[name] })
|
||||
}
|
||||
function setDate(field, val) {
|
||||
pushPatch({ [field]: val || null })
|
||||
}
|
||||
|
||||
// Fetch when the panel opens; refetch (debounced) whenever the active filter
|
||||
// changes so the live counts track the current scope. Never fires on scroll —
|
||||
// the panel is the only caller of loadFacets.
|
||||
onMounted(() => store.loadFacets())
|
||||
|
||||
let debounce = null
|
||||
watch(() => store.filter, () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => store.loadFacets(), 250)
|
||||
})
|
||||
onBeforeUnmount(() => { if (debounce) clearTimeout(debounce) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-facets {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
.fc-facets__bar { position: absolute; top: 0; left: 0; right: 0; }
|
||||
.fc-facets__group { display: flex; align-items: center; gap: 8px; }
|
||||
.fc-facets__label {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-facets__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
/* The count rides inside the chip as a dimmer trailing number. */
|
||||
.fc-facets__count {
|
||||
margin-left: 6px;
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
.fc-facets__empty { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem; }
|
||||
.fc-facets__date { max-width: 160px; }
|
||||
.fc-facets__dash { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="fc-filterbar">
|
||||
<div class="fc-filterbar-wrap">
|
||||
<div class="fc-filterbar">
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="searchItems"
|
||||
@@ -59,19 +60,31 @@
|
||||
@update:model-value="setSort"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
:color="refineOpen ? 'accent' : undefined"
|
||||
:variant="refineOpen || hasRefineFilters ? 'tonal' : 'text'"
|
||||
size="small"
|
||||
:append-icon="refineOpen ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="toggleRefine"
|
||||
>Refine{{ refineCount ? ` (${refineCount})` : '' }}</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="hasActiveFilters" variant="text" size="small"
|
||||
prepend-icon="mdi-close" @click="clearAll"
|
||||
>Clear</v-btn>
|
||||
</div>
|
||||
|
||||
<GalleryFacetPanel v-if="refineOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useGalleryStore } from '../../stores/gallery.js'
|
||||
import { cloneFilter, filterToQuery, useGalleryStore } from '../../stores/gallery.js'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import GalleryFacetPanel from './GalleryFacetPanel.vue'
|
||||
|
||||
const store = useGalleryStore()
|
||||
const tagStore = useTagStore()
|
||||
@@ -88,13 +101,34 @@ const searchItems = ref([])
|
||||
const searchLoading = ref(false)
|
||||
let debounce = null
|
||||
|
||||
// The faceted-refine sub-filters (platform / curation flags / date range).
|
||||
const refineCount = computed(() => {
|
||||
const f = store.filter
|
||||
return (f.platform ? 1 : 0) + (f.untagged ? 1 : 0) + (f.no_artist ? 1 : 0)
|
||||
+ (f.date_from ? 1 : 0) + (f.date_to ? 1 : 0)
|
||||
})
|
||||
const hasRefineFilters = computed(() => refineCount.value > 0)
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
store.filter.tag_ids.length > 0 ||
|
||||
store.filter.artist_id != null ||
|
||||
store.filter.media_type != null ||
|
||||
store.filter.sort !== 'newest'
|
||||
store.filter.sort !== 'newest' ||
|
||||
hasRefineFilters.value
|
||||
)
|
||||
|
||||
// Auto-open the panel when refine filters are present in the URL (deep-link /
|
||||
// back button). The parent applies the query in its onMounted — after this
|
||||
// child has set up — so watch for the transition rather than reading the
|
||||
// initial (still-default) filter state.
|
||||
const refineOpen = ref(false)
|
||||
watch(hasRefineFilters, (v) => { if (v) refineOpen.value = true })
|
||||
function toggleRefine() {
|
||||
// The panel fetches facets itself on mount (and refetches on filter change);
|
||||
// opening it is enough.
|
||||
refineOpen.value = !refineOpen.value
|
||||
}
|
||||
|
||||
function iconFor(raw) {
|
||||
if (raw.kind === 'artist') return 'mdi-account'
|
||||
return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant',
|
||||
@@ -162,39 +196,45 @@ function clearAll() { router.push({ name: 'gallery', query: {} }) }
|
||||
|
||||
// Single write path: clone the current filter, mutate, serialize to the URL.
|
||||
// The route watcher in GalleryView applies it to the store and reloads.
|
||||
// cloneFilter/filterToQuery are shared with GalleryFacetPanel so the refine
|
||||
// sub-filters survive a bar push and vice versa.
|
||||
function pushFilter(mutate) {
|
||||
const f = store.filter
|
||||
const n = {
|
||||
tag_ids: [...f.tag_ids],
|
||||
artist_id: f.artist_id,
|
||||
media_type: f.media_type,
|
||||
sort: f.sort,
|
||||
}
|
||||
const n = cloneFilter(store.filter)
|
||||
mutate(n)
|
||||
const q = {}
|
||||
if (n.tag_ids.length) q.tag_id = n.tag_ids.join(',')
|
||||
if (n.artist_id) q.artist_id = String(n.artist_id)
|
||||
if (n.media_type) q.media = n.media_type
|
||||
if (n.sort && n.sort !== 'newest') q.sort = n.sort
|
||||
router.push({ name: 'gallery', query: q })
|
||||
router.push({ name: 'gallery', query: filterToQuery(n) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Pinned under the 64px TopNav, matching the app's sticky v-tabs chrome
|
||||
(SettingsView / ArtistHeader / SubscriptionsView). */
|
||||
.fc-filterbar {
|
||||
/* The whole chrome (bar row + expandable refine panel) is one sticky,
|
||||
hazey block pinned directly under the 64px TopNav and continuous with it. */
|
||||
.fc-filterbar-wrap {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 4;
|
||||
z-index: 5;
|
||||
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
|
||||
so the bar sits flush at 64px even at scroll 0 — without this it detaches
|
||||
and a gap shows through when scrolled to the top. */
|
||||
margin-top: -8px;
|
||||
margin-bottom: 12px;
|
||||
/* Same hazey obsidian (#14171A = 20,23,26) + blur as the TopNav so the two
|
||||
read as one piece of chrome; content scrolls under both. */
|
||||
background: rgba(20, 23, 26, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
.fc-filterbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 4px;
|
||||
margin-bottom: 12px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
||||
padding: 10px 12px;
|
||||
}
|
||||
/* The inputs/toggles float on the haze: still translucent, but a touch more
|
||||
opaque than the bar/nav so the controls stay legible. */
|
||||
.fc-filterbar-wrap :deep(.v-field),
|
||||
.fc-filterbar-wrap :deep(.v-btn-group) {
|
||||
background-color: rgba(20, 23, 26, 0.72);
|
||||
}
|
||||
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import { arrowNavAllowed } from '../../utils/textEntry.js'
|
||||
import ImageCanvas from './ImageCanvas.vue'
|
||||
import VideoCanvas from './VideoCanvas.vue'
|
||||
import TagPanel from './TagPanel.vue'
|
||||
@@ -105,11 +106,13 @@ function onKeyDown(ev) {
|
||||
ev.preventDefault()
|
||||
emit('close')
|
||||
} else if (ev.key === 'ArrowLeft') {
|
||||
if (isTextEntry(ev.target)) return
|
||||
// Navigate unless the caret is in a non-empty text field (then let it move
|
||||
// through the text). An empty tag-entry field still navigates.
|
||||
if (!arrowNavAllowed(ev.target)) return
|
||||
ev.preventDefault()
|
||||
modal.goPrev()
|
||||
} else if (ev.key === 'ArrowRight') {
|
||||
if (isTextEntry(ev.target)) return
|
||||
if (!arrowNavAllowed(ev.target)) return
|
||||
ev.preventDefault()
|
||||
modal.goNext()
|
||||
}
|
||||
@@ -135,17 +138,14 @@ watch(() => modal.currentImageId, async () => {
|
||||
function nextFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(resolve))
|
||||
}
|
||||
|
||||
function isTextEntry(el) {
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-viewer {
|
||||
position: fixed; inset: 0; z-index: 2000;
|
||||
/* Single source of truth for the metadata side-panel width — the next
|
||||
arrow offsets off it so it never overlaps the panel. */
|
||||
--fc-side-w: 320px;
|
||||
/* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
|
||||
mid-opacity + blur so the page behind shows through faintly. */
|
||||
background: rgba(20, 23, 26, 0.65);
|
||||
@@ -174,7 +174,12 @@ function isTextEntry(el) {
|
||||
position: absolute; top: 72px; right: 16px; z-index: 3;
|
||||
}
|
||||
.fc-viewer__nav--prev { left: 16px; transform: translateY(-50%); }
|
||||
.fc-viewer__nav--next { right: 16px; transform: translateY(-50%); }
|
||||
/* Sit just inside the image area, clear of the metadata side panel —
|
||||
not floating over it (operator-flagged 2026-06-04). */
|
||||
.fc-viewer__nav--next {
|
||||
right: calc(var(--fc-side-w) + 16px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.fc-viewer__body {
|
||||
flex: 1; display: flex; min-height: 0;
|
||||
}
|
||||
@@ -187,7 +192,7 @@ function isTextEntry(el) {
|
||||
min-width: 0; min-height: 0;
|
||||
}
|
||||
.fc-viewer__side {
|
||||
width: 320px; flex-shrink: 0;
|
||||
width: var(--fc-side-w); flex-shrink: 0;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
||||
overflow-y: auto;
|
||||
@@ -195,6 +200,8 @@ function isTextEntry(el) {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fc-viewer__body { flex-direction: column; }
|
||||
/* Side panel drops below the image — the next arrow uses the full width. */
|
||||
.fc-viewer__nav--next { right: 16px; }
|
||||
.fc-viewer__side {
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Database maintenance</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">
|
||||
VACUUM (ANALYZE) reclaims dead-tuple bloat — which slows the random
|
||||
showcase, since it samples physical blocks — and refreshes the query
|
||||
planner's statistics. Runs automatically each week; trigger a pass
|
||||
here after a large import or cleanup.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-icon start>mdi-database-cog</v-icon> Run VACUUM ANALYZE now
|
||||
</v-btn>
|
||||
<span v-if="queued" class="ml-3 text-caption text-success">Queued ✓</span>
|
||||
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
|
||||
|
||||
<v-table
|
||||
v-if="store.tables.length" density="compact" class="mt-4 fc-dbm__table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th class="text-right">Live rows</th>
|
||||
<th class="text-right">Dead</th>
|
||||
<th class="text-right">Dead %</th>
|
||||
<th>Last vacuum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in store.tables" :key="t.table">
|
||||
<td>{{ t.table }}</td>
|
||||
<td class="text-right">{{ t.live.toLocaleString() }}</td>
|
||||
<td class="text-right">{{ t.dead.toLocaleString() }}</td>
|
||||
<td
|
||||
class="text-right"
|
||||
:class="{ 'text-warning': t.dead_pct >= 20 }"
|
||||
>{{ t.dead_pct }}%</td>
|
||||
<td class="text-caption">{{ fmt(t.last_vacuum || t.last_autovacuum) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<p v-else class="text-caption mt-3" style="opacity: 0.6;">
|
||||
No table statistics yet.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { useDbMaintenanceStore } from '../../stores/dbMaintenance.js'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
|
||||
const store = useDbMaintenanceStore()
|
||||
const busy = ref(false)
|
||||
const queued = ref(false)
|
||||
|
||||
onMounted(() => store.loadStats())
|
||||
|
||||
async function run () {
|
||||
busy.value = true
|
||||
queued.value = false
|
||||
try {
|
||||
await store.runVacuum()
|
||||
queued.value = true
|
||||
// pg_stat updates after the vacuum lands — refresh shortly after.
|
||||
setTimeout(() => store.loadStats(), 5000)
|
||||
} catch (e) {
|
||||
toast({ text: e.message, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmt (iso) {
|
||||
return iso ? new Date(iso).toLocaleString() : '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dbm__table { background: transparent; }
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@
|
||||
<MLThresholdSliders class="mt-4" />
|
||||
<AllowlistTable class="mt-4" />
|
||||
<AliasTable class="mt-4" />
|
||||
<DbMaintenanceCard class="mt-6" />
|
||||
<BackupCard class="mt-6" />
|
||||
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
|
||||
operates on the existing library which fits the Cleanup-tab
|
||||
@@ -31,6 +32,7 @@ import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import AllowlistTable from './AllowlistTable.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import BackupCard from './BackupCard.vue'
|
||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
|
||||
export const useDbMaintenanceStore = defineStore('dbMaintenance', () => {
|
||||
const api = useApi()
|
||||
const tables = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true
|
||||
try {
|
||||
const body = await api.get('/api/admin/maintenance/db-stats')
|
||||
tables.value = body.tables || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runVacuum() {
|
||||
return await api.post('/api/admin/maintenance/vacuum')
|
||||
}
|
||||
|
||||
return { tables, loading, loadStats, runVacuum }
|
||||
})
|
||||
@@ -24,7 +24,16 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
const filter = ref({
|
||||
tag_ids: [], artist_id: null, media_type: null,
|
||||
sort: 'newest', post_id: null,
|
||||
// Phase-2 faceted refine params.
|
||||
platform: null, untagged: false, no_artist: false,
|
||||
date_from: null, date_to: null,
|
||||
})
|
||||
|
||||
// Live facet counts for the refine panel; fetched on-demand (panel open +
|
||||
// filter change), never on plain scroll. Null until first load.
|
||||
const facets = ref(null)
|
||||
const facetsLoading = ref(false)
|
||||
const facetsInflight = useInflightToken()
|
||||
// Display names for the active filter chips — resolved by id on deep-link
|
||||
// and pre-noted by the filter bar when a user picks from autocomplete.
|
||||
const tagLabels = ref({}) // tagId -> name
|
||||
@@ -100,9 +109,31 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
if (filter.value.artist_id) p.artist_id = filter.value.artist_id
|
||||
if (filter.value.media_type) p.media = filter.value.media_type
|
||||
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
|
||||
if (filter.value.platform) p.platform = filter.value.platform
|
||||
if (filter.value.untagged) p.untagged = '1'
|
||||
if (filter.value.no_artist) p.no_artist = '1'
|
||||
if (filter.value.date_from) p.date_from = filter.value.date_from
|
||||
if (filter.value.date_to) p.date_to = filter.value.date_to
|
||||
return p
|
||||
}
|
||||
|
||||
// Live facet counts, scoped to the current filter (panel-gated — callers
|
||||
// are the refine panel only). Single-flighted so rapid toggles don't let a
|
||||
// stale response clobber a newer one.
|
||||
async function loadFacets() {
|
||||
facetsLoading.value = true
|
||||
const t = facetsInflight.claim()
|
||||
try {
|
||||
const body = await api.get('/api/gallery/facets', { params: activeFilterParam() })
|
||||
if (!t.isCurrent()) return
|
||||
facets.value = body
|
||||
} catch (e) {
|
||||
if (t.isCurrent()) error.value = e.message
|
||||
} finally {
|
||||
if (t.isCurrent()) facetsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// URL is the source of truth for filters. GalleryView calls this on mount
|
||||
// and on every route-query change; the filter bar mutates the URL
|
||||
// (router.push) rather than the store directly, so deep-links, the back
|
||||
@@ -114,12 +145,22 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
|
||||
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
|
||||
post_id: _toId(q.post_id),
|
||||
platform: q.platform || null,
|
||||
untagged: _truthy(q.untagged),
|
||||
no_artist: _truthy(q.no_artist),
|
||||
date_from: _parseDate(q.date_from),
|
||||
date_to: _parseDate(q.date_to),
|
||||
}
|
||||
await loadInitial()
|
||||
await loadTimeline()
|
||||
_resolveLabels()
|
||||
}
|
||||
|
||||
function _truthy(v) { return v === '1' || v === 'true' || v === true }
|
||||
function _parseDate(v) {
|
||||
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null
|
||||
}
|
||||
|
||||
function _toId(v) {
|
||||
const n = Number(v)
|
||||
return Number.isInteger(n) && n > 0 ? n : null
|
||||
@@ -158,11 +199,37 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
return {
|
||||
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo,
|
||||
facets, facetsLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets,
|
||||
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
|
||||
}
|
||||
})
|
||||
|
||||
// Shared by GalleryFilterBar and GalleryFacetPanel so both write the URL in
|
||||
// one format. post_id is intentionally absent — the bar/panel are hidden in
|
||||
// the exclusive post-detail view.
|
||||
export function cloneFilter(f) {
|
||||
return {
|
||||
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
|
||||
sort: f.sort, platform: f.platform, untagged: f.untagged,
|
||||
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
|
||||
}
|
||||
}
|
||||
|
||||
export function filterToQuery(f) {
|
||||
const q = {}
|
||||
if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',')
|
||||
if (f.artist_id) q.artist_id = String(f.artist_id)
|
||||
if (f.media_type) q.media = f.media_type
|
||||
if (f.sort && f.sort !== 'newest') q.sort = f.sort
|
||||
if (f.platform) q.platform = f.platform
|
||||
if (f.untagged) q.untagged = '1'
|
||||
if (f.no_artist) q.no_artist = '1'
|
||||
if (f.date_from) q.date_from = f.date_from
|
||||
if (f.date_to) q.date_to = f.date_to
|
||||
return q
|
||||
}
|
||||
|
||||
function mergeGroups(existing, incoming) {
|
||||
// Merge sequential groups with the same (year, month) instead of duplicating.
|
||||
const merged = [...existing]
|
||||
|
||||
+123
-89
@@ -1,37 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { preloadImage } from '../utils/preloadImage.js'
|
||||
|
||||
// Operator-flagged 2026-05-30 (round 3): the all-parallel fetch was fast
|
||||
// but risked later chunks arriving first — undesirable even when each
|
||||
// chunk is a random sample. Switched to a PIPELINE: only one fetch in
|
||||
// flight at any moment, but the next fetch kicks off as soon as the
|
||||
// previous one resolves (NOT after its trickle finishes). The next RTT
|
||||
// overlaps with the current batch's trickle, hiding the per-batch
|
||||
// round-trip behind the visible animation cadence. Responses arrive in
|
||||
// fire-order, so no out-of-order rendering surprises.
|
||||
//
|
||||
// Smaller PAGE (3 vs 5) → first chunk's items appear sooner: a chunk of
|
||||
// 3 trickles in 240 ms, well within one RTT, so by the time chunk 2 is
|
||||
// in-hand the trickle is just finishing. Total wall-clock is roughly
|
||||
// RTT + N × max(trickle_time, RTT); APPEND_DELAY_MS keeps the visible
|
||||
// cadence smooth throughout.
|
||||
// Buffered producer/consumer so the cascade cadence is decoupled from fetch
|
||||
// latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch
|
||||
// right after its fetch and bet the next round-trip would finish inside the
|
||||
// ~240ms trickle window; when a fetch ran long (TABLESAMPLE hits random,
|
||||
// sometimes-cold blocks; RTT jitter) the animation starved and the view
|
||||
// "burped" out a clump of images. Now:
|
||||
// - PRODUCER (_fill): races ahead fetching batches into `queue` up to a
|
||||
// target depth, refilling whenever the queue dips below BUFFER_MIN. It
|
||||
// also kicks off the image PRELOAD for each queued item so decoding
|
||||
// pipelines ahead of the reveal.
|
||||
// - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully
|
||||
// decoded, then reveals it — at most one per CADENCE_MS. Because the
|
||||
// producer preloads ahead, the decode-wait is usually already satisfied,
|
||||
// so the reveal stays evenly paced without idling the network.
|
||||
// The decode-gate is what makes the showcase's signature cascade land each
|
||||
// tile fully-loaded (the flip-in animates a real image, never a gray
|
||||
// placeholder); the single-item reveal keeps it strictly one-at-a-time
|
||||
// (operator-flagged 2026-06-04). The very first image still waits on the first
|
||||
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
|
||||
// everything after it is buffer-smoothed.
|
||||
const PAGE = 3
|
||||
const INITIAL_BATCHES = 20
|
||||
const APPEND_DELAY_MS = 80 // ≈ the MasonryGrid stagger animation (70 ms)
|
||||
// Operator-flagged 2026-06-01: scrolling the showcase eventually hit a
|
||||
// premature "End." because /api/showcase returns a *random sample* and
|
||||
// after enough scrolling the `seen` Set accumulated enough to fully
|
||||
// collide with a 3-item batch. The showcase is supposed to be endless;
|
||||
// only a genuinely empty API response (library has zero images) should
|
||||
// mark it exhausted. Retry up to FETCH_RETRY_CAP times on all-dupe
|
||||
// batches; only flip `exhausted` when the API returns 0 items OR every
|
||||
// retry came back dupe-only (graceful fallback for tiny libraries
|
||||
// where retries will keep returning the same handful of items).
|
||||
const CADENCE_MS = 80 // floor between fully-loaded reveals
|
||||
const PRIME = 6 // items buffered before the drain starts
|
||||
const BUFFER_TARGET = 30 // producer tops the queue up to this
|
||||
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
|
||||
const INITIAL_COUNT = 60 // cascade length on load / shuffle
|
||||
const SCROLL_COUNT = 15 // cascade length per infinite-scroll demand
|
||||
// Showcase is endless by design (random sample); an unlucky all-duplicate
|
||||
// batch must be retried — only a genuinely empty API response is exhaustion.
|
||||
const FETCH_RETRY_CAP = 8
|
||||
|
||||
|
||||
function _sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
function _sleep(ms) { return new Promise((r) => setTimeout(r, ms)) }
|
||||
|
||||
|
||||
export const useShowcaseStore = defineStore('showcase', () => {
|
||||
@@ -42,95 +46,125 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
||||
const exhausted = ref(false)
|
||||
const seen = new Set()
|
||||
|
||||
// Sequence token: every call to loadInitial bumps this. _trickleAppend
|
||||
// bails between items if its captured seq is no longer current — guards
|
||||
// against a fast shuffle / mount-then-shuffle from interleaving two
|
||||
// trickles into the same images.value.
|
||||
// Internal buffer (not reactive — the consumer is what feeds the UI via
|
||||
// images.value).
|
||||
let queue = []
|
||||
// id -> Promise that settles when the thumbnail is paint-ready. Started by
|
||||
// the producer so decoding runs ahead of the reveal; awaited by the consumer
|
||||
// so no tile is shown before its image is loaded.
|
||||
let _preloads = new Map()
|
||||
// Sequence token: shuffle / re-mount bumps it so in-flight producers and
|
||||
// the drain bail instead of interleaving two runs into one images list.
|
||||
let _seq = 0
|
||||
let _filling = false
|
||||
let _draining = false
|
||||
|
||||
async function _trickleAppend(items, mySeq) {
|
||||
for (const item of items) {
|
||||
if (mySeq !== _seq) return
|
||||
if (seen.has(item.id)) continue
|
||||
seen.add(item.id)
|
||||
images.value.push(item)
|
||||
await _sleep(APPEND_DELAY_MS)
|
||||
}
|
||||
function _preload(item) {
|
||||
if (!_preloads.has(item.id)) _preloads.set(item.id, preloadImage(item.thumbnail_url))
|
||||
return _preloads.get(item.id)
|
||||
}
|
||||
|
||||
// Single batch — used by infinite-scroll appends. Trickles its items
|
||||
// in for the same one-at-a-time cadence as the initial load. Retries
|
||||
// up to FETCH_RETRY_CAP times when the API's random sample comes back
|
||||
// all-duplicates (the showcase is endless by design; only a genuinely
|
||||
// empty API response should mark it exhausted, not an unlucky sample).
|
||||
async function fetchPage() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
// One batch, retried while the random sample comes back all-duplicates.
|
||||
// Returns the fresh items, or null when the API is genuinely empty.
|
||||
async function _fetchFresh(mySeq) {
|
||||
for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) {
|
||||
if (mySeq !== _seq) return []
|
||||
const body = await api
|
||||
.get('/api/showcase', { params: { limit: PAGE } })
|
||||
.catch((e) => {
|
||||
error.value = error.value || (e.message || String(e))
|
||||
return null
|
||||
})
|
||||
if (mySeq !== _seq) return []
|
||||
const items = (body && body.images) || []
|
||||
if (items.length === 0) return null
|
||||
const fresh = items.filter((i) => !seen.has(i.id))
|
||||
fresh.forEach((f) => seen.add(f.id))
|
||||
if (fresh.length) return fresh
|
||||
}
|
||||
return null // retry cap hit → tiny library, treat as exhausted
|
||||
}
|
||||
|
||||
// Producer: top the buffer up to `target`. Single-flight.
|
||||
async function _fill(mySeq, target) {
|
||||
if (_filling) return
|
||||
_filling = true
|
||||
try {
|
||||
for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) {
|
||||
const body = await api.get('/api/showcase', { params: { limit: PAGE } })
|
||||
const items = body.images || []
|
||||
// API genuinely empty → library is empty / endpoint exhausted.
|
||||
if (items.length === 0) { exhausted.value = true; return }
|
||||
const fresh = items.filter(i => !seen.has(i.id))
|
||||
if (fresh.length > 0) {
|
||||
await _trickleAppend(fresh, _seq)
|
||||
return
|
||||
}
|
||||
// All-dupes batch — keep trying. Showcase is endless by intent.
|
||||
while (mySeq === _seq && !exhausted.value && queue.length < target) {
|
||||
const batch = await _fetchFresh(mySeq)
|
||||
if (mySeq !== _seq) return
|
||||
if (batch === null) { exhausted.value = true; return }
|
||||
queue.push(...batch)
|
||||
batch.forEach(_preload) // pipeline decoding ahead of the reveal
|
||||
}
|
||||
// Retry cap hit with zero fresh items: library is probably much
|
||||
// smaller than the running `seen` set, fall back to exhausted so
|
||||
// the UI stops trying. Operator can shuffle to reset `seen`.
|
||||
exhausted.value = true
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
_filling = false
|
||||
}
|
||||
}
|
||||
|
||||
function _fetchOne() {
|
||||
return api.get('/api/showcase', { params: { limit: PAGE } }).catch(e => {
|
||||
error.value = error.value || (e.message || String(e))
|
||||
return null
|
||||
})
|
||||
// Consumer: show `count` items at a fixed cadence, topping up the buffer as
|
||||
// it drains. Single-flight so the initial cascade and scroll appends can't
|
||||
// interleave.
|
||||
async function _drain(mySeq, count) {
|
||||
if (_draining) return
|
||||
_draining = true
|
||||
loading.value = true
|
||||
try {
|
||||
for (let shown = 0; shown < count && mySeq === _seq; shown++) {
|
||||
if (!exhausted.value && queue.length < BUFFER_MIN) {
|
||||
_fill(mySeq, BUFFER_TARGET) // topup, no await
|
||||
}
|
||||
let guard = 0
|
||||
while (queue.length === 0 && !exhausted.value && mySeq === _seq) {
|
||||
await _sleep(20)
|
||||
if (++guard > 500) break // 10s starvation safety net
|
||||
}
|
||||
if (mySeq !== _seq) return
|
||||
if (queue.length === 0) return // exhausted + empty
|
||||
const item = queue.shift()
|
||||
await _preload(item) // reveal only once the thumbnail is fully decoded
|
||||
if (mySeq !== _seq) return
|
||||
images.value.push(item)
|
||||
await _sleep(CADENCE_MS)
|
||||
}
|
||||
} finally {
|
||||
if (mySeq === _seq) {
|
||||
_draining = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state and pipeline INITIAL_BATCHES fetches: only one in flight
|
||||
// at a time, but kick off the next one as soon as the previous resolves
|
||||
// (NOT after its trickle finishes), so the next RTT runs concurrently
|
||||
// with the current batch's trickle. Responses arrive in fire-order, so
|
||||
// items always render in the order they were fetched — no out-of-order
|
||||
// surprises from parallel races.
|
||||
async function loadInitial() {
|
||||
_seq += 1
|
||||
const mySeq = _seq
|
||||
images.value = []
|
||||
queue = []
|
||||
_preloads = new Map()
|
||||
seen.clear()
|
||||
exhausted.value = false
|
||||
error.value = null
|
||||
_filling = false
|
||||
_draining = false
|
||||
loading.value = true
|
||||
try {
|
||||
let nextFetch = _fetchOne()
|
||||
for (let i = 0; i < INITIAL_BATCHES; i++) {
|
||||
if (mySeq !== _seq) return
|
||||
const body = await nextFetch
|
||||
// Fire the NEXT fetch immediately so its RTT overlaps the trickle.
|
||||
if (i + 1 < INITIAL_BATCHES) nextFetch = _fetchOne()
|
||||
if (!body || !body.images || body.images.length === 0) {
|
||||
exhausted.value = true
|
||||
break
|
||||
}
|
||||
await _trickleAppend(body.images, mySeq)
|
||||
}
|
||||
if (mySeq === _seq && images.value.length === 0) exhausted.value = true
|
||||
// Prime a small buffer before the cascade starts so it doesn't starve
|
||||
// at the front; the drain's own topup grows it to BUFFER_TARGET.
|
||||
await _fill(mySeq, PRIME)
|
||||
if (mySeq !== _seq) return
|
||||
if (queue.length === 0 && exhausted.value) return // empty library
|
||||
await _drain(mySeq, INITIAL_COUNT)
|
||||
} finally {
|
||||
if (mySeq === _seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage() {
|
||||
// Infinite-scroll demand — append another cascade of SCROLL_COUNT.
|
||||
if (_draining || exhausted.value) return
|
||||
await _drain(_seq, SCROLL_COUNT)
|
||||
}
|
||||
|
||||
async function shuffle() {
|
||||
await loadInitial()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Resolve once the image at `url` is fully loaded AND decoded (paint-ready),
|
||||
// or after `timeoutMs` as a safety net so a broken/slow image never stalls a
|
||||
// cascade. Never rejects — the caller only cares that it's safe to reveal.
|
||||
//
|
||||
// Used by the showcase cascade to gate each tile's entry animation on the
|
||||
// thumbnail actually being ready, so the flip-in plays on a real image rather
|
||||
// than on a gray placeholder (operator-flagged 2026-06-04).
|
||||
export function preloadImage (url, timeoutMs = 4000) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false
|
||||
const finish = () => { if (!done) { done = true; resolve() } }
|
||||
const img = new Image()
|
||||
img.onload = finish
|
||||
img.onerror = finish
|
||||
img.src = url
|
||||
// decode() resolves at paint-ready (a beat after onload); prefer it when
|
||||
// available, but onload/onerror/timeout all still settle the promise.
|
||||
if (typeof img.decode === 'function') img.decode().then(finish).catch(() => {})
|
||||
setTimeout(finish, timeoutMs)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Helpers for deciding whether a keyboard shortcut should fire or yield to a
|
||||
// focused text field.
|
||||
|
||||
export function isTextEntry (el) {
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
||||
}
|
||||
|
||||
export function hasText (el) {
|
||||
if (!el) return false
|
||||
if (el.isContentEditable) return (el.textContent || '').length > 0
|
||||
return (el.value || '').length > 0
|
||||
}
|
||||
|
||||
// Prev/next arrow navigation should fire UNLESS focus is in a text entry that
|
||||
// already has content — in that case the arrows belong to the caret so it can
|
||||
// move through the text. An empty (or non-text) target still navigates.
|
||||
export function arrowNavAllowed (target) {
|
||||
return !(isTextEntry(target) && hasText(target))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
import GalleryFacetPanel from '../../src/components/gallery/GalleryFacetPanel.vue'
|
||||
import { useGalleryStore } from '../../src/stores/gallery.js'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
const FACETS = {
|
||||
total: 5,
|
||||
platforms: [
|
||||
{ value: 'patreon', count: 3 },
|
||||
{ value: null, count: 2 }, // unsourced → "No platform" bucket
|
||||
],
|
||||
untagged: 4,
|
||||
no_artist: 1,
|
||||
date_min: '2020-01-01T00:00:00+00:00',
|
||||
date_max: '2026-06-01T00:00:00+00:00',
|
||||
}
|
||||
|
||||
function stubFetchOk(body) {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true, status: 200, statusText: 'OK',
|
||||
text: async () => JSON.stringify(body),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('GalleryFacetPanel', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('renders platform options with counts and the curation flags', () => {
|
||||
const pinia = freshPinia()
|
||||
stubFetchOk(FACETS) // covers the onMounted loadFacets() call
|
||||
const s = useGalleryStore()
|
||||
s.facets = FACETS // seed so counts render synchronously
|
||||
const w = mountComponent(GalleryFacetPanel, { pinia })
|
||||
const t = w.text()
|
||||
expect(t).toContain('patreon')
|
||||
expect(t).toContain('3')
|
||||
expect(t).toContain('No platform')
|
||||
expect(t).toContain('Untagged')
|
||||
expect(t).toContain('4')
|
||||
expect(t).toContain('No artist')
|
||||
})
|
||||
|
||||
it('shows an en-dash placeholder for a flag count before facets load', () => {
|
||||
const pinia = freshPinia()
|
||||
stubFetchOk(FACETS)
|
||||
useGalleryStore().facets = null // not loaded yet
|
||||
const w = mountComponent(GalleryFacetPanel, { pinia })
|
||||
expect(w.text()).toContain('Untagged')
|
||||
expect(w.text()).toContain('–')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useDbMaintenanceStore } from '../src/stores/dbMaintenance.js'
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
const { status, body } = handler(url, init)
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: String(status),
|
||||
text: async () => (body == null ? '' : JSON.stringify(body)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('dbMaintenance store', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('loadStats populates the bloat table', async () => {
|
||||
const s = useDbMaintenanceStore()
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { tables: [{ table: 'image_record', live: 5, dead: 1, dead_pct: 16.7, last_vacuum: null }] },
|
||||
}))
|
||||
await s.loadStats()
|
||||
expect(s.tables).toHaveLength(1)
|
||||
expect(s.tables[0].table).toBe('image_record')
|
||||
})
|
||||
|
||||
it('runVacuum POSTs the trigger endpoint', async () => {
|
||||
const s = useDbMaintenanceStore()
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
return { status: 202, body: { status: 'queued' } }
|
||||
})
|
||||
await s.runVacuum()
|
||||
const c = calls.at(-1)
|
||||
expect(c.url).toContain('/api/admin/maintenance/vacuum')
|
||||
expect(c.init.method).toBe('POST')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useGalleryStore } from '../src/stores/gallery.js'
|
||||
import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
@@ -73,6 +73,55 @@ describe('gallery store: composable filter', () => {
|
||||
expect(s.artistLabel).toBe('Kubo')
|
||||
})
|
||||
|
||||
it('parses faceted refine params and loadMore forwards them', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
|
||||
await s.applyFilterFromQuery({
|
||||
platform: 'pixiv', untagged: '1', no_artist: '1',
|
||||
date_from: '2024-01-01', date_to: '2024-12-31',
|
||||
})
|
||||
expect(s.filter.platform).toBe('pixiv')
|
||||
expect(s.filter.untagged).toBe(true)
|
||||
expect(s.filter.no_artist).toBe(true)
|
||||
expect(s.filter.date_from).toBe('2024-01-01')
|
||||
expect(s.filter.date_to).toBe('2024-12-31')
|
||||
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
|
||||
expect(scroll).toContain('platform=pixiv')
|
||||
expect(scroll).toContain('untagged=1')
|
||||
expect(scroll).toContain('no_artist=1')
|
||||
expect(scroll).toContain('date_from=2024-01-01')
|
||||
expect(scroll).toContain('date_to=2024-12-31')
|
||||
})
|
||||
|
||||
it('drops a malformed date in the query', async () => {
|
||||
const s = useGalleryStore()
|
||||
stubFetch(() => ({ status: 200, body: EMPTY }))
|
||||
await s.applyFilterFromQuery({ date_from: 'garbage' })
|
||||
expect(s.filter.date_from).toBe(null)
|
||||
})
|
||||
|
||||
it('loadFacets fetches counts scoped to the active filter', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
const FACETS = {
|
||||
total: 4, platforms: [{ value: 'patreon', count: 2 }],
|
||||
untagged: 1, no_artist: 0,
|
||||
date_min: '2020-01-01T00:00:00+00:00', date_max: '2026-06-01T00:00:00+00:00',
|
||||
}
|
||||
stubFetch((url) => {
|
||||
urls.push(url)
|
||||
if (url.includes('/api/gallery/facets')) return { status: 200, body: FACETS }
|
||||
return { status: 200, body: EMPTY }
|
||||
})
|
||||
await s.applyFilterFromQuery({ platform: 'patreon', untagged: '1' })
|
||||
await s.loadFacets()
|
||||
const facetsUrl = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/facets')))
|
||||
expect(facetsUrl).toContain('platform=patreon')
|
||||
expect(facetsUrl).toContain('untagged=1')
|
||||
expect(s.facets.total).toBe(4)
|
||||
})
|
||||
|
||||
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
@@ -88,3 +137,34 @@ describe('gallery store: composable filter', () => {
|
||||
expect(scrollCalls[0]).toContain('limit=50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterToQuery / cloneFilter', () => {
|
||||
it('serializes faceted params incl. platform sentinel and flags', () => {
|
||||
const q = filterToQuery({
|
||||
tag_ids: [3], artist_id: null, media_type: null, sort: 'newest',
|
||||
platform: '__unsourced__', untagged: true, no_artist: false,
|
||||
date_from: '2024-01-01', date_to: null,
|
||||
})
|
||||
expect(q.tag_id).toBe('3')
|
||||
expect(q.platform).toBe('__unsourced__')
|
||||
expect(q.untagged).toBe('1')
|
||||
expect(q.no_artist).toBeUndefined()
|
||||
expect(q.date_from).toBe('2024-01-01')
|
||||
expect(q.date_to).toBeUndefined()
|
||||
expect(q.sort).toBeUndefined() // newest is the default → omitted
|
||||
})
|
||||
|
||||
it('cloneFilter copies refine fields and detaches tag_ids', () => {
|
||||
const orig = {
|
||||
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
|
||||
platform: 'patreon', untagged: true, no_artist: true,
|
||||
date_from: '2024-01-01', date_to: '2024-02-01',
|
||||
}
|
||||
const c = cloneFilter(orig)
|
||||
c.tag_ids.push(9)
|
||||
expect(orig.tag_ids).toEqual([1]) // detached
|
||||
expect(c.platform).toBe('patreon')
|
||||
expect(c.untagged).toBe(true)
|
||||
expect(c.date_to).toBe('2024-02-01')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the decode-gate so the cascade is paced purely by the store's cadence
|
||||
// in tests (no real <img> load events in happy-dom). Individual tests can
|
||||
// override per-call to exercise the gating itself.
|
||||
vi.mock('../src/utils/preloadImage.js', () => ({
|
||||
preloadImage: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
import { preloadImage } from '../src/utils/preloadImage.js'
|
||||
import { useShowcaseStore } from '../src/stores/showcase.js'
|
||||
|
||||
let nextId
|
||||
|
||||
function stubShowcase({ empty = false } = {}) {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
const images = empty
|
||||
? []
|
||||
: Array.from({ length: 3 }, () => ({
|
||||
id: nextId++, sha256: 's', mime: 'image/jpeg',
|
||||
width: 1, height: 1, thumbnail_url: '/t',
|
||||
}))
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: '200',
|
||||
text: async () => JSON.stringify({ images }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('showcase store: buffered cascade', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
nextId = 1
|
||||
preloadImage.mockReset()
|
||||
preloadImage.mockImplementation(() => Promise.resolve())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('drains buffered items in fire-order at a fixed cadence, no dupes', async () => {
|
||||
stubShowcase()
|
||||
const s = useShowcaseStore()
|
||||
const p = s.loadInitial()
|
||||
// Past INITIAL_COUNT (60) × CADENCE (80ms) plus fetch microtasks.
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await p
|
||||
expect(s.images.length).toBe(60)
|
||||
const ids = s.images.map((i) => i.id)
|
||||
expect(new Set(ids).size).toBe(60) // dedup held
|
||||
expect(ids).toEqual([...ids].sort((a, b) => a - b)) // shown in fire order
|
||||
})
|
||||
|
||||
it('reveals at most one item per cadence tick (decoupled from fetch)', async () => {
|
||||
stubShowcase()
|
||||
const s = useShowcaseStore()
|
||||
const p = s.loadInitial()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
const early = s.images.length
|
||||
// Even though fetches resolve instantly, the consumer is rate-limited by
|
||||
// the cadence — a 1s window must not dump the whole buffer at once.
|
||||
expect(early).toBeGreaterThan(0)
|
||||
expect(early).toBeLessThan(60)
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await p
|
||||
expect(s.images.length).toBe(60)
|
||||
})
|
||||
|
||||
it('does not reveal a tile until its image has decoded', async () => {
|
||||
stubShowcase()
|
||||
let release
|
||||
// The first tile's preload hangs until we release it; all others resolve.
|
||||
preloadImage.mockImplementationOnce(() => new Promise((r) => { release = r }))
|
||||
const s = useShowcaseStore()
|
||||
const p = s.loadInitial()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(s.images.length).toBe(0) // gated on the un-decoded first image
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await p
|
||||
expect(s.images.length).toBe(60) // cascade proceeds once it decodes
|
||||
})
|
||||
|
||||
it('flags an empty library without starting the cascade', async () => {
|
||||
stubShowcase({ empty: true })
|
||||
const s = useShowcaseStore()
|
||||
const p = s.loadInitial()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await p
|
||||
expect(s.images.length).toBe(0)
|
||||
expect(s.isEmpty).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { arrowNavAllowed, hasText, isTextEntry } from '../src/utils/textEntry.js'
|
||||
|
||||
describe('textEntry helpers', () => {
|
||||
it('navigates when the target is not a text field', () => {
|
||||
expect(arrowNavAllowed(document.createElement('div'))).toBe(true)
|
||||
expect(arrowNavAllowed(null)).toBe(true)
|
||||
})
|
||||
|
||||
it('navigates when a focused tag input is empty', () => {
|
||||
const input = document.createElement('input')
|
||||
input.value = ''
|
||||
expect(arrowNavAllowed(input)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT navigate when the input has text (caret moves instead)', () => {
|
||||
const input = document.createElement('input')
|
||||
input.value = 'naruto'
|
||||
expect(arrowNavAllowed(input)).toBe(false)
|
||||
})
|
||||
|
||||
it('isTextEntry / hasText recognise inputs and textareas', () => {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = 'x'
|
||||
expect(isTextEntry(ta)).toBe(true)
|
||||
expect(hasText(ta)).toBe(true)
|
||||
expect(isTextEntry(document.createElement('div'))).toBe(false)
|
||||
})
|
||||
})
|
||||
+42
-27
@@ -104,32 +104,51 @@ async def client(app):
|
||||
_SEED_SNAPSHOT: dict[str, list[dict]] | None = None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def _truncate_engine():
|
||||
"""Session-scoped sync engine reused by the per-test DB-reset teardown.
|
||||
|
||||
Creating a fresh engine + Postgres connection for EVERY test's teardown was
|
||||
the integration suite's dominant cost — `--durations` showed the 15 slowest
|
||||
operations in both long shards were all ~1.5-2s teardowns, i.e. the
|
||||
connect+SCRAM handshake, not test logic. A single pooled connection reused
|
||||
across teardowns cuts that to the TRUNCATE itself. `pool_pre_ping` guards
|
||||
against Postgres reaping the idle connection mid-suite (a stale-connection
|
||||
failure would be a nasty flaky bounce; the ping is sub-millisecond).
|
||||
|
||||
`create_engine` is lazy — it opens no connection until first `.begin()` —
|
||||
so the no-DB unit job instantiates this object but never connects.
|
||||
"""
|
||||
eng = create_engine(_sync_database_url(), future=True, pool_pre_ping=True)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_db_after_integration(request):
|
||||
def _reset_db_after_integration(request, _truncate_engine):
|
||||
"""Integration tests exercise app/celery code that commits on its own
|
||||
connection, which the rollback fixtures above can't undo — so data
|
||||
would leak between tests. Capture the seeded baseline at the first
|
||||
integration test, then after each integration-marked test truncate
|
||||
every model table and restore that baseline. Connects to the DB ONLY
|
||||
for integration-marked tests, so the no-DB fast unit job is untouched.
|
||||
every model table and restore that baseline. Touches the DB ONLY for
|
||||
integration-marked tests, so the no-DB fast unit job is untouched.
|
||||
|
||||
Uses the session-scoped `_truncate_engine` (pooled, reused) rather than
|
||||
building a fresh engine per test — see that fixture's docstring.
|
||||
"""
|
||||
global _SEED_SNAPSHOT
|
||||
is_integration = request.node.get_closest_marker("integration") is not None
|
||||
|
||||
if is_integration and _SEED_SNAPSHOT is None:
|
||||
eng = create_engine(_sync_database_url(), future=True)
|
||||
snap: dict[str, list[dict]] = {}
|
||||
try:
|
||||
with eng.connect() as conn:
|
||||
for t in Base.metadata.sorted_tables:
|
||||
rows = [
|
||||
dict(m)
|
||||
for m in conn.execute(t.select()).mappings().all()
|
||||
]
|
||||
if rows:
|
||||
snap[t.name] = rows
|
||||
finally:
|
||||
eng.dispose()
|
||||
with _truncate_engine.connect() as conn:
|
||||
for t in Base.metadata.sorted_tables:
|
||||
rows = [
|
||||
dict(m)
|
||||
for m in conn.execute(t.select()).mappings().all()
|
||||
]
|
||||
if rows:
|
||||
snap[t.name] = rows
|
||||
_SEED_SNAPSHOT = snap
|
||||
|
||||
yield
|
||||
@@ -139,18 +158,14 @@ def _reset_db_after_integration(request):
|
||||
tables = ", ".join(t.name for t in Base.metadata.sorted_tables)
|
||||
if not tables:
|
||||
return
|
||||
eng = create_engine(_sync_database_url(), future=True)
|
||||
try:
|
||||
with eng.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
f"TRUNCATE {tables} RESTART IDENTITY CASCADE"
|
||||
)
|
||||
for t in Base.metadata.sorted_tables:
|
||||
rows = (_SEED_SNAPSHOT or {}).get(t.name)
|
||||
if rows:
|
||||
conn.execute(t.insert(), rows)
|
||||
finally:
|
||||
eng.dispose()
|
||||
with _truncate_engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
f"TRUNCATE {tables} RESTART IDENTITY CASCADE"
|
||||
)
|
||||
for t in Base.metadata.sorted_tables:
|
||||
rows = (_SEED_SNAPSHOT or {}).get(t.name)
|
||||
if rows:
|
||||
conn.execute(t.insert(), rows)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
|
||||
@@ -409,3 +409,29 @@ async def test_purge_legacy_commit_deletes_only_legacy(client, db):
|
||||
)
|
||||
)).scalar_one()
|
||||
assert gone == 0
|
||||
|
||||
|
||||
# --- DB maintenance: bloat readout + manual VACUUM trigger ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_stats_returns_table_bloat_shape(client):
|
||||
resp = await client.get("/api/admin/maintenance/db-stats")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert "tables" in body
|
||||
names = {t["table"] for t in body["tables"]}
|
||||
assert "image_record" in names
|
||||
for t in body["tables"]:
|
||||
assert {"table", "live", "dead", "dead_pct", "last_vacuum"} <= set(t)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_vacuum_queues_the_task(client, monkeypatch):
|
||||
from backend.app.tasks import maintenance
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(maintenance.vacuum_analyze, "delay", lambda: calls.append(1))
|
||||
resp = await client.post("/api/admin/maintenance/vacuum")
|
||||
assert resp.status_code == 202
|
||||
assert calls == [1]
|
||||
|
||||
@@ -77,3 +77,40 @@ async def test_scroll_media_param(client, db):
|
||||
resp = await client.get("/api/gallery/scroll?limit=10&media=video")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["images"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_facets_endpoint(client, db):
|
||||
await _seed(db, 3) # filesystem images: no artist, no tags, no platform
|
||||
resp = await client.get("/api/gallery/facets")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["total"] == 3
|
||||
assert body["untagged"] == 3
|
||||
assert body["no_artist"] == 3
|
||||
assert "platforms" in body
|
||||
assert body["date_min"] is not None
|
||||
assert body["date_max"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_facets_rejects_bad_date(client):
|
||||
resp = await client.get("/api/gallery/facets?date_from=notadate")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_untagged_param(client, db):
|
||||
await _seed(db, 2)
|
||||
resp = await client.get("/api/gallery/scroll?untagged=1&limit=10")
|
||||
assert resp.status_code == 200
|
||||
assert len((await resp.get_json())["images"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_date_from_param(client, db):
|
||||
await _seed(db, 2) # all created ~now
|
||||
# A future date_from excludes everything.
|
||||
resp = await client.get("/api/gallery/scroll?date_from=2099-01-01&limit=10")
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())["images"] == []
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Phase-2 faceted refine: new composable filter params + the facets()
|
||||
aggregate (platform / curation-flags / date-range counts, scoped live to the
|
||||
current filter with per-group minus-self semantics)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models import (
|
||||
Artist,
|
||||
ImageProvenance,
|
||||
ImageRecord,
|
||||
Post,
|
||||
Source,
|
||||
Tag,
|
||||
TagKind,
|
||||
)
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.gallery_service import (
|
||||
UNSOURCED_PLATFORM,
|
||||
GalleryService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_BASE = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
async def _artist(db, name):
|
||||
a = Artist(name=name, slug=name.lower())
|
||||
db.add(a)
|
||||
await db.flush()
|
||||
return a
|
||||
|
||||
|
||||
async def _sourced_image(db, n, platform, artist, eff):
|
||||
"""A downloaded image with a Source(platform) + Post + provenance row."""
|
||||
s = Source(
|
||||
artist_id=artist.id, platform=platform,
|
||||
url=f"https://{platform}.test/{artist.slug}/{n}",
|
||||
)
|
||||
db.add(s)
|
||||
await db.flush()
|
||||
p = Post(source_id=s.id, artist_id=artist.id, external_post_id=f"{platform}-{n}")
|
||||
db.add(p)
|
||||
await db.flush()
|
||||
rec = ImageRecord(
|
||||
path=f"/images/s/{platform}-{n}.jpg", sha256=f"a{n:063d}",
|
||||
size_bytes=1, mime="image/jpeg", width=1, height=1,
|
||||
origin="downloaded", integrity_status="ok",
|
||||
artist_id=artist.id, primary_post_id=p.id,
|
||||
)
|
||||
rec.created_at = eff
|
||||
rec.effective_date = eff
|
||||
db.add(rec)
|
||||
await db.flush()
|
||||
db.add(ImageProvenance(image_record_id=rec.id, post_id=p.id, source_id=s.id))
|
||||
await db.flush()
|
||||
return rec
|
||||
|
||||
|
||||
async def _fs_image(db, n, eff, artist=None):
|
||||
"""A filesystem-imported image: no provenance/source (unsourced),
|
||||
artist optional."""
|
||||
rec = ImageRecord(
|
||||
path=f"/images/fs/{n}.jpg", sha256=f"b{n:063d}",
|
||||
size_bytes=1, mime="image/jpeg", width=1, height=1,
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
artist_id=artist.id if artist else None,
|
||||
)
|
||||
rec.created_at = eff
|
||||
rec.effective_date = eff
|
||||
db.add(rec)
|
||||
await db.flush()
|
||||
return rec
|
||||
|
||||
|
||||
async def _tag(db, image, name):
|
||||
t = Tag(name=name, kind=TagKind.general)
|
||||
db.add(t)
|
||||
await db.flush()
|
||||
await db.execute(image_tag.insert().values(
|
||||
image_record_id=image.id, tag_id=t.id, source="manual"))
|
||||
await db.flush()
|
||||
return t
|
||||
|
||||
|
||||
# --- facets() aggregate ----------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_facets_platform_counts(db):
|
||||
ar = await _artist(db, "Ar")
|
||||
await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||
await _sourced_image(db, 2, "patreon", ar, _BASE)
|
||||
await _sourced_image(db, 3, "pixiv", ar, _BASE)
|
||||
await _fs_image(db, 4, _BASE) # unsourced → null bucket
|
||||
svc = GalleryService(db)
|
||||
f = await svc.facets()
|
||||
counts = {p["value"]: p["count"] for p in f.platforms}
|
||||
assert counts["patreon"] == 2
|
||||
assert counts["pixiv"] == 1
|
||||
assert counts[None] == 1
|
||||
assert f.total == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_facets_counts_image_under_each_platform(db):
|
||||
"""A single image cross-posted to two platforms counts under both
|
||||
(DISTINCT image per platform), but is one image in total."""
|
||||
ar = await _artist(db, "Ar")
|
||||
rec = await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||
s2 = Source(artist_id=ar.id, platform="pixiv", url="https://pixiv.test/ar/x")
|
||||
db.add(s2)
|
||||
await db.flush()
|
||||
p2 = Post(source_id=s2.id, artist_id=ar.id, external_post_id="pixiv-x")
|
||||
db.add(p2)
|
||||
await db.flush()
|
||||
db.add(ImageProvenance(image_record_id=rec.id, post_id=p2.id, source_id=s2.id))
|
||||
await db.flush()
|
||||
svc = GalleryService(db)
|
||||
f = await svc.facets()
|
||||
counts = {p["value"]: p["count"] for p in f.platforms}
|
||||
assert counts["patreon"] == 1
|
||||
assert counts["pixiv"] == 1
|
||||
assert f.total == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_facets_curation_flags(db):
|
||||
ar = await _artist(db, "Ar")
|
||||
tagged = await _fs_image(db, 1, _BASE, artist=ar)
|
||||
await _tag(db, tagged, "x")
|
||||
await _fs_image(db, 2, _BASE, artist=ar) # untagged, has artist
|
||||
await _fs_image(db, 3, _BASE, artist=None) # untagged, no artist
|
||||
svc = GalleryService(db)
|
||||
f = await svc.facets()
|
||||
assert f.untagged == 2
|
||||
assert f.no_artist == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_facets_date_bounds(db):
|
||||
ar = await _artist(db, "Ar")
|
||||
await _fs_image(db, 1, datetime(2020, 1, 1, tzinfo=UTC), artist=ar)
|
||||
await _fs_image(db, 2, datetime(2026, 6, 1, tzinfo=UTC), artist=ar)
|
||||
svc = GalleryService(db)
|
||||
f = await svc.facets()
|
||||
assert f.date_min == datetime(2020, 1, 1, tzinfo=UTC)
|
||||
assert f.date_max == datetime(2026, 6, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_facets_platform_minus_self(db):
|
||||
"""The platform group ignores its OWN active selection so siblings stay
|
||||
visible/switchable; total still honors the platform filter."""
|
||||
ar = await _artist(db, "Ar")
|
||||
await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||
await _sourced_image(db, 2, "pixiv", ar, _BASE)
|
||||
svc = GalleryService(db)
|
||||
f = await svc.facets(platform="patreon")
|
||||
counts = {p["value"]: p["count"] for p in f.platforms}
|
||||
assert counts.get("pixiv") == 1
|
||||
assert counts.get("patreon") == 1
|
||||
assert f.total == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_facets_other_filter_scopes_platforms(db):
|
||||
"""A non-platform filter (media) narrows the platform counts."""
|
||||
ar = await _artist(db, "Ar")
|
||||
await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||
vid = await _sourced_image(db, 2, "patreon", ar, _BASE)
|
||||
vid.mime = "video/mp4"
|
||||
await db.flush()
|
||||
svc = GalleryService(db)
|
||||
f = await svc.facets(media_type="image")
|
||||
counts = {p["value"]: p["count"] for p in f.platforms}
|
||||
assert counts["patreon"] == 1
|
||||
|
||||
|
||||
# --- new scroll filter params ----------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_platform_filter(db):
|
||||
ar = await _artist(db, "Ar")
|
||||
pat = await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||
await _sourced_image(db, 2, "pixiv", ar, _BASE)
|
||||
svc = GalleryService(db)
|
||||
page = await svc.scroll(cursor=None, limit=10, platform="patreon")
|
||||
assert [i.id for i in page.images] == [pat.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_platform_unsourced(db):
|
||||
ar = await _artist(db, "Ar")
|
||||
await _sourced_image(db, 1, "patreon", ar, _BASE)
|
||||
fs = await _fs_image(db, 2, _BASE)
|
||||
svc = GalleryService(db)
|
||||
page = await svc.scroll(cursor=None, limit=10, platform=UNSOURCED_PLATFORM)
|
||||
assert [i.id for i in page.images] == [fs.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_untagged_filter(db):
|
||||
ar = await _artist(db, "Ar")
|
||||
tagged = await _fs_image(db, 1, _BASE, artist=ar)
|
||||
await _tag(db, tagged, "x")
|
||||
untag = await _fs_image(db, 2, _BASE, artist=ar)
|
||||
svc = GalleryService(db)
|
||||
page = await svc.scroll(cursor=None, limit=10, untagged=True)
|
||||
assert [i.id for i in page.images] == [untag.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_no_artist_filter(db):
|
||||
ar = await _artist(db, "Ar")
|
||||
await _fs_image(db, 1, _BASE, artist=ar)
|
||||
none = await _fs_image(db, 2, _BASE, artist=None)
|
||||
svc = GalleryService(db)
|
||||
page = await svc.scroll(cursor=None, limit=10, no_artist=True)
|
||||
assert [i.id for i in page.images] == [none.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_date_range_filter(db):
|
||||
ar = await _artist(db, "Ar")
|
||||
old = await _fs_image(db, 1, datetime(2020, 1, 1, tzinfo=UTC), artist=ar)
|
||||
new = await _fs_image(db, 2, datetime(2026, 6, 1, tzinfo=UTC), artist=ar)
|
||||
svc = GalleryService(db)
|
||||
page = await svc.scroll(
|
||||
cursor=None, limit=10, date_from=datetime(2026, 1, 1, tzinfo=UTC))
|
||||
assert [i.id for i in page.images] == [new.id]
|
||||
page2 = await svc.scroll(
|
||||
cursor=None, limit=10, date_to=datetime(2021, 1, 1, tzinfo=UTC))
|
||||
assert [i.id for i in page2.images] == [old.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_combines_new_filters_and(db):
|
||||
"""platform + untagged + no_artist compose with AND."""
|
||||
ar = await _artist(db, "Ar")
|
||||
# match: unsourced, untagged, no artist
|
||||
match = await _fs_image(db, 1, _BASE, artist=None)
|
||||
# decoys violating one clause each
|
||||
await _fs_image(db, 2, _BASE, artist=ar) # has artist
|
||||
tagged = await _fs_image(db, 3, _BASE, artist=None)
|
||||
await _tag(db, tagged, "x") # tagged
|
||||
svc = GalleryService(db)
|
||||
page = await svc.scroll(
|
||||
cursor=None, limit=10,
|
||||
platform=UNSOURCED_PLATFORM, untagged=True, no_artist=True)
|
||||
assert [i.id for i in page.images] == [match.id]
|
||||
@@ -638,3 +638,12 @@ def test_recover_stalled_download_dedupes_per_source(db_sync):
|
||||
select(Source.consecutive_failures).where(Source.id == sid)
|
||||
).scalar_one()
|
||||
assert failures == 1
|
||||
|
||||
|
||||
def test_vacuum_analyze_runs_over_high_churn_tables():
|
||||
"""VACUUM (ANALYZE) runs (on its own AUTOCOMMIT connection) and reports the
|
||||
tables it touched."""
|
||||
from backend.app.tasks.maintenance import VACUUM_TABLES, vacuum_analyze
|
||||
|
||||
result = vacuum_analyze.apply().get()
|
||||
assert result["vacuumed"] == list(VACUUM_TABLES)
|
||||
|
||||
Reference in New Issue
Block a user