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

This commit was merged in pull request #62.
This commit is contained in:
2026-06-04 08:27:47 -04:00
28 changed files with 1612 additions and 343 deletions
+30 -149
View File
@@ -92,28 +92,25 @@ jobs:
- run: npm run test:unit - run: npm run test:unit
- run: npm run build - run: npm run build
# Integration suite split into THREE parallel shards (2026-05-25, runner # Single integration job — collapsed from a 3-way shard split on 2026-06-04.
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service # The shards existed to parallelize ~8.5min of integration tests; once the
# set and runs alembic + a disjoint subset of integration tests. Shards # throwaway Postgres runs with fsync OFF (the durability step below) the whole
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py # suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
# stays single-threaded per shard but multiple shards run in parallel # (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
# wall-clock. Approximate split — rebalance once --durations=15 output # runner slots for no wall-clock gain. One job now: spin up once, install
# reveals which shard is the long pole. # once, migrate once, run every integration test.
# #
# Each shard's docker-ps filter uses its own unique job name to scope # The docker-ps filter scopes to THIS job's own Postgres/Redis service
# service-container resolution. act_runner appears to strip underscores # containers by job name. act_runner strips underscores from job names when
# from job names when building container labels — `int_api` yielded # labelling containers (`int_api` matched nothing on 2026-05-25), so the name
# zero matches on 2026-05-25 — so shards use no-separator names # stays separator-free (`integration`). The step prints `docker ps -a` first
# (`intapi`, `intimp`, `intcore`) instead. Each step prints # so a future naming-convention shift surfaces in the log without a
# `docker ps -a` first so a future naming-convention shift surfaces in # guess-and-push cycle.
# the log without another guess-and-push cycle.
# #
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT # Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
# done — per ci-requirements.md, FC is the only Python consumer of that # per ci-requirements.md, FC is the only Python consumer of that image and the
# image and the CI-Runner project's "add deps to image when used by >1 # CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
# project" rule keeps the install per-job. integration:
intapi:
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -144,14 +141,14 @@ jobs:
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: API integration shard (resolve service IPs, migrate, test) - name: Integration suite (resolve service IPs, migrate, test)
run: | run: |
set -eux set -eux
echo "=== container landscape (diagnostic for filter scoping) ===" echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ===" echo "=== end landscape ==="
PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1) PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1) RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
test -n "$PG" && test -n "$RD" test -n "$PG" && test -n "$RD"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD") RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
@@ -168,130 +165,14 @@ jobs:
else else
pip install -r requirements.txt pytest pytest-asyncio pip install -r requirements.txt pytest pytest-asyncio
fi fi
# Relax durability on the throwaway CI Postgres so the per-test
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
# surprise can't red the job; fabledcurator is the postgres image's
# bootstrap superuser.
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
alembic upgrade head alembic upgrade head
pytest tests/test_api_*.py -v -m integration --durations=15 pytest tests/ -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'
+47 -1
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import hashlib import hashlib
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import select from sqlalchemy import select, text
from ..extensions import get_session from ..extensions import get_session
from ..models import Artist 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) lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run)
) )
return jsonify(result) 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
View File
@@ -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 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") 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(): def _parse_filters():
"""Parse the composable gallery filters from query args. Raises """Parse the composable gallery filters from query args, returning
ValueError (→ 400) on malformed ids. `tag_id` accepts a single id or a ``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.
comma-separated list (AND); `media` is image|video; `sort` is
newest|oldest.""" `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_raw = request.args.get("tag_id")
tag_ids = ( tag_ids = (
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None [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 media_type = media if media in ("image", "video") else None
sort = request.args.get("sort") sort = request.args.get("sort")
sort = sort if sort in ("newest", "oldest") else "newest" 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"]) @gallery_bp.route("/scroll", methods=["GET"])
@@ -33,7 +61,7 @@ async def scroll():
cursor = request.args.get("cursor") or None cursor = request.args.get("cursor") or None
try: try:
limit = int(request.args.get("limit", "50")) limit = int(request.args.get("limit", "50"))
tag_ids, post_id, artist_id, media_type, sort = _parse_filters() filters, sort = _parse_filters()
except ValueError: except ValueError:
return jsonify({"error": "invalid filter or limit parameter"}), 400 return jsonify({"error": "invalid filter or limit parameter"}), 400
@@ -41,9 +69,7 @@ async def scroll():
svc = GalleryService(session) svc = GalleryService(session)
try: try:
page = await svc.scroll( page = await svc.scroll(
cursor=cursor, limit=limit, tag_ids=tag_ids, cursor=cursor, limit=limit, sort=sort, **filters,
post_id=post_id, artist_id=artist_id,
media_type=media_type, sort=sort,
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
@@ -75,16 +101,13 @@ async def scroll():
@gallery_bp.route("/timeline", methods=["GET"]) @gallery_bp.route("/timeline", methods=["GET"])
async def timeline(): async def timeline():
try: try:
tag_ids, post_id, artist_id, media_type, _sort = _parse_filters() filters, _sort = _parse_filters()
except ValueError: except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400 return jsonify({"error": "invalid filter parameter"}), 400
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
buckets = await svc.timeline( buckets = await svc.timeline(**filters)
tag_ids=tag_ids, post_id=post_id, artist_id=artist_id,
media_type=media_type,
)
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
return jsonify( 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"]) @gallery_bp.route("/jump", methods=["GET"])
async def jump(): async def jump():
try: try:
year = int(request.args["year"]) year = int(request.args["year"])
month = int(request.args["month"]) month = int(request.args["month"])
tag_ids, post_id, artist_id, media_type, sort = _parse_filters() filters, sort = _parse_filters()
except (KeyError, ValueError): except (KeyError, ValueError):
return jsonify({"error": "year and month query params required"}), 400 return jsonify({"error": "year and month query params required"}), 400
async with get_session() as session: async with get_session() as session:
svc = GalleryService(session) svc = GalleryService(session)
try: try:
cursor = await svc.jump_cursor( cursor = await svc.jump_cursor(
year=year, month=month, tag_ids=tag_ids, year=year, month=month, sort=sort, **filters,
post_id=post_id, artist_id=artist_id,
media_type=media_type, sort=sort,
) )
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
+4
View File
@@ -97,6 +97,10 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.prune_task_runs", "task": "backend.app.tasks.maintenance.prune_task_runs",
"schedule": 86400.0, # daily "schedule": 86400.0, # daily
}, },
"vacuum-analyze": {
"task": "backend.app.tasks.maintenance.vacuum_analyze",
"schedule": 604800.0, # weekly — reclaim dead-tuple bloat + refresh stats
},
"fc3h-backup-db-nightly": { "fc3h-backup-db-nightly": {
"task": "backend.app.tasks.backup.backup_db_nightly", "task": "backend.app.tasks.backup.backup_db_nightly",
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour "schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
+173 -7
View File
@@ -18,15 +18,22 @@ import base64
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased 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 from ..models.tag import image_tag
CURSOR_SEPARATOR = "|" CURSOR_SEPARATOR = "|"
# Reserved `platform` filter value selecting images with NO platformed
# provenance (filesystem imports). Returned by facets() as a null-valued
# bucket; the frontend maps that null back to this sentinel in the URL so the
# bucket is selectable. Underscore-wrapped so it can't collide with a real
# gallery-dl platform name (patreon/pixiv/...).
UNSOURCED_PLATFORM = "__unsourced__"
def encode_cursor(effective_date: datetime, image_id: int) -> str: def encode_cursor(effective_date: datetime, image_id: int) -> str:
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}" raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
@@ -92,6 +99,16 @@ class TimelineBucket:
count: int count: int
@dataclass(frozen=True)
class GalleryFacets:
total: int # images matching the FULL active filter
platforms: list[dict] # [{"value": str|None, "count": int}], null = unsourced
untagged: int # how many the Untagged flag would isolate
no_artist: int # how many the No-artist flag would isolate
date_min: datetime | None
date_max: datetime | None
def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str: def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str:
"""Return the URL to fetch a thumbnail. """Return the URL to fetch a thumbnail.
@@ -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): def _apply_scope(
"""Apply the composable gallery filters to a statement already joined stmt, *, tag_ids, post_id, artist_id, media_type,
to Post via _outer_join_primary_post. 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 All clauses are correlated EXISTS / scalar predicates on ImageRecord, so
(AND), which avoids the row-multiplication a multi-join would cause. 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 - post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
by _require_single_filter). by _require_single_filter).
- media_type: 'image' | 'video' narrows by mime prefix. - 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 []: for tid in tag_ids or []:
stmt = stmt.where( 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/%")) stmt = stmt.where(ImageRecord.mime.like("image/%"))
elif media_type == "video": elif media_type == "video":
stmt = stmt.where(ImageRecord.mime.like("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 return stmt
def _platform_clause(platform):
"""Correlated EXISTS on a provenance row whose Source carries `platform`.
The UNSOURCED_PLATFORM sentinel inverts to NOT EXISTS(any sourced
provenance) — i.e. filesystem-imported content with no platform."""
src = aliased(Source)
if platform == UNSOURCED_PLATFORM:
return ~exists().where(
ImageProvenance.image_record_id == ImageRecord.id,
ImageProvenance.source_id == src.id,
)
return exists().where(
ImageProvenance.image_record_id == ImageRecord.id,
ImageProvenance.source_id == src.id,
src.platform == platform,
)
def _provenance_clause(post_id, artist_id): def _provenance_clause(post_id, artist_id):
"""Correlated EXISTS clause (NOT a join) so an image with multiple """Correlated EXISTS clause (NOT a join) so an image with multiple
matching provenance rows is returned exactly once and the matching provenance rows is returned exactly once and the
@@ -215,6 +274,11 @@ class GalleryService:
artist_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, media_type: str | None = None,
sort: str = "newest", sort: str = "newest",
platform: str | None = None,
untagged: bool = False,
no_artist: bool = False,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> GalleryPage: ) -> GalleryPage:
if limit < 1 or limit > 200: if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200") raise ValueError("limit must be between 1 and 200")
@@ -226,6 +290,8 @@ class GalleryService:
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, 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" descending = sort != "oldest"
@@ -286,6 +352,11 @@ class GalleryService:
post_id: int | None = None, post_id: int | None = None,
artist_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, media_type: str | None = None,
platform: str | None = None,
untagged: bool = False,
no_artist: bool = False,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[TimelineBucket]: ) -> list[TimelineBucket]:
eff = _effective_date_col() eff = _effective_date_col()
year_col = func.date_part("year", eff).label("yr") year_col = func.date_part("year", eff).label("yr")
@@ -298,6 +369,8 @@ class GalleryService:
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, 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()) stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
rows = (await self.session.execute(stmt)).all() rows = (await self.session.execute(stmt)).all()
@@ -307,6 +380,9 @@ class GalleryService:
self, year: int, month: int, tag_ids: list[int] | None = None, self, year: int, month: int, tag_ids: list[int] | None = None,
post_id: int | None = None, artist_id: int | None = None, post_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, sort: str = "newest", media_type: str | None = None, sort: str = "newest",
platform: str | None = None, untagged: bool = False,
no_artist: bool = False, date_from: datetime | None = None,
date_to: datetime | None = None,
) -> str | None: ) -> str | None:
"""Returns a cursor that, when passed to scroll() with the same sort, """Returns a cursor that, when passed to scroll() with the same sort,
positions at the first image of the given year-month. None if the positions at the first image of the given year-month. None if the
@@ -324,6 +400,8 @@ class GalleryService:
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id, stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type, 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" descending = sort != "oldest"
if descending: if descending:
@@ -339,6 +417,94 @@ class GalleryService:
boundary = record.id + 1 if descending else record.id - 1 boundary = record.id + 1 if descending else record.id - 1
return encode_cursor(eff_date, boundary) 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: async def get_image_with_tags(self, image_id: int) -> dict | None:
record = await self.session.get(ImageRecord, image_id) record = await self.session.get(ImageRecord, image_id)
if record is None: if record is None:
+7
View File
@@ -34,3 +34,10 @@ def sync_session_factory():
) )
_SESSIONMAKER = sessionmaker(_ENGINE, expire_on_commit=False) _SESSIONMAKER = sessionmaker(_ENGINE, expire_on_commit=False)
return _SESSIONMAKER return _SESSIONMAKER
def get_sync_engine():
"""The process-wide sync Engine — for raw work that needs a connection
directly (e.g. AUTOCOMMIT VACUUM, which can't run inside a transaction)."""
sync_session_factory() # ensure _ENGINE is initialized
return _ENGINE
+34
View File
@@ -22,10 +22,27 @@ from ..models import (
TaskRun, TaskRun,
) )
from ..utils.phash import compute_phash from ..utils.phash import compute_phash
from ._sync_engine import get_sync_engine
from ._sync_engine import sync_session_factory as _sync_session_factory from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# High-churn tables whose dead-tuple bloat matters: the TABLESAMPLE showcase
# reads physical blocks (bloat slows it directly), and the periodic
# prune/backfill/recovery tasks generate dead tuples faster than autovacuum
# always keeps up with. VACUUM reclaims them; ANALYZE refreshes planner stats.
# Allowlist ONLY — names are interpolated into VACUUM, so they must never come
# from request input.
VACUUM_TABLES = (
"image_record",
"image_provenance",
"post_attachment",
"download_event",
"task_run",
"import_task",
"import_batch",
)
STUCK_THRESHOLD_MINUTES = 5 STUCK_THRESHOLD_MINUTES = 5
# Archive ImportTasks run the per-member pipeline inline for every # Archive ImportTasks run the per-member pipeline inline for every
# member (import_archive_file: soft=30min/hard=35min). The ImportTask # member (import_archive_file: soft=30min/hard=35min). The ImportTask
@@ -761,3 +778,20 @@ def cleanup_old_download_events() -> int:
) )
session.commit() session.commit()
return result.rowcount or 0 return result.rowcount or 0
@celery.task(name="backend.app.tasks.maintenance.vacuum_analyze")
def vacuum_analyze() -> dict:
"""Periodic VACUUM (ANALYZE) over the high-churn tables (VACUUM_TABLES) to
reclaim dead-tuple bloat and refresh planner statistics. VACUUM cannot run
inside a transaction block, so it runs on an AUTOCOMMIT connection.
Scheduled weekly; also operator-triggerable from Settings → Maintenance.
"""
engine = get_sync_engine()
done = []
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
for table in VACUUM_TABLES:
conn.exec_driver_sql(f"VACUUM (ANALYZE) {table}")
done.append(table)
log.info("vacuum_analyze complete: %s", done)
return {"vacuumed": done}
@@ -6,7 +6,6 @@
v-for="item in col" :key="item.id" v-for="item in col" :key="item.id"
class="fc-masonry__item" class="fc-masonry__item"
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }" :class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
:style="itemStyle(item)"
type="button" type="button"
@click="$emit('open', item.id)" @click="$emit('open', item.id)"
> >
@@ -66,12 +65,6 @@ function shouldAnimate(item) {
return idx !== undefined && idx >= props.animateFromIndex 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) { function aspectStyle(item) {
const w = Number(item.width) const w = Number(item.width)
const h = Number(item.height) const h = Number(item.height)
@@ -117,12 +110,15 @@ useInfiniteScroll(sentinelEl, () => {
.fc-masonry__end { text-align: center; padding: 32px 0; } .fc-masonry__end { text-align: center; padding: 32px 0; }
/* Cascade entry: each tile flips up out of a backward tilt and settles /* 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 into place — more pronounced than a plain fade so the showcase reads as an
showcase reads as an "experience" (operator-flagged 2026-05-28). The "experience" (operator-flagged 2026-05-28). The reveal is paced entirely by
`both` fill holds the hidden/tilted 0% state until each tile's staggered the store, which pushes one fully-decoded item at a time (showcase.js); each
turn; the cubic-bezier overshoots slightly past flat then settles. tile therefore animates the instant it mounts, with NO per-index CSS delay —
Honors prefers-reduced-motion. Tunables: tilt (-28deg), stagger (70ms), the old `animation-delay: index×70ms` compounded on top of the insert
duration (0.6s). */ 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 { @keyframes fc-masonry-item-in {
0% { 0% {
opacity: 0; opacity: 0;
@@ -138,7 +134,6 @@ useInfiniteScroll(sentinelEl, () => {
transform-origin: center top; transform-origin: center top;
backface-visibility: hidden; backface-visibility: hidden;
animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both; 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) { @media (prefers-reduced-motion: reduce) {
.fc-masonry__item--anim { .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> <template>
<div class="fc-filterbar"> <div class="fc-filterbar-wrap">
<div class="fc-filterbar">
<v-autocomplete <v-autocomplete
v-model="selected" v-model="selected"
:items="searchItems" :items="searchItems"
@@ -59,19 +60,31 @@
@update:model-value="setSort" @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-btn
v-if="hasActiveFilters" variant="text" size="small" v-if="hasActiveFilters" variant="text" size="small"
prepend-icon="mdi-close" @click="clearAll" prepend-icon="mdi-close" @click="clearAll"
>Clear</v-btn> >Clear</v-btn>
</div>
<GalleryFacetPanel v-if="refineOpen" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js' 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 { useTagStore } from '../../stores/tags.js'
import GalleryFacetPanel from './GalleryFacetPanel.vue'
const store = useGalleryStore() const store = useGalleryStore()
const tagStore = useTagStore() const tagStore = useTagStore()
@@ -88,13 +101,34 @@ const searchItems = ref([])
const searchLoading = ref(false) const searchLoading = ref(false)
let debounce = null 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(() => const hasActiveFilters = computed(() =>
store.filter.tag_ids.length > 0 || store.filter.tag_ids.length > 0 ||
store.filter.artist_id != null || store.filter.artist_id != null ||
store.filter.media_type != 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) { function iconFor(raw) {
if (raw.kind === 'artist') return 'mdi-account' if (raw.kind === 'artist') return 'mdi-account'
return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant', 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. // Single write path: clone the current filter, mutate, serialize to the URL.
// The route watcher in GalleryView applies it to the store and reloads. // 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) { function pushFilter(mutate) {
const f = store.filter const n = cloneFilter(store.filter)
const n = {
tag_ids: [...f.tag_ids],
artist_id: f.artist_id,
media_type: f.media_type,
sort: f.sort,
}
mutate(n) mutate(n)
const q = {} router.push({ name: 'gallery', query: filterToQuery(n) })
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 })
} }
</script> </script>
<style scoped> <style scoped>
/* Pinned under the 64px TopNav, matching the app's sticky v-tabs chrome /* The whole chrome (bar row + expandable refine panel) is one sticky,
(SettingsView / ArtistHeader / SubscriptionsView). */ hazey block pinned directly under the 64px TopNav and continuous with it. */
.fc-filterbar { .fc-filterbar-wrap {
position: sticky; position: sticky;
top: 64px; 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; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 8px 4px; padding: 10px 12px;
margin-bottom: 12px; }
background: rgb(var(--v-theme-surface)); /* The inputs/toggles float on the haze: still translucent, but a touch more
border-bottom: 1px solid rgb(var(--v-theme-surface-light)); 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__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+17 -10
View File
@@ -60,6 +60,7 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useModalStore } from '../../stores/modal.js' import { useModalStore } from '../../stores/modal.js'
import { arrowNavAllowed } from '../../utils/textEntry.js'
import ImageCanvas from './ImageCanvas.vue' import ImageCanvas from './ImageCanvas.vue'
import VideoCanvas from './VideoCanvas.vue' import VideoCanvas from './VideoCanvas.vue'
import TagPanel from './TagPanel.vue' import TagPanel from './TagPanel.vue'
@@ -105,11 +106,13 @@ function onKeyDown(ev) {
ev.preventDefault() ev.preventDefault()
emit('close') emit('close')
} else if (ev.key === 'ArrowLeft') { } 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() ev.preventDefault()
modal.goPrev() modal.goPrev()
} else if (ev.key === 'ArrowRight') { } else if (ev.key === 'ArrowRight') {
if (isTextEntry(ev.target)) return if (!arrowNavAllowed(ev.target)) return
ev.preventDefault() ev.preventDefault()
modal.goNext() modal.goNext()
} }
@@ -135,17 +138,14 @@ watch(() => modal.currentImageId, async () => {
function nextFrame() { function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve)) 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> </script>
<style scoped> <style scoped>
.fc-viewer { .fc-viewer {
position: fixed; inset: 0; z-index: 2000; 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, /* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav,
mid-opacity + blur so the page behind shows through faintly. */ mid-opacity + blur so the page behind shows through faintly. */
background: rgba(20, 23, 26, 0.65); background: rgba(20, 23, 26, 0.65);
@@ -174,7 +174,12 @@ function isTextEntry(el) {
position: absolute; top: 72px; right: 16px; z-index: 3; position: absolute; top: 72px; right: 16px; z-index: 3;
} }
.fc-viewer__nav--prev { left: 16px; transform: translateY(-50%); } .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 { .fc-viewer__body {
flex: 1; display: flex; min-height: 0; flex: 1; display: flex; min-height: 0;
} }
@@ -187,7 +192,7 @@ function isTextEntry(el) {
min-width: 0; min-height: 0; min-width: 0; min-height: 0;
} }
.fc-viewer__side { .fc-viewer__side {
width: 320px; flex-shrink: 0; width: var(--fc-side-w); flex-shrink: 0;
background: rgb(var(--v-theme-surface)); background: rgb(var(--v-theme-surface));
border-left: 1px solid rgb(var(--v-theme-surface-light)); border-left: 1px solid rgb(var(--v-theme-surface-light));
overflow-y: auto; overflow-y: auto;
@@ -195,6 +200,8 @@ function isTextEntry(el) {
@media (max-width: 900px) { @media (max-width: 900px) {
.fc-viewer__body { flex-direction: column; } .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 { .fc-viewer__side {
width: 100%; width: 100%;
max-height: 40vh; 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" /> <MLThresholdSliders class="mt-4" />
<AllowlistTable class="mt-4" /> <AllowlistTable class="mt-4" />
<AliasTable class="mt-4" /> <AliasTable class="mt-4" />
<DbMaintenanceCard class="mt-6" />
<BackupCard class="mt-6" /> <BackupCard class="mt-6" />
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) it <!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) it
operates on the existing library which fits the Cleanup-tab 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 MLThresholdSliders from './MLThresholdSliders.vue'
import AllowlistTable from './AllowlistTable.vue' import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue' import AliasTable from './AliasTable.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import BackupCard from './BackupCard.vue' import BackupCard from './BackupCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js' import { useSystemActivityStore } from '../../stores/systemActivity.js'
+25
View File
@@ -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 }
})
+68 -1
View File
@@ -24,7 +24,16 @@ export const useGalleryStore = defineStore('gallery', () => {
const filter = ref({ const filter = ref({
tag_ids: [], artist_id: null, media_type: null, tag_ids: [], artist_id: null, media_type: null,
sort: 'newest', post_id: 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 // 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. // and pre-noted by the filter bar when a user picks from autocomplete.
const tagLabels = ref({}) // tagId -> name 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.artist_id) p.artist_id = filter.value.artist_id
if (filter.value.media_type) p.media = filter.value.media_type 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.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 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 // 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 // and on every route-query change; the filter bar mutates the URL
// (router.push) rather than the store directly, so deep-links, the back // (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, media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest', sort: q.sort === 'oldest' ? 'oldest' : 'newest',
post_id: _toId(q.post_id), 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 loadInitial()
await loadTimeline() await loadTimeline()
_resolveLabels() _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) { function _toId(v) {
const n = Number(v) const n = Number(v)
return Number.isInteger(n) && n > 0 ? n : null return Number.isInteger(n) && n > 0 ? n : null
@@ -158,11 +199,37 @@ export const useGalleryStore = defineStore('gallery', () => {
return { return {
images, dateGroups, hasMore, isEmpty, loading, error, images, dateGroups, hasMore, isEmpty, loading, error,
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading, filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
loadInitial, loadMore, loadTimeline, jumpTo, facets, facetsLoading,
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets,
applyFilterFromQuery, noteTagLabel, noteArtistLabel, 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) { function mergeGroups(existing, incoming) {
// Merge sequential groups with the same (year, month) instead of duplicating. // Merge sequential groups with the same (year, month) instead of duplicating.
const merged = [...existing] const merged = [...existing]
+123 -89
View File
@@ -1,37 +1,41 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' 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 // Buffered producer/consumer so the cascade cadence is decoupled from fetch
// but risked later chunks arriving first — undesirable even when each // latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch
// chunk is a random sample. Switched to a PIPELINE: only one fetch in // right after its fetch and bet the next round-trip would finish inside the
// flight at any moment, but the next fetch kicks off as soon as the // ~240ms trickle window; when a fetch ran long (TABLESAMPLE hits random,
// previous one resolves (NOT after its trickle finishes). The next RTT // sometimes-cold blocks; RTT jitter) the animation starved and the view
// overlaps with the current batch's trickle, hiding the per-batch // "burped" out a clump of images. Now:
// round-trip behind the visible animation cadence. Responses arrive in // - PRODUCER (_fill): races ahead fetching batches into `queue` up to a
// fire-order, so no out-of-order rendering surprises. // target depth, refilling whenever the queue dips below BUFFER_MIN. It
// // also kicks off the image PRELOAD for each queued item so decoding
// Smaller PAGE (3 vs 5) → first chunk's items appear sooner: a chunk of // pipelines ahead of the reveal.
// 3 trickles in 240 ms, well within one RTT, so by the time chunk 2 is // - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully
// in-hand the trickle is just finishing. Total wall-clock is roughly // decoded, then reveals it — at most one per CADENCE_MS. Because the
// RTT + N × max(trickle_time, RTT); APPEND_DELAY_MS keeps the visible // producer preloads ahead, the decode-wait is usually already satisfied,
// cadence smooth throughout. // 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 PAGE = 3
const INITIAL_BATCHES = 20 const CADENCE_MS = 80 // floor between fully-loaded reveals
const APPEND_DELAY_MS = 80 // ≈ the MasonryGrid stagger animation (70 ms) const PRIME = 6 // items buffered before the drain starts
// Operator-flagged 2026-06-01: scrolling the showcase eventually hit a const BUFFER_TARGET = 30 // producer tops the queue up to this
// premature "End." because /api/showcase returns a *random sample* and const BUFFER_MIN = 12 // ...and refills once the queue dips below this
// after enough scrolling the `seen` Set accumulated enough to fully const INITIAL_COUNT = 60 // cascade length on load / shuffle
// collide with a 3-item batch. The showcase is supposed to be endless; const SCROLL_COUNT = 15 // cascade length per infinite-scroll demand
// only a genuinely empty API response (library has zero images) should // Showcase is endless by design (random sample); an unlucky all-duplicate
// mark it exhausted. Retry up to FETCH_RETRY_CAP times on all-dupe // batch must be retried — only a genuinely empty API response is exhaustion.
// 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 FETCH_RETRY_CAP = 8 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', () => { export const useShowcaseStore = defineStore('showcase', () => {
@@ -42,95 +46,125 @@ export const useShowcaseStore = defineStore('showcase', () => {
const exhausted = ref(false) const exhausted = ref(false)
const seen = new Set() const seen = new Set()
// Sequence token: every call to loadInitial bumps this. _trickleAppend // Internal buffer (not reactive — the consumer is what feeds the UI via
// bails between items if its captured seq is no longer current — guards // images.value).
// against a fast shuffle / mount-then-shuffle from interleaving two let queue = []
// trickles into the same images.value. // 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 _seq = 0
let _filling = false
let _draining = false
async function _trickleAppend(items, mySeq) { function _preload(item) {
for (const item of items) { if (!_preloads.has(item.id)) _preloads.set(item.id, preloadImage(item.thumbnail_url))
if (mySeq !== _seq) return return _preloads.get(item.id)
if (seen.has(item.id)) continue
seen.add(item.id)
images.value.push(item)
await _sleep(APPEND_DELAY_MS)
}
} }
// Single batch — used by infinite-scroll appends. Trickles its items // One batch, retried while the random sample comes back all-duplicates.
// in for the same one-at-a-time cadence as the initial load. Retries // Returns the fresh items, or null when the API is genuinely empty.
// up to FETCH_RETRY_CAP times when the API's random sample comes back async function _fetchFresh(mySeq) {
// all-duplicates (the showcase is endless by design; only a genuinely for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) {
// empty API response should mark it exhausted, not an unlucky sample). if (mySeq !== _seq) return []
async function fetchPage() { const body = await api
if (loading.value) return .get('/api/showcase', { params: { limit: PAGE } })
loading.value = true .catch((e) => {
error.value = null 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 { try {
for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) { while (mySeq === _seq && !exhausted.value && queue.length < target) {
const body = await api.get('/api/showcase', { params: { limit: PAGE } }) const batch = await _fetchFresh(mySeq)
const items = body.images || [] if (mySeq !== _seq) return
// API genuinely empty → library is empty / endpoint exhausted. if (batch === null) { exhausted.value = true; return }
if (items.length === 0) { exhausted.value = true; return } queue.push(...batch)
const fresh = items.filter(i => !seen.has(i.id)) batch.forEach(_preload) // pipeline decoding ahead of the reveal
if (fresh.length > 0) {
await _trickleAppend(fresh, _seq)
return
}
// All-dupes batch — keep trying. Showcase is endless by intent.
} }
// 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 { } finally {
loading.value = false _filling = false
} }
} }
function _fetchOne() { // Consumer: show `count` items at a fixed cadence, topping up the buffer as
return api.get('/api/showcase', { params: { limit: PAGE } }).catch(e => { // it drains. Single-flight so the initial cascade and scroll appends can't
error.value = error.value || (e.message || String(e)) // interleave.
return null 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() { async function loadInitial() {
_seq += 1 _seq += 1
const mySeq = _seq const mySeq = _seq
images.value = [] images.value = []
queue = []
_preloads = new Map()
seen.clear() seen.clear()
exhausted.value = false exhausted.value = false
error.value = null error.value = null
_filling = false
_draining = false
loading.value = true loading.value = true
try { try {
let nextFetch = _fetchOne() // Prime a small buffer before the cascade starts so it doesn't starve
for (let i = 0; i < INITIAL_BATCHES; i++) { // at the front; the drain's own topup grows it to BUFFER_TARGET.
if (mySeq !== _seq) return await _fill(mySeq, PRIME)
const body = await nextFetch if (mySeq !== _seq) return
// Fire the NEXT fetch immediately so its RTT overlaps the trickle. if (queue.length === 0 && exhausted.value) return // empty library
if (i + 1 < INITIAL_BATCHES) nextFetch = _fetchOne() await _drain(mySeq, INITIAL_COUNT)
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
} finally { } finally {
if (mySeq === _seq) loading.value = false 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() { async function shuffle() {
await loadInitial() await loadInitial()
} }
+21
View File
@@ -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)
})
}
+21
View File
@@ -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('')
})
})
+44
View File
@@ -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')
})
})
+81 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useGalleryStore } from '../src/stores/gallery.js' import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'
function stubFetch(handler) { function stubFetch(handler) {
globalThis.fetch = vi.fn(async (url, init) => { globalThis.fetch = vi.fn(async (url, init) => {
@@ -73,6 +73,55 @@ describe('gallery store: composable filter', () => {
expect(s.artistLabel).toBe('Kubo') 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 () => { it('loadInitial issues exactly one scroll request at the initial limit', async () => {
const s = useGalleryStore() const s = useGalleryStore()
const urls = [] const urls = []
@@ -88,3 +137,34 @@ describe('gallery store: composable filter', () => {
expect(scrollCalls[0]).toContain('limit=50') 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')
})
})
+97
View File
@@ -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)
})
})
+31
View File
@@ -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
View File
@@ -104,32 +104,51 @@ async def client(app):
_SEED_SNAPSHOT: dict[str, list[dict]] | None = None _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) @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 """Integration tests exercise app/celery code that commits on its own
connection, which the rollback fixtures above can't undo — so data connection, which the rollback fixtures above can't undo — so data
would leak between tests. Capture the seeded baseline at the first would leak between tests. Capture the seeded baseline at the first
integration test, then after each integration-marked test truncate integration test, then after each integration-marked test truncate
every model table and restore that baseline. Connects to the DB ONLY every model table and restore that baseline. Touches the DB ONLY for
for integration-marked tests, so the no-DB fast unit job is untouched. 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 global _SEED_SNAPSHOT
is_integration = request.node.get_closest_marker("integration") is not None is_integration = request.node.get_closest_marker("integration") is not None
if is_integration and _SEED_SNAPSHOT is None: if is_integration and _SEED_SNAPSHOT is None:
eng = create_engine(_sync_database_url(), future=True)
snap: dict[str, list[dict]] = {} snap: dict[str, list[dict]] = {}
try: with _truncate_engine.connect() as conn:
with eng.connect() as conn: for t in Base.metadata.sorted_tables:
for t in Base.metadata.sorted_tables: rows = [
rows = [ dict(m)
dict(m) for m in conn.execute(t.select()).mappings().all()
for m in conn.execute(t.select()).mappings().all() ]
] if rows:
if rows: snap[t.name] = rows
snap[t.name] = rows
finally:
eng.dispose()
_SEED_SNAPSHOT = snap _SEED_SNAPSHOT = snap
yield yield
@@ -139,18 +158,14 @@ def _reset_db_after_integration(request):
tables = ", ".join(t.name for t in Base.metadata.sorted_tables) tables = ", ".join(t.name for t in Base.metadata.sorted_tables)
if not tables: if not tables:
return return
eng = create_engine(_sync_database_url(), future=True) with _truncate_engine.begin() as conn:
try: conn.exec_driver_sql(
with eng.begin() as conn: f"TRUNCATE {tables} RESTART IDENTITY CASCADE"
conn.exec_driver_sql( )
f"TRUNCATE {tables} RESTART IDENTITY CASCADE" for t in Base.metadata.sorted_tables:
) rows = (_SEED_SNAPSHOT or {}).get(t.name)
for t in Base.metadata.sorted_tables: if rows:
rows = (_SEED_SNAPSHOT or {}).get(t.name) conn.execute(t.insert(), rows)
if rows:
conn.execute(t.insert(), rows)
finally:
eng.dispose()
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
+26
View File
@@ -409,3 +409,29 @@ async def test_purge_legacy_commit_deletes_only_legacy(client, db):
) )
)).scalar_one() )).scalar_one()
assert gone == 0 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]
+37
View File
@@ -77,3 +77,40 @@ async def test_scroll_media_param(client, db):
resp = await client.get("/api/gallery/scroll?limit=10&media=video") resp = await client.get("/api/gallery/scroll?limit=10&media=video")
assert resp.status_code == 200 assert resp.status_code == 200
assert (await resp.get_json())["images"] == [] 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"] == []
+252
View File
@@ -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]
+9
View File
@@ -638,3 +638,12 @@ def test_recover_stalled_download_dedupes_per_source(db_sync):
select(Source.consecutive_failures).where(Source.id == sid) select(Source.consecutive_failures).where(Source.id == sid)
).scalar_one() ).scalar_one()
assert failures == 1 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)