Compare commits

...
15 Commits
Author SHA1 Message Date
bvandeusen 23aee56ce3 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
2026-06-04 08:27:47 -04:00
bvandeusenandClaude Opus 4.8 3f6ea601f8 perf(ci): collapse the 3 integration shards into one job
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m55s
With fsync-off the whole integration suite runs in ~45s (was ~13min across
shards), so the 3-way split only triplicated the ~2min fixed overhead
(container + install + migrate) and consumed 3 of 6 runner slots for no
wall-clock gain. Merge intapi/intimp/intcore into one `integration` job:
spin up once, install once, migrate once, run `pytest -m integration` over
the whole suite. Frees 2 runner slots (6 jobs -> 4) and drops ~140 lines of
near-duplicate YAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:17:50 -04:00
bvandeusenandClaude Opus 4.8 6a25db4b8b perf(ci): relax Postgres durability in integration shards (fsync off)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 2m15s
CI / intcore (push) Successful in 2m28s
CI / intapi (push) Successful in 2m34s
Option 1 (pooling the teardown connection) left teardowns at ~1.5-2s/test, so
the cost is the per-test TRUNCATE's commit forcing an fsync, not the connect
handshake. Each shard now ALTER SYSTEM SETs fsync/synchronous_commit/
full_page_writes off + pg_reload_conf() right after deps install, before
alembic — sighup/user-context GUCs apply with no restart. The DB is ephemeral
(rebuilt per run) so fsync-off is safe; the step is non-fatal so a perms
surprise can't red a shard. Speeds up every test's commit (setup inserts +
the teardown TRUNCATE), stacking on the pooled engine from the prior commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:45:21 -04:00
bvandeusenandClaude Opus 4.8 c802b26406 perf(ci): reuse a session-scoped engine for the per-test DB reset
CI / intimp (push) Successful in 3m35s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m33s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 14s
CI / frontend-build (push) Successful in 20s
The autouse integration teardown created a fresh SQLAlchemy engine + Postgres
connection for EVERY test, then disposed it — --durations showed the 15
slowest ops in both long shards were all ~1.5-2s teardowns (the connect+SCRAM
handshake, not test logic). Hoist the truncate engine to a session-scoped,
pool_pre_ping'd fixture so the pooled connection is reused across teardowns;
the TRUNCATE+restore still runs per test, so isolation is unchanged. Lazy
create_engine means the no-DB unit job instantiates but never connects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:35:28 -04:00
bvandeusenandClaude Opus 4.8 3a4270e6be fix(showcase): reveal each tile only once its image is fully decoded
CI / backend-lint-and-test (push) Successful in 13s
CI / intapi (push) Successful in 7m41s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / intimp (push) Successful in 3m33s
CI / intcore (push) Successful in 8m23s
The buffered cascade revealed tiles on an 80ms timer regardless of image
load, so the flip-in animation played on a gray placeholder and the thumbnail
popped in afterward. Worse, MasonryGrid ALSO applied a per-index
animation-delay (index×70ms) that compounded on top of the insert cadence,
so the cascade visibly dragged and desynced as it grew.

Now the producer preloads each queued thumbnail (decode pipelined ahead) and
the consumer awaits that decode before pushing the item — every tile animates
in fully loaded, strictly one at a time. Drop the compounding CSS stagger;
the store's one-item-at-a-time push is the sole pacer, so each tile animates
the instant it mounts. New utils/preloadImage.js (load+decode+timeout gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:19:44 -04:00
bvandeusenandClaude Opus 4.8 ae569c0f9a fix(gallery): ruff C408 (dict literal) + panel auto-open on deep-link
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m29s
CI / intapi (push) Successful in 7m35s
CI / intcore (push) Successful in 9m2s
Rewrite facets() common/plat_scope as dict literals (C408). Open the refine
panel via a watch on hasRefineFilters rather than reading filter state at
bar-setup time — the parent applies the URL query in its onMounted, after the
bar child has set up, so the initial read was always the default (empty) state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:14:15 -04:00
bvandeusenandClaude Opus 4.8 1adc47f59c feat(gallery): faceted refine panel UI (Phase 2 frontend)
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m34s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m30s
Add a 'Refine ▾' toggle to the gallery filter bar that expands a full-width
GalleryFacetPanel below it, inside the same sticky hazey chrome. The panel
offers platform chips (with live counts + a 'No platform' unsourced bucket),
two count-badged curation-flag toggles (Untagged / No artist), and a from/to
date range bounded by the facet min/max.

Store gains the platform/untagged/no_artist/date_from/date_to filter params
(URL-mirrored, AND-composed) and a panel-gated, single-flighted loadFacets()
that fetches /api/gallery/facets scoped to the active filter. Shared
cloneFilter/filterToQuery helpers keep the bar and panel writing one URL
format. The panel auto-opens on deep-link when refine filters are present and
refetches counts (debounced) on every filter change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:06:51 -04:00
bvandeusenandClaude Opus 4.8 9fe534139a feat(gallery): faceted filter params + /facets counts endpoint (Phase 2 backend)
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 18s
CI / intimp (push) Successful in 3m34s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m50s
Extend the composable gallery filter with platform / untagged / no_artist /
date_from / date_to, AND-composed with the existing tag/artist/media/sort
params and threaded through scroll, timeline, and jump_cursor.

Add GalleryService.facets() + GET /api/gallery/facets returning live counts
scoped to the current filter with per-group minus-self semantics: platform
counts (COUNT(DISTINCT image) incl. a null unsourced bucket), curation-flag
counts (untagged / no_artist), and effective_date min/max bounds. The
UNSOURCED_PLATFORM sentinel makes filesystem-imported content reachable via
the platform facet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:01:07 -04:00
bvandeusenandClaude Opus 4.8 16e0268da7 fix(modal): next arrow clears metadata panel + arrows work in empty tag input
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 25s
CI / intimp (push) Successful in 3m35s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m23s
Image viewer (#609):
- The next (▶) arrow was offset from the viewport edge (right:16px) so it
  floated over the 320px metadata side panel. Offset it off a shared
  --fc-side-w var so it sits at the image's right edge instead; full-width
  again below 900px when the panel stacks under the image.
- Arrow nav was fully disabled whenever a text field was focused. Now it
  yields to the caret ONLY when the field has text; an empty tag-entry field
  still navigates ←/→. Extracted to utils/textEntry.js (arrowNavAllowed).
  ESC behaviour unchanged (already closes the modal, overlay-aware).

Test: arrowNavAllowed — empty/non-text → navigate, text present → don't.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:37:16 -04:00
bvandeusenandClaude Opus 4.8 1f4ce8513b style: ruff I001 — keep _sync_engine as-import on its own line (combine-as-imports=false)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 22s
CI / frontend-build (push) Successful in 19s
CI / intapi (push) Successful in 8m17s
CI / intcore (push) Successful in 8m38s
CI / intimp (push) Successful in 4m6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:59:26 -04:00
bvandeusenandClaude Opus 4.8 5a116ca9d0 style: ruff I001 — aliased _sync_session_factory sorts before get_sync_engine
CI / lint (push) Failing after 4s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 22s
CI / intimp (push) Successful in 3m49s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 9m37s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:55:18 -04:00
bvandeusenandClaude Opus 4.8 ef3ee5aceb feat(maintenance): DB maintenance UI card + fix ruff I001
CI / lint (push) Failing after 2s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 18s
CI / intimp (push) Successful in 4m10s
CI / intapi (push) Successful in 8m26s
CI / intcore (push) Successful in 9m54s
- Settings → Maintenance gains a "Database maintenance" card: a "Run VACUUM
  ANALYZE now" button (enqueues the maintenance task) plus a per-table bloat
  readout (live/dead/dead%/last vacuum) from /api/admin/maintenance/db-stats.
- dbMaintenance store (loadStats / runVacuum) + test.
- Fix ruff I001: combine the two _sync_engine imports onto one line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:52:36 -04:00
bvandeusenandClaude Opus 4.8 914033db29 feat(maintenance): scheduled + manual DB VACUUM ANALYZE + bloat readout
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 22s
CI / frontend-build (push) Successful in 23s
CI / intimp (push) Successful in 3m35s
CI / intapi (push) Successful in 7m53s
CI / intcore (push) Successful in 9m15s
The TABLESAMPLE showcase reads physical blocks (bloat-sensitive), and the
periodic prune/backfill/recovery tasks churn dead tuples faster than
autovacuum always keeps up — so explicit maintenance earns its keep here.

- tasks.maintenance.vacuum_analyze: VACUUM (ANALYZE) over high-churn tables
  (VACUUM_TABLES) on an AUTOCOMMIT connection (VACUUM can't run in a txn).
  Scheduled weekly via Beat; also operator-triggerable.
- _sync_engine.get_sync_engine(): expose the process engine for the
  autocommit connection.
- GET  /api/admin/maintenance/db-stats: per-table n_live/n_dead/dead_pct +
  last (auto)vacuum/analyze from pg_stat_user_tables — visibility, not a
  black box.
- POST /api/admin/maintenance/vacuum: enqueue the task on demand.

Tests: vacuum task runs + reports tables; db-stats shape; trigger queues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:49:39 -04:00
bvandeusenandClaude Opus 4.8 d495605c12 style(gallery): hazey filter bar attached to the TopNav
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m43s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m49s
- Filter bar gets the same obsidian translucent + backdrop-blur as the
  TopNav so the two read as one piece of chrome.
- margin-top:-8px cancels the v-container's pt-2 so the bar sits flush at
  64px even at scroll 0 — fixes the gap/separation when scrolled to top.
- Inputs/toggles get a more-opaque backing so they stay legible on the haze.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:40:32 -04:00
bvandeusenandClaude Opus 4.8 76d8ad42a8 fix(showcase): buffered producer/consumer for a steady cascade
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m32s
CI / intapi (push) Successful in 7m35s
CI / intcore (push) Successful in 9m8s
The cascade "burped" — chunks appeared unevenly — because the old pipeline
coupled display to fetch timing: it trickled each batch right after its
fetch and assumed the next round-trip would land inside the ~240ms trickle
window. When a fetch ran long (TABLESAMPLE hits random, sometimes-cold
blocks; RTT jitter) the animation starved, then a clump burst in.

Decouple the two:
- Producer (_fill) races ahead fetching batches into a buffer up to a
  target depth, refilling when it dips below BUFFER_MIN.
- Consumer (_drain) reveals one item every CADENCE_MS regardless of when
  fetches land; it only waits if the buffer genuinely starves.

A small PRIME buffer precedes the drain so it doesn't starve at the front;
the buffer (BUFFER_MIN×CADENCE runway) absorbs per-fetch jitter so images
appear at an even pace. Public store API (loadInitial/shuffle/fetchPage/
images/loading/hasMore/isEmpty) unchanged — ShowcaseView/MasonryGrid need
no change.

Test (fake timers): fire-order + dedup, one-item-per-cadence rate limit,
empty-library flag.

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