Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8f7cd8b6 | ||
|
|
86efbf7f2c | ||
|
|
3a0cca5aca | ||
|
|
83f8af8090 | ||
|
|
a5b3702863 | ||
|
|
9a2617c1a2 | ||
|
|
509a7958cf | ||
|
|
81688815a0 | ||
|
|
5a6a95682d | ||
|
|
91b0145bc8 | ||
|
|
26e47a86cb | ||
|
|
773128c3bf | ||
|
|
928e3037f0 | ||
|
|
ce7b154ae9 | ||
|
|
b08b12eb8f | ||
|
|
9430a9d9c3 | ||
|
|
4fd6d4cc29 | ||
|
|
304e8aa878 | ||
|
|
0497394710 | ||
|
|
21a73cd1dc | ||
|
|
79cd1234e2 | ||
|
|
23aee56ce3 | ||
|
|
3f6ea601f8 | ||
|
|
6a25db4b8b | ||
|
|
c802b26406 | ||
|
|
3a4270e6be | ||
|
|
ae569c0f9a | ||
|
|
1adc47f59c | ||
|
|
9fe534139a | ||
|
|
16e0268da7 | ||
|
|
1f4ce8513b | ||
|
|
5a116ca9d0 | ||
|
|
ef3ee5aceb | ||
|
|
914033db29 | ||
|
|
d495605c12 | ||
|
|
76d8ad42a8 | ||
|
|
711abea567 | ||
|
|
6d630d13d6 | ||
|
|
3f30327fa5 | ||
|
|
4f9464d215 | ||
|
|
e678d1dfdf | ||
|
|
d9ab6e15c6 | ||
|
|
e05e0b9f37 | ||
|
|
56cc253009 | ||
|
|
844bb86802 | ||
|
|
576e16d14d | ||
|
|
a8f6a464aa | ||
|
|
9cb24c9e1b | ||
|
|
6590dcdb39 | ||
|
|
ab9922ad2e | ||
|
|
3162cff96b | ||
|
|
b65e956ad2 | ||
|
|
0533807669 | ||
|
|
d3245f0c22 | ||
|
|
279dff3fb6 | ||
|
|
e450145304 | ||
|
|
a6e8d4b52e | ||
|
|
37e66cddc4 | ||
|
|
f1860866de | ||
|
|
9cf6b2d363 | ||
|
|
b181d779fe | ||
|
|
0fbb19dc24 | ||
|
|
8326e5447a | ||
|
|
1fd594baaf | ||
|
|
ecac6c4bda | ||
|
|
6ef0fed41f | ||
|
|
9f7261b9c0 | ||
|
|
f05aaa707b | ||
|
|
4df98171ab | ||
|
|
8d75ade1d5 | ||
|
|
75c63e1511 | ||
|
|
98673d4dca | ||
|
|
89b48f8f35 | ||
|
|
4bff1d8558 | ||
|
|
d60e0b9494 | ||
|
|
e30f50e6fe |
+30
-149
@@ -92,28 +92,25 @@ jobs:
|
||||
- run: npm run test:unit
|
||||
- run: npm run build
|
||||
|
||||
# Integration suite split into THREE parallel shards (2026-05-25, runner
|
||||
# capacity bumped 2→6). Each shard gets its own Postgres + Redis service
|
||||
# set and runs alembic + a disjoint subset of integration tests. Shards
|
||||
# share no DB state, so the autouse TRUNCATE fixture in tests/conftest.py
|
||||
# stays single-threaded per shard but multiple shards run in parallel
|
||||
# wall-clock. Approximate split — rebalance once --durations=15 output
|
||||
# reveals which shard is the long pole.
|
||||
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
|
||||
# The shards existed to parallelize ~8.5min of integration tests; once the
|
||||
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
|
||||
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
|
||||
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
|
||||
# runner slots for no wall-clock gain. One job now: spin up once, install
|
||||
# once, migrate once, run every integration test.
|
||||
#
|
||||
# Each shard's docker-ps filter uses its own unique job name to scope
|
||||
# service-container resolution. act_runner appears to strip underscores
|
||||
# from job names when building container labels — `int_api` yielded
|
||||
# zero matches on 2026-05-25 — so shards use no-separator names
|
||||
# (`intapi`, `intimp`, `intcore`) instead. Each step prints
|
||||
# `docker ps -a` first so a future naming-convention shift surfaces in
|
||||
# the log without another guess-and-push cycle.
|
||||
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
|
||||
# containers by job name. act_runner strips underscores from job names when
|
||||
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
|
||||
# stays separator-free (`integration`). The step prints `docker ps -a` first
|
||||
# so a future naming-convention shift surfaces in the log without a
|
||||
# guess-and-push cycle.
|
||||
#
|
||||
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT
|
||||
# done — per ci-requirements.md, FC is the only Python consumer of that
|
||||
# image and the CI-Runner project's "add deps to image when used by >1
|
||||
# project" rule keeps the install per-job.
|
||||
|
||||
intapi:
|
||||
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
|
||||
# per ci-requirements.md, FC is the only Python consumer of that image and the
|
||||
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
|
||||
integration:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -144,14 +141,14 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: API integration shard (resolve service IPs, migrate, test)
|
||||
- name: Integration suite (resolve service IPs, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
echo "=== end landscape ==="
|
||||
PG=$(docker ps --filter "name=intapi" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intapi" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
test -n "$PG" && test -n "$RD"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||
@@ -168,130 +165,14 @@ jobs:
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
# Relax durability on the throwaway CI Postgres so the per-test
|
||||
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
|
||||
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
|
||||
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
|
||||
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
|
||||
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
|
||||
# surprise can't red the job; fabledcurator is the postgres image's
|
||||
# bootstrap superuser.
|
||||
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
|
||||
alembic upgrade head
|
||||
pytest tests/test_api_*.py -v -m integration --durations=15
|
||||
|
||||
intimp:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
DB_USER: fabledcurator
|
||||
DB_PASSWORD: ci_integration
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: fabledcurator_test
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: fabledcurator
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: fabledcurator_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U fabledcurator"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Importer integration shard (resolve service IPs, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
echo "=== end landscape ==="
|
||||
PG=$(docker ps --filter "name=intimp" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intimp" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
test -n "$PG" && test -n "$RD"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||
test -n "$PG_IP" && test -n "$RD_IP"
|
||||
export DB_HOST="$PG_IP"
|
||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
||||
for i in $(seq 1 60); do
|
||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
alembic upgrade head
|
||||
pytest tests/test_importer*.py tests/test_import_*.py tests/test_migration_*.py tests/test_phash_*.py tests/test_sidecar_*.py tests/test_scan_*.py tests/test_archive_extractor.py tests/test_backfill_phash.py -v -m integration --durations=15
|
||||
|
||||
intcore:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
DB_USER: fabledcurator
|
||||
DB_PASSWORD: ci_integration
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: fabledcurator_test
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: fabledcurator
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: fabledcurator_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U fabledcurator"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for filter scoping) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
echo "=== end landscape ==="
|
||||
PG=$(docker ps --filter "name=intcore" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
RD=$(docker ps --filter "name=intcore" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
||||
test -n "$PG" && test -n "$RD"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
||||
test -n "$PG_IP" && test -n "$RD_IP"
|
||||
export DB_HOST="$PG_IP"
|
||||
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
||||
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
||||
for i in $(seq 1 60); do
|
||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
||||
else
|
||||
pip install -r requirements.txt pytest pytest-asyncio
|
||||
fi
|
||||
alembic upgrade head
|
||||
pytest tests/ -v -m integration --durations=15 \
|
||||
--ignore-glob='tests/test_api_*.py' \
|
||||
--ignore-glob='tests/test_importer*.py' \
|
||||
--ignore-glob='tests/test_import_*.py' \
|
||||
--ignore-glob='tests/test_migration_*.py' \
|
||||
--ignore-glob='tests/test_phash_*.py' \
|
||||
--ignore-glob='tests/test_sidecar_*.py' \
|
||||
--ignore-glob='tests/test_scan_*.py' \
|
||||
--ignore-glob='tests/test_archive_extractor.py' \
|
||||
--ignore-glob='tests/test_backfill_phash.py'
|
||||
pytest tests/ -v -m integration --durations=15
|
||||
|
||||
@@ -61,6 +61,9 @@ Thumbs.db
|
||||
|
||||
# Claude Code per-user local overrides (shared .claude/settings.json is OK to commit)
|
||||
.claude/settings.local.json
|
||||
# Transient scheduler lock/state (committed by accident in 3f30327)
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/scheduled_tasks*.json
|
||||
|
||||
# Alembic / DB scratch
|
||||
alembic/versions/__pycache__/
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""source.error_type: surface ErrorType taxonomy in FailingSourcesCard
|
||||
|
||||
Revision ID: 0032
|
||||
Revises: 0031
|
||||
Create Date: 2026-06-02
|
||||
|
||||
Audit 2026-06-02: the backend computes 13 ErrorType categories (auth_error,
|
||||
rate_limited, not_found, access_denied, validation_failed, etc.) and
|
||||
stamps each one on DownloadEvent.metadata, but the Source row only carried
|
||||
the free-text last_error. Operators couldn't bulk-triage failing sources
|
||||
("all auth_error → rotate cookies, all rate_limited → just wait") without
|
||||
opening Logs per row.
|
||||
|
||||
This column receives the last error_type from _update_source_health
|
||||
and gets cleared on a successful run. Nullable + indexed so the failing-
|
||||
sources rollup can filter/group cheaply.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0032"
|
||||
down_revision: Union[str, None] = "0031"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"source",
|
||||
sa.Column("error_type", sa.String(length=32), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_source_error_type", "source", ["error_type"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_source_error_type", table_name="source")
|
||||
op.drop_column("source", "error_type")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""suggestion_threshold default 0.50 → 0.70
|
||||
|
||||
Revision ID: 0033
|
||||
Revises: 0032
|
||||
Create Date: 2026-06-02
|
||||
|
||||
Operator-flagged 2026-06-02 — the 0.50 default (set on 2026-06-01) is
|
||||
too noisy in practice; raise to 0.70 for both suggestion categories.
|
||||
|
||||
Only conditionally updates singletons whose current value is still the
|
||||
2026-06-01 default (0.50). Operators who deliberately tuned their row
|
||||
to some other value (0.55, 0.65, 0.80, etc. via the Settings UI) keep
|
||||
their pick — the migration only catches the unchanged-default case.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0033"
|
||||
down_revision: Union[str, None] = "0032"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"UPDATE ml_settings "
|
||||
"SET suggestion_threshold_character = 0.70 "
|
||||
"WHERE id = 1 AND suggestion_threshold_character = 0.50"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE ml_settings "
|
||||
"SET suggestion_threshold_general = 0.70 "
|
||||
"WHERE id = 1 AND suggestion_threshold_general = 0.50"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"UPDATE ml_settings "
|
||||
"SET suggestion_threshold_character = 0.50 "
|
||||
"WHERE id = 1 AND suggestion_threshold_character = 0.70"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE ml_settings "
|
||||
"SET suggestion_threshold_general = 0.50 "
|
||||
"WHERE id = 1 AND suggestion_threshold_general = 0.70"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""artist_visit: per-artist last-viewed timestamp for the "+N new" badge
|
||||
|
||||
Revision ID: 0034
|
||||
Revises: 0033
|
||||
Create Date: 2026-06-03
|
||||
|
||||
Powers the artists-directory "+N new since last visit" badge + ArtistView
|
||||
banner. Single row per artist (no user_id yet — rule #47 multi-user ACL
|
||||
is aspirational; widens to (user_id, artist_id) PK when User lands).
|
||||
|
||||
Seed every existing artist with `last_viewed_at = NOW()` so the badge
|
||||
starts at 0 across the board — no noisy "you have 5000 unseen images"
|
||||
on first deploy. New artists auto-get a row via
|
||||
`ArtistService.find_or_create`.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0034"
|
||||
down_revision: Union[str, None] = "0033"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"artist_visit",
|
||||
sa.Column(
|
||||
"artist_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("artist.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"last_viewed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
)
|
||||
# Seed: every existing artist starts "fully caught up". Without this,
|
||||
# every operator with N artists would see N badges (worth of every
|
||||
# image ever imported) on first deploy.
|
||||
op.execute(
|
||||
"INSERT INTO artist_visit (artist_id, last_viewed_at) "
|
||||
"SELECT id, NOW() FROM artist"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("artist_visit")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""image_record.effective_date: materialized gallery sort key + index
|
||||
|
||||
Revision ID: 0035
|
||||
Revises: 0034
|
||||
Create Date: 2026-06-04
|
||||
|
||||
The gallery ordered/cursored on COALESCE(post.post_date,
|
||||
image_record.created_at) across the Post outer join. That expression spans
|
||||
two tables, so no index can serve it — every /scroll sorted a large slice
|
||||
of the library, and the frontend fired ten of them serially per initial
|
||||
load. Materialize the value into image_record.effective_date and index
|
||||
(effective_date DESC, id DESC) so the cursor scroll is an index range scan.
|
||||
|
||||
Backfill = COALESCE(primary post's post_date, created_at) so existing rows
|
||||
keep their exact ordering. New rows get the created_at-equivalent server
|
||||
default; services/importer.py overrides it with the post's date when a
|
||||
primary post with a date is linked.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0035"
|
||||
down_revision: Union[str, None] = "0034"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add nullable first so the backfill can populate before NOT NULL.
|
||||
op.add_column(
|
||||
"image_record",
|
||||
sa.Column("effective_date", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Pure set-based UPDATEs (no per-row params) — immune to the 65535
|
||||
# bind-parameter ceiling regardless of library size.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE image_record AS ir
|
||||
SET effective_date = COALESCE(p.post_date, ir.created_at)
|
||||
FROM post AS p
|
||||
WHERE ir.primary_post_id = p.id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE image_record
|
||||
SET effective_date = created_at
|
||||
WHERE effective_date IS NULL
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"image_record",
|
||||
"effective_date",
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
)
|
||||
# DESC/DESC matches the gallery's ORDER BY effective_date DESC, id DESC
|
||||
# so the scroll is a forward index scan; raw SQL because alembic's
|
||||
# column list doesn't express per-column DESC cleanly.
|
||||
op.execute(
|
||||
"CREATE INDEX ix_image_record_effective_date "
|
||||
"ON image_record (effective_date DESC, id DESC)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_image_record_effective_date", table_name="image_record")
|
||||
op.drop_column("image_record", "effective_date")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""image_record.siglip_embedding: HNSW cosine index for "more like this"
|
||||
|
||||
Revision ID: 0036
|
||||
Revises: 0035
|
||||
Create Date: 2026-06-04
|
||||
|
||||
Gallery Phase 3 (visual similarity search) ranks images by
|
||||
`siglip_embedding.cosine_distance(source_embedding)`. Without an index that's
|
||||
a sequential scan computing a 1152-dim distance for every row — fine at small
|
||||
scale, but it grows linearly with the library. Add an HNSW index with
|
||||
`vector_cosine_ops` so the top-N nearest search is sub-50ms ANN.
|
||||
|
||||
1152 dims is under pgvector's 2000-dim HNSW limit, so HNSW (no training,
|
||||
better recall than IVFFlat) is the right choice. ONE-TIME COST: building the
|
||||
index over the existing embeddings (~57k vectors on the operator's library)
|
||||
locks image_record for ~30-60s during this migration on deploy — acceptable
|
||||
for a single-operator homelab. NULL embeddings (videos / not-yet-embedded
|
||||
rows) are simply not indexed.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0036"
|
||||
down_revision: Union[str, None] = "0035"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Raw SQL: alembic's create_index doesn't express the `USING hnsw (...
|
||||
# vector_cosine_ops)` access-method + opclass cleanly. Must match the
|
||||
# query's cosine_distance operator class to be usable by the planner.
|
||||
op.execute(
|
||||
"CREATE INDEX ix_image_record_siglip_hnsw "
|
||||
"ON image_record USING hnsw (siglip_embedding vector_cosine_ops)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_image_record_siglip_hnsw", table_name="image_record")
|
||||
@@ -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,70 @@ async def tags_purge_legacy():
|
||||
lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run)
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/reset-content", methods=["POST"])
|
||||
async def tags_reset_content():
|
||||
"""Tier-A: delete ALL general + character tags (the Camie-suggestable
|
||||
content vocabulary) so the operator can re-tag from scratch via
|
||||
auto-suggest. fandom + series tags + series_page ordering are preserved,
|
||||
and image tagger_predictions are untouched so suggestions repopulate.
|
||||
dry-run preview returns per-kind counts + applications + a sample so the
|
||||
UI shows exactly what'll go before the operator confirms (dry_run=false).
|
||||
Irreversible except via DB backup restore."""
|
||||
from ..services.cleanup_service import reset_content_tagging
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", False))
|
||||
|
||||
async with get_session() as session:
|
||||
result = await session.run_sync(
|
||||
lambda sync_sess: reset_content_tagging(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
|
||||
|
||||
@@ -154,12 +154,15 @@ async def audit_history():
|
||||
limit = min(int(request.args.get("limit", "20")), 100)
|
||||
except ValueError:
|
||||
return _bad("invalid_limit")
|
||||
# Optional rule filter so a card can reconnect to ITS latest run on mount
|
||||
# (?rule=transparency&limit=1) — the audit survives navigation; the UI
|
||||
# rehydrates from this rather than losing the in-flight scan.
|
||||
rule = request.args.get("rule") or None
|
||||
async with get_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(LibraryAuditRun)
|
||||
.order_by(LibraryAuditRun.id.desc())
|
||||
.limit(limit)
|
||||
)).scalars().all()
|
||||
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
|
||||
if rule is not None:
|
||||
stmt = stmt.where(LibraryAuditRun.rule == rule)
|
||||
rows = (await session.execute(stmt.limit(limit))).scalars().all()
|
||||
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
||||
|
||||
|
||||
|
||||
+131
-44
@@ -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,47 +10,88 @@ from ..services.gallery_service import GalleryService
|
||||
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
||||
|
||||
|
||||
def _image_json(i):
|
||||
"""Serialize a GalleryImage for the scroll/similar list responses."""
|
||||
return {
|
||||
"id": i.id,
|
||||
"sha256": i.sha256,
|
||||
"mime": i.mime,
|
||||
"width": i.width,
|
||||
"height": i.height,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
||||
"thumbnail_url": i.thumbnail_url,
|
||||
"artist": i.artist,
|
||||
}
|
||||
|
||||
|
||||
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, 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
|
||||
) or None
|
||||
post_id_raw = request.args.get("post_id")
|
||||
post_id = int(post_id_raw) if post_id_raw else None
|
||||
artist_id_raw = request.args.get("artist_id")
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
media = request.args.get("media")
|
||||
media_type = media if media in ("image", "video") else None
|
||||
sort = request.args.get("sort")
|
||||
sort = sort if sort in ("newest", "oldest") else "newest"
|
||||
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"])
|
||||
async def scroll():
|
||||
cursor = request.args.get("cursor") or None
|
||||
try:
|
||||
limit = int(request.args.get("limit", "50"))
|
||||
filters, sort = _parse_filters()
|
||||
except ValueError:
|
||||
return jsonify({"error": "limit must be an integer"}), 400
|
||||
tag_id_raw = request.args.get("tag_id")
|
||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
||||
post_id_raw = request.args.get("post_id")
|
||||
post_id = int(post_id_raw) if post_id_raw else None
|
||||
artist_id_raw = request.args.get("artist_id")
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
return jsonify({"error": "invalid filter or limit parameter"}), 400
|
||||
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
page = await svc.scroll(
|
||||
cursor=cursor, limit=limit, tag_id=tag_id,
|
||||
post_id=post_id, artist_id=artist_id,
|
||||
cursor=cursor, limit=limit, sort=sort, **filters,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"id": i.id,
|
||||
"sha256": i.sha256,
|
||||
"mime": i.mime,
|
||||
"width": i.width,
|
||||
"height": i.height,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
"posted_at": i.posted_at.isoformat() if i.posted_at else None,
|
||||
"effective_date": i.effective_date.isoformat(),
|
||||
"thumbnail_url": i.thumbnail_url,
|
||||
"artist": i.artist,
|
||||
}
|
||||
for i in page.images
|
||||
],
|
||||
"images": [_image_json(i) for i in page.images],
|
||||
"next_cursor": page.next_cursor,
|
||||
"date_groups": [
|
||||
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
|
||||
@@ -57,20 +100,46 @@ async def scroll():
|
||||
)
|
||||
|
||||
|
||||
@gallery_bp.route("/timeline", methods=["GET"])
|
||||
async def timeline():
|
||||
tag_id_raw = request.args.get("tag_id")
|
||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
||||
post_id_raw = request.args.get("post_id")
|
||||
post_id = int(post_id_raw) if post_id_raw else None
|
||||
artist_id_raw = request.args.get("artist_id")
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
@gallery_bp.route("/similar", methods=["GET"])
|
||||
async def similar():
|
||||
"""Visual "more like this": images ranked by cosine distance to the
|
||||
`similar_to` image's embedding. Composes with the scope filters (AND) but
|
||||
ignores post_id and sort. Bounded top-N, no cursor."""
|
||||
try:
|
||||
similar_to = int(request.args["similar_to"])
|
||||
limit = int(request.args.get("limit", "100"))
|
||||
filters, _sort = _parse_filters()
|
||||
except (KeyError, ValueError):
|
||||
return jsonify({"error": "similar_to query param required"}), 400
|
||||
# post_id is the exclusive post-detail view — not a similarity scope.
|
||||
scope = {k: v for k, v in filters.items() if k != "post_id"}
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
buckets = await svc.timeline(
|
||||
tag_id=tag_id, post_id=post_id, artist_id=artist_id
|
||||
)
|
||||
images = await svc.similar(image_id=similar_to, limit=limit, **scope)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if images is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"images": [_image_json(i) for i in images],
|
||||
"next_cursor": None,
|
||||
"date_groups": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@gallery_bp.route("/timeline", methods=["GET"])
|
||||
async def timeline():
|
||||
try:
|
||||
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(**filters)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(
|
||||
@@ -78,25 +147,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"])
|
||||
filters, sort = _parse_filters()
|
||||
except (KeyError, ValueError):
|
||||
return jsonify({"error": "year and month query params required"}), 400
|
||||
tag_id_raw = request.args.get("tag_id")
|
||||
tag_id = int(tag_id_raw) if tag_id_raw else None
|
||||
post_id_raw = request.args.get("post_id")
|
||||
post_id = int(post_id_raw) if post_id_raw else None
|
||||
artist_id_raw = request.args.get("artist_id")
|
||||
artist_id = int(artist_id_raw) if artist_id_raw else None
|
||||
async with get_session() as session:
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
cursor = await svc.jump_cursor(
|
||||
year=year, month=month, tag_id=tag_id,
|
||||
post_id=post_id, artist_id=artist_id,
|
||||
year=year, month=month, sort=sort, **filters,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
@@ -35,10 +35,26 @@ async def trigger_scan():
|
||||
@import_admin_bp.route("/status", methods=["GET"])
|
||||
async def status():
|
||||
async with get_session() as session:
|
||||
# Active batch = running batch that still has outstanding work.
|
||||
# Plain "most recent running" picks freshly-created scans that
|
||||
# enqueued zero new files and hides the older batch that's
|
||||
# actually being processed. Mirrors the EXISTS predicate
|
||||
# /api/system/stats already uses (api/settings.py:145-160).
|
||||
# Audit 2026-06-02 — /api/import/status and /api/system/stats
|
||||
# used to disagree on the active-batch predicate; the UI banner
|
||||
# said "Scanning…" indefinitely while the stats card said idle.
|
||||
active = (
|
||||
await session.execute(
|
||||
select(ImportBatch)
|
||||
.where(ImportBatch.status == "running")
|
||||
.where(
|
||||
ImportBatch.status == "running",
|
||||
select(ImportTask.id)
|
||||
.where(
|
||||
ImportTask.batch_id == ImportBatch.id,
|
||||
ImportTask.status.in_(["pending", "queued", "processing"]),
|
||||
)
|
||||
.exists(),
|
||||
)
|
||||
.order_by(ImportBatch.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
+48
-6
@@ -194,15 +194,46 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
|
||||
return "", 204
|
||||
|
||||
|
||||
@tags_bp.route("/tags/<int:tag_id>", methods=["GET"])
|
||||
async def get_tag(tag_id: int):
|
||||
"""Resolve a single tag (used by the gallery to label its active
|
||||
tag-filter chip)."""
|
||||
async with get_session() as session:
|
||||
tag = await session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
return jsonify({"error": "tag not found"}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"kind": tag.kind.value,
|
||||
"fandom_id": tag.fandom_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
|
||||
async def rename_tag(tag_id: int):
|
||||
body = await request.get_json()
|
||||
if not body or "name" not in body:
|
||||
return jsonify({"error": "name required"}), 400
|
||||
async def update_tag(tag_id: int):
|
||||
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
|
||||
`fandom_id` (a fandom tag id, or null to clear — character tags only).
|
||||
`merge: true` resolves a collision by merging into the existing tag.
|
||||
"""
|
||||
body = await request.get_json() or {}
|
||||
has_name = "name" in body
|
||||
has_fandom = "fandom_id" in body
|
||||
if not has_name and not has_fandom:
|
||||
return jsonify({"error": "name or fandom_id required"}), 400
|
||||
do_merge = bool(body.get("merge"))
|
||||
async with get_session() as session:
|
||||
svc = TagService(session)
|
||||
try:
|
||||
tag = await svc.rename(tag_id, body["name"])
|
||||
tag = None
|
||||
if has_name:
|
||||
tag = await svc.rename(tag_id, body["name"])
|
||||
if has_fandom:
|
||||
tag = await svc.set_fandom(
|
||||
tag_id, body["fandom_id"], merge=do_merge
|
||||
)
|
||||
except TagMergeConflict as exc:
|
||||
return jsonify(
|
||||
{
|
||||
@@ -219,7 +250,12 @@ async def rename_tag(tag_id: int):
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
await session.commit()
|
||||
return jsonify(
|
||||
{"id": tag.id, "name": tag.name, "kind": tag.kind.value}
|
||||
{
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"kind": tag.kind.value,
|
||||
"fandom_id": tag.fandom_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -245,6 +281,12 @@ async def merge_tag(source_id: int):
|
||||
from ..tasks.ml import apply_allowlist_tags
|
||||
|
||||
apply_allowlist_tags.delay(tag_id=result.target_id)
|
||||
# Tag merge invalidates the target's centroid (the merged-in source
|
||||
# tag's images now contribute to it). Daily list_drifted catches it
|
||||
# within 24h, but eager recompute closes the suggestion-quality dip
|
||||
# in the meantime. Audit 2026-06-02.
|
||||
from ..tasks.ml import recompute_centroid
|
||||
recompute_centroid.delay(result.target_id)
|
||||
return jsonify(
|
||||
{
|
||||
"target": {
|
||||
|
||||
@@ -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
|
||||
@@ -105,6 +109,41 @@ def make_celery() -> Celery:
|
||||
"task": "backend.app.tasks.backup.prune_backups",
|
||||
"schedule": 86400.0, # daily
|
||||
},
|
||||
# Audit 2026-06-02 — three new per-entity recovery sweeps.
|
||||
# Each runs every 5 min like the other recover_stalled_*
|
||||
# sweeps; each is a no-op when nothing is stuck.
|
||||
"recover-stalled-backup-runs": {
|
||||
"task": "backend.app.tasks.maintenance.recover_stalled_backup_runs",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
"recover-stalled-library-audit-runs": {
|
||||
"task": "backend.app.tasks.maintenance.recover_stalled_library_audit_runs",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
"recover-stalled-import-batches": {
|
||||
"task": "backend.app.tasks.maintenance.recover_stalled_import_batches",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
# Audit 2026-06-02 — daily retention for two entities
|
||||
# whose terminal rows otherwise accumulate forever.
|
||||
"prune-library-audit-runs": {
|
||||
"task": "backend.app.tasks.maintenance.prune_library_audit_runs",
|
||||
"schedule": 86400.0,
|
||||
},
|
||||
"prune-import-batches": {
|
||||
"task": "backend.app.tasks.maintenance.prune_import_batches",
|
||||
"schedule": 86400.0,
|
||||
},
|
||||
# Audit 2026-06-02 — backfill_thumbnails's docstring claimed
|
||||
# "periodic Beat" but the entry was never registered, so the
|
||||
# library got no self-healing thumbnail repair; only the
|
||||
# manual admin-UI button fired it. Daily cadence is gentle
|
||||
# (the task is idempotent and only enqueues regen for rows
|
||||
# whose stored thumbnails are missing or corrupt).
|
||||
"backfill-thumbnails-daily": {
|
||||
"task": "backend.app.tasks.thumbnail.backfill_thumbnails",
|
||||
"schedule": 86400.0,
|
||||
},
|
||||
},
|
||||
timezone="UTC",
|
||||
)
|
||||
|
||||
@@ -54,7 +54,14 @@ _INT32_MIN = -2_147_483_648
|
||||
|
||||
def _queue_for(task) -> str:
|
||||
"""Reverse the task→queue routing from celery_app.task_routes.
|
||||
Keep in sync if task_routes is reordered."""
|
||||
Keep in sync if task_routes is reordered.
|
||||
|
||||
Audit 2026-06-02: backup/admin/library_audit prefixes were
|
||||
missing here even though task_routes sent all three to
|
||||
'maintenance'. The TaskRun.queue column then lied for those
|
||||
rows (claimed 'default') so per-queue dashboard filters and
|
||||
per-queue threshold overrides silently missed them.
|
||||
"""
|
||||
name = getattr(task, "name", "") or ""
|
||||
if name.startswith("backend.app.tasks.import_file."):
|
||||
return "import"
|
||||
@@ -66,7 +73,12 @@ def _queue_for(task) -> str:
|
||||
return "download"
|
||||
if name.startswith("backend.app.tasks.scan."):
|
||||
return "scan"
|
||||
if name.startswith("backend.app.tasks.maintenance."):
|
||||
if name.startswith((
|
||||
"backend.app.tasks.maintenance.",
|
||||
"backend.app.tasks.backup.",
|
||||
"backend.app.tasks.admin.",
|
||||
"backend.app.tasks.library_audit.",
|
||||
)):
|
||||
return "maintenance"
|
||||
return "default"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from .app_setting import AppSetting
|
||||
from .artist import Artist
|
||||
from .artist_visit import ArtistVisit
|
||||
from .backup_run import BackupRun
|
||||
from .base import Base
|
||||
from .credential import Credential
|
||||
@@ -28,6 +29,7 @@ __all__ = [
|
||||
"Base",
|
||||
"AppSetting",
|
||||
"Artist",
|
||||
"ArtistVisit",
|
||||
"BackupRun",
|
||||
"Source",
|
||||
"Credential",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""ArtistVisit — per-artist 'last viewed' timestamp.
|
||||
|
||||
Powers the "+N new since last visit" badge on the artists directory and
|
||||
the matching banner on `ArtistView`. One row per artist, single global
|
||||
operator. When the multi-user model lands, the PK widens to
|
||||
`(user_id, artist_id)` — currently aspirational only (no User model,
|
||||
no services/access.py); operator approved skipping `user_id` for now
|
||||
under rule #22 (breaking changes welcome).
|
||||
|
||||
Seed at migration time: every existing artist gets `last_viewed_at = NOW()`
|
||||
so the badge starts at 0 across the board (no noisy "5000 unseen" on
|
||||
first deploy). New artists also auto-get a row via
|
||||
`ArtistService.find_or_create`.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ArtistVisit(Base):
|
||||
__tablename__ = "artist_visit"
|
||||
|
||||
artist_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("artist.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
last_viewed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
@@ -74,6 +74,17 @@ class ImageRecord(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
# Denormalized gallery sort key = COALESCE(primary post's post_date,
|
||||
# created_at) (alembic 0035). The gallery used to compute this as a
|
||||
# COALESCE across the Post outer join on every /scroll, which can't use
|
||||
# an index and re-sorted a large slice of the library per page (×10 with
|
||||
# the old serial batching). Materializing it lets the cursor scroll read
|
||||
# ix_image_record_effective_date directly. Maintained by the importer
|
||||
# (services/importer.py _apply_sidecar) when a primary post with a date
|
||||
# is linked; plain inserts keep the created_at-equivalent server default.
|
||||
effective_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
||||
@@ -16,13 +16,14 @@ class MLSettings(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
suggestion_threshold_character: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
Float, nullable=False, default=0.70
|
||||
)
|
||||
# Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that
|
||||
# 0.95 hid most general suggestions. Operator-tunable via Settings →
|
||||
# ML if too noisy.
|
||||
# Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50
|
||||
# surfaced too many low-confidence picks; 0.70 keeps the rail
|
||||
# signal-rich while still surfacing more than the original 0.95
|
||||
# which hid almost everything. Operator-tunable via Settings → ML.
|
||||
suggestion_threshold_general: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
Float, nullable=False, default=0.70
|
||||
)
|
||||
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.55
|
||||
|
||||
@@ -26,6 +26,11 @@ class Source(Base):
|
||||
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# alembic 0032: last ErrorType category (auth_error, rate_limited,
|
||||
# not_found, ...). Lets FailingSourcesCard surface the taxonomy as
|
||||
# a colored chip so operators can bulk-triage by error class. Set
|
||||
# by _update_source_health alongside last_error; cleared on 'ok'.
|
||||
error_type: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ from __future__ import annotations
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import and_, exists, func, or_, select
|
||||
from sqlalchemy import and_, case, exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Artist, ImageRecord, Source
|
||||
from ..models import Artist, ArtistVisit, ImageRecord, Source
|
||||
from .gallery_service import thumbnail_url
|
||||
|
||||
_SEP = "|"
|
||||
@@ -58,9 +58,27 @@ class ArtistDirectoryService:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
|
||||
count_col = func.count(ImageRecord.id).label("image_count")
|
||||
# Unseen = images imported since the artist's last_viewed_at.
|
||||
# NULL last_viewed_at (artist created before alembic 0034 seed
|
||||
# or before find_or_create autoseed) defensively counts as
|
||||
# "never visited" → all images unseen. Single grouped query, no
|
||||
# N+1.
|
||||
unseen_col = func.count(
|
||||
case(
|
||||
(
|
||||
or_(
|
||||
ArtistVisit.last_viewed_at.is_(None),
|
||||
ImageRecord.created_at > ArtistVisit.last_viewed_at,
|
||||
),
|
||||
ImageRecord.id,
|
||||
),
|
||||
else_=None,
|
||||
)
|
||||
).label("unseen_count")
|
||||
stmt = (
|
||||
select(Artist, count_col)
|
||||
select(Artist, count_col, unseen_col)
|
||||
.outerjoin(ImageRecord, ImageRecord.artist_id == Artist.id)
|
||||
.outerjoin(ArtistVisit, ArtistVisit.artist_id == Artist.id)
|
||||
.group_by(Artist.id)
|
||||
)
|
||||
if q:
|
||||
@@ -94,7 +112,7 @@ class ArtistDirectoryService:
|
||||
next_cursor = _encode(last_artist.name, last_artist.id)
|
||||
rows = rows[:limit]
|
||||
|
||||
artist_ids = [a.id for a, _ in rows]
|
||||
artist_ids = [a.id for a, _, _ in rows]
|
||||
previews = await self._previews(artist_ids)
|
||||
|
||||
cards = [
|
||||
@@ -104,9 +122,10 @@ class ArtistDirectoryService:
|
||||
"slug": artist.slug,
|
||||
"is_subscription": bool(artist.is_subscription),
|
||||
"image_count": int(image_count),
|
||||
"unseen_count": int(unseen_count),
|
||||
"preview_thumbnails": previews.get(artist.id, []),
|
||||
}
|
||||
for artist, image_count in rows
|
||||
for artist, image_count, unseen_count in rows
|
||||
]
|
||||
return DirectoryPage(cards=cards, next_cursor=next_cursor)
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ Dates come from Post.post_date via ImageProvenance.post_id.
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import (
|
||||
Artist,
|
||||
ArtistVisit,
|
||||
ImageProvenance,
|
||||
ImageRecord,
|
||||
Post,
|
||||
@@ -122,6 +124,12 @@ class ArtistService:
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
# Mark this artist as "visited now"; the returned count is what
|
||||
# the operator should see in the banner ("N new since last
|
||||
# visit"). Done LAST so the read aggregates above all see the
|
||||
# pre-visit state (cosmetic — none depend on visit data).
|
||||
unseen_at_visit = await self._mark_visited_returning_unseen(aid)
|
||||
|
||||
return {
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
@@ -129,6 +137,7 @@ class ArtistService:
|
||||
"is_subscription": bool(artist.is_subscription),
|
||||
"image_count": int(image_count),
|
||||
"post_count": int(post_count),
|
||||
"unseen_count_at_visit": unseen_at_visit,
|
||||
"date_range": {
|
||||
"min": dmin.isoformat() if dmin else None,
|
||||
"max": dmax.isoformat() if dmax else None,
|
||||
@@ -157,6 +166,39 @@ class ArtistService:
|
||||
],
|
||||
}
|
||||
|
||||
async def _mark_visited_returning_unseen(self, artist_id: int) -> int:
|
||||
"""Read pre-visit `last_viewed_at`, count images added since,
|
||||
then upsert `last_viewed_at = NOW()`. Returns the count BEFORE
|
||||
the upsert so the banner has data to render.
|
||||
|
||||
Postgres UPSERT (`ON CONFLICT DO UPDATE`) keeps the write
|
||||
atomic — no SELECT-then-INSERT race per
|
||||
`reference_scalar_one_or_none_duplicates`.
|
||||
"""
|
||||
prev = (
|
||||
await self.session.execute(
|
||||
select(ArtistVisit.last_viewed_at).where(
|
||||
ArtistVisit.artist_id == artist_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
count_stmt = select(func.count(ImageRecord.id)).where(
|
||||
ImageRecord.artist_id == artist_id
|
||||
)
|
||||
if prev is not None:
|
||||
count_stmt = count_stmt.where(ImageRecord.created_at > prev)
|
||||
unseen = (await self.session.execute(count_stmt)).scalar_one()
|
||||
|
||||
upsert = pg_insert(ArtistVisit.__table__).values(artist_id=artist_id)
|
||||
upsert = upsert.on_conflict_do_update(
|
||||
index_elements=["artist_id"],
|
||||
set_={"last_viewed_at": func.now()},
|
||||
)
|
||||
await self.session.execute(upsert)
|
||||
await self.session.commit()
|
||||
return int(unseen)
|
||||
|
||||
async def images(
|
||||
self, slug: str, cursor: str | None, limit: int = 60
|
||||
) -> ArtistImagesPage | None:
|
||||
@@ -208,27 +250,39 @@ class ArtistService:
|
||||
)
|
||||
|
||||
async def find_or_create(self, name: str) -> tuple[Artist, bool]:
|
||||
"""Return (artist, created). Slug-keyed; idempotent under races."""
|
||||
"""Return (artist, created). Slug-keyed; idempotent under races.
|
||||
|
||||
Audit 2026-06-02: switched from session.rollback() to a
|
||||
begin_nested savepoint + IntegrityError recovery so a lost
|
||||
race doesn't unwind the calling request's surrounding work.
|
||||
Mirrors importer._get_or_create.
|
||||
"""
|
||||
cleaned = (name or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("artist name must not be empty")
|
||||
slug = slugify(cleaned)
|
||||
|
||||
existing = (await self.session.execute(
|
||||
select(Artist).where(Artist.slug == slug)
|
||||
)).scalar_one_or_none()
|
||||
select_existing = select(Artist).where(Artist.slug == slug)
|
||||
existing = (await self.session.execute(select_existing)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
|
||||
artist = Artist(name=cleaned, slug=slug)
|
||||
self.session.add(artist)
|
||||
sp = await self.session.begin_nested()
|
||||
try:
|
||||
artist = Artist(name=cleaned, slug=slug)
|
||||
self.session.add(artist)
|
||||
await self.session.flush()
|
||||
# New artist starts "caught up" — seed ArtistVisit so the
|
||||
# directory's `+N new` badge stays at 0 until real new
|
||||
# content arrives. Without this, the unseen-count query
|
||||
# treats NULL last_viewed_at as "never visited" and would
|
||||
# count every image imported in the same session.
|
||||
self.session.add(ArtistVisit(artist_id=artist.id))
|
||||
await self.session.flush()
|
||||
await sp.commit()
|
||||
except IntegrityError:
|
||||
await self.session.rollback()
|
||||
existing = (await self.session.execute(
|
||||
select(Artist).where(Artist.slug == slug)
|
||||
)).scalar_one()
|
||||
await sp.rollback()
|
||||
existing = (await self.session.execute(select_existing)).scalar_one()
|
||||
return existing, False
|
||||
await self.session.commit()
|
||||
return artist, True
|
||||
|
||||
@@ -11,7 +11,7 @@ the one-and-done GS/IR migration tooling.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -187,10 +187,14 @@ def unlink_image_files(
|
||||
out["thumbnail"] = True
|
||||
except OSError:
|
||||
out["thumbnail"] = False
|
||||
# Convention thumbs dir — try all extensions; missing OK.
|
||||
# Convention thumbs dir — try both extensions thumbnailer writes
|
||||
# (.jpg for opaque, .png for alpha). `.webp` used to be in this
|
||||
# tuple but the thumbnailer never writes it (operator-flagged in
|
||||
# the 2026-06-02 audit) — keep the tuple aligned with what
|
||||
# actually lands on disk.
|
||||
if image.sha256:
|
||||
bucket = image.sha256[:3]
|
||||
for ext in ("jpg", "png", "webp"):
|
||||
for ext in ("jpg", "png"):
|
||||
try:
|
||||
(images_root / "thumbs" / bucket / f"{image.sha256}.{ext}").unlink(
|
||||
missing_ok=True,
|
||||
@@ -451,6 +455,62 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes
|
||||
# these so the operator can re-tag from scratch via auto-suggest. fandom +
|
||||
# series (and series_page ordering) are deliberately NOT here — they're kept.
|
||||
RESETTABLE_TAG_KINDS = ("general", "character")
|
||||
|
||||
|
||||
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
||||
"""Count (dry_run) or DELETE every general + character tag so the operator
|
||||
can re-tag from scratch via the Camie auto-suggest.
|
||||
|
||||
PRESERVED: fandom + series tags and their series_page ordering, plus every
|
||||
image's image_record.tagger_predictions (untouched) so suggestions
|
||||
repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
|
||||
tag_reference_embedding / tag_suggestion_rejection clears each deleted
|
||||
tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting
|
||||
character tags never touches the fandom rows. Irreversible except via DB
|
||||
backup restore.
|
||||
|
||||
Returns:
|
||||
{"by_kind": {"general": N, "character": M},
|
||||
"count": total tags,
|
||||
"applications": image_tag rows that will be / were removed,
|
||||
"sample_names": [first 50],
|
||||
and on live runs "deleted": total}
|
||||
"""
|
||||
predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS)
|
||||
rows = session.execute(
|
||||
select(Tag.id, Tag.name, Tag.kind).where(predicate)
|
||||
).all()
|
||||
by_kind: dict[str, int] = {}
|
||||
for _id, _name, kind in rows:
|
||||
key = kind.value if hasattr(kind, "value") else str(kind)
|
||||
by_kind[key] = by_kind.get(key, 0) + 1
|
||||
# Headline impact: applications (image_tag rows) that vanish via cascade.
|
||||
applications = session.execute(
|
||||
select(func.count())
|
||||
.select_from(image_tag)
|
||||
.where(image_tag.c.tag_id.in_(select(Tag.id).where(predicate)))
|
||||
).scalar_one()
|
||||
sample = [name for _id, name, _kind in rows[:50]]
|
||||
total = len(rows)
|
||||
result = {
|
||||
"by_kind": by_kind,
|
||||
"count": total,
|
||||
"applications": applications,
|
||||
"sample_names": sample,
|
||||
}
|
||||
if dry_run:
|
||||
return result
|
||||
if total:
|
||||
session.execute(Tag.__table__.delete().where(predicate))
|
||||
session.commit()
|
||||
result["deleted"] = total
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -517,6 +577,9 @@ class ConfirmTokenMismatch(Exception):
|
||||
_VALID_RULES = ("transparency", "single_color")
|
||||
|
||||
|
||||
_AUDIT_GUARD_THRESHOLD_MINUTES = 135 # matches LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES
|
||||
|
||||
|
||||
def start_audit_run(
|
||||
session: Session, *, rule: str, params: dict[str, Any],
|
||||
) -> int:
|
||||
@@ -524,11 +587,21 @@ def start_audit_run(
|
||||
scan_library_for_rule Celery task. Returns the new audit_id.
|
||||
|
||||
Concurrent-runs guard: raises AuditAlreadyRunning if any audit_run
|
||||
has status='running'. Operator must cancel or wait."""
|
||||
has status='running' AND started recently. Audit 2026-06-02 made
|
||||
the guard age-aware: a SIGKILL'd run leaves a row in 'running'
|
||||
that the recovery sweep flips on its next pass (~5 min), but a
|
||||
fresh start_audit_run between the SIGKILL and the sweep would
|
||||
previously block forever. Past the threshold, treat the running
|
||||
row as stale and let the sweep clean it up — the new run still
|
||||
gets to start.
|
||||
"""
|
||||
if rule not in _VALID_RULES:
|
||||
raise ValueError(f"unknown rule {rule!r}; expected one of {_VALID_RULES}")
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=_AUDIT_GUARD_THRESHOLD_MINUTES)
|
||||
existing = session.execute(
|
||||
select(LibraryAuditRun.id).where(LibraryAuditRun.status == "running")
|
||||
select(LibraryAuditRun.id)
|
||||
.where(LibraryAuditRun.status == "running")
|
||||
.where(LibraryAuditRun.started_at >= cutoff)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise AuditAlreadyRunning(existing)
|
||||
|
||||
@@ -1,40 +1,82 @@
|
||||
"""Fernet-based encryption for credential blobs.
|
||||
|
||||
The key is a single 32-byte value (urlsafe-base64-encoded; what
|
||||
Fernet.generate_key produces) stored at a fixed path inside the
|
||||
images/data root. Created on first boot if absent; mode 0600. No KDF
|
||||
needed — the file contents are already maximum-entropy random bytes.
|
||||
Fernet.generate_key produces) stored at /images/secrets/credential_key.b64
|
||||
(mode 0600, parent dir 0700). The 2026-06-02 audit caught a silent
|
||||
key-regeneration path: on a partial disaster restore where the DB was
|
||||
restored but the secrets dir was lost, the old `_load_or_create_key`
|
||||
would mint a fresh key with no log, producing a working-looking system
|
||||
where every authenticated download failed AUTH_ERROR until the operator
|
||||
re-uploaded every credential by hand. Now the constructor refuses to
|
||||
auto-generate unless either:
|
||||
|
||||
Operator backup procedure must include this file alongside the rest
|
||||
of /images/ — losing it makes existing encrypted_blob rows
|
||||
undecryptable (recovery = delete the rows and re-upload).
|
||||
* the caller explicitly passes `bootstrap_ok=True` (tests, scripts), or
|
||||
* the env var `CURATOR_BOOTSTRAP_NEW_KEY=1` is set (operator opt-in
|
||||
during first-time setup).
|
||||
|
||||
Otherwise it raises `MissingCredentialKey` so the app fails fast at
|
||||
startup and the operator can restore the key file from backup.
|
||||
|
||||
Operator backup procedure must include /images/secrets/ alongside the
|
||||
rest of /images/ — losing the key file makes existing encrypted_blob
|
||||
rows undecryptable (recovery = delete the rows and re-upload).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BOOTSTRAP_ENV_VAR = "CURATOR_BOOTSTRAP_NEW_KEY"
|
||||
|
||||
|
||||
class InvalidCredentialBlob(Exception):
|
||||
"""Raised when decryption fails (wrong key, tampered blob, …)."""
|
||||
|
||||
|
||||
class MissingCredentialKey(Exception):
|
||||
"""The Fernet key file is missing AND the caller hasn't opted in to
|
||||
generating a new one. Audit 2026-06-02: prevents silent key
|
||||
regeneration on partial DB-restored / secrets-lost deployments.
|
||||
Set CURATOR_BOOTSTRAP_NEW_KEY=1 for first-time setup, or restore the
|
||||
key file from backup."""
|
||||
|
||||
|
||||
class CredentialCrypto:
|
||||
"""Fernet encrypt/decrypt with an on-disk key file.
|
||||
|
||||
Instantiate with a path; the file is created on first access and
|
||||
reused thereafter. Tests pass a tmp_path; production calls with
|
||||
Instantiate with a path; the file is loaded if present, or created
|
||||
if absent AND the caller has opted in (bootstrap_ok=True or
|
||||
CURATOR_BOOTSTRAP_NEW_KEY=1 env var). Production sites:
|
||||
`IMAGES_ROOT / "secrets" / "credential_key.b64"`.
|
||||
"""
|
||||
|
||||
def __init__(self, key_path: Path):
|
||||
def __init__(self, key_path: Path, *, bootstrap_ok: bool | None = None):
|
||||
self._key_path = Path(key_path)
|
||||
self._fernet = Fernet(self._load_or_create_key())
|
||||
if bootstrap_ok is None:
|
||||
bootstrap_ok = os.environ.get(_BOOTSTRAP_ENV_VAR) == "1"
|
||||
self._fernet = Fernet(self._load_or_create_key(bootstrap_ok))
|
||||
|
||||
def _load_or_create_key(self) -> bytes:
|
||||
def _load_or_create_key(self, bootstrap_ok: bool) -> bytes:
|
||||
if self._key_path.exists():
|
||||
return self._key_path.read_bytes()
|
||||
if not bootstrap_ok:
|
||||
raise MissingCredentialKey(
|
||||
f"Fernet key file not found at {self._key_path}. "
|
||||
f"For first-time setup, set {_BOOTSTRAP_ENV_VAR}=1. "
|
||||
f"If this is a restored instance, restore the key file "
|
||||
f"from backup — generating a new one would make every "
|
||||
f"existing Credential row undecryptable."
|
||||
)
|
||||
log.warning(
|
||||
"Generating NEW Fernet credential key at %s. Any existing "
|
||||
"encrypted_blob rows in the DB will be undecryptable — "
|
||||
"re-upload each credential after this completes.",
|
||||
self._key_path,
|
||||
)
|
||||
parent = self._key_path.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(parent, 0o700)
|
||||
|
||||
@@ -35,6 +35,7 @@ from .gallery_dl import (
|
||||
)
|
||||
from .importer import Importer
|
||||
from .patreon_resolver import resolve_campaign_id
|
||||
from .platforms import auth_type_for
|
||||
from .scheduler_service import set_platform_cooldown
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -90,10 +91,25 @@ class DownloadService:
|
||||
return setup["event_id"]
|
||||
ctx = setup
|
||||
|
||||
# Release the phase-1 DB connections before the (up to ~19.5-min in
|
||||
# backfill) gallery-dl subprocess. Held checked-out across that idle
|
||||
# window, the asyncpg/psycopg connections get reaped by the server,
|
||||
# and phase 3's first query then hits a dead socket
|
||||
# (asyncpg ConnectionDoesNotExistError) → download_source autoretry →
|
||||
# _phase1_setup's in-flight guard no-ops the retry → the event
|
||||
# strands empty for the recovery sweep (Anduo #40014, 2026-06-04).
|
||||
# pool_pre_ping can't help a *held* connection — it only validates on
|
||||
# pool checkout. Closing returns them to the pool so phase 3 re-
|
||||
# acquires a live one (the async task engine uses NullPool, the sync
|
||||
# engine pre_ping + pool_recycle=300). This is what makes the
|
||||
# "Phase 2 — no DB connection" contract in the class docstring true.
|
||||
await self.async_session.close()
|
||||
self.sync_session.close()
|
||||
|
||||
source_config = SourceConfig.from_dict(ctx["config_overrides"] or {})
|
||||
# alembic 0031 / plan #544: derive skip_value + timeout from the
|
||||
# source's backfill_runs_remaining counter. When > 0, walk the full
|
||||
# post history (skip: True + 1800s); when 0, exit gallery-dl after
|
||||
# post history (skip: True + 1170s); when 0, exit gallery-dl after
|
||||
# 20 contiguous archived items (skip: "exit:20" + the default
|
||||
# 870s). Operator sets backfill via POST /api/sources/{id}/backfill.
|
||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||
@@ -177,6 +193,13 @@ class DownloadService:
|
||||
return {"status": "in_flight", "event_id": existing.id}
|
||||
if existing and existing.status == "pending":
|
||||
existing.status = "running"
|
||||
# Reset started_at on the pending→running transition so the
|
||||
# recovery sweep (DOWNLOAD_STALL_THRESHOLD_MINUTES, 30 min)
|
||||
# measures from real start, not from enqueue. On heavy-queue
|
||||
# days a freshly-promoted event whose original started_at
|
||||
# predated the cutoff would otherwise get swept mid-flight,
|
||||
# racing phase3's commit. Audit 2026-06-02.
|
||||
existing.started_at = datetime.now(UTC)
|
||||
await self.async_session.commit()
|
||||
event_id = existing.id
|
||||
else:
|
||||
@@ -187,7 +210,12 @@ class DownloadService:
|
||||
event_id = ev.id
|
||||
|
||||
artist = source.artist
|
||||
if source.platform in ("discord", "pixiv"):
|
||||
# Drive cookies-vs-token selection from the platform registry's
|
||||
# auth_type so a new 7th token-platform automatically picks the
|
||||
# right credential path. The hardcoded tuple here used to drift
|
||||
# out of sync with credential_service's auth_type_for(). Audit
|
||||
# 2026-06-02.
|
||||
if auth_type_for(source.platform) == "token":
|
||||
cookies_path = None
|
||||
auth_token = await self.cred_service.get_token(source.platform)
|
||||
else:
|
||||
@@ -308,6 +336,23 @@ class DownloadService:
|
||||
# failure. Don't flag the run as error; the file stays
|
||||
# on disk for operator inspection.
|
||||
import_summary["skipped"] += 1
|
||||
elif result.status == "failed":
|
||||
# Hard failure (today only: archive probe crash/timeout).
|
||||
# The original archive sits in /images/ as an orphan; the
|
||||
# filesystem scanner would re-import and re-crash on the
|
||||
# same file, so delete the source file and surface the
|
||||
# error in import_summary. Audit 2026-06-02.
|
||||
import_summary["errors"] += 1
|
||||
try:
|
||||
path.unlink(missing_ok=True) # noqa: ASYNC240
|
||||
except OSError:
|
||||
pass
|
||||
elif result.status == "refreshed":
|
||||
# Currently unreachable from attach_in_place (the download
|
||||
# path never runs in deep=True mode), but the importer's
|
||||
# ImportResult contract enumerates it. Treat the same as
|
||||
# 'attached' — work happened, no error. Audit 2026-06-02.
|
||||
import_summary["attached"] += 1
|
||||
else:
|
||||
import_summary["errors"] += 1
|
||||
|
||||
@@ -354,12 +399,26 @@ class DownloadService:
|
||||
# backfill run drained the queue (gallery-dl exited 0 + zero files
|
||||
# downloaded means there was nothing to fetch); otherwise decrement
|
||||
# the counter. Next tick falls back to tick mode once it hits 0.
|
||||
#
|
||||
# Audit 2026-06-02 gating: VALIDATION_FAILED also exits the
|
||||
# subprocess with return_code=0 and files_downloaded=0 (every
|
||||
# file was quarantined), which used to match the auto-complete
|
||||
# predicate exactly — zeroing the operator's armed budget on
|
||||
# the FIRST quarantine run instead of decrementing. Require
|
||||
# dl_result.success + no error_type so only genuinely-empty
|
||||
# successful runs drain the counter.
|
||||
backfill_remaining = ctx.get("backfill_runs_remaining", 0) or 0
|
||||
if backfill_remaining > 0:
|
||||
src = (await self.async_session.execute(
|
||||
select(Source).where(Source.id == ctx["source_id"])
|
||||
)).scalar_one()
|
||||
if dl_result.return_code == 0 and dl_result.files_downloaded == 0:
|
||||
queue_drained = (
|
||||
dl_result.success
|
||||
and dl_result.error_type is None
|
||||
and dl_result.return_code == 0
|
||||
and dl_result.files_downloaded == 0
|
||||
)
|
||||
if queue_drained:
|
||||
src.backfill_runs_remaining = 0
|
||||
else:
|
||||
src.backfill_runs_remaining = max(0, backfill_remaining - 1)
|
||||
@@ -390,9 +449,15 @@ class DownloadService:
|
||||
if status == "ok":
|
||||
source.consecutive_failures = 0
|
||||
source.last_error = None
|
||||
# alembic 0032 — clear the failure-class chip on success.
|
||||
source.error_type = None
|
||||
elif status == "error":
|
||||
source.consecutive_failures = (source.consecutive_failures or 0) + 1
|
||||
source.last_error = error_message
|
||||
# alembic 0032 — stamp the failure-class so FailingSourcesCard
|
||||
# can render a colored chip and operators can bulk-triage
|
||||
# by error class without opening Logs per row.
|
||||
source.error_type = error_type
|
||||
if error_type == "rate_limited":
|
||||
await set_platform_cooldown(self.async_session, source.platform)
|
||||
elif status == "skipped":
|
||||
|
||||
@@ -59,29 +59,33 @@ TICK_SKIP_VALUE = "exit:20"
|
||||
# Source.backfill_runs_remaining > 0 selects this mode; the longer
|
||||
# timeout below absorbs creators with thousands of posts.
|
||||
#
|
||||
# 30 seconds shy of Celery's hard `time_limit=1200` on download_source
|
||||
# (tasks/download.py:33). subprocess.run MUST raise TimeoutExpired
|
||||
# before Celery SIGKILLs the worker — same rationale as the tick
|
||||
# default at line 74. The audit (2026-06-02) caught this at 1800,
|
||||
# guaranteeing SIGKILL on any backfill that ran to its subprocess
|
||||
# budget: stdout/stderr lost, backfill_runs_remaining never
|
||||
# decrements, recovery sweep stamps generic "stranded" 30 min later.
|
||||
# Recreates the exact Knuxy #38275 failure mode the tick 870s default
|
||||
# was added to prevent. backfill_runs_remaining=3 still gives ~58
|
||||
# minutes of cumulative walk across three runs for prolific creators.
|
||||
# Sits below download_source's Celery soft_time_limit
|
||||
# (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py) with ~180s of
|
||||
# headroom for phase-3 persist. subprocess.run MUST raise TimeoutExpired
|
||||
# before Celery raises SoftTimeLimitExceeded — that exception path
|
||||
# captures partial stdout/stderr and finalizes the event; the soft-limit
|
||||
# path (until the 2026-06-03 fix) did not. Audit history: 1800 guaranteed
|
||||
# SIGKILL against the old hard limit (Knuxy #38275); 1170 was then sized
|
||||
# "30s shy of the hard limit (1200)" but still EXCEEDED the soft limit
|
||||
# (900), so SoftTimeLimitExceeded preempted TimeoutExpired and every
|
||||
# backfill stranded empty (Anduo #39912). Raising the Celery soft/hard
|
||||
# limits to 1350/1500 (tasks/download.py) is what made 1170 safe.
|
||||
# backfill_runs_remaining=3 still gives ~58 minutes of cumulative walk
|
||||
# across three runs for prolific creators.
|
||||
BACKFILL_SKIP_VALUE = True
|
||||
BACKFILL_TIMEOUT_SECONDS = 1170
|
||||
|
||||
|
||||
# 30 seconds shy of download_source's Celery soft_time_limit (900s, see
|
||||
# tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before
|
||||
# Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race,
|
||||
# SIGKILLs the worker, in-memory stdout/stderr is lost, and the
|
||||
# DownloadEvent ends up empty-logged with "stranded by recovery sweep"
|
||||
# 18 minutes later (operator-flagged 2026-05-31, Knuxy event #38275).
|
||||
# The 30s buffer absorbs scheduler jitter / GC pauses without making
|
||||
# legitimately-long-running syncs timeout-friendlier. Per-source bumps
|
||||
# still live in source.config_overrides for legitimately long syncs.
|
||||
# Sits well below download_source's Celery soft_time_limit
|
||||
# (DOWNLOAD_SOFT_TIME_LIMIT=1350, tasks/download.py). subprocess.run MUST
|
||||
# raise TimeoutExpired before Celery raises SoftTimeLimitExceeded —
|
||||
# otherwise Celery wins the race, SIGKILLs the worker, in-memory
|
||||
# stdout/stderr is lost, and the DownloadEvent ends up empty-logged with
|
||||
# "stranded by recovery sweep" (operator-flagged 2026-05-31, Knuxy event
|
||||
# #38275; recurred in backfill mode as Anduo #39912). Per-source bumps
|
||||
# still live in source.config_overrides for legitimately long syncs —
|
||||
# keep any override below the soft limit, or the soft-limit salvage path
|
||||
# in tasks/download.py (_finalize_soft_limited) is the only safety net.
|
||||
_DEFAULT_GDL_TIMEOUT_SECONDS = 870
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -43,16 +50,17 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||
|
||||
|
||||
def _effective_date_col():
|
||||
"""SQL expression: COALESCE(post.post_date, image_record.created_at).
|
||||
"""The materialized gallery sort key: image_record.effective_date
|
||||
(alembic 0035) = COALESCE(primary post's post_date, created_at),
|
||||
maintained at write time by the importer.
|
||||
|
||||
Used as the canonical sort/group/filter key across the gallery so
|
||||
images backfilled with primary_post_id (e.g. via tag_apply phase 4)
|
||||
surface at their original publish date, not their FC import date.
|
||||
Images without a Post (or with Post.post_date NULL) fall back to
|
||||
image_record.created_at and still order coherently against
|
||||
post-attached ones.
|
||||
Canonical sort/group/filter key across the gallery so images attached
|
||||
to a post surface at their original publish date, not their FC import
|
||||
date — and, now that it's a single indexed column rather than a
|
||||
COALESCE across the Post outer join, the cursor scroll is an index
|
||||
range scan instead of a full re-sort per page.
|
||||
"""
|
||||
return func.coalesce(Post.post_date, ImageRecord.created_at)
|
||||
return ImageRecord.effective_date
|
||||
|
||||
|
||||
def _outer_join_primary_post(stmt: Select) -> Select:
|
||||
@@ -91,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.
|
||||
|
||||
@@ -117,13 +135,85 @@ def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str
|
||||
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
|
||||
|
||||
|
||||
def _require_single_filter(tag_id, post_id, artist_id) -> None:
|
||||
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
|
||||
def _require_single_filter(tag_ids, post_id, artist_id) -> None:
|
||||
"""post_id is the post-detail view — it can't combine with the
|
||||
composable filters. tag_ids + artist_id (+ media_type) compose freely
|
||||
(AND)."""
|
||||
if post_id is not None and (tag_ids or artist_id is not None):
|
||||
raise ValueError(
|
||||
"tag_id, post_id, artist_id are mutually exclusive"
|
||||
"post_id cannot be combined with tag or artist filters"
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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(
|
||||
exists().where(
|
||||
image_tag.c.image_record_id == ImageRecord.id,
|
||||
image_tag.c.tag_id == tid,
|
||||
)
|
||||
)
|
||||
prov = _provenance_clause(post_id, artist_id)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
if media_type == "image":
|
||||
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
|
||||
@@ -155,6 +245,27 @@ def _provenance_clause(post_id, artist_id):
|
||||
return None
|
||||
|
||||
|
||||
def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]:
|
||||
"""Build GalleryImage list from (record, posted_at, eff_date) rows + the
|
||||
artist hydration map. Shared by scroll() and similar()."""
|
||||
return [
|
||||
GalleryImage(
|
||||
id=record.id,
|
||||
path=record.path,
|
||||
sha256=record.sha256,
|
||||
mime=record.mime,
|
||||
width=record.width,
|
||||
height=record.height,
|
||||
created_at=record.created_at,
|
||||
effective_date=eff_date,
|
||||
posted_at=posted_at,
|
||||
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||
artist=artists.get(record.id),
|
||||
)
|
||||
for record, posted_at, eff_date in rows
|
||||
]
|
||||
|
||||
|
||||
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||
"""Map image_id -> {"name","slug"} via the canonical
|
||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||
@@ -179,35 +290,50 @@ class GalleryService:
|
||||
self,
|
||||
cursor: str | None,
|
||||
limit: int = 50,
|
||||
tag_id: int | None = None,
|
||||
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,
|
||||
) -> GalleryPage:
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
_require_single_filter(tag_id, post_id, artist_id)
|
||||
_require_single_filter(tag_ids, post_id, artist_id)
|
||||
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||
image_tag.c.tag_id == tag_id
|
||||
)
|
||||
prov = _provenance_clause(post_id, artist_id)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
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 cursor:
|
||||
cur_ts, cur_id = decode_cursor(cursor)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
eff < cur_ts,
|
||||
and_(eff == cur_ts, ImageRecord.id < cur_id),
|
||||
# The cursor is just (last eff, last id); the request's sort
|
||||
# decides which side of it the next page lies on.
|
||||
if descending:
|
||||
stmt = stmt.where(
|
||||
or_(eff < cur_ts, and_(eff == cur_ts, ImageRecord.id < cur_id))
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(
|
||||
or_(eff > cur_ts, and_(eff == cur_ts, ImageRecord.id > cur_id))
|
||||
)
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1)
|
||||
if descending:
|
||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
||||
stmt = stmt.limit(limit + 1)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
|
||||
next_cursor = None
|
||||
@@ -219,22 +345,7 @@ class GalleryService:
|
||||
artists = await _artists_for(
|
||||
self.session, [r[0].id for r in rows]
|
||||
)
|
||||
images = [
|
||||
GalleryImage(
|
||||
id=record.id,
|
||||
path=record.path,
|
||||
sha256=record.sha256,
|
||||
mime=record.mime,
|
||||
width=record.width,
|
||||
height=record.height,
|
||||
created_at=record.created_at,
|
||||
effective_date=eff_date,
|
||||
posted_at=posted_at,
|
||||
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||
artist=artists.get(record.id),
|
||||
)
|
||||
for record, posted_at, eff_date in rows
|
||||
]
|
||||
images = _gallery_images(rows, artists)
|
||||
return GalleryPage(
|
||||
images=images,
|
||||
next_cursor=next_cursor,
|
||||
@@ -243,9 +354,15 @@ class GalleryService:
|
||||
|
||||
async def timeline(
|
||||
self,
|
||||
tag_id: int | None = None,
|
||||
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,
|
||||
) -> list[TimelineBucket]:
|
||||
eff = _effective_date_col()
|
||||
year_col = func.date_part("year", eff).label("yr")
|
||||
@@ -254,25 +371,28 @@ class GalleryService:
|
||||
year_col, month_col, func.count(ImageRecord.id).label("cnt")
|
||||
)
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
_require_single_filter(tag_id, post_id, artist_id)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||
image_tag.c.tag_id == tag_id
|
||||
)
|
||||
prov = _provenance_clause(post_id, artist_id)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
_require_single_filter(tag_ids, post_id, artist_id)
|
||||
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()
|
||||
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
|
||||
|
||||
async def jump_cursor(
|
||||
self, year: int, month: int, tag_id: int | None = None,
|
||||
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(), positions at the
|
||||
first image of the given year-month (by effective_date, not
|
||||
created_at). None if the bucket is empty.
|
||||
"""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
|
||||
bucket is empty.
|
||||
"""
|
||||
from sqlalchemy import extract
|
||||
|
||||
@@ -282,22 +402,157 @@ class GalleryService:
|
||||
extract("month", eff) == month,
|
||||
)
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
_require_single_filter(tag_id, post_id, artist_id)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
|
||||
image_tag.c.tag_id == tag_id
|
||||
)
|
||||
prov = _provenance_clause(post_id, artist_id)
|
||||
if prov is not None:
|
||||
stmt = stmt.where(prov)
|
||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
|
||||
first = (await self.session.execute(stmt)).first()
|
||||
_require_single_filter(tag_ids, post_id, artist_id)
|
||||
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:
|
||||
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
|
||||
first = (await self.session.execute(stmt.limit(1))).first()
|
||||
if first is None:
|
||||
return None
|
||||
record, eff_date = first
|
||||
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
|
||||
# is the first result in the next scroll().
|
||||
return encode_cursor(eff_date, record.id + 1)
|
||||
# Cursor is exclusive; nudge the id one past the boundary row (in the
|
||||
# scan direction) so the row itself is the first result of scroll().
|
||||
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 similar(
|
||||
self, image_id: int, limit: int = 100, *,
|
||||
tag_ids: list[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[GalleryImage] | None:
|
||||
"""Visual "more like this": images ranked by cosine distance to
|
||||
`image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036).
|
||||
No ML inference here; the embedding was computed at import.
|
||||
|
||||
Returns None if the source image doesn't exist (→ 404), [] if it has
|
||||
no embedding (a video / not-yet-embedded). Composes with the Phase-1/2
|
||||
scope filters (AND) but REPLACES the date sort — always nearest-first,
|
||||
bounded to `limit` (no cursor; distance-ranking has no date cursor).
|
||||
"""
|
||||
if limit < 1 or limit > 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
src = await self.session.get(ImageRecord, image_id)
|
||||
if src is None:
|
||||
return None
|
||||
if src.siglip_embedding is None:
|
||||
return []
|
||||
|
||||
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
stmt = _outer_join_primary_post(stmt)
|
||||
stmt = stmt.where(
|
||||
ImageRecord.siglip_embedding.is_not(None),
|
||||
ImageRecord.id != image_id,
|
||||
)
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=None,
|
||||
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.order_by(distance.asc()).limit(limit)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||
return _gallery_images(rows, artists)
|
||||
|
||||
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||
record = await self.session.get(ImageRecord, image_id)
|
||||
@@ -336,6 +591,9 @@ class GalleryService:
|
||||
"height": record.height,
|
||||
"size_bytes": record.size_bytes,
|
||||
"integrity_status": record.integrity_status,
|
||||
# Phase 3: lets the modal hide the "Related"/find-similar surface
|
||||
# for images that have no embedding yet (videos / pending ML).
|
||||
"has_embedding": record.siglip_embedding is not None,
|
||||
"created_at": record.created_at.isoformat(),
|
||||
"posted_at": posted_at.isoformat() if posted_at else None,
|
||||
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||
@@ -357,17 +615,10 @@ class GalleryService:
|
||||
}
|
||||
|
||||
async def _neighbors(self, record: ImageRecord) -> dict:
|
||||
# Compute the boundary image's effective_date in Python (one query
|
||||
# below + the SELECT we already have on `record`) and use it for
|
||||
# the neighbor comparison. Cheaper than re-deriving in SQL via
|
||||
# correlated subquery.
|
||||
boundary_eff = record.created_at
|
||||
if record.primary_post_id is not None:
|
||||
post_date = (await self.session.execute(
|
||||
select(Post.post_date).where(Post.id == record.primary_post_id)
|
||||
)).scalar_one_or_none()
|
||||
if post_date is not None:
|
||||
boundary_eff = post_date
|
||||
# The boundary image's sort key is materialized on the row now
|
||||
# (alembic 0035) — read it directly instead of re-deriving COALESCE
|
||||
# via an extra Post lookup.
|
||||
boundary_eff = record.effective_date
|
||||
|
||||
eff = _effective_date_col()
|
||||
prev_stmt = _outer_join_primary_post(
|
||||
|
||||
@@ -374,10 +374,19 @@ class Importer:
|
||||
artist = self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist)
|
||||
sha = _sha256_of(source)
|
||||
existing = self.session.execute(
|
||||
select(PostAttachment).where(PostAttachment.sha256 == sha)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
select_existing = select(PostAttachment).where(PostAttachment.sha256 == sha)
|
||||
existing = self.session.execute(select_existing).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
self.session.commit()
|
||||
return ImportResult(status="attached")
|
||||
# Savepoint + IntegrityError recovery — PostAttachment.sha256 is
|
||||
# UNIQUE, so two workers can both pass the SELECT and only the
|
||||
# second INSERT fails. Without savepoint, the outer transaction
|
||||
# poisons and the calling task crashes. attachments.store is
|
||||
# sha-addressed so both workers race to write the same target
|
||||
# path; shutil.copy2 + rename is idempotent. Audit 2026-06-02.
|
||||
sp = self.session.begin_nested()
|
||||
try:
|
||||
stored = self.attachments.store(source, sha)
|
||||
self.session.add(PostAttachment(
|
||||
post_id=post.id if post else None,
|
||||
@@ -390,10 +399,19 @@ class Importer:
|
||||
size_bytes=source.stat().st_size,
|
||||
))
|
||||
self.session.flush()
|
||||
sp.commit()
|
||||
except IntegrityError:
|
||||
sp.rollback()
|
||||
# Lost the race — the other worker's row is canonical.
|
||||
self.session.execute(select_existing).scalar_one()
|
||||
self.session.commit()
|
||||
return ImportResult(status="attached")
|
||||
|
||||
def _import_archive(self, source: Path) -> ImportResult:
|
||||
def _import_archive(
|
||||
self, source: Path, *,
|
||||
artist: Artist | None = None,
|
||||
source_row: Source | None = None,
|
||||
) -> ImportResult:
|
||||
# Layer-3 isolation: bomb-size guard + integrity test in a
|
||||
# spawned child BEFORE extracting in this process. A
|
||||
# decompression bomb or a native-lib crash on a malformed
|
||||
@@ -401,6 +419,14 @@ class Importer:
|
||||
# instead of OOMing/segfaulting the import worker. extract_archive
|
||||
# is already fail-soft for plain exceptions, so this only adds
|
||||
# the hard-crash protection.
|
||||
#
|
||||
# Audit 2026-06-02: optional artist/source_row kwargs let the
|
||||
# download path thread its explicit subscription context
|
||||
# through instead of having _resolve_artist re-derive from
|
||||
# path-walk (which works by coincidence today because gallery-dl
|
||||
# lays files out under /images/<artist_slug>/...). Filesystem
|
||||
# import still calls bare _import_archive(source) and falls
|
||||
# back to the path-walk derivation as before.
|
||||
probe = safe_probe.probe_archive(source)
|
||||
if not probe.ok:
|
||||
if probe.crashed:
|
||||
@@ -412,24 +438,28 @@ class Importer:
|
||||
# still preserve the archive file itself as an attachment so
|
||||
# nothing silently vanishes, matching extract_archive's
|
||||
# fail-soft contract.
|
||||
artist = self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist)
|
||||
self._capture_attachment(source, post=post, artist=artist, resolved=True)
|
||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist_use)
|
||||
self._capture_attachment(
|
||||
source, post=post, artist=artist_use, resolved=True,
|
||||
)
|
||||
return ImportResult(status="attached")
|
||||
|
||||
artist = self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist)
|
||||
artist_use = artist if artist is not None else self._resolve_artist(source)
|
||||
post = self._post_for_sidecar(source, artist_use)
|
||||
member_ids: list[int] = []
|
||||
with extract_archive(source) as members:
|
||||
for _name, member_path in members:
|
||||
if not is_supported(member_path):
|
||||
continue # non-media preserved via the stored archive
|
||||
res = self._import_media(member_path, source)
|
||||
res = self._import_media(
|
||||
member_path, source, explicit_source=source_row,
|
||||
)
|
||||
if res.status in ("imported", "superseded") and res.image_id:
|
||||
member_ids.append(res.image_id)
|
||||
# Preserve the archive itself (links to the same Post/Artist).
|
||||
self._capture_attachment(
|
||||
source, post=post, artist=artist, resolved=True
|
||||
source, post=post, artist=artist_use, resolved=True
|
||||
)
|
||||
if member_ids:
|
||||
return ImportResult(
|
||||
@@ -439,7 +469,8 @@ class Importer:
|
||||
return ImportResult(status="attached")
|
||||
|
||||
def _import_media(
|
||||
self, source: Path, attribution_path: Path
|
||||
self, source: Path, attribution_path: Path,
|
||||
*, explicit_source: Source | None = None,
|
||||
) -> ImportResult:
|
||||
"""The media import pipeline (filters, dedup, copy, provenance).
|
||||
|
||||
@@ -586,7 +617,15 @@ class Importer:
|
||||
artist = self._attach_artist(record, artist_name)
|
||||
|
||||
# Sidecar provenance (best-effort; never fails the import).
|
||||
self._apply_sidecar(record, attribution_path, artist)
|
||||
# explicit_source lets the FC-3c download path bind the new
|
||||
# ImageProvenance row to its subscription Source instead of
|
||||
# having _apply_sidecar re-derive via _lookup_source_for_sidecar.
|
||||
# Audit 2026-06-02 — archive members extracted from a
|
||||
# subscription-downloaded zip previously lost subscription
|
||||
# linkage if the on-disk layout didn't match assumptions.
|
||||
self._apply_sidecar(
|
||||
record, attribution_path, artist, explicit_source=explicit_source,
|
||||
)
|
||||
|
||||
# Thumbnail is queued separately by the calling task; the importer
|
||||
# does not generate thumbnails inline so the import queue stays moving.
|
||||
@@ -673,7 +712,9 @@ class Importer:
|
||||
error="sidecar json is metadata, not content",
|
||||
)
|
||||
if is_archive(path):
|
||||
return self._import_archive(path)
|
||||
return self._import_archive(
|
||||
path, artist=artist, source_row=source,
|
||||
)
|
||||
if not is_supported(path):
|
||||
post = self._post_for_sidecar(path, artist) if artist else None
|
||||
return self._capture_attachment(
|
||||
@@ -749,7 +790,8 @@ class Importer:
|
||||
if rel == "smaller_exists":
|
||||
target = self.session.get(ImageRecord, match_id)
|
||||
self._supersede(
|
||||
target, path, sha, phash, width, height, new_path=path
|
||||
target, path, sha, phash, width, height,
|
||||
new_path=path, artist=artist, source_row=source,
|
||||
)
|
||||
return ImportResult(status="superseded", image_id=match_id)
|
||||
|
||||
@@ -918,6 +960,14 @@ class Importer:
|
||||
sp.rollback()
|
||||
if record.primary_post_id is None:
|
||||
record.primary_post_id = post.id
|
||||
# Keep the denormalized gallery sort key (alembic 0035) aligned with
|
||||
# the primary post's publish date so /scroll orders off
|
||||
# ix_image_record_effective_date instead of COALESCE-ing across the
|
||||
# post join. Only override when THIS post is the primary AND carries
|
||||
# a date; otherwise the column keeps its created_at-equivalent server
|
||||
# default (matches the old COALESCE(post_date, created_at) fallback).
|
||||
if record.primary_post_id == post.id and post.post_date is not None:
|
||||
record.effective_date = post.post_date
|
||||
self.session.flush()
|
||||
|
||||
def _copy_to_library(
|
||||
@@ -944,6 +994,8 @@ class Importer:
|
||||
self, existing: ImageRecord, source: Path, sha: str,
|
||||
phash: str, width: int | None, height: int | None,
|
||||
*, new_path: Path | None = None,
|
||||
artist: Artist | None = None,
|
||||
source_row: Source | None = None,
|
||||
) -> None:
|
||||
"""Replace `existing`'s file with the larger `source`, keeping the
|
||||
row id (so tags/series/curation stay attached). ML is cleared so
|
||||
@@ -995,8 +1047,14 @@ class Importer:
|
||||
# _apply_sidecar resolves artist from the sidecar itself if the
|
||||
# existing row has none, and is internally guarded against
|
||||
# missing-or-malformed sidecars (silent return).
|
||||
# Audit 2026-06-02: thread artist/source_row from the
|
||||
# download-path caller (attach_in_place smaller_exists branch)
|
||||
# so the supersede preserves explicit subscription linkage
|
||||
# instead of re-deriving via path-walk.
|
||||
try:
|
||||
self._apply_sidecar(existing, source, None)
|
||||
self._apply_sidecar(
|
||||
existing, source, artist, explicit_source=source_row,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Don't unwind the supersede DB swap if sidecar parsing
|
||||
# blows up unexpectedly — the file replacement is the
|
||||
|
||||
@@ -19,7 +19,6 @@ from ...models import (
|
||||
TagReferenceEmbedding,
|
||||
)
|
||||
from ...models.tag import image_tag
|
||||
from .embedder import MODEL_VERSION as SIGLIP_VERSION
|
||||
|
||||
ELIGIBLE_KINDS = {
|
||||
TagKind.character,
|
||||
@@ -46,6 +45,21 @@ class CentroidService:
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def _model_version(self) -> str:
|
||||
"""Audit 2026-06-02: SigLIP model-version stamp comes from the
|
||||
DB row, not the env constant. tag_and_embed (tasks/ml.py:110)
|
||||
already reads from MLSettings.embedder_model_version, so by
|
||||
sourcing centroid stamps + drift checks from the same row, we
|
||||
eliminate the silent-drift case the audit flagged. env
|
||||
SIGLIP_MODEL_VERSION still drives which model embedder.py
|
||||
loads at runtime; the version stamp is purely the operator-
|
||||
controlled identifier."""
|
||||
return (
|
||||
await self.session.execute(
|
||||
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def recompute_for_tag(self, tag_id: int) -> bool:
|
||||
"""Recompute one tag's centroid. Returns True if a centroid was
|
||||
written, False if skipped (ineligible kind or too few members)."""
|
||||
@@ -69,19 +83,20 @@ class CentroidService:
|
||||
return False
|
||||
|
||||
centroid = np.mean(np.stack(embeddings), axis=0).astype(np.float32)
|
||||
model_version = await self._model_version()
|
||||
|
||||
stmt = insert(TagReferenceEmbedding).values(
|
||||
tag_id=tag_id,
|
||||
embedding=centroid.tolist(),
|
||||
reference_count=len(embeddings),
|
||||
model_version=SIGLIP_VERSION,
|
||||
model_version=model_version,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["tag_id"],
|
||||
set_={
|
||||
"embedding": centroid.tolist(),
|
||||
"reference_count": len(embeddings),
|
||||
"model_version": SIGLIP_VERSION,
|
||||
"model_version": model_version,
|
||||
"updated_at": func.now(),
|
||||
},
|
||||
)
|
||||
@@ -92,6 +107,7 @@ class CentroidService:
|
||||
"""Tag ids whose centroid is stale: member count != reference_count,
|
||||
OR no centroid row, OR centroid built on a different SigLIP version.
|
||||
Only considers eligible-kind tags with embeddings present."""
|
||||
current_model_version = await self._model_version()
|
||||
member_counts = (
|
||||
select(
|
||||
image_tag.c.tag_id.label("tag_id"),
|
||||
@@ -116,7 +132,7 @@ class CentroidService:
|
||||
TagReferenceEmbedding.reference_count
|
||||
!= member_counts.c.members
|
||||
)
|
||||
| (TagReferenceEmbedding.model_version != SIGLIP_VERSION)
|
||||
| (TagReferenceEmbedding.model_version != current_model_version)
|
||||
)
|
||||
)
|
||||
return list((await self.session.execute(stmt)).scalars().all())
|
||||
|
||||
@@ -4,7 +4,7 @@ threshold-filtered, category-grouped, ranked suggestions for one image.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...models import (
|
||||
@@ -16,6 +16,7 @@ from ...models import (
|
||||
from ...models.tag import image_tag
|
||||
from .aliases import AliasService
|
||||
from .centroids import CentroidService
|
||||
from .tag_name import normalize as normalize_tag_name
|
||||
from .tagger import SURFACED_CATEGORIES
|
||||
|
||||
|
||||
@@ -84,7 +85,12 @@ class SuggestionService:
|
||||
)
|
||||
|
||||
# --- Camie predictions ---
|
||||
candidates: list[tuple[str, str, float]] = []
|
||||
# candidates carry (raw_name, display_name, category, confidence).
|
||||
# raw_name = the booru-formatted vocab key, kept for alias_map
|
||||
# lookup since alias rows are hand-curated against raw keys.
|
||||
# display_name = normalize_tag_name(raw_name) — what the operator
|
||||
# sees AND what gets written to tag.name on Accept.
|
||||
candidates: list[tuple[str, str, str, float]] = []
|
||||
for name, p in predictions.items():
|
||||
category = p.get("category", "general")
|
||||
if category not in SURFACED_CATEGORIES:
|
||||
@@ -92,10 +98,14 @@ class SuggestionService:
|
||||
conf = float(p.get("confidence", 0.0))
|
||||
if conf < self._threshold_for(settings, category):
|
||||
continue
|
||||
candidates.append((name, category, conf))
|
||||
display = normalize_tag_name(name)
|
||||
if display is None:
|
||||
# emoticon / pure-punctuation vocab entry — drop entirely
|
||||
continue
|
||||
candidates.append((name, display, category, conf))
|
||||
|
||||
alias_map = await self.aliases.resolve_many(
|
||||
[(n, c) for n, c, _ in candidates]
|
||||
[(raw, c) for raw, _disp, c, _conf in candidates]
|
||||
)
|
||||
|
||||
merged: dict[object, Suggestion] = {}
|
||||
@@ -116,8 +126,8 @@ class SuggestionService:
|
||||
creates_new_tag=existing.creates_new_tag,
|
||||
)
|
||||
|
||||
for name, category, conf in candidates:
|
||||
canonical = alias_map.get((name, category))
|
||||
for raw, display, category, conf in candidates:
|
||||
canonical = alias_map.get((raw, category))
|
||||
if canonical is not None:
|
||||
if canonical.id in applied or canonical.id in rejected:
|
||||
continue
|
||||
@@ -133,9 +143,17 @@ class SuggestionService:
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Case-insensitive match on BOTH the raw camie key AND
|
||||
# the normalized form — covers legacy underscore-named
|
||||
# Tag rows accepted before normalization shipped, AND
|
||||
# any tag the operator created with the human form.
|
||||
existing_tag = (
|
||||
await self.session.execute(
|
||||
select(Tag).where(Tag.name == name)
|
||||
select(Tag).where(
|
||||
func.lower(Tag.name).in_(
|
||||
[raw.lower(), display.lower()]
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing_tag is not None:
|
||||
@@ -157,10 +175,10 @@ class SuggestionService:
|
||||
)
|
||||
else:
|
||||
_merge(
|
||||
f"raw:{name}:{category}",
|
||||
f"raw:{display}:{category}",
|
||||
Suggestion(
|
||||
canonical_tag_id=None,
|
||||
display_name=name,
|
||||
display_name=display,
|
||||
category=category,
|
||||
score=conf,
|
||||
source="tagger",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Camie vocabulary -> human-readable tag-name normalization.
|
||||
|
||||
Camie v2's ~57k tag vocabulary is booru-derived and arrives as raw
|
||||
strings like `uchiha_sasuke_(naruto)`, `#unicus_(idolmaster)`,
|
||||
`1000-nen_ikiteru_(vocaloid)`, or `:/`. We want the operator to see
|
||||
"Uchiha Sasuke", "Unicus", "1000-Nen Ikiteru", or to never see the
|
||||
emoticon at all — and we want the same clean string to be what lands
|
||||
in `tag.name` when the suggestion is accepted, so Accept matches the
|
||||
existing-tag convention (`tag_service.find_or_create`).
|
||||
|
||||
Rules (operator-approved 2026-06-03):
|
||||
1. Strip leading junk chars (#, ., +, ;, ~, _, whitespace)
|
||||
2. Drop trailing `_(disambiguator)` block(s), iteratively
|
||||
3. Strip wrapping single/double quotes (after disambig removal so
|
||||
`"foo_em_up"_(series)` -> `"foo_em_up"` -> `foo_em_up`)
|
||||
4. Replace remaining `_` with space; collapse runs of whitespace
|
||||
5. Add a space after any `:` (namespace:tag -> namespace: tag)
|
||||
6. Preserve hyphens (booru hyphens often carry meaning)
|
||||
7. Title-case each space-separated word (first character only —
|
||||
apostrophes, digits, hyphens stay)
|
||||
8. If no letters AND no digits remain, return None (drops emoticons
|
||||
like `:/` or `^_^`; preserves bare digit tags like `2005`)
|
||||
9. No surname/givenname swap — no reliable signal in the vocab
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_LEADING_JUNK = re.compile(r"^[#.+;~_\s]+")
|
||||
_TRAILING_DISAMBIG = re.compile(r"_\([^)]*\)\s*$")
|
||||
_MULTISPACE = re.compile(r"\s+")
|
||||
_COLON_NOSPACE = re.compile(r":(?=\S)")
|
||||
_HAS_ALPHANUMERIC = re.compile(r"[A-Za-z0-9]")
|
||||
|
||||
|
||||
def _strip_wrapping_quotes(s: str) -> str:
|
||||
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
|
||||
return s[1:-1]
|
||||
return s
|
||||
|
||||
|
||||
def _title_word(w: str) -> str:
|
||||
return w[:1].upper() + w[1:] if w else w
|
||||
|
||||
|
||||
def normalize(raw: str) -> str | None:
|
||||
"""Return the human-readable form of a raw Camie tag, or None if the
|
||||
string is junk (emoticon, empty after stripping)."""
|
||||
if not raw:
|
||||
return None
|
||||
s = _LEADING_JUNK.sub("", raw)
|
||||
while True:
|
||||
new = _TRAILING_DISAMBIG.sub("", s)
|
||||
if new == s:
|
||||
break
|
||||
s = new
|
||||
s = _strip_wrapping_quotes(s)
|
||||
s = s.replace("_", " ")
|
||||
s = _COLON_NOSPACE.sub(": ", s)
|
||||
s = _MULTISPACE.sub(" ", s).strip()
|
||||
if not s or not _HAS_ALPHANUMERIC.search(s):
|
||||
return None
|
||||
return " ".join(_title_word(w) for w in s.split(" "))
|
||||
@@ -59,6 +59,7 @@ class SourceRecord:
|
||||
config_overrides: dict | None
|
||||
last_checked_at: str | None
|
||||
last_error: str | None
|
||||
error_type: str | None
|
||||
check_interval_override: int | None
|
||||
consecutive_failures: int
|
||||
next_check_at: str | None
|
||||
@@ -76,6 +77,7 @@ class SourceRecord:
|
||||
"config_overrides": self.config_overrides,
|
||||
"last_checked_at": self.last_checked_at,
|
||||
"last_error": self.last_error,
|
||||
"error_type": self.error_type,
|
||||
"check_interval_override": self.check_interval_override,
|
||||
"consecutive_failures": self.consecutive_failures,
|
||||
"next_check_at": self.next_check_at,
|
||||
@@ -144,6 +146,7 @@ class SourceService:
|
||||
config_overrides=source.config_overrides,
|
||||
last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None,
|
||||
last_error=source.last_error,
|
||||
error_type=source.error_type,
|
||||
check_interval_override=source.check_interval_override,
|
||||
consecutive_failures=source.consecutive_failures or 0,
|
||||
next_check_at=nxt.isoformat() if nxt else None,
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import and_, case, exists, func, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Tag, TagKind, image_tag
|
||||
@@ -86,9 +87,12 @@ class TagService:
|
||||
f"fandom_id {fandom_id} does not reference a fandom tag"
|
||||
)
|
||||
|
||||
# Upsert via INSERT ... ON CONFLICT DO NOTHING. We can't use the
|
||||
# uniqueness index name directly (it's a partial coalesce-based
|
||||
# expression), so we re-select after insert.
|
||||
# Audit 2026-06-02: race-safe upsert via savepoint +
|
||||
# IntegrityError recovery. The partial uniqueness index on
|
||||
# (name, kind, COALESCE(fandom_id, -1)) catches concurrent
|
||||
# inserts; without the savepoint the outer transaction would
|
||||
# poison and the calling request crashes. Mirrors
|
||||
# importer._get_or_create.
|
||||
stmt = (
|
||||
select(Tag)
|
||||
.where(Tag.name == name)
|
||||
@@ -101,10 +105,16 @@ class TagService:
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
|
||||
self.session.add(new_tag)
|
||||
await self.session.flush()
|
||||
return new_tag
|
||||
sp = await self.session.begin_nested()
|
||||
try:
|
||||
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
|
||||
self.session.add(new_tag)
|
||||
await self.session.flush()
|
||||
await sp.commit()
|
||||
return new_tag
|
||||
except IntegrityError:
|
||||
await sp.rollback()
|
||||
return (await self.session.execute(stmt)).scalar_one()
|
||||
|
||||
async def autocomplete(
|
||||
self,
|
||||
@@ -270,6 +280,69 @@ class TagService:
|
||||
await self.session.flush()
|
||||
return tag
|
||||
|
||||
async def set_fandom(
|
||||
self, tag_id: int, fandom_id: int | None, *, merge: bool = False
|
||||
) -> Tag:
|
||||
"""Set / change / clear a character tag's fandom.
|
||||
|
||||
Raises TagValidationError unless the tag is a character and fandom_id
|
||||
(when given) references a fandom tag. If the change would collide with
|
||||
an existing character of the same name in the TARGET fandom, raises
|
||||
TagMergeConflict (the API turns that into a 409 merge hint) — unless
|
||||
merge=True, in which case this tag is merged INTO that existing
|
||||
character (a deliberate cross-fandom merge) and the surviving target
|
||||
is returned. Passing fandom_id=None clears the fandom.
|
||||
"""
|
||||
tag = await self.session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
raise TagValidationError(f"Tag {tag_id} not found")
|
||||
if tag.kind != TagKind.character:
|
||||
raise TagValidationError("Only character tags can have a fandom")
|
||||
if fandom_id is not None:
|
||||
fandom = await self.session.get(Tag, fandom_id)
|
||||
if fandom is None or fandom.kind != TagKind.fandom:
|
||||
raise TagValidationError(
|
||||
f"fandom_id {fandom_id} does not reference a fandom tag"
|
||||
)
|
||||
if fandom_id == tag.fandom_id:
|
||||
return tag
|
||||
|
||||
# Collision: another character with the same name already lives in the
|
||||
# target fandom. Mirrors rename's (name, kind, fandom_id) uniqueness.
|
||||
clash_stmt = (
|
||||
select(Tag)
|
||||
.where(Tag.name == tag.name)
|
||||
.where(Tag.kind == TagKind.character)
|
||||
.where(
|
||||
Tag.fandom_id.is_(None)
|
||||
if fandom_id is None
|
||||
else Tag.fandom_id == fandom_id
|
||||
)
|
||||
.where(Tag.id != tag_id)
|
||||
)
|
||||
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
|
||||
if clash is not None:
|
||||
if not merge:
|
||||
source_image_count = await self.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(image_tag)
|
||||
.where(image_tag.c.tag_id == tag_id)
|
||||
)
|
||||
will_alias = await self._keep_as_alias(tag_id)
|
||||
raise TagMergeConflict(
|
||||
f"A character named {tag.name!r} already exists in that fandom",
|
||||
target_id=clash.id,
|
||||
target_name=clash.name,
|
||||
source_image_count=int(source_image_count or 0),
|
||||
will_alias=will_alias,
|
||||
)
|
||||
await self._do_merge(tag, clash)
|
||||
return clash
|
||||
|
||||
tag.fandom_id = fandom_id
|
||||
await self.session.flush()
|
||||
return tag
|
||||
|
||||
async def merge(self, source_id: int, target_id: int) -> MergeResult:
|
||||
"""Transactionally repoint every FK from source→target, optionally
|
||||
keep source's name as a tagger alias, delete source. Atomic: any
|
||||
@@ -288,7 +361,14 @@ class TagService:
|
||||
raise TagValidationError(
|
||||
"Tags must be the same kind and fandom to merge"
|
||||
)
|
||||
return await self._do_merge(source, target)
|
||||
|
||||
async def _do_merge(self, source: Tag, target: Tag) -> MergeResult:
|
||||
"""Repoint every FK source→target, optionally keep source's name as a
|
||||
tagger alias, delete source. NO kind/fandom validation — callers that
|
||||
need it (public merge()) validate first; set_fandom's collision
|
||||
resolution calls this directly for a deliberate CROSS-fandom merge."""
|
||||
source_id, target_id = source.id, target.id
|
||||
keep_as_alias = await self._keep_as_alias(source_id)
|
||||
source_name = source.name
|
||||
source_kind = source.kind
|
||||
|
||||
@@ -10,6 +10,7 @@ disposes it (``await engine.dispose()``) when its loop ends.
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from ..config import get_config
|
||||
|
||||
@@ -17,5 +18,13 @@ from ..config import get_config
|
||||
def async_session_factory():
|
||||
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine."""
|
||||
cfg = get_config()
|
||||
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
|
||||
# NullPool: this engine lives for ONE task (created + disposed per
|
||||
# asyncio.run loop), so intra-task connection pooling buys nothing and
|
||||
# actively bit us — download_source releases its phase-1 connection
|
||||
# before a multi-minute gallery-dl subprocess, and a *pooled* idle
|
||||
# connection would be reaped by the server and handed back dead to
|
||||
# phase 3 (asyncpg ConnectionDoesNotExistError, Anduo #40014). NullPool
|
||||
# opens a fresh real connection on each checkout, so phase 3 always
|
||||
# reconnects clean; pre_ping is then redundant.
|
||||
engine = create_async_engine(cfg.database_url, future=True, poolclass=NullPool)
|
||||
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -240,8 +240,11 @@ def prune_backups() -> dict:
|
||||
|
||||
Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}.
|
||||
Tagged rows (tag IS NOT NULL) are never pruned.
|
||||
Status='running' / 'restoring' rows are never pruned (recovery
|
||||
sweep from FC-3i handles those via task_run).
|
||||
Status='running' / 'restoring' rows are never pruned — the
|
||||
recover_stalled_backup_runs sweep flips truly-stuck ones to
|
||||
'error' first. (Earlier docstring claimed the FC-3i TaskRun sweep
|
||||
handled those, but TaskRun cleanup never touched BackupRun rows.
|
||||
Audit 2026-06-02 added the dedicated sweep.)
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"""download_source Celery task — runs DownloadService for one source."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||
from sqlalchemy.orm import Session as SyncSession
|
||||
|
||||
from ..celery_app import celery
|
||||
from ..models import ImportSettings
|
||||
from ..models import DownloadEvent, ImportSettings, Source
|
||||
from ..services.credential_crypto import CredentialCrypto
|
||||
from ..services.credential_service import CredentialService
|
||||
from ..services.download_service import DownloadService
|
||||
@@ -16,9 +21,79 @@ from ..services.thumbnailer import Thumbnailer
|
||||
from ._async_session import async_session_factory
|
||||
from .import_file import _sync_session_factory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
IMAGES_ROOT = Path("/images")
|
||||
_KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
|
||||
|
||||
# Celery time budget for one download_source run. The ceiling that
|
||||
# governs *clean* teardown is the SOFT limit: it raises a catchable
|
||||
# SoftTimeLimitExceeded in-process, whereas the HARD limit SIGKILLs the
|
||||
# worker (no chance to finalize). Both gallery-dl subprocess budgets
|
||||
# (gallery_dl.py: _DEFAULT_GDL_TIMEOUT_SECONDS=870 tick,
|
||||
# BACKFILL_TIMEOUT_SECONDS=1170 backfill) MUST sit below the soft limit
|
||||
# so subprocess.run raises its own TimeoutExpired first — that path
|
||||
# captures partial stdout/stderr and finalizes the DownloadEvent. soft is
|
||||
# max-subprocess (1170) + ~180s phase-3 persist headroom; hard is soft +
|
||||
# 150s SIGKILL backstop. Audit 2026-06-03 (Anduo #39912): the old
|
||||
# soft=900 sat BELOW the 1170 backfill budget, so SoftTimeLimitExceeded
|
||||
# preempted TimeoutExpired and the event stranded empty. The recovery
|
||||
# sweep's DOWNLOAD_STALL_THRESHOLD_MINUTES (30 min) still trails the new
|
||||
# 25-min hard kill by 5 min, so it stays a true backstop. Invariant
|
||||
# guarded by test_timeout_ladder_keeps_subprocess_budgets_under_soft_limit.
|
||||
DOWNLOAD_SOFT_TIME_LIMIT = 1350
|
||||
DOWNLOAD_HARD_TIME_LIMIT = 1500
|
||||
|
||||
|
||||
def _finalize_soft_limited(session: SyncSession, source_id: int) -> None:
|
||||
"""Defense in depth for the soft-time-limit kill path.
|
||||
|
||||
A SoftTimeLimitExceeded unwinds download_source before phase 3 can
|
||||
finalize the DownloadEvent, leaving it 'running' until the recovery
|
||||
sweep stamps a context-free "stranded" error 30 min later — AND
|
||||
leaving backfill_runs_remaining undecremented so the source re-runs
|
||||
and re-strands every tick (Anduo #39912, 2026-06-03). Flip the
|
||||
in-flight event to error with a real reason, mirror phase 3's
|
||||
source-health write, and decrement any backfill budget so a
|
||||
chronically-slow source self-heals back to tick mode.
|
||||
|
||||
The caller owns the commit. All mutations are gated on actually
|
||||
finding a running event, so a benign late soft-limit (phase 3 already
|
||||
committed) is a no-op.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
ev = session.execute(
|
||||
select(DownloadEvent)
|
||||
.where(DownloadEvent.source_id == source_id)
|
||||
.where(DownloadEvent.status == "running")
|
||||
.order_by(DownloadEvent.id.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if ev is None:
|
||||
return
|
||||
ev.status = "error"
|
||||
ev.finished_at = now
|
||||
ev.error = (
|
||||
f"killed by Celery soft time limit ({DOWNLOAD_SOFT_TIME_LIMIT}s) "
|
||||
"before the gallery-dl subprocess returned — the run exceeded its "
|
||||
"budget and its stdout/stderr were lost with the worker thread. "
|
||||
"If this recurs, the source is too large for one run; the backfill "
|
||||
"budget was decremented so the next tick walks less."
|
||||
)
|
||||
ev.metadata_ = {
|
||||
**(ev.metadata_ or {}),
|
||||
"error_type": "timeout",
|
||||
"soft_time_limited": True,
|
||||
}
|
||||
src = session.get(Source, source_id)
|
||||
if src is not None:
|
||||
src.consecutive_failures = (src.consecutive_failures or 0) + 1
|
||||
src.last_error = "soft time limit exceeded"
|
||||
src.error_type = "timeout"
|
||||
src.last_checked_at = now
|
||||
if (src.backfill_runs_remaining or 0) > 0:
|
||||
src.backfill_runs_remaining = max(0, src.backfill_runs_remaining - 1)
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.download.download_source",
|
||||
@@ -29,8 +104,8 @@ _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
|
||||
retry_backoff_max=120,
|
||||
retry_jitter=True,
|
||||
max_retries=3,
|
||||
soft_time_limit=900,
|
||||
time_limit=1200,
|
||||
soft_time_limit=DOWNLOAD_SOFT_TIME_LIMIT,
|
||||
time_limit=DOWNLOAD_HARD_TIME_LIMIT,
|
||||
)
|
||||
def download_source(self, source_id: int) -> int:
|
||||
"""Returns the DownloadEvent.id."""
|
||||
@@ -73,4 +148,19 @@ def download_source(self, source_id: int) -> int:
|
||||
finally:
|
||||
await async_engine.dispose()
|
||||
|
||||
return asyncio.run(_run())
|
||||
try:
|
||||
return asyncio.run(_run())
|
||||
except SoftTimeLimitExceeded:
|
||||
# phase 3 never ran — salvage the in-flight event so the operator
|
||||
# sees a real reason instead of the recovery sweep's generic
|
||||
# "stranded" 30 min later (Anduo #39912). Best-effort: a failure
|
||||
# here must not mask the timeout. Re-raise so Celery + the
|
||||
# task_run signal handler still record the kill.
|
||||
try:
|
||||
SyncFactory = _sync_session_factory()
|
||||
with SyncFactory() as session:
|
||||
_finalize_soft_limited(session, source_id)
|
||||
session.commit()
|
||||
except Exception: # noqa: BLE001 — cleanup must not swallow the kill
|
||||
log.exception("soft-limit finalize failed for source %s", source_id)
|
||||
raise
|
||||
|
||||
@@ -11,18 +11,38 @@ from sqlalchemy import Integer, and_, cast, delete, func, or_, select, update
|
||||
|
||||
from ..celery_app import celery
|
||||
from ..models import (
|
||||
BackupRun,
|
||||
DownloadEvent,
|
||||
ImageRecord,
|
||||
ImportBatch,
|
||||
ImportSettings,
|
||||
ImportTask,
|
||||
LibraryAuditRun,
|
||||
Source,
|
||||
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
|
||||
@@ -43,9 +63,12 @@ MAX_RECOVERY_ATTEMPTS = 3
|
||||
ORPHAN_PENDING_THRESHOLD_MINUTES = 30
|
||||
|
||||
# DownloadEvent (pending|running) recovery threshold. download_source has
|
||||
# time_limit=1200s (20 min); 30 min is 10 min past that, so a legitimately-
|
||||
# running task is never killed by the sweep. Operator-confirmed 2026-05-29
|
||||
# after 43 sources stranded at "last check never" by the in-flight guard.
|
||||
# time_limit=1500s (25 min, DOWNLOAD_HARD_TIME_LIMIT); 30 min is 5 min past
|
||||
# that, so a legitimately-running task is hard-killed before the sweep ever
|
||||
# touches it — the sweep only catches events whose worker died without
|
||||
# finalizing. Operator-confirmed 2026-05-29 after 43 sources stranded at
|
||||
# "last check never" by the in-flight guard; budget bumped 2026-06-03 with
|
||||
# the soft/hard limit raise (Anduo #39912).
|
||||
DOWNLOAD_STALL_THRESHOLD_MINUTES = 30
|
||||
|
||||
OLD_TASK_DAYS = 7
|
||||
@@ -55,6 +78,29 @@ FFPROBE_TIMEOUT_SECONDS = 10
|
||||
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
|
||||
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||
|
||||
# Audit 2026-06-02: per-entity recovery sweep thresholds. Each must be
|
||||
# > the entity's longest legitimate runtime (its task's time_limit + a
|
||||
# small buffer) so the sweep never flags in-flight work.
|
||||
#
|
||||
# Backups: images backup has time_limit=23400s (6.5h). 7h covers it
|
||||
# with a 30-min buffer; db backup at 12 min hard limit fits trivially.
|
||||
BACKUP_STALL_THRESHOLD_MINUTES = 7 * 60
|
||||
# Library audit: scan_library_for_rule has time_limit=7500s (2h5m).
|
||||
# 2h15m gives a 10-min buffer.
|
||||
LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES = 135
|
||||
# Import batches finalize only after every child ImportTask hits a
|
||||
# terminal state. The recovery sweep targets the case where every
|
||||
# task is done but the batch never got its closing UPDATE
|
||||
# (orchestrator crashed at the wrong instant). 2h is well past any
|
||||
# realistic single-batch import.
|
||||
IMPORT_BATCH_STALL_THRESHOLD_MINUTES = 120
|
||||
|
||||
# Retention windows (terminal rows older than these get deleted by
|
||||
# the daily prune sweeps). 30 days = operator-flagged "useful for
|
||||
# triage for a few weeks, then noise."
|
||||
LIBRARY_AUDIT_KEEP_DAYS = 30
|
||||
IMPORT_BATCH_KEEP_DAYS = 30
|
||||
|
||||
# Overrides for recover_stalled_task_runs (the TaskRun 'running' sweep).
|
||||
# Tasks/queues that legitimately run longer than the default 5-min
|
||||
# threshold need their own larger value, else the sweep marks in-flight
|
||||
@@ -69,9 +115,24 @@ TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||
# files); time_limit=2100.
|
||||
QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
|
||||
"ml": 25,
|
||||
# Audit 2026-06-02 — maintenance/scan queues run tasks that
|
||||
# legitimately exceed the 5-min default (verify_integrity at 70m
|
||||
# hard, scan_directory at 70m hard, apply_allowlist_tags /
|
||||
# recompute_centroids / backfill_phash at 35m hard). 75 min lives
|
||||
# above the longest of those and the per-task overrides below
|
||||
# cover the outliers (backups, library audit).
|
||||
"maintenance": 75,
|
||||
"scan": 75,
|
||||
}
|
||||
TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
|
||||
"backend.app.tasks.import_file.import_archive_file": 40,
|
||||
# Backup images runs hours, not minutes (6.5h hard limit). The
|
||||
# task-name override beats the queue's 75-min default so a
|
||||
# legitimately-running backup isn't flagged.
|
||||
"backend.app.tasks.backup.backup_images_task": 420,
|
||||
"backend.app.tasks.backup.restore_images_task": 420,
|
||||
# Library audit scans the full library — 2h hard limit.
|
||||
"backend.app.tasks.library_audit.scan_library_for_rule": 130,
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +421,12 @@ def prune_task_runs() -> dict:
|
||||
return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted}
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.backfill_phash")
|
||||
@celery.task(
|
||||
name="backend.app.tasks.maintenance.backfill_phash",
|
||||
# Audit 2026-06-02 — keyset-paginated phash recompute over the whole
|
||||
# library; legitimately runs >5 min on large libraries.
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def backfill_phash() -> int:
|
||||
"""Recompute phash for stored images that have none (imported before
|
||||
FC-2d-i+ii). Keyset-paginated by id (restart-safe), NULL-only fill,
|
||||
@@ -438,7 +504,13 @@ def _verify_one(path: Path, expected_sha: str, mime: str, sha_fn) -> str:
|
||||
return "failed_verification"
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.verify_integrity")
|
||||
@celery.task(
|
||||
name="backend.app.tasks.maintenance.verify_integrity",
|
||||
# Audit 2026-06-02 — full library sha256 + decode probe; on 100k-image
|
||||
# libraries this runs an hour or more. Match the maintenance queue's
|
||||
# recovery threshold (75 min) with 30s buffer below.
|
||||
soft_time_limit=3600, time_limit=4200,
|
||||
)
|
||||
def verify_integrity() -> int:
|
||||
"""Verify every ImageRecord file: sha256 recompute + decode/probe
|
||||
(PIL for images; ffprobe for videos). Writes integrity_status
|
||||
@@ -483,7 +555,7 @@ def recover_stalled_download_events() -> int:
|
||||
tasks.scan._tick_due_sources_async) inserts DownloadEvent(status='pending')
|
||||
and fires download_source.delay(). If that task dies before finalizing the
|
||||
event — worker OOM/SIGKILL, lost task, or a gallery-dl that didn't unwind
|
||||
on the 1200s hard time_limit — the event stays in-flight forever. The next
|
||||
on the 1500s hard time_limit — the event stays in-flight forever. The next
|
||||
tick then skips that source because of the in-flight guard (scan.py:168)
|
||||
and Source.last_checked_at never updates; the operator sees "last check
|
||||
never" in the Subscriptions health column, permanently.
|
||||
@@ -534,6 +606,156 @@ def recover_stalled_download_events() -> int:
|
||||
return events_recovered
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_backup_runs")
|
||||
def recover_stalled_backup_runs() -> int:
|
||||
"""Flip BackupRun rows stuck in running/restoring past the hard limit
|
||||
to error. Audit 2026-06-02.
|
||||
|
||||
prune_backups (FC-3h) used to claim the FC-3i task_run sweep handled
|
||||
these — but that sweep only flips TaskRun rows, not the BackupRun
|
||||
artifact rows. A SIGKILL'd backup left BackupRun stuck forever
|
||||
(dashboard showed phantom in-flight backups, keep_last_n offset
|
||||
arithmetic skewed because zombies sat outside the ok/error window).
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=BACKUP_STALL_THRESHOLD_MINUTES)
|
||||
msg = (
|
||||
f"stranded by recovery sweep (no terminal status after "
|
||||
f"{BACKUP_STALL_THRESHOLD_MINUTES // 60}h)"
|
||||
)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(BackupRun)
|
||||
.where(BackupRun.status.in_(["running", "restoring"]))
|
||||
.where(BackupRun.started_at < cutoff)
|
||||
.values(status="error", finished_at=now, error=msg)
|
||||
)
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info("recover_stalled_backup_runs: recovered %d rows", recovered)
|
||||
return recovered
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_library_audit_runs")
|
||||
def recover_stalled_library_audit_runs() -> int:
|
||||
"""Flip LibraryAuditRun rows stuck in running past the hard limit
|
||||
to error. Audit 2026-06-02.
|
||||
|
||||
LibraryAuditRun.status='running' was protected by an exclusive
|
||||
guard in start_audit_run — a SIGKILL'd run would block all future
|
||||
audits until manual DB surgery. (The guard is now age-aware, but
|
||||
this sweep is what makes that work in practice.)
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES)
|
||||
msg = (
|
||||
f"stranded by recovery sweep (no terminal status after "
|
||||
f"{LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES} min)"
|
||||
)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.status == "running")
|
||||
.where(LibraryAuditRun.started_at < cutoff)
|
||||
.values(status="error", finished_at=now, error=msg)
|
||||
)
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info(
|
||||
"recover_stalled_library_audit_runs: recovered %d rows", recovered,
|
||||
)
|
||||
return recovered
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_import_batches")
|
||||
def recover_stalled_import_batches() -> int:
|
||||
"""Finalize ImportBatch rows stuck in running past the hard limit
|
||||
when NO outstanding ImportTask remains. Audit 2026-06-02.
|
||||
|
||||
A batch row finalizes only after every child task hits a terminal
|
||||
state. The orphan case: scanner crashed between the last task's
|
||||
completion and the batch's closing UPDATE. The
|
||||
`/api/import/status` route then surfaces the batch as 'active'
|
||||
indefinitely while `/api/system/stats` (which uses the same
|
||||
EXISTS predicate we apply below) correctly returns null.
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=IMPORT_BATCH_STALL_THRESHOLD_MINUTES)
|
||||
with SessionLocal() as session:
|
||||
# Batches still 'running' past the cutoff whose tasks are all
|
||||
# terminal — there's no outstanding work, so flip the batch
|
||||
# too. Mirrors the EXISTS predicate the active-batch surfaces use.
|
||||
result = session.execute(
|
||||
update(ImportBatch)
|
||||
.where(ImportBatch.status == "running")
|
||||
.where(ImportBatch.started_at < cutoff)
|
||||
.where(
|
||||
~select(ImportTask.id)
|
||||
.where(
|
||||
ImportTask.batch_id == ImportBatch.id,
|
||||
ImportTask.status.in_(["pending", "queued", "processing"]),
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
.values(status="complete", finished_at=now)
|
||||
)
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info(
|
||||
"recover_stalled_import_batches: finalized %d zombie batches",
|
||||
recovered,
|
||||
)
|
||||
return recovered
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.prune_library_audit_runs")
|
||||
def prune_library_audit_runs() -> int:
|
||||
"""Daily retention: delete terminal LibraryAuditRun rows older than
|
||||
LIBRARY_AUDIT_KEEP_DAYS. Never touches 'running'. Audit 2026-06-02.
|
||||
|
||||
Audit rows carry matched_ids JSONB blobs that can hold tens of
|
||||
thousands of ids; without retention these accumulate.
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
cutoff = datetime.now(UTC) - timedelta(days=LIBRARY_AUDIT_KEEP_DAYS)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
delete(LibraryAuditRun)
|
||||
.where(LibraryAuditRun.status.in_(["ready", "applied", "cancelled", "error"]))
|
||||
.where(LibraryAuditRun.finished_at < cutoff)
|
||||
)
|
||||
session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.prune_import_batches")
|
||||
def prune_import_batches() -> int:
|
||||
"""Daily retention: delete terminal ImportBatch rows older than
|
||||
IMPORT_BATCH_KEEP_DAYS. Cascade-deletes child ImportTask rows via
|
||||
the model relationship. Never touches 'running'. Audit 2026-06-02.
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
cutoff = datetime.now(UTC) - timedelta(days=IMPORT_BATCH_KEEP_DAYS)
|
||||
with SessionLocal() as session:
|
||||
# ORM-level delete here (not Core delete) so the
|
||||
# ImportBatch->tasks cascade fires; Core delete would skip it.
|
||||
old_batches = session.execute(
|
||||
select(ImportBatch)
|
||||
.where(ImportBatch.status.in_(["complete", "cancelled"]))
|
||||
.where(ImportBatch.finished_at < cutoff)
|
||||
).scalars().all()
|
||||
for batch in old_batches:
|
||||
session.delete(batch)
|
||||
session.commit()
|
||||
return len(old_batches)
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.cleanup_old_download_events")
|
||||
def cleanup_old_download_events() -> int:
|
||||
"""FC-3d: delete terminal DownloadEvent rows older than the configured
|
||||
@@ -556,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}
|
||||
|
||||
+15
-2
@@ -212,7 +212,14 @@ def backfill(self) -> int:
|
||||
return enqueued
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.apply_allowlist_tags", bind=True)
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.apply_allowlist_tags",
|
||||
bind=True,
|
||||
# Audit 2026-06-02 — the full-sweep mode (neither tag_id nor image_id)
|
||||
# is O(images × allowlist) and legitimately runs >5 min on large
|
||||
# libraries. Cap matches the maintenance queue's recovery threshold.
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def apply_allowlist_tags(self, tag_id: int | None = None,
|
||||
image_id: int | None = None) -> int:
|
||||
"""Retroactively apply allowlisted tags.
|
||||
@@ -341,7 +348,13 @@ def recompute_centroid(self, tag_id: int) -> bool:
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.recompute_centroids", bind=True)
|
||||
@celery.task(
|
||||
name="backend.app.tasks.ml.recompute_centroids",
|
||||
bind=True,
|
||||
# Audit 2026-06-02 — drifted-centroid rebuild over potentially
|
||||
# hundreds of tags.
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def recompute_centroids(self) -> int:
|
||||
"""Daily: find drifted centroids, enqueue recompute_centroid for each."""
|
||||
import asyncio
|
||||
|
||||
@@ -35,7 +35,15 @@ def _iter_import_files(import_root: Path):
|
||||
yield entry
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.scan.scan_directory", bind=True)
|
||||
@celery.task(
|
||||
name="backend.app.tasks.scan.scan_directory",
|
||||
bind=True,
|
||||
# Audit 2026-06-02 — large libraries make the scan legitimately long.
|
||||
# Hard cap at 70 min so the corresponding QUEUE_STUCK_THRESHOLD_MINUTES
|
||||
# ("scan") of 75 min always wins; soft limit gives the task a clean
|
||||
# exit window before SIGKILL.
|
||||
soft_time_limit=3600, time_limit=4200,
|
||||
)
|
||||
def scan_directory(self, triggered_by: str = "manual",
|
||||
mode: str = "quick") -> int:
|
||||
"""Walks the import root and creates ImportTasks. `mode` is 'quick'
|
||||
|
||||
@@ -194,9 +194,20 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
if (platform.authType === 'cookies') {
|
||||
const cookies = await extractCookiesForPlatform(key);
|
||||
if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
|
||||
// Verify the captured cookies are actually live BEFORE
|
||||
// uploading. Skips upload on confirmed-stale sessions so we
|
||||
// don't overwrite FC-side credentials with garbage. Platforms
|
||||
// without a verify config (verify.ok === null) fall through
|
||||
// to upload as before.
|
||||
const v = await verifyCookiesForPlatform(key);
|
||||
if (v.ok === false) {
|
||||
return {
|
||||
error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`,
|
||||
};
|
||||
}
|
||||
const data = toNetscapeFormat(cookies);
|
||||
await api.uploadCredentials(key, 'cookies', data);
|
||||
return { success: true, cookieCount: cookies.length };
|
||||
return { success: true, cookieCount: cookies.length, verified: v.ok === true };
|
||||
}
|
||||
if (key === 'discord') {
|
||||
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
||||
@@ -229,8 +240,13 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
results[key] = { skipped: true, reason: 'no cookies' };
|
||||
continue;
|
||||
}
|
||||
const v = await verifyCookiesForPlatform(key);
|
||||
if (v.ok === false) {
|
||||
results[key] = { error: `verify failed: ${v.reason}` };
|
||||
continue;
|
||||
}
|
||||
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
|
||||
results[key] = { success: true, cookieCount: cookies.length };
|
||||
results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true };
|
||||
} catch (e) {
|
||||
results[key] = { error: e.message };
|
||||
}
|
||||
|
||||
@@ -76,3 +76,38 @@ async function getCookieCount(platformKey) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cookies are live by hitting an authenticated endpoint with the
|
||||
* browser's current cookie jar. Returns:
|
||||
* { ok: true, status } — verified
|
||||
* { ok: false, status, reason } — endpoint said we're not logged in
|
||||
* { ok: null, reason } — no verify config for this platform; caller
|
||||
* should treat as "verify not available,
|
||||
* proceed with upload"
|
||||
*
|
||||
* Implementation note: extensions with `host_permissions` for the target
|
||||
* domain get the user's cookies auto-attached to fetch() — same set
|
||||
* gallery-dl will later use on the backend.
|
||||
*/
|
||||
async function verifyCookiesForPlatform(platformKey) {
|
||||
const platform = PLATFORMS[platformKey];
|
||||
if (!platform) return { ok: false, reason: `Unknown platform: ${platformKey}` };
|
||||
if (!platform.verify) return { ok: null, reason: 'verify-not-configured' };
|
||||
|
||||
const { url, method, okStatuses } = platform.verify;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, { method, credentials: 'include', cache: 'no-store' });
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `Verify request failed: ${e.message}` };
|
||||
}
|
||||
if (okStatuses.includes(resp.status)) {
|
||||
return { ok: true, status: resp.status };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: resp.status,
|
||||
reason: `${url} returned HTTP ${resp.status} — session looks stale or logged out`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ const PLATFORMS = {
|
||||
authType: 'cookies',
|
||||
color: '#FF424D',
|
||||
urlPattern: /^https?:\/\/(www\.)?patreon\.com/,
|
||||
// Patreon's `/api/current_user` returns 200 + the logged-in user
|
||||
// when authenticated, 401 otherwise. Cheapest definitive check.
|
||||
verify: {
|
||||
url: 'https://www.patreon.com/api/current_user',
|
||||
method: 'GET',
|
||||
okStatuses: [200],
|
||||
},
|
||||
},
|
||||
subscribestar: {
|
||||
name: 'SubscribeStar',
|
||||
@@ -26,6 +33,9 @@ const PLATFORMS = {
|
||||
authType: 'cookies',
|
||||
color: '#FFD700',
|
||||
urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/,
|
||||
// No known stable auth-required endpoint that returns a definitive
|
||||
// status code; skipping verify so we don't false-positive-fail
|
||||
// good cookies. Operator can add later if a clean endpoint surfaces.
|
||||
},
|
||||
hentaifoundry: {
|
||||
name: 'Hentai Foundry',
|
||||
@@ -33,6 +43,14 @@ const PLATFORMS = {
|
||||
authType: 'cookies',
|
||||
color: '#9C27B0',
|
||||
urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/,
|
||||
// Mirror gallery-dl's _init_site_filters: HEAD on `?enterAgree=1`.
|
||||
// Logged in → 200, logged out → 401. Catches the exact failure mode
|
||||
// the backend extractor would hit later.
|
||||
verify: {
|
||||
url: 'https://www.hentai-foundry.com/?enterAgree=1',
|
||||
method: 'HEAD',
|
||||
okStatuses: [200],
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
name: 'Discord',
|
||||
@@ -56,6 +74,9 @@ const PLATFORMS = {
|
||||
authType: 'cookies',
|
||||
color: '#05CC47',
|
||||
urlPattern: /^https?:\/\/(www\.)?deviantart\.com/,
|
||||
// DA's logged-in-only endpoints sit behind their internal _napi
|
||||
// namespace which shifts; skipping verify until a stable check
|
||||
// surfaces. Same posture as SubscribeStar.
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
|
||||
@@ -132,8 +132,9 @@ async function exportPlatformCookies(key, card) {
|
||||
if (r.error) showError(r.error);
|
||||
else {
|
||||
const n = r.cookieCount ?? null;
|
||||
const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
|
||||
const msg = n !== null
|
||||
? `${PLATFORMS[key].name}: ${n} cookies exported`
|
||||
? `${PLATFORMS[key].name}: ${n} cookies exported${verifiedSuffix}`
|
||||
: `${PLATFORMS[key].name}: token exported`;
|
||||
showSuccess(msg);
|
||||
await loadPlatformStatus();
|
||||
|
||||
+14
-1
@@ -9,7 +9,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppShell from './components/AppShell.vue'
|
||||
import AppSnackbar from './components/AppSnackbar.vue'
|
||||
import ImageViewer from './components/modal/ImageViewer.vue'
|
||||
@@ -17,9 +19,20 @@ import { useModalStore } from './stores/modal.js'
|
||||
|
||||
const modal = useModalStore()
|
||||
const snackbar = ref(null)
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(() => {
|
||||
// Expose snackbar via a simple global so stores can call it without props.
|
||||
window.__fcToast = (opts) => snackbar.value?.open(opts)
|
||||
})
|
||||
|
||||
// Audit 2026-06-02: the modal is an overlay, not a page. When the
|
||||
// route changes (RouterLink inside the modal, history back/forward,
|
||||
// programmatic push from any view), close the modal so it doesn't
|
||||
// hover over a different route. Watching route.name (not the path)
|
||||
// keeps within-route nav like /artist/foo → /artist/bar from
|
||||
// dismissing the modal mid-browse.
|
||||
watch(() => route.name, () => {
|
||||
if (modal.isOpen) modal.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<PipelineStatusChip />
|
||||
</div>
|
||||
|
||||
<!-- Desktop: inline links, centered. Hidden on mobile (see media query),
|
||||
where they fold into the hamburger menu on the right. -->
|
||||
<nav class="fc-links">
|
||||
<RouterLink
|
||||
v-for="r in navRoutes"
|
||||
@@ -20,9 +22,31 @@
|
||||
>{{ r.meta.title }}</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- Per-view contextual actions teleport here (Showcase: Shuffle,
|
||||
Gallery: Select). TopNav owns the slot, not its contents. -->
|
||||
<div id="fc-nav-actions" class="fc-nav-actions" />
|
||||
<div class="fc-nav-right">
|
||||
<!-- Per-view contextual actions teleport here (Showcase: Shuffle,
|
||||
Gallery: Select). TopNav owns the slot, not its contents. -->
|
||||
<div id="fc-nav-actions" class="fc-nav-actions" />
|
||||
|
||||
<!-- Mobile nav: the link row collides with brand + actions below ~768px
|
||||
(7+ links in one flex row), so collapse it into a menu. -->
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-menu" variant="text" size="small"
|
||||
class="fc-nav-burger" aria-label="Menu"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact" min-width="180">
|
||||
<v-list-item
|
||||
v-for="r in navRoutes"
|
||||
:key="r.name"
|
||||
:to="{ name: r.name }"
|
||||
:title="r.meta.title"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -137,7 +161,7 @@ const health = computed(() => {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fc-nav-actions {
|
||||
.fc-nav-right {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -145,4 +169,22 @@ const health = computed(() => {
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.fc-nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
/* The hamburger only exists on mobile; the inline links carry desktop. */
|
||||
.fc-nav-burger { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fc-topnav { gap: 0.5rem; padding: 0.6rem 0.75rem; }
|
||||
/* Fold the link row into the hamburger menu. */
|
||||
.fc-links { display: none; }
|
||||
.fc-nav-burger { display: inline-flex; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
/* Reclaim width on the smallest phones — the glyph alone still brands. */
|
||||
.fc-brand__text { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useArtistStore } from '../../stores/artist.js'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import MasonryGrid from '../discovery/MasonryGrid.vue'
|
||||
@@ -24,22 +21,9 @@ const props = defineProps({
|
||||
|
||||
const store = useArtistStore()
|
||||
const modal = useModalStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
const initial = parseInt(route.query.image, 10)
|
||||
if (!isNaN(initial)) modal.open(initial)
|
||||
})
|
||||
|
||||
watch(() => route.query.image, (q) => {
|
||||
const id = parseInt(q, 10)
|
||||
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
|
||||
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
|
||||
})
|
||||
|
||||
function openImage (id) {
|
||||
router.push({ query: { ...route.query, image: id } })
|
||||
modal.open(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -112,6 +112,16 @@ onMounted(async () => {
|
||||
await store.loadDefaults()
|
||||
threshold.value = store.defaults.single_color_threshold
|
||||
tolerance.value = store.defaults.single_color_tolerance
|
||||
// Reconnect to this rule's latest run so a scan started before navigating
|
||||
// away keeps showing progress / its result on return (the scan itself runs
|
||||
// backend-side regardless).
|
||||
try {
|
||||
const latest = await store.latestAuditForRule('single_color')
|
||||
if (latest) {
|
||||
audit.value = latest
|
||||
if (latest.status === 'running') startPoll(latest.id)
|
||||
}
|
||||
} catch { /* non-fatal — card still works for a fresh scan */ }
|
||||
})
|
||||
|
||||
onUnmounted(() => stopPoll())
|
||||
|
||||
@@ -97,6 +97,16 @@ let pollTimer = null
|
||||
onMounted(async () => {
|
||||
await store.loadDefaults()
|
||||
threshold.value = store.defaults.transparency_threshold
|
||||
// Reconnect to this rule's latest run so a scan started before navigating
|
||||
// away keeps showing progress / its result on return (the scan itself runs
|
||||
// backend-side regardless).
|
||||
try {
|
||||
const latest = await store.latestAuditForRule('transparency')
|
||||
if (latest) {
|
||||
audit.value = latest
|
||||
if (latest.status === 'running') startPoll(latest.id)
|
||||
}
|
||||
} catch { /* non-fatal — card still works for a fresh scan */ }
|
||||
})
|
||||
|
||||
onUnmounted(() => stopPoll())
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
<div v-if="card.preview_thumbnails.length === 0" class="fc-artistcard__noimg">
|
||||
No preview
|
||||
</div>
|
||||
<!-- Accent pill in the corner when this artist has content imported
|
||||
since the operator last opened their detail view. Caps at 99+
|
||||
to keep the layout compact; the actual count appears in the
|
||||
banner inside ArtistView. -->
|
||||
<span
|
||||
v-if="(card.unseen_count || 0) > 0"
|
||||
class="fc-artistcard__unseen"
|
||||
:aria-label="`${card.unseen_count} new since last visit`"
|
||||
>+{{ card.unseen_count > 99 ? '99+' : card.unseen_count }}</span>
|
||||
</div>
|
||||
<v-card-text class="fc-artistcard__body">
|
||||
<div class="fc-artistcard__name">{{ card.name }}</div>
|
||||
@@ -37,6 +46,7 @@ function onCardClick() {
|
||||
<style scoped>
|
||||
.fc-artistcard { cursor: pointer; }
|
||||
.fc-artistcard__previews {
|
||||
position: relative;
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2px; aspect-ratio: 3 / 1;
|
||||
/* Explicit floor + ceiling so tall source images can't escape the
|
||||
@@ -45,6 +55,19 @@ function onCardClick() {
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-artistcard__unseen {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgb(var(--v-theme-on-accent, 0, 0, 0));
|
||||
background: rgb(var(--v-theme-accent));
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
.fc-artistcard__previews img {
|
||||
display: block;
|
||||
width: 100%; height: 100%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-if="card.kind === 'character'"
|
||||
title="Set fandom…"
|
||||
prepend-icon="mdi-book-open-page-variant"
|
||||
@click="$emit('set-fandom', card)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Merge with…"
|
||||
prepend-icon="mdi-call-merge"
|
||||
@@ -78,7 +84,9 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({ card: { type: Object, required: true } })
|
||||
const emit = defineEmits(['open', 'rename', 'manage', 'read', 'merge-with', 'delete'])
|
||||
const emit = defineEmits([
|
||||
'open', 'rename', 'manage', 'read', 'merge-with', 'delete', 'set-fandom',
|
||||
])
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref('')
|
||||
|
||||
@@ -124,10 +124,16 @@ async function onRetry() {
|
||||
if (!props.event.source_id) return
|
||||
retrying.value = true
|
||||
try {
|
||||
await sourcesStore.checkNow(props.event.source_id)
|
||||
toast({
|
||||
text: `Source check re-queued`, type: 'success',
|
||||
})
|
||||
const body = await sourcesStore.checkNow(props.event.source_id)
|
||||
// Audit 2026-06-02: the previous handler unconditionally toasted
|
||||
// "re-queued" even when the platform was in cooldown (202 +
|
||||
// status='deferred'). Operator thought work was in flight when
|
||||
// nothing was actually enqueued.
|
||||
if (body?.status === 'deferred') {
|
||||
toast({ text: 'Retry deferred — platform in cooldown', type: 'info' })
|
||||
} else {
|
||||
toast({ text: 'Source check re-queued', type: 'success' })
|
||||
}
|
||||
} catch (e) {
|
||||
const isInFlight = !!e?.body?.download_event_id
|
||||
toast({
|
||||
|
||||
@@ -186,7 +186,9 @@ async function onDeleteConfirm(token) {
|
||||
<style scoped>
|
||||
.fc-bulk-panel {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: 320px; height: 100vh; z-index: 1100;
|
||||
/* min(320px, 90vw): on phones the panel never swallows the whole screen —
|
||||
leaves a sliver of the gallery visible behind it. */
|
||||
width: min(320px, 90vw); height: 100vh; z-index: 1100;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
||||
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.4);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<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)); }
|
||||
|
||||
/* Phones: let each facet group wrap and the side-by-side date inputs grow to
|
||||
full width so they don't overflow a ~360px viewport. */
|
||||
@media (max-width: 480px) {
|
||||
.fc-facets__group { flex-wrap: wrap; }
|
||||
.fc-facets__date { max-width: none; flex: 1 1 9rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="fc-filterbar-wrap">
|
||||
<div class="fc-filterbar">
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="searchItems"
|
||||
:loading="searchLoading"
|
||||
item-title="name" item-value="value"
|
||||
no-filter clearable hide-details density="compact" variant="outlined"
|
||||
placeholder="Filter by tag or artist…"
|
||||
prepend-inner-icon="mdi-filter-variant"
|
||||
class="fc-filterbar__search"
|
||||
@update:search="onSearch"
|
||||
@update:model-value="onPick"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps" :title="item.raw.name">
|
||||
<template #prepend>
|
||||
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{ item.raw.kind === 'artist' ? 'artist'
|
||||
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
|
||||
<div class="fc-filterbar__chips">
|
||||
<v-chip
|
||||
v-for="id in store.filter.tag_ids" :key="`t${id}`"
|
||||
size="small" closable :color="chipColor(id)" variant="tonal"
|
||||
@click:close="removeTag(id)"
|
||||
>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
|
||||
<v-chip
|
||||
v-if="store.filter.artist_id"
|
||||
size="small" closable color="accent" variant="tonal"
|
||||
prepend-icon="mdi-account"
|
||||
@click:close="clearArtist"
|
||||
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip>
|
||||
<v-chip
|
||||
v-if="store.filter.similar_to"
|
||||
size="small" closable color="accent" variant="tonal"
|
||||
prepend-icon="mdi-image-multiple"
|
||||
@click:close="clearSimilar"
|
||||
>Similar to #{{ store.filter.similar_to }}</v-chip>
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn-toggle
|
||||
:model-value="store.filter.media_type ?? 'all'"
|
||||
density="compact" mandatory variant="outlined" divided
|
||||
@update:model-value="(v) => setMedia(v === 'all' ? null : v)"
|
||||
>
|
||||
<v-btn value="all" size="small">All</v-btn>
|
||||
<v-btn value="image" size="small">Images</v-btn>
|
||||
<v-btn value="video" size="small">Videos</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<!-- Sort is meaningless in similar-mode (results are distance-ranked). -->
|
||||
<v-select
|
||||
v-if="!store.filter.similar_to"
|
||||
:model-value="store.filter.sort"
|
||||
:items="SORTS"
|
||||
density="compact" hide-details variant="outlined"
|
||||
class="fc-filterbar__sort"
|
||||
@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, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApi } from '../../composables/useApi.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()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
|
||||
const SORTS = [
|
||||
{ title: 'Newest first', value: 'newest' },
|
||||
{ title: 'Oldest first', value: 'oldest' },
|
||||
]
|
||||
|
||||
const selected = ref(null)
|
||||
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.similar_to != null ||
|
||||
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',
|
||||
series: 'mdi-bookshelf' }[raw.kind] || 'mdi-tag'
|
||||
}
|
||||
function chipColor(id) {
|
||||
// Tag chips use the same per-kind palette as the rest of the app; we only
|
||||
// know the kind from the autocomplete pick, so fall back to a neutral tone.
|
||||
return tagStore.colorFor(pickedKind.value[id] || 'general')
|
||||
}
|
||||
const pickedKind = ref({})
|
||||
|
||||
function onSearch(q) {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
if (!q || !q.trim()) { searchItems.value = []; return }
|
||||
debounce = setTimeout(async () => {
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const [tags, artists] = await Promise.all([
|
||||
api.get('/api/tags/autocomplete', { params: { q, limit: 10 } }),
|
||||
api.get('/api/artists/autocomplete', { params: { q, limit: 10 } }),
|
||||
])
|
||||
searchItems.value = [
|
||||
...(artists || []).map((a) => ({
|
||||
kind: 'artist', id: a.id, name: a.name, value: `artist:${a.id}`,
|
||||
})),
|
||||
...(tags || []).map((t) => ({
|
||||
kind: t.kind, id: t.id, name: t.name, value: `tag:${t.id}`,
|
||||
fandom_name: t.fandom_name,
|
||||
})),
|
||||
]
|
||||
} catch {
|
||||
searchItems.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function onPick(value) {
|
||||
const item = searchItems.value.find((i) => i.value === value)
|
||||
selected.value = null
|
||||
searchItems.value = []
|
||||
if (!item) return
|
||||
if (item.kind === 'artist') {
|
||||
store.noteArtistLabel(item.name)
|
||||
pushFilter((n) => { n.artist_id = item.id })
|
||||
} else {
|
||||
store.noteTagLabel(item.id, item.name)
|
||||
pickedKind.value = { ...pickedKind.value, [item.id]: item.kind }
|
||||
pushFilter((n) => { if (!n.tag_ids.includes(item.id)) n.tag_ids.push(item.id) })
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(id) {
|
||||
pushFilter((n) => { n.tag_ids = n.tag_ids.filter((t) => t !== id) })
|
||||
}
|
||||
function clearArtist() {
|
||||
store.noteArtistLabel(null)
|
||||
pushFilter((n) => { n.artist_id = null })
|
||||
}
|
||||
function clearSimilar() { pushFilter((n) => { n.similar_to = null }) }
|
||||
function setMedia(m) { pushFilter((n) => { n.media_type = m }) }
|
||||
function setSort(s) { pushFilter((n) => { n.sort = s }) }
|
||||
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 n = cloneFilter(store.filter)
|
||||
mutate(n)
|
||||
router.push({ name: 'gallery', query: filterToQuery(n) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* The whole chrome (bar row + expandable refine panel) is one sticky,
|
||||
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
||||
.fc-filterbar-wrap {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
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;
|
||||
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
|
||||
the two read as one continuous piece of chrome — images scroll visibly
|
||||
under both. The nav's gradient fades to transparent at ITS bottom; this
|
||||
bar re-darkens at its top, so a faint seam (the page/image showing through
|
||||
the nav's transparent edge) separates them when scrolled to the very top,
|
||||
while under-scroll they frost as one. */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
.fc-filterbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
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; }
|
||||
.fc-filterbar__sort { max-width: 150px; }
|
||||
|
||||
/* Phones: the search's 200px min-width jams the wrapping bar. Give search its
|
||||
own full-width row and let sort grow; everything else wraps under it. */
|
||||
@media (max-width: 600px) {
|
||||
.fc-filterbar { gap: 8px; }
|
||||
.fc-filterbar__search { min-width: 100%; max-width: none; }
|
||||
.fc-filterbar__sort { max-width: none; flex: 1 1 auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,19 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Similar-mode (and any non-date-grouped result set) returns no date
|
||||
groups — the results are ranked, not chronological. Render them as a
|
||||
single flat list in their given order rather than nothing. -->
|
||||
<div
|
||||
v-if="!store.dateGroups.length && store.images.length"
|
||||
class="fc-gallery-grid__items"
|
||||
>
|
||||
<GalleryItem
|
||||
v-for="img in store.images"
|
||||
:key="img.id" :image="img" @open="$emit('open', img.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="fc-gallery-grid__sentinel">
|
||||
<v-progress-circular indeterminate color="accent" size="28" />
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,16 @@
|
||||
>
|
||||
<div class="fc-gallery-item__media">
|
||||
<img
|
||||
v-if="!isVideo" :src="image.thumbnail_url" :alt="`Image ${image.id}`"
|
||||
loading="lazy" @error="onThumbError"
|
||||
v-if="!isVideo" ref="imgEl" :src="image.thumbnail_url"
|
||||
:alt="`Image ${image.id}`" loading="lazy"
|
||||
:class="{ 'is-loaded': loaded }"
|
||||
@load="loaded = true" @error="onThumbError"
|
||||
>
|
||||
<div v-else class="fc-gallery-item__video-thumb">
|
||||
<img :src="image.thumbnail_url" :alt="`Video ${image.id}`" loading="lazy">
|
||||
<img
|
||||
ref="imgEl" :src="image.thumbnail_url" :alt="`Video ${image.id}`"
|
||||
loading="lazy" :class="{ 'is-loaded': loaded }" @load="loaded = true"
|
||||
>
|
||||
<v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" />
|
||||
</div>
|
||||
<div v-if="thumbError" class="fc-gallery-item__placeholder">
|
||||
@@ -38,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
|
||||
|
||||
const props = defineProps({ image: { type: Object, required: true } })
|
||||
@@ -46,6 +51,18 @@ const emit = defineEmits(['open'])
|
||||
|
||||
const sel = useGallerySelectionStore()
|
||||
const thumbError = ref(false)
|
||||
|
||||
// Reveal each tile when ITS OWN thumbnail finishes loading (`@load`), not
|
||||
// when its metadata batch lands — so tiles fade/flip in individually in
|
||||
// load order instead of popping in together (operator-flagged 2026-06-04).
|
||||
const loaded = ref(false)
|
||||
const imgEl = ref(null)
|
||||
onMounted(() => {
|
||||
// A cached thumbnail can already be complete before @load binds; reflect
|
||||
// that so the tile reveals instead of sitting invisible at opacity 0.
|
||||
const el = imgEl.value
|
||||
if (el && el.complete && el.naturalWidth > 0) loaded.value = true
|
||||
})
|
||||
const isVideo = computed(
|
||||
() => props.image.mime && props.image.mime.startsWith('video/')
|
||||
)
|
||||
@@ -82,6 +99,30 @@ function onThumbError() { thumbError.value = true }
|
||||
}
|
||||
.fc-gallery-item__media img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
/* Entrance: each thumbnail flips up out of a slight backward tilt and
|
||||
settles into place, played WHEN ITS OWN image finishes loading (the
|
||||
`is-loaded` class) rather than on a batch/timer — so tiles cascade in
|
||||
natural load order instead of popping in together. Mirrors the showcase
|
||||
MasonryGrid entrance styling (operator-flagged 2026-06-04). */
|
||||
.fc-gallery-item__media img.is-loaded {
|
||||
animation: fc-gallery-item-in 0.5s cubic-bezier(0.34, 1.45, 0.64, 1) both;
|
||||
}
|
||||
@keyframes fc-gallery-item-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: perspective(1000px) rotateX(-22deg) translateY(20px) scale(0.96);
|
||||
}
|
||||
55% { opacity: 1; }
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: perspective(1000px) rotateX(0) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-gallery-item__media img { opacity: 1; }
|
||||
.fc-gallery-item__media img.is-loaded { animation: none; }
|
||||
}
|
||||
.fc-gallery-item__video-thumb { position: relative; height: 100%; }
|
||||
.fc-gallery-item__video-badge {
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<!-- Not every character belongs to a fandom (original characters,
|
||||
unsorted, etc.). "No fandom" creates the character unassigned;
|
||||
a fandom can still be set later from the chip's kebab menu. -->
|
||||
<v-btn variant="text" @click="onNoFandom">No fandom</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('cancel')">Cancel</v-btn>
|
||||
<v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn>
|
||||
@@ -45,4 +49,9 @@ function onConfirm() {
|
||||
const f = store.fandomCache.find(x => x.id === selectedId.value)
|
||||
if (f) emit('confirm', f)
|
||||
}
|
||||
// Create the character with no fandom. Emits null so the caller knows this
|
||||
// was a deliberate "unassigned", not a cancel.
|
||||
function onNoFandom() {
|
||||
emit('confirm', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="text-body-1">Fandom for “{{ tag.name }}”</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="!collision">
|
||||
<v-autocomplete
|
||||
v-model="selectedId"
|
||||
:items="store.fandomCache"
|
||||
:item-title="(f) => f.name" :item-value="(f) => f.id"
|
||||
label="Fandom" clearable density="compact"
|
||||
:hint="selectedId == null
|
||||
? 'No fandom — the character will be unassigned.' : ''"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-divider class="my-3" />
|
||||
<p class="text-caption mb-2">Or create a new fandom:</p>
|
||||
<div class="d-flex" style="gap: 8px;">
|
||||
<v-text-field
|
||||
v-model="newName" placeholder="New fandom name"
|
||||
density="compact" hide-details
|
||||
@keydown.enter.prevent="onCreate"
|
||||
/>
|
||||
<v-btn
|
||||
:disabled="!newName.trim() || busy" rounded="pill"
|
||||
@click="onCreate"
|
||||
>Create</v-btn>
|
||||
</div>
|
||||
<v-alert
|
||||
v-if="error" type="error" variant="tonal" density="compact"
|
||||
class="mt-3"
|
||||
>{{ error }}</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
A character named “{{ tag.name }}” already exists in that fandom.
|
||||
</v-alert>
|
||||
<p class="text-body-2">
|
||||
Merge this tag into “{{ collision.target.name }}”?
|
||||
{{ collision.source_image_count }} image
|
||||
association{{ collision.source_image_count === 1 ? '' : 's' }}
|
||||
will move over and this tag will be deleted{{
|
||||
collision.will_alias ? ' (its name kept as a tagger alias)' : '' }}.
|
||||
</p>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<template v-if="!collision">
|
||||
<v-btn variant="text" :disabled="busy" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill" :loading="busy"
|
||||
:disabled="selectedId === (tag.fandom_id ?? null)"
|
||||
@click="onSave"
|
||||
>Save</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn variant="text" :disabled="busy" @click="collision = null">
|
||||
Back
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning" variant="flat" rounded="pill" :loading="busy"
|
||||
@click="onConfirmMerge"
|
||||
>Merge</v-btn>
|
||||
</template>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
const props = defineProps({ tag: { type: Object, required: true } })
|
||||
const emit = defineEmits(['updated', 'cancel'])
|
||||
|
||||
const store = useTagStore()
|
||||
|
||||
const selectedId = ref(props.tag.fandom_id ?? null)
|
||||
const newName = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref(null)
|
||||
const collision = ref(null)
|
||||
|
||||
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
|
||||
|
||||
async function onCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const f = await store.createFandom(name)
|
||||
selectedId.value = f.id
|
||||
newName.value = ''
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(merge) {
|
||||
busy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const body = await store.setFandom(props.tag.id, selectedId.value, { merge })
|
||||
emit('updated', body)
|
||||
} catch (e) {
|
||||
// 409 on first attempt → surface the merge confirmation; cross-fandom
|
||||
// collisions can't go through the regular /merge endpoint, so the
|
||||
// resolution is a second setFandom with merge: true.
|
||||
if (!merge && e.status === 409 && e.body && e.body.target) {
|
||||
collision.value = e.body
|
||||
} else {
|
||||
error.value = e.message || String(e)
|
||||
collision.value = null
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSave() { save(false) }
|
||||
function onConfirmMerge() { save(true) }
|
||||
</script>
|
||||
@@ -51,6 +51,9 @@
|
||||
<aside v-if="modal.current" class="fc-viewer__side">
|
||||
<ProvenancePanel />
|
||||
<TagPanel />
|
||||
<!-- Non-blocking: fetches its own similar set after the modal is up;
|
||||
collapses silently if empty/slow/failed (see RelatedStrip). -->
|
||||
<RelatedStrip />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,10 +63,12 @@
|
||||
<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'
|
||||
import ProvenancePanel from './ProvenancePanel.vue'
|
||||
import RelatedStrip from './RelatedStrip.vue'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
@@ -105,11 +110,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 +142,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 +178,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 +196,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 +204,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,133 @@
|
||||
<template>
|
||||
<!-- Collapses entirely unless the source has an embedding AND we're loading
|
||||
or have results — so a slow/empty/failed fetch leaves no trace and never
|
||||
affects the modal. -->
|
||||
<div v-if="show" class="fc-related">
|
||||
<div class="fc-related__head">
|
||||
<span class="fc-related__title">Related</span>
|
||||
<v-btn
|
||||
size="x-small" variant="text" color="accent"
|
||||
:disabled="loading || !results.length"
|
||||
@click="seeAll"
|
||||
>See all similar</v-btn>
|
||||
</div>
|
||||
<div class="fc-related__row">
|
||||
<template v-if="loading">
|
||||
<div v-for="n in 6" :key="n" class="fc-related__skel" />
|
||||
</template>
|
||||
<button
|
||||
v-for="img in results" :key="img.id"
|
||||
class="fc-related__item" type="button"
|
||||
@click="openImage(img.id)"
|
||||
>
|
||||
<img :src="img.thumbnail_url" :alt="`image ${img.id}`" loading="lazy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
|
||||
// Deferred so the modal's main image gets network/decode priority — the strip
|
||||
// is a nice-to-have and must never block or slow the modal load.
|
||||
const DEFER_MS = 200
|
||||
const STRIP_LIMIT = 12
|
||||
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const modal = useModalStore()
|
||||
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const hasEmbedding = computed(() => modal.current?.has_embedding === true)
|
||||
const show = computed(() => hasEmbedding.value && (loading.value || results.value.length > 0))
|
||||
|
||||
let seq = 0
|
||||
let timer = null
|
||||
|
||||
async function fetchSimilar(id) {
|
||||
const mine = ++seq
|
||||
loading.value = true
|
||||
results.value = []
|
||||
try {
|
||||
const body = await api.get('/api/gallery/similar', {
|
||||
params: { similar_to: id, limit: STRIP_LIMIT },
|
||||
})
|
||||
if (mine !== seq) return
|
||||
results.value = body.images || []
|
||||
} catch {
|
||||
if (mine === seq) results.value = [] // quietly collapse on error
|
||||
} finally {
|
||||
if (mine === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch whenever the modal lands on a new embedded image. modal.current is
|
||||
// null while the next image loads, so this also clears the strip during
|
||||
// prev/next nav and repopulates once the new payload arrives.
|
||||
watch(
|
||||
() => (hasEmbedding.value ? modal.current?.id : null),
|
||||
(id) => {
|
||||
if (timer) { clearTimeout(timer); timer = null }
|
||||
seq++ // cancel any in-flight fetch
|
||||
results.value = []
|
||||
loading.value = false
|
||||
if (!id) return
|
||||
timer = setTimeout(() => fetchSimilar(id), DEFER_MS)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onBeforeUnmount(() => { if (timer) clearTimeout(timer) })
|
||||
|
||||
function openImage(id) {
|
||||
modal.open(id)
|
||||
}
|
||||
|
||||
function seeAll() {
|
||||
const id = modal.current?.id
|
||||
if (!id) return
|
||||
modal.close()
|
||||
router.push({ name: 'gallery', query: { similar_to: String(id) } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-related {
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-related__head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fc-related__title {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-related__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.fc-related__item {
|
||||
display: block; padding: 0; border: 0; background: none;
|
||||
cursor: pointer; border-radius: 4px; overflow: hidden;
|
||||
aspect-ratio: 1; width: 100%;
|
||||
}
|
||||
.fc-related__item img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
transition: transform 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
.fc-related__item:hover img { transform: scale(1.05); filter: brightness(1.1); }
|
||||
.fc-related__skel {
|
||||
aspect-ratio: 1; border-radius: 4px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -19,34 +19,45 @@
|
||||
>
|
||||
Accept
|
||||
</v-btn>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
class="fc-suggestion__menu"
|
||||
icon="mdi-dots-vertical" size="small"
|
||||
variant="outlined" density="compact"
|
||||
:aria-label="`More actions for ${suggestion.display_name}`"
|
||||
v-bind="props"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="$emit('alias', suggestion)">
|
||||
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('dismiss', suggestion)">
|
||||
<v-list-item-title>Dismiss for this image</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
|
||||
prior `#activator` + `v-bind="props"` path never toggled the menu
|
||||
inside this teleported modal, while v-model-driven overlays (the
|
||||
dialogs in this modal) work fine. So drive the menu explicitly:
|
||||
the button toggles `menuOpen` with @click.stop (also shields any
|
||||
parent), and `activator="parent"` anchors the menu for positioning
|
||||
only — `:open-on-click="false"` keeps Vuetify's activator-click out
|
||||
of it, so there's a single, reliable opener. -->
|
||||
<span class="fc-suggestion__menu-wrap">
|
||||
<v-btn
|
||||
class="fc-suggestion__menu"
|
||||
icon="mdi-dots-vertical" size="small"
|
||||
variant="outlined" density="compact"
|
||||
:aria-label="`More actions for ${suggestion.display_name}`"
|
||||
@click.stop="menuOpen = !menuOpen"
|
||||
/>
|
||||
<v-menu
|
||||
v-model="menuOpen" activator="parent" :open-on-click="false"
|
||||
>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="$emit('alias', suggestion)">
|
||||
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('dismiss', suggestion)">
|
||||
<v-list-item-title>Dismiss for this image</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({ suggestion: { type: Object, required: true } })
|
||||
defineEmits(['accept', 'alias', 'dismiss'])
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||
</script>
|
||||
|
||||
@@ -90,6 +101,11 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||
.fc-suggestion__accept :deep(.v-btn__content) {
|
||||
font-size: 12px; letter-spacing: 0.02em;
|
||||
}
|
||||
.fc-suggestion__menu-wrap {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.fc-suggestion__menu {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -147,10 +147,14 @@ function onCreate () {
|
||||
reset()
|
||||
}
|
||||
|
||||
// fandom is null when the user picked "No fandom" — characters don't all
|
||||
// belong to a fandom. The backend already accepts fandom_id: null for the
|
||||
// character kind (tag.kind check + nullable fandom_id), and a fandom can be
|
||||
// assigned later from the chip kebab's "Set fandom…".
|
||||
function onFandomChosen (fandom) {
|
||||
fandomDialog.value = false
|
||||
emit('pick-new', {
|
||||
name: pendingNewName, kind: 'character', fandom_id: fandom.id,
|
||||
name: pendingNewName, kind: 'character', fandom_id: fandom ? fandom.id : null,
|
||||
})
|
||||
pendingNewName = null
|
||||
reset()
|
||||
|
||||
@@ -10,19 +10,36 @@
|
||||
>
|
||||
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
|
||||
{{ tag.name }}<span v-if="tag.fandom_id">→</span>
|
||||
<v-menu>
|
||||
<template #activator="{ props: mp }">
|
||||
<v-icon
|
||||
v-bind="mp" size="x-small" class="ml-1"
|
||||
icon="mdi-dots-vertical" @click.stop
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="openRename(tag)">
|
||||
<v-list-item-title>Rename…</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
|
||||
never opened inside this teleported modal. Drive it explicitly
|
||||
instead (same mechanism as the dialogs below, which work): the
|
||||
icon toggles `openTagId` with @click.stop (shielding the chip's
|
||||
close button), and `activator="parent"` + `:open-on-click=false`
|
||||
anchors the menu for positioning only. One tag's menu open at a
|
||||
time, so a single id is enough. -->
|
||||
<span class="kebab-wrap">
|
||||
<v-icon
|
||||
size="x-small" class="ml-1 kebab-icon"
|
||||
icon="mdi-dots-vertical"
|
||||
@click.stop="openTagId = openTagId === tag.id ? null : tag.id"
|
||||
/>
|
||||
<v-menu
|
||||
:model-value="openTagId === tag.id"
|
||||
activator="parent" :open-on-click="false"
|
||||
@update:model-value="v => { if (!v) openTagId = null }"
|
||||
>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="openRename(tag)">
|
||||
<v-list-item-title>Rename…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
|
||||
>
|
||||
<v-list-item-title>Set fandom…</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</span>
|
||||
</v-chip>
|
||||
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
|
||||
</div>
|
||||
@@ -49,6 +66,13 @@
|
||||
@renamed="onRenamed" @cancel="renameDialog = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="fandomDialog" max-width="460">
|
||||
<FandomSetDialog
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialog = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -59,10 +83,14 @@ import { useTagStore } from '../../stores/tags.js'
|
||||
import TagAutocomplete from './TagAutocomplete.vue'
|
||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||
import TagRenameDialog from './TagRenameDialog.vue'
|
||||
import FandomSetDialog from './FandomSetDialog.vue'
|
||||
|
||||
const modal = useModalStore()
|
||||
const store = useTagStore()
|
||||
const errorMsg = ref(null)
|
||||
// Which tag chip's kebab menu is open (only one at a time). Drives each
|
||||
// chip menu's v-model so opening never depends on Vuetify's activator click.
|
||||
const openTagId = ref(null)
|
||||
|
||||
const KIND_ICONS = {
|
||||
general: 'mdi-tag', character: 'mdi-account-circle',
|
||||
@@ -99,6 +127,18 @@ async function onRenamed() {
|
||||
// Reflect the new name in the modal's current tag list without a full reload.
|
||||
await modal.reloadTags()
|
||||
}
|
||||
|
||||
const fandomDialog = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
function openSetFandom(tag) {
|
||||
fandomTarget.value = tag
|
||||
fandomDialog.value = true
|
||||
}
|
||||
async function onFandomUpdated() {
|
||||
fandomDialog.value = false
|
||||
// A fandom change can merge the tag away; reload to reflect the new state.
|
||||
await modal.reloadTags()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -113,4 +153,6 @@ async function onRenamed() {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.kebab-wrap { display: inline-flex; align-items: center; }
|
||||
.kebab-icon { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
<template>
|
||||
<v-card
|
||||
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
|
||||
variant="outlined"
|
||||
:tabindex="expanded ? -1 : 0"
|
||||
@click="onCardClick"
|
||||
@keydown.enter="onCardClick"
|
||||
>
|
||||
<v-card class="fc-post-card" variant="outlined">
|
||||
<div class="fc-post-card__head">
|
||||
<!-- Posts with no live subscription have source=null (alembic
|
||||
0030); show a "filesystem import" affordance instead of a
|
||||
platform chip. -->
|
||||
<!-- Posts with no live subscription have source=null (alembic 0030);
|
||||
show a "filesystem import" affordance instead of a platform chip. -->
|
||||
<v-chip size="x-small" variant="tonal">
|
||||
{{ post.source?.platform ?? 'filesystem import' }}
|
||||
</v-chip>
|
||||
<RouterLink
|
||||
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
||||
class="fc-post-card__artist"
|
||||
@click.stop
|
||||
>{{ post.artist.name }}</RouterLink>
|
||||
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
|
||||
<span v-if="expanded && images.length" class="fc-post-card__meta">
|
||||
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
|
||||
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
|
||||
<span v-if="totalImages" class="fc-post-card__meta">
|
||||
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
@@ -31,140 +20,108 @@
|
||||
:href="post.post_url" target="_blank" rel="noopener"
|
||||
icon="mdi-open-in-new" size="x-small" variant="text"
|
||||
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
|
||||
@click.stop
|
||||
/>
|
||||
<v-btn
|
||||
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
size="x-small" variant="text"
|
||||
:aria-label="expanded ? 'Collapse post' : 'Expand post'"
|
||||
@click.stop="toggleExpanded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. -->
|
||||
<div v-if="!expanded" class="fc-post-card__body">
|
||||
<div class="fc-post-card__body">
|
||||
<div class="fc-post-card__media">
|
||||
<template v-if="images.length">
|
||||
<div class="fc-post-card__hero">
|
||||
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
|
||||
</div>
|
||||
<div v-if="rail.length" class="fc-post-card__rail">
|
||||
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
|
||||
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
|
||||
</div>
|
||||
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
|
||||
+{{ moreCount }}
|
||||
</div>
|
||||
<!-- Images open the post-scoped image modal (look bigger + arrow
|
||||
through ALL the post's images) — the card never expands. -->
|
||||
<button
|
||||
type="button" class="fc-post-card__hero"
|
||||
aria-label="Open images" @click="openModal(hero.image_id)"
|
||||
>
|
||||
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
|
||||
</button>
|
||||
<div v-if="rail.length || moreCount" class="fc-post-card__rail">
|
||||
<button
|
||||
v-for="t in rail" :key="t.image_id" type="button"
|
||||
class="fc-post-card__rail-cell"
|
||||
aria-label="Open image" @click="openModal(t.image_id)"
|
||||
>
|
||||
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
|
||||
</button>
|
||||
<button
|
||||
v-if="moreCount > 0" type="button"
|
||||
class="fc-post-card__rail-more"
|
||||
:aria-label="`Open ${moreCount} more images`"
|
||||
@click="openModalAtMore"
|
||||
>+{{ moreCount }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<PostEmptyThumbs v-else />
|
||||
</div>
|
||||
|
||||
<div class="fc-post-card__text">
|
||||
<h3 v-if="plainTitle" class="fc-post-card__title">
|
||||
{{ plainTitle }}
|
||||
</h3>
|
||||
<h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
|
||||
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||
Post {{ post.external_post_id }}
|
||||
</h3>
|
||||
|
||||
<p v-if="post.description_plain" class="fc-post-card__desc">
|
||||
{{ post.description_plain }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasDescription" ref="descEl"
|
||||
class="fc-post-card__desc"
|
||||
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
|
||||
>{{ descText }}</p>
|
||||
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||
(no description)
|
||||
</p>
|
||||
|
||||
<div v-if="post.attachments?.length" class="fc-post-card__atts">
|
||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
||||
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The ONLY in-place expansion: the post text, and only when it's
|
||||
actually truncated (server flag or a CSS-clamp overflow). -->
|
||||
<button
|
||||
v-if="canExpand" type="button" class="fc-post-card__more"
|
||||
@click="toggleDesc"
|
||||
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
|
||||
|
||||
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
|
||||
attachments. Lazy-loaded detail via getPostFull. -->
|
||||
<div v-else class="fc-post-card__expanded">
|
||||
<h2 v-if="plainTitle" class="fc-post-card__title-full">
|
||||
{{ plainTitle }}
|
||||
</h2>
|
||||
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
|
||||
Post {{ post.external_post_id }}
|
||||
</h2>
|
||||
|
||||
<section v-if="images.length" class="fc-post-card__sec">
|
||||
<PostImageGrid :thumbnails="images" />
|
||||
<div v-if="!detailLoaded" class="fc-post-card__loading-hint">
|
||||
Loading full image list…
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="descriptionHtml" class="fc-post-card__sec">
|
||||
<div class="fc-post-card__desc-full" v-html="descriptionHtml" />
|
||||
</section>
|
||||
<section v-else-if="detailLoaded" class="fc-post-card__sec">
|
||||
<p class="fc-post-card__desc fc-post-card__desc--missing">(no description)</p>
|
||||
</section>
|
||||
|
||||
<section v-if="attachments.length" class="fc-post-card__sec">
|
||||
<h3 class="fc-post-card__h3">Attachments</h3>
|
||||
<div class="fc-post-card__atts-full">
|
||||
<div v-if="attachments.length" class="fc-post-card__atts">
|
||||
<a
|
||||
v-for="att in attachments" :key="att.id"
|
||||
:href="att.download_url" download
|
||||
class="fc-post-card__att"
|
||||
@click.stop
|
||||
:href="att.download_url" download class="fc-post-card__att"
|
||||
>
|
||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
||||
<span>{{ att.original_filename }}</span>
|
||||
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js'
|
||||
import { toPlainText } from '../../utils/htmlSanitize.js'
|
||||
import PostEmptyThumbs from './PostEmptyThumbs.vue'
|
||||
import PostImageGrid from './PostImageGrid.vue'
|
||||
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const postsStore = usePostsStore()
|
||||
const modal = useModalStore()
|
||||
|
||||
// Per-card expand state. No global modal — each PostCard owns its own
|
||||
// view-mode and lazy-loaded detail.
|
||||
const expanded = ref(false)
|
||||
// Full detail (uncapped thumbnails + full description), fetched lazily — only
|
||||
// when opening the modal for a post with >6 images, or expanding a
|
||||
// server-truncated description.
|
||||
const detail = ref(null)
|
||||
const detailLoaded = ref(false)
|
||||
const detailError = ref(null)
|
||||
|
||||
// When expanded + detail loaded, prefer the uncapped detail thumbnails +
|
||||
// full description. Falls back to feed shape if detail fetch is in flight
|
||||
// or failed.
|
||||
const merged = computed(() => detail.value || props.post)
|
||||
const images = computed(() => merged.value.thumbnails || [])
|
||||
const attachments = computed(() => merged.value.attachments || [])
|
||||
|
||||
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render as
|
||||
// plain text — the CSS makes the title bold.
|
||||
const attachments = computed(() => props.post.attachments || [])
|
||||
const images = computed(() => props.post.thumbnails || [])
|
||||
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
|
||||
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
||||
|
||||
// Compact-view hero+rail derived from the feed-shape (capped 6).
|
||||
const hero = computed(() => props.post.thumbnails?.[0])
|
||||
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
|
||||
const hero = computed(() => images.value[0])
|
||||
const rail = computed(() => images.value.slice(1, 4))
|
||||
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
||||
const moreCount = computed(() => {
|
||||
const more = props.post.thumbnails_more || 0
|
||||
const railLen = rail.value.length
|
||||
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
|
||||
const extraShown = Math.max(0, images.value.length - visibleCount.value)
|
||||
return more + extraShown
|
||||
})
|
||||
|
||||
@@ -180,49 +137,77 @@ const relativeDate = computed(() => {
|
||||
return new Date(sortDateIso.value).toLocaleDateString()
|
||||
})
|
||||
|
||||
const descriptionHtml = computed(() => {
|
||||
// Detail endpoint returns description_full as plain text (the service
|
||||
// uses html_to_plain on the stored description). Render plain text in
|
||||
// <p> wrappers; sanitize defensively in case the backend ever returns
|
||||
// raw HTML.
|
||||
const raw = merged.value.description_full || merged.value.description_plain
|
||||
if (!raw) return ''
|
||||
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
|
||||
const esc = raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
return esc
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('')
|
||||
})
|
||||
|
||||
async function loadDetailIfNeeded () {
|
||||
if (detailLoaded.value || detail.value) return
|
||||
try {
|
||||
detail.value = await postsStore.getPostFull(props.post.id)
|
||||
detailLoaded.value = true
|
||||
} catch (e) {
|
||||
detailError.value = e.message
|
||||
// Leave merged on feed-shape; the card still renders the truncated
|
||||
// body so the operator isn't staring at a blank panel.
|
||||
// --- images → post-scoped modal ---------------------------------------
|
||||
async function fullImageIds () {
|
||||
// Feed caps thumbnails at 6; load detail for the complete id list only when
|
||||
// there are more, so the modal can arrow through ALL of the post's images.
|
||||
if ((props.post.thumbnails_more || 0) === 0) {
|
||||
return images.value.map((t) => t.image_id)
|
||||
}
|
||||
if (!detail.value) {
|
||||
try {
|
||||
detail.value = await postsStore.getPostFull(props.post.id)
|
||||
} catch { /* fall back to the capped feed list */ }
|
||||
}
|
||||
return (detail.value?.thumbnails || images.value).map((t) => t.image_id)
|
||||
}
|
||||
|
||||
function toggleExpanded () {
|
||||
expanded.value = !expanded.value
|
||||
if (expanded.value) loadDetailIfNeeded()
|
||||
async function openModal (imageId) {
|
||||
modal.open(imageId, { postImageIds: await fullImageIds() })
|
||||
}
|
||||
|
||||
function onCardClick (e) {
|
||||
// Inner interactive elements use @click.stop so they never reach here.
|
||||
// Whole-card click expands a collapsed card; collapsing is chevron-only
|
||||
// so a mosaic-image click on an expanded card can never accidentally
|
||||
// collapse the surrounding card.
|
||||
if (expanded.value) return
|
||||
expanded.value = true
|
||||
loadDetailIfNeeded()
|
||||
async function openModalAtMore () {
|
||||
const ids = await fullImageIds()
|
||||
const first = ids[visibleCount.value] ?? ids[0]
|
||||
if (first != null) modal.open(first, { postImageIds: ids })
|
||||
}
|
||||
|
||||
// --- description "Show more" (text-only, in place, only when truncated) ----
|
||||
const descExpanded = ref(false)
|
||||
const cssOverflow = ref(false)
|
||||
const descEl = ref(null)
|
||||
|
||||
const hasDescription = computed(() => !!props.post.description_plain)
|
||||
const fullDescription = computed(() => detail.value?.description_full || null)
|
||||
const descText = computed(() =>
|
||||
descExpanded.value
|
||||
? (fullDescription.value || props.post.description_plain)
|
||||
: props.post.description_plain,
|
||||
)
|
||||
// Show the toggle iff the server truncated the text OR the clamp is cutting it.
|
||||
const canExpand = computed(
|
||||
() => props.post.description_truncated === true || cssOverflow.value,
|
||||
)
|
||||
|
||||
function measureOverflow () {
|
||||
const el = descEl.value
|
||||
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
|
||||
}
|
||||
|
||||
let ro = null
|
||||
onMounted(() => {
|
||||
nextTick(measureOverflow)
|
||||
// Re-measure when the card resizes (the container-query clamp differs by
|
||||
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
|
||||
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
|
||||
ro = new ResizeObserver(() => { if (!descExpanded.value) measureOverflow() })
|
||||
ro.observe(descEl.value)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
|
||||
|
||||
async function toggleDesc () {
|
||||
if (!descExpanded.value) {
|
||||
if (props.post.description_truncated && !fullDescription.value && !detail.value) {
|
||||
try {
|
||||
detail.value = await postsStore.getPostFull(props.post.id)
|
||||
} catch { /* render the truncated text rather than nothing */ }
|
||||
}
|
||||
descExpanded.value = true
|
||||
} else {
|
||||
descExpanded.value = false
|
||||
nextTick(measureOverflow)
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes (n) {
|
||||
@@ -239,20 +224,6 @@ function formatBytes (n) {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
container-type: inline-size;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.fc-post-card:not(.fc-post-card--expanded) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-post-card:not(.fc-post-card--expanded):hover {
|
||||
border-color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-card:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.fc-post-card--expanded {
|
||||
border-color: rgb(var(--v-theme-accent) / 0.6);
|
||||
}
|
||||
|
||||
.fc-post-card__head {
|
||||
@@ -273,7 +244,6 @@ function formatBytes (n) {
|
||||
.fc-post-card__date,
|
||||
.fc-post-card__meta { white-space: nowrap; }
|
||||
|
||||
/* ---- COMPACT BODY ---- */
|
||||
.fc-post-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -288,6 +258,13 @@ function formatBytes (n) {
|
||||
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
|
||||
}
|
||||
|
||||
/* Image tiles are buttons (open the post-scoped modal) — reset button chrome. */
|
||||
.fc-post-card__hero,
|
||||
.fc-post-card__rail-cell,
|
||||
.fc-post-card__rail-more {
|
||||
display: block; padding: 0; border: 0; background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-post-card__hero {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
@@ -297,10 +274,13 @@ function formatBytes (n) {
|
||||
.fc-post-card__hero img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover; display: block;
|
||||
transition: transform 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
.fc-post-card__hero:hover img,
|
||||
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
|
||||
|
||||
.fc-post-card__rail {
|
||||
display: flex; gap: 6px; margin-top: 6px;
|
||||
display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.fc-post-card__rail-cell {
|
||||
width: 80px; height: 80px;
|
||||
@@ -318,6 +298,10 @@ function formatBytes (n) {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.fc-post-card__rail-more:hover {
|
||||
border-color: rgb(var(--v-theme-accent));
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
|
||||
.fc-post-card__title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
@@ -346,67 +330,36 @@ function formatBytes (n) {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
margin: 0 0 12px 0;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* Clamp ONLY while collapsed; expanding drops the clamp to show it all. */
|
||||
.fc-post-card__desc--clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@container (min-width: 800px) {
|
||||
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
|
||||
}
|
||||
.fc-post-card__desc--missing {
|
||||
font-style: italic;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
@container (min-width: 800px) {
|
||||
.fc-post-card__desc { -webkit-line-clamp: 5; }
|
||||
|
||||
.fc-post-card__more {
|
||||
margin-top: 6px;
|
||||
padding: 0;
|
||||
background: none; border: 0; cursor: pointer;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
font-size: 0.85rem; font-weight: 600;
|
||||
}
|
||||
.fc-post-card__more:hover { text-decoration: underline; }
|
||||
|
||||
.fc-post-card__atts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
/* ---- EXPANDED BODY ---- */
|
||||
.fc-post-card__expanded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.fc-post-card__title-full {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
@container (min-width: 800px) {
|
||||
.fc-post-card__title-full { font-size: 26px; }
|
||||
}
|
||||
.fc-post-card__sec { margin: 0; }
|
||||
.fc-post-card__h3 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-post-card__loading-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-card__desc-full {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-post-card__desc-full :deep(p) { margin: 0 0 12px 0; }
|
||||
.fc-post-card__desc-full :deep(a) { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-post-card__atts-full {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.fc-post-card__att {
|
||||
display: inline-flex;
|
||||
@@ -423,5 +376,6 @@ function formatBytes (n) {
|
||||
color: rgb(var(--v-theme-accent));
|
||||
border-color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<div class="fc-post-grid">
|
||||
<button
|
||||
v-for="(t, idx) in thumbnails"
|
||||
:key="t.image_id"
|
||||
type="button"
|
||||
class="fc-post-grid__cell"
|
||||
:aria-label="`Open image ${idx + 1} of ${thumbnails.length}`"
|
||||
@click="openImage(t.image_id, idx)"
|
||||
>
|
||||
<img
|
||||
:src="t.thumbnail_url"
|
||||
:alt="`thumbnail ${idx + 1}`"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
|
||||
const props = defineProps({
|
||||
thumbnails: { type: Array, required: true }, // [{ image_id, thumbnail_url, ... }]
|
||||
})
|
||||
|
||||
const modal = useModalStore()
|
||||
|
||||
const imageIds = computed(() => props.thumbnails.map(t => t.image_id))
|
||||
|
||||
function openImage (id, idx) {
|
||||
modal.open(id, { postImageIds: imageIds.value, initialIndex: idx })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.fc-post-grid__cell {
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgb(var(--v-theme-background));
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.fc-post-grid__cell:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 2px rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-grid__cell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@
|
||||
clearable
|
||||
no-filter
|
||||
return-object
|
||||
style="min-width: 240px"
|
||||
class="fc-posts-filters__artist"
|
||||
@update:search="onArtistSearch"
|
||||
@update:model-value="onArtistPicked"
|
||||
/>
|
||||
@@ -25,7 +25,7 @@
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
style="min-width: 180px"
|
||||
class="fc-posts-filters__platform"
|
||||
@update:model-value="emitFilters"
|
||||
/>
|
||||
|
||||
@@ -135,6 +135,14 @@ watch(() => props.platform, (val) => {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.fc-posts-filters__artist { min-width: 240px; flex: 1 1 240px; max-width: 360px; }
|
||||
.fc-posts-filters__platform { min-width: 180px; flex: 1 1 180px; max-width: 240px; }
|
||||
/* Phones: each field takes a full-width row instead of overflowing. */
|
||||
@media (max-width: 600px) {
|
||||
.fc-posts-filters__artist,
|
||||
.fc-posts-filters__platform { min-width: 100%; max-width: none; flex-basis: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -2,6 +2,42 @@
|
||||
<v-card>
|
||||
<v-card-title>Import filters</v-card-title>
|
||||
<v-card-text v-if="store.settings">
|
||||
<!-- Near-duplicate dedup sensitivity, hoisted to the top: it's the
|
||||
most-asked knob — too loose and edits/variants of the same image
|
||||
get dropped as duplicates on import. Slider with sane labelled
|
||||
stops for the gist + a number field for precision; both bind the
|
||||
same phash_threshold. -->
|
||||
<div class="fc-phash">
|
||||
<div class="fc-phash__title">Near-duplicate sensitivity</div>
|
||||
<div class="fc-help mb-1">
|
||||
How aggressively imports merge look-alike images (perceptual-hash
|
||||
distance). <strong>Lower it if edits/variants of the same image are
|
||||
being dropped as duplicates;</strong> raise it to collapse more
|
||||
look-alikes. Applies to new imports.
|
||||
</div>
|
||||
<v-row align="center" no-gutters>
|
||||
<v-col cols="12" sm="9">
|
||||
<v-slider
|
||||
v-model="local.phash_threshold"
|
||||
:min="0" :max="16" :step="1"
|
||||
:ticks="PHASH_TICKS" show-ticks="always" tick-size="4"
|
||||
thumb-label color="accent" hide-details
|
||||
class="fc-phash__slider"
|
||||
@end="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="8" sm="3" class="ps-sm-4 mt-2 mt-sm-0">
|
||||
<v-text-field
|
||||
v-model.number="local.phash_threshold"
|
||||
label="Distance" type="number" min="0"
|
||||
density="compact" hide-details @blur="save"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
@@ -41,14 +77,6 @@
|
||||
:disabled="!local.skip_single_color" @end="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.phash_threshold"
|
||||
label="Perceptual-hash threshold" type="number" min="0"
|
||||
density="compact" hide-details @blur="save"
|
||||
/>
|
||||
<div class="fc-help">Higher = looser near-duplicate matching.</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
|
||||
@@ -66,6 +94,9 @@ import { reactive, watch } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
|
||||
const store = useImportStore()
|
||||
// Labelled stops so the less-initiated get the gist without knowing what a
|
||||
// Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default.
|
||||
const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' }
|
||||
// Downloader + schedule-defaults fields moved to
|
||||
// /subscriptions?tab=settings (operator decision 2026-05-27). This form
|
||||
// now only owns image-import filters.
|
||||
@@ -89,4 +120,11 @@ async function save() {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fc-phash__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
/* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */
|
||||
.fc-phash__slider { margin-bottom: 18px; }
|
||||
</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'
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { computed } from 'vue'
|
||||
const props = defineProps({
|
||||
queues: { type: Object, default: null }, // store.queues
|
||||
workers: { type: Object, default: null }, // store.workers
|
||||
recentMinute: { type: Array, default: () => [] }, // store.recentMinute
|
||||
recentRuns: { type: Array, default: () => [] }, // store.recentRuns
|
||||
compact: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ function activeCount(name) {
|
||||
|
||||
const recentByQueue = computed(() => {
|
||||
const out = {}
|
||||
for (const r of props.recentMinute) {
|
||||
for (const r of props.recentRuns) {
|
||||
if (!out[r.queue]) out[r.queue] = { ok: 0, err: 0 }
|
||||
if (r.status === 'ok') out[r.queue].ok++
|
||||
else if (r.status === 'error' || r.status === 'timeout') out[r.queue].err++
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<QueuesTable
|
||||
:queues="store.queues"
|
||||
:workers="store.workers"
|
||||
:recent-minute="store.recentMinute"
|
||||
:recent-runs="store.recentRuns"
|
||||
compact
|
||||
/>
|
||||
</v-card-text>
|
||||
@@ -47,7 +47,7 @@ function pollOnce() {
|
||||
if (document.hidden) return
|
||||
store.loadQueues()
|
||||
store.loadWorkers()
|
||||
store.loadRecentMinute()
|
||||
store.loadRecentRuns()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<QueuesTable
|
||||
:queues="store.queues"
|
||||
:workers="store.workers"
|
||||
:recent-minute="store.recentMinute"
|
||||
:recent-runs="store.recentRuns"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -202,7 +202,7 @@ function pollQueues() {
|
||||
if (document.hidden) return
|
||||
store.loadQueues()
|
||||
store.loadWorkers()
|
||||
store.loadRecentMinute()
|
||||
store.loadRecentRuns()
|
||||
}
|
||||
function pollFailures() {
|
||||
if (document.hidden) return
|
||||
|
||||
@@ -89,6 +89,53 @@
|
||||
@click="onKindCommit"
|
||||
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong class="text-error">Reset content tagging.</strong>
|
||||
Deletes every <code>general</code> and <code>character</code> tag and
|
||||
removes them from every image, so you can re-tag from scratch with the
|
||||
auto-suggest. <strong>Fandoms and series (with their page order) are
|
||||
kept</strong>, and each image's saved predictions are untouched — open
|
||||
an image and its suggestions reappear.
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
Irreversible — there's no undo except restoring a DB backup.
|
||||
Back one up first (Settings → Maintenance → Backup).
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingResetPreview"
|
||||
class="mb-3"
|
||||
@click="onResetPreview"
|
||||
>Preview content-tag reset</v-btn>
|
||||
|
||||
<div v-if="resetPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ resetPreview.count }}</strong> content tag(s)
|
||||
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
|
||||
({{ k }}: {{ n }})
|
||||
</span>
|
||||
across <strong>{{ resetPreview.applications }}</strong> image
|
||||
application(s).
|
||||
</p>
|
||||
<div v-if="resetPreview.sample_names?.length" class="fc-name-grid mb-3">
|
||||
<span v-for="n in resetPreview.sample_names" :key="n" class="fc-name">
|
||||
{{ n }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-alert"
|
||||
:disabled="!resetPreview.count"
|
||||
:loading="resetCommitting"
|
||||
@click="onResetCommit"
|
||||
>Delete {{ resetPreview.count }} content tag(s) +
|
||||
{{ resetPreview.applications }} application(s)</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -105,6 +152,9 @@ const committing = ref(false)
|
||||
const kindPreview = ref(null)
|
||||
const loadingKindPreview = ref(false)
|
||||
const kindCommitting = ref(false)
|
||||
const resetPreview = ref(null)
|
||||
const loadingResetPreview = ref(false)
|
||||
const resetCommitting = ref(false)
|
||||
|
||||
async function onPreview() {
|
||||
loadingPreview.value = true
|
||||
@@ -143,6 +193,25 @@ async function onKindCommit() {
|
||||
kindCommitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetPreview() {
|
||||
loadingResetPreview.value = true
|
||||
try {
|
||||
resetPreview.value = await store.resetContentTagging({ dryRun: true })
|
||||
} finally {
|
||||
loadingResetPreview.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetCommit() {
|
||||
resetCommitting.value = true
|
||||
try {
|
||||
await store.resetContentTagging({ dryRun: false })
|
||||
resetPreview.value = { count: 0, by_kind: {}, applications: 0, sample_names: [] }
|
||||
} finally {
|
||||
resetCommitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
<v-chip size="x-small" color="error" variant="flat" label class="fc-fail__count">
|
||||
{{ s.consecutive_failures }}× failed
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="s.error_type"
|
||||
size="x-small" variant="outlined" label
|
||||
:color="errorTypeColor(s.error_type)"
|
||||
class="fc-fail__class"
|
||||
:title="errorTypeHint(s.error_type)"
|
||||
>
|
||||
{{ s.error_type }}
|
||||
</v-chip>
|
||||
<span class="fc-fail__err" :title="s.last_error || ''">
|
||||
{{ s.last_error || 'no error message recorded' }}
|
||||
</span>
|
||||
@@ -66,6 +75,42 @@ const open = ref(true)
|
||||
// Per-row loading flag so the spinner lives on the row whose Logs
|
||||
// button was clicked, not on every row.
|
||||
const logLoadingIds = ref(new Set())
|
||||
|
||||
// Audit 2026-06-02: surface the ErrorType taxonomy as a colored chip
|
||||
// next to the consecutive-failures count so operators can bulk-triage
|
||||
// by error class. Color reflects "what to do next":
|
||||
// warning (yellow) — auth/cookie issue: operator should rotate
|
||||
// info (blue) — backend-paced (cooldown / rate limit / timeout)
|
||||
// error (red) — likely terminal without operator intervention
|
||||
const ERROR_TYPE_COLOR = {
|
||||
auth_error: 'warning',
|
||||
rate_limited: 'info',
|
||||
timeout: 'info',
|
||||
network_error: 'info',
|
||||
not_found: 'error',
|
||||
access_denied: 'error',
|
||||
validation_failed: 'error',
|
||||
unsupported_url: 'error',
|
||||
http_error: 'error',
|
||||
unknown_error: 'error',
|
||||
partial: 'info',
|
||||
tier_limited: 'info',
|
||||
no_new_content: 'info',
|
||||
}
|
||||
const ERROR_TYPE_HINT = {
|
||||
auth_error: 'Cookies likely expired — re-upload in Credentials.',
|
||||
rate_limited: 'Platform-wide cooldown active. Will retry after it expires.',
|
||||
timeout: 'Subprocess exceeded its time budget. Often retries cleanly.',
|
||||
network_error: 'Transient network issue. Will retry on next tick.',
|
||||
not_found: 'URL 404 — creator may have renamed or deleted.',
|
||||
access_denied: 'Subscription tier may not grant this content.',
|
||||
validation_failed: 'Downloaded files were quarantined by the validator.',
|
||||
http_error: 'Generic HTTP error — see Logs.',
|
||||
unsupported_url: 'gallery-dl does not support this URL pattern.',
|
||||
unknown_error: 'Could not classify — see Logs.',
|
||||
}
|
||||
function errorTypeColor(t) { return ERROR_TYPE_COLOR[t] || 'error' }
|
||||
function errorTypeHint(t) { return ERROR_TYPE_HINT[t] || '' }
|
||||
async function onViewLogs(s) {
|
||||
if (logLoadingIds.value.has(s.id)) return
|
||||
logLoadingIds.value = new Set(logLoadingIds.value).add(s.id)
|
||||
@@ -115,6 +160,7 @@ async function onViewLogs(s) {
|
||||
}
|
||||
.fc-fail__artist { font-weight: 600; white-space: nowrap; }
|
||||
.fc-fail__count { flex: 0 0 auto; }
|
||||
.fc-fail__class { flex: 0 0 auto; }
|
||||
.fc-fail__err {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="fc-source-card">
|
||||
<div class="fc-source-card__top">
|
||||
<SourceHealthDot :source="source" :warning-threshold="warningThreshold" />
|
||||
<v-chip size="x-small" variant="tonal" label>{{ source.platform }}</v-chip>
|
||||
<v-spacer />
|
||||
<v-switch
|
||||
:model-value="source.enabled"
|
||||
density="compact" hide-details color="accent"
|
||||
@click.stop
|
||||
@update:model-value="onToggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="source.url" target="_blank" rel="noopener"
|
||||
class="fc-source-card__url" @click.stop
|
||||
>{{ source.url }}</a>
|
||||
|
||||
<div class="fc-source-card__meta">
|
||||
<span>Last {{ formatRelative(source.last_checked_at) }}</span>
|
||||
<span>Next {{ formatRelative(source.next_check_at, { future: true }) }}</span>
|
||||
<v-chip
|
||||
v-if="(source.consecutive_failures || 0) > 0"
|
||||
size="x-small" color="error" variant="tonal" label
|
||||
>{{ source.consecutive_failures }} err</v-chip>
|
||||
<v-chip
|
||||
v-else-if="(source.backfill_runs_remaining || 0) > 0"
|
||||
size="x-small" color="info" variant="tonal" label
|
||||
>backfill ({{ source.backfill_runs_remaining }}×)</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="fc-source-card__actions">
|
||||
<v-btn
|
||||
size="x-small" variant="text" :loading="checking"
|
||||
@click.stop="$emit('check', source)"
|
||||
>
|
||||
<v-icon>mdi-play</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small" variant="text"
|
||||
:disabled="(source.backfill_runs_remaining || 0) > 0"
|
||||
@click.stop="$emit('backfill', source)"
|
||||
>
|
||||
<v-icon>mdi-magnify-scan</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Deep scan</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click.stop="$emit('edit', source)">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small" variant="text" color="error"
|
||||
@click.stop="$emit('remove', source)"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Remove</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SourceHealthDot from './SourceHealthDot.vue'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
|
||||
// Mobile-stacked equivalent of SourceRow (the desktop <tr>) — same data and
|
||||
// emits, but laid out vertically so the wide source columns never force the
|
||||
// lateral scroll the operator flagged on phones.
|
||||
const props = defineProps({
|
||||
source: { type: Object, required: true },
|
||||
checking: { type: Boolean, default: false },
|
||||
warningThreshold: { type: Number, default: 5 },
|
||||
})
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill'])
|
||||
|
||||
function onToggleEnabled(value) {
|
||||
emit('toggle', { source: props.source, enabled: value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-source-card {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 6px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.fc-source-card__top { display: flex; align-items: center; gap: 8px; }
|
||||
.fc-source-card__url {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.fc-source-card__url:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-source-card__meta {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px;
|
||||
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fc-source-card__actions {
|
||||
display: flex; gap: 2px; justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -26,14 +26,14 @@
|
||||
:items="STATUS_OPTIONS"
|
||||
:disabled="needsAttention"
|
||||
density="compact" variant="outlined" hide-details
|
||||
style="max-width: 180px"
|
||||
class="fc-subs__status"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Search subscriptions"
|
||||
style="max-width: 320px"
|
||||
class="fc-subs__search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<p v-else>No subscriptions match the current filter.</p>
|
||||
</div>
|
||||
|
||||
<v-card v-else class="fc-subs__card" variant="outlined">
|
||||
<v-card v-else-if="!isMobile" class="fc-subs__card" variant="outlined">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredGroups"
|
||||
@@ -189,6 +189,76 @@
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Mobile: compact cards (several fit per screen), expanding to STACKED
|
||||
source cards so the wide source columns never force lateral scroll. -->
|
||||
<div v-else class="fc-subs__mlist">
|
||||
<div
|
||||
v-for="item in filteredGroups" :key="item.key"
|
||||
class="fc-subs__mcard"
|
||||
>
|
||||
<div class="fc-subs__mhead" @click="toggleExpand(item)">
|
||||
<v-checkbox-btn
|
||||
:model-value="isSelected(item)" density="compact" hide-details
|
||||
@click.stop @update:model-value="toggleSelect(item)"
|
||||
/>
|
||||
<span class="fc-subs__name">{{ item.artist.name }}</span>
|
||||
<v-spacer />
|
||||
<SourceHealthDot
|
||||
v-if="item.worstSource"
|
||||
:source="item.worstSource" :warning-threshold="failureThreshold"
|
||||
/>
|
||||
<v-icon size="small">
|
||||
{{ isExpanded(item) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
|
||||
</v-icon>
|
||||
</div>
|
||||
<div class="fc-subs__mmeta">
|
||||
<PlatformChip
|
||||
v-for="p in item.platforms" :key="p" :platform="p" size="x-small"
|
||||
/>
|
||||
<span class="fc-subs__mmeta-text">
|
||||
{{ item.sources.length }} src · {{ formatRelative(item.lastActivity) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(item)" class="fc-subs__mbody">
|
||||
<div class="fc-subs__mactions">
|
||||
<v-btn
|
||||
size="small" variant="text" :loading="anyChecking(item.sources)"
|
||||
@click="checkAll(item)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Check all</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" @click="openAddSource(item.artist)">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" :to="`/posts?artist_id=${item.artist.id}`">
|
||||
<v-icon>mdi-rss</v-icon>
|
||||
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" :to="`/artist/${item.artist.slug}`">
|
||||
<v-icon>mdi-account</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Artist page</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
<SourceCard
|
||||
v-for="s in item.sources" :key="s.id" :source="s"
|
||||
:checking="store.checkingIds.has(s.id)"
|
||||
:warning-threshold="failureThreshold"
|
||||
@edit="openEditSource"
|
||||
@remove="removeSource"
|
||||
@toggle="toggleSourceEnabled"
|
||||
@check="onCheck"
|
||||
@backfill="onBackfill"
|
||||
/>
|
||||
<div v-if="item.sources.length === 0" class="fc-subs__sources-empty">
|
||||
No sources yet. Tap + to add one.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SourceFormDialog
|
||||
v-model="showSourceDialog"
|
||||
:source="editingSource"
|
||||
@@ -202,11 +272,13 @@
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import SourceRow from './SourceRow.vue'
|
||||
import SourceCard from './SourceCard.vue'
|
||||
import SourceHealthDot from './SourceHealthDot.vue'
|
||||
import SourceFormDialog from './SourceFormDialog.vue'
|
||||
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
||||
@@ -230,6 +302,10 @@ const STATUS_OPTIONS = [
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const display = useDisplay()
|
||||
// Match the 600px breakpoint the rest of the hub uses; below it the table is
|
||||
// replaced by the custom compact-card list.
|
||||
const isMobile = computed(() => display.width.value < 600)
|
||||
const store = useSourcesStore()
|
||||
const platformsStore = usePlatformsStore()
|
||||
const importStore = useImportStore()
|
||||
@@ -239,6 +315,19 @@ const statusFilter = ref('all')
|
||||
const needsAttention = ref(false)
|
||||
const expanded = ref([])
|
||||
const selected = ref([])
|
||||
|
||||
// Mobile card list drives the same `selected`/`expanded` key arrays the
|
||||
// desktop v-data-table binds, so selection + bulk actions work identically.
|
||||
function _toggleKey(arr, key) {
|
||||
const i = arr.value.indexOf(key)
|
||||
if (i === -1) arr.value = [...arr.value, key]
|
||||
else arr.value = arr.value.filter((k) => k !== key)
|
||||
}
|
||||
function isSelected(item) { return selected.value.includes(item.key) }
|
||||
function toggleSelect(item) { _toggleKey(selected, item.key) }
|
||||
function isExpanded(item) { return expanded.value.includes(item.key) }
|
||||
function toggleExpand(item) { _toggleKey(expanded, item.key) }
|
||||
|
||||
const showSourceDialog = ref(false)
|
||||
const editingSource = ref(null)
|
||||
const editingArtist = ref(null)
|
||||
@@ -412,6 +501,17 @@ function onArtistCreated(artist) {
|
||||
async function onCheck(source) {
|
||||
try {
|
||||
const body = await store.checkNow(source.id)
|
||||
// Audit 2026-06-02: /api/sources/<id>/check returns 202 with
|
||||
// `{status:'deferred', cooldown_until}` when the platform is in
|
||||
// cooldown — the previous handler treated this as success and
|
||||
// toasted "event #undefined", masking that nothing was enqueued.
|
||||
if (body?.status === 'deferred') {
|
||||
toast({
|
||||
text: 'Check deferred — platform in cooldown',
|
||||
type: 'info',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
text: `Check enqueued (event #${body.download_event_id})`,
|
||||
type: 'success',
|
||||
@@ -465,17 +565,23 @@ async function onBackfill(source) {
|
||||
async function checkAll(group) {
|
||||
let ok = 0
|
||||
let conflict = 0
|
||||
let deferred = 0
|
||||
for (const s of group.sources) {
|
||||
if (!s.enabled) continue
|
||||
try {
|
||||
await store.checkNow(s.id)
|
||||
ok += 1
|
||||
const body = await store.checkNow(s.id)
|
||||
// Audit 2026-06-02: deferred (202 + cooldown_until) used to be
|
||||
// counted as queued, inflating the success tally and hiding
|
||||
// that the cooldown actually held the work back.
|
||||
if (body?.status === 'deferred') deferred += 1
|
||||
else ok += 1
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) conflict += 1
|
||||
}
|
||||
}
|
||||
const parts = []
|
||||
if (ok) parts.push(`${ok} queued`)
|
||||
if (deferred) parts.push(`${deferred} deferred (cooldown)`)
|
||||
if (conflict) parts.push(`${conflict} already running`)
|
||||
toast({
|
||||
text: parts.join(', ') || 'Nothing to check (no enabled sources)',
|
||||
@@ -551,6 +657,18 @@ async function bulkDelete() {
|
||||
padding: 8px 0 1rem;
|
||||
}
|
||||
.fc-subs__bar .v-chip { cursor: pointer; }
|
||||
.fc-subs__status { max-width: 180px; }
|
||||
.fc-subs__search { max-width: 320px; flex: 1 1 200px; }
|
||||
/* Phones: status + search each take a full-width row; drop the spacer that
|
||||
would otherwise eat a row pushing them around. */
|
||||
@media (max-width: 600px) {
|
||||
.fc-subs__status, .fc-subs__search { max-width: none; flex-basis: 100%; }
|
||||
.fc-subs__bar :deep(.v-spacer) { display: none; }
|
||||
/* The table renders as stacked cards (mobile-breakpoint); reclaim the
|
||||
desktop indent on the expanded sources detail (it keeps its own
|
||||
horizontal scroll for the wide source columns). */
|
||||
.fc-subs__sources-cell { padding-left: 0.5rem !important; }
|
||||
}
|
||||
.fc-subs__loading, .fc-subs__empty {
|
||||
display: flex; justify-content: center; padding: 2rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
@@ -558,6 +676,30 @@ async function bulkDelete() {
|
||||
.fc-subs__card {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
/* Mobile compact-card list (replaces the data-table <600px). */
|
||||
.fc-subs__mlist { display: flex; flex-direction: column; gap: 8px; }
|
||||
.fc-subs__mcard {
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 8px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.fc-subs__mhead { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.fc-subs__mmeta {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.fc-subs__mmeta-text {
|
||||
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fc-subs__mbody {
|
||||
margin-top: 8px; padding-top: 8px;
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.fc-subs__mactions { display: flex; gap: 2px; }
|
||||
.fc-subs__name { font-weight: 600; }
|
||||
.fc-subs__chips {
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
|
||||
@@ -127,6 +127,21 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Destructive: deletes ALL general + character tags so the operator can
|
||||
// re-tag from scratch via auto-suggest. fandom + series preserved.
|
||||
async function resetContentTagging({ dryRun = true } = {}) {
|
||||
lastError.value = null
|
||||
try {
|
||||
return await api.post(
|
||||
'/api/admin/tags/reset-content',
|
||||
{ body: { dry_run: dryRun } },
|
||||
)
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task progress polling (taps FC-3i activity dashboard) --------
|
||||
|
||||
/**
|
||||
@@ -162,6 +177,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
tagUsageCount,
|
||||
pruneUnusedTags,
|
||||
purgeLegacyTags,
|
||||
resetContentTagging,
|
||||
pollTaskUntilDone,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -55,6 +55,14 @@ export const useCleanupStore = defineStore('cleanup', () => {
|
||||
return body.runs
|
||||
}
|
||||
|
||||
// The most recent audit run for a given rule, or null. Cards call this on
|
||||
// mount to reconnect to a scan that's still running (or to show the last
|
||||
// completed result) after the user navigates away and back.
|
||||
async function latestAuditForRule(rule) {
|
||||
const body = await api.get('/api/cleanup/audit', { params: { rule, limit: 1 } })
|
||||
return (body.runs && body.runs[0]) || null
|
||||
}
|
||||
|
||||
async function applyAudit(id, confirm) {
|
||||
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
|
||||
}
|
||||
@@ -67,6 +75,6 @@ export const useCleanupStore = defineStore('cleanup', () => {
|
||||
defaults, recentRuns,
|
||||
loadDefaults,
|
||||
previewMinDim, deleteMinDim,
|
||||
startAudit, getAudit, loadHistory, applyAudit, cancelAudit,
|
||||
startAudit, getAudit, loadHistory, latestAuditForRule, applyAudit, cancelAudit,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
+186
-30
@@ -3,12 +3,15 @@ import { ref, computed } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
|
||||
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
|
||||
// 50-item request so items render as each batch lands. Total initial
|
||||
// count is unchanged (PAGE * INITIAL_BATCHES = 50). Infinite-scroll also
|
||||
// pulls PAGE per trigger to keep appends progressive.
|
||||
const PAGE = 5
|
||||
const INITIAL_BATCHES = 10
|
||||
// Initial paint is a SINGLE request (INITIAL_LIMIT). The old 10×serial-
|
||||
// batch loop (2026-05-30) only staggered METADATA, which isn't the visual
|
||||
// bottleneck — thumbnails load as independent `<img>` requests, and
|
||||
// GalleryItem now reveals each tile on its own image `@load`. One fetch is
|
||||
// far fewer round-trips and faster to first paint; the reveal-on-load is
|
||||
// what makes appearance progressive. Infinite scroll pulls PAGE per trigger.
|
||||
// Reworked 2026-06-04.
|
||||
const PAGE = 25
|
||||
const INITIAL_LIMIT = 50
|
||||
|
||||
export const useGalleryStore = defineStore('gallery', () => {
|
||||
const api = useApi()
|
||||
@@ -18,7 +21,26 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
const nextCursor = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const filter = ref({ tag_id: null, post_id: null })
|
||||
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,
|
||||
// Phase-3 visual similarity: when set, the gallery is in "similar mode" —
|
||||
// ranked by cosine distance to this image, bounded top-N, no cursor.
|
||||
similar_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
|
||||
const artistLabel = ref(null)
|
||||
|
||||
const timelineBuckets = ref([])
|
||||
const timelineLoading = ref(false)
|
||||
@@ -32,22 +54,16 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
images.value = []
|
||||
dateGroups.value = []
|
||||
nextCursor.value = null
|
||||
// Sequentially fetch INITIAL_BATCHES chunks so items render as each
|
||||
// batch lands rather than blocking on one big response. Stop early
|
||||
// when the backend reports no more pages.
|
||||
for (let i = 0; i < INITIAL_BATCHES; i++) {
|
||||
if (i > 0 && nextCursor.value === null) break
|
||||
await loadMore()
|
||||
}
|
||||
await loadMore(INITIAL_LIMIT)
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
async function loadMore(limit = PAGE) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const params = { limit: PAGE, ...activeFilterParam() }
|
||||
const params = { limit, ...activeFilterParam() }
|
||||
if (nextCursor.value) params.cursor = nextCursor.value
|
||||
const body = await api.get('/api/gallery/scroll', { params })
|
||||
if (!t.isCurrent()) return
|
||||
@@ -71,6 +87,39 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Visual "more like this": ranked top-N by cosine distance, scope filters
|
||||
// composed (AND). No cursor / no timeline — bounded result set.
|
||||
async function loadSimilar() {
|
||||
inflight.cancel()
|
||||
images.value = []
|
||||
dateGroups.value = []
|
||||
nextCursor.value = null
|
||||
timelineBuckets.value = []
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const t = inflight.claim()
|
||||
try {
|
||||
const f = filter.value
|
||||
const params = { similar_to: f.similar_to, limit: 100 }
|
||||
if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
|
||||
if (f.artist_id) params.artist_id = f.artist_id
|
||||
if (f.media_type) params.media = f.media_type
|
||||
if (f.platform) params.platform = f.platform
|
||||
if (f.untagged) params.untagged = '1'
|
||||
if (f.no_artist) params.no_artist = '1'
|
||||
if (f.date_from) params.date_from = f.date_from
|
||||
if (f.date_to) params.date_to = f.date_to
|
||||
const body = await api.get('/api/gallery/similar', { params })
|
||||
if (!t.isCurrent()) return
|
||||
images.value = body.images
|
||||
// ranked + bounded → no next page (nextCursor stays null → hasMore false)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
if (t.isCurrent()) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function jumpTo(year, month) {
|
||||
// Rapid timeline-jump clicks need the same race guard as
|
||||
// loadMore — first jump's late body could clobber the second
|
||||
@@ -89,23 +138,101 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
}
|
||||
|
||||
function activeFilterParam() {
|
||||
if (filter.value.tag_id) return { tag_id: filter.value.tag_id }
|
||||
// post_id is the exclusive post-detail view.
|
||||
if (filter.value.post_id) return { post_id: filter.value.post_id }
|
||||
return {}
|
||||
const p = {}
|
||||
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
|
||||
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
|
||||
}
|
||||
|
||||
function setTagFilter(tagId) {
|
||||
filter.value.tag_id = tagId
|
||||
filter.value.post_id = null
|
||||
loadInitial()
|
||||
loadTimeline()
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
function setPostFilter(postId) {
|
||||
filter.value.post_id = postId
|
||||
filter.value.tag_id = null
|
||||
loadInitial()
|
||||
loadTimeline()
|
||||
// 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
|
||||
// button, and bar actions all funnel through one path.
|
||||
async function applyFilterFromQuery(q) {
|
||||
filter.value = {
|
||||
tag_ids: _parseIds(q.tag_id),
|
||||
artist_id: _toId(q.artist_id),
|
||||
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),
|
||||
similar_to: _toId(q.similar_to),
|
||||
}
|
||||
if (filter.value.similar_to) {
|
||||
// Similar mode: ranked, no timeline scroll.
|
||||
await loadSimilar()
|
||||
} else {
|
||||
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
|
||||
}
|
||||
function _parseIds(raw) {
|
||||
if (!raw) return []
|
||||
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
||||
}
|
||||
|
||||
// Pre-seed a label so a freshly-picked chip shows its name without a
|
||||
// round-trip; the bar calls these before pushing the new URL.
|
||||
function noteTagLabel(id, name) { tagLabels.value = { ...tagLabels.value, [id]: name } }
|
||||
function noteArtistLabel(name) { artistLabel.value = name || null }
|
||||
|
||||
async function _resolveLabels() {
|
||||
for (const id of filter.value.tag_ids) {
|
||||
if (tagLabels.value[id]) continue
|
||||
try {
|
||||
const t = await api.get(`/api/tags/${id}`)
|
||||
tagLabels.value = { ...tagLabels.value, [id]: t.name }
|
||||
} catch { /* chip falls back to #id */ }
|
||||
}
|
||||
if (filter.value.artist_id && !artistLabel.value) {
|
||||
// The filtered set is this artist — derive the chip label from a tile.
|
||||
const hit = images.value.find(
|
||||
(i) => i.artist && i.artist.id === filter.value.artist_id
|
||||
)
|
||||
artistLabel.value = hit?.artist?.name || null
|
||||
}
|
||||
if (!filter.value.artist_id) artistLabel.value = null
|
||||
}
|
||||
|
||||
const hasMore = computed(() => nextCursor.value !== null)
|
||||
@@ -113,11 +240,40 @@ export const useGalleryStore = defineStore('gallery', () => {
|
||||
|
||||
return {
|
||||
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||
filter, timelineBuckets, timelineLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter
|
||||
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
|
||||
facets, facetsLoading,
|
||||
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar,
|
||||
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,
|
||||
similar_to: f.similar_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
|
||||
if (f.similar_to) q.similar_to = String(f.similar_to)
|
||||
return q
|
||||
}
|
||||
|
||||
function mergeGroups(existing, incoming) {
|
||||
// Merge sequential groups with the same (year, month) instead of duplicating.
|
||||
const merged = [...existing]
|
||||
|
||||
@@ -18,9 +18,9 @@ export const useModalStore = defineStore('modal', () => {
|
||||
const inflight = useInflightToken()
|
||||
|
||||
// Post-scoped cycle. When set, prev/next cycles within this array
|
||||
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
|
||||
// null, prev/next falls back to current.value.neighbors (the
|
||||
// gallery-store-driven /api/gallery/image/<id> neighbors).
|
||||
// (used by PostCard image clicks — the modal is scoped to that post's
|
||||
// images). When null, prev/next falls back to current.value.neighbors
|
||||
// (the gallery-store-driven /api/gallery/image/<id> neighbors).
|
||||
const postImageIds = ref(null)
|
||||
const postImageIndex = ref(0)
|
||||
|
||||
@@ -155,6 +155,19 @@ export const useModalStore = defineStore('modal', () => {
|
||||
const imageId = currentImageId.value
|
||||
if (!imageId) return
|
||||
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
|
||||
// Audit 2026-06-02: a kind='fandom' created here used to be
|
||||
// invisible to FandomPicker until a full page reload — its load
|
||||
// gates on fandomCache.length, so a non-empty cache skips the
|
||||
// refetch and the new fandom never appears. Push it into the
|
||||
// cache directly so the next open sees it.
|
||||
if (kind === 'fandom') {
|
||||
const { useTagStore } = await import('./tags.js')
|
||||
const tagStore = useTagStore()
|
||||
tagStore.fandomCache.push({
|
||||
id: tag.id, name: tag.name, kind: 'fandom',
|
||||
fandom_id: null, fandom_name: null, image_count: 0,
|
||||
})
|
||||
}
|
||||
if (currentImageId.value !== imageId) return // navigated away
|
||||
await addExistingTag(tag.id)
|
||||
}
|
||||
|
||||
+124
-89
@@ -1,37 +1,42 @@
|
||||
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 = 160 // floor between fully-loaded reveals (doubled
|
||||
// 2026-06-04 — slower, more deliberate cadence)
|
||||
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 +47,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()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
|
||||
// Live polled state.
|
||||
const queues = ref(null) // { queues: {name: depth|null}, fetched_at }
|
||||
const workers = ref(null) // { workers: {hostname: {...}}, fetched_at }
|
||||
const recentMinute = ref([]) // last-60s rows (for Overview summary)
|
||||
const recentRuns = ref([]) // last-60s rows (for Overview summary)
|
||||
const failures = ref(null) // { recent, count_by_type, since }
|
||||
|
||||
// Paginated runs (Activity tab "All recent activity" pane).
|
||||
@@ -45,14 +45,14 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentMinute() {
|
||||
async function loadRecentRuns() {
|
||||
// Used by the Overview summary card: pull last 60s of runs to compute
|
||||
// per-queue ok/err counts. One call covers all queues; UI groups.
|
||||
try {
|
||||
const body = await api.get('/api/system/activity/runs', {
|
||||
params: { limit: 200 },
|
||||
})
|
||||
recentMinute.value = body.runs || []
|
||||
recentRuns.value = body.runs || []
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
}
|
||||
@@ -106,10 +106,10 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
queues, workers, recentMinute, failures, summary,
|
||||
queues, workers, recentRuns, failures, summary,
|
||||
runs, runsCursor, runsHasMore, runsFilter,
|
||||
loading, lastError,
|
||||
loadQueues, loadWorkers, loadRecentMinute,
|
||||
loadQueues, loadWorkers, loadRecentRuns,
|
||||
loadRuns, loadFailures, loadSummary, setFilter,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,8 +49,21 @@ export const useTagStore = defineStore('tags', () => {
|
||||
return fandom
|
||||
}
|
||||
|
||||
// Set / change / clear a character tag's fandom. fandomId null clears it.
|
||||
// Throws ApiError (status 409, body.target) on a name collision in the
|
||||
// target fandom; pass { merge: true } to resolve it by merging this tag
|
||||
// into the existing character. Returns the updated/surviving tag.
|
||||
async function setFandom(tagId, fandomId, { merge = false } = {}) {
|
||||
const body = { fandom_id: fandomId ?? null }
|
||||
if (merge) body.merge = true
|
||||
return await api.patch(`/api/tags/${tagId}`, { body })
|
||||
}
|
||||
|
||||
function kindOptions() { return KIND_OPTIONS }
|
||||
function colorFor(kind) { return KIND_COLOR[kind] || 'on-surface' }
|
||||
|
||||
return { fandomCache, autocomplete, loadFandoms, createFandom, kindOptions, colorFor }
|
||||
return {
|
||||
fandomCache, autocomplete, loadFandoms, createFandom, setFandom,
|
||||
kindOptions, colorFor
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Resolve once the image at `url` is fully loaded AND decoded (paint-ready),
|
||||
// or after `timeoutMs` as a safety net so a broken/slow image never stalls a
|
||||
// cascade. Never rejects — the caller only cares that it's safe to reveal.
|
||||
//
|
||||
// Used by the showcase cascade to gate each tile's entry animation on the
|
||||
// thumbnail actually being ready, so the flip-in plays on a real image rather
|
||||
// than on a gray placeholder (operator-flagged 2026-06-04).
|
||||
export function preloadImage (url, timeoutMs = 4000) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false
|
||||
const finish = () => { if (!done) { done = true; resolve() } }
|
||||
const img = new Image()
|
||||
img.onload = finish
|
||||
img.onerror = finish
|
||||
img.src = url
|
||||
// decode() resolves at paint-ready (a beat after onload); prefer it when
|
||||
// available, but onload/onerror/timeout all still settle the promise.
|
||||
if (typeof img.decode === 'function') img.decode().then(finish).catch(() => {})
|
||||
setTimeout(finish, timeoutMs)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Helpers for deciding whether a keyboard shortcut should fire or yield to a
|
||||
// focused text field.
|
||||
|
||||
export function isTextEntry (el) {
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
||||
}
|
||||
|
||||
export function hasText (el) {
|
||||
if (!el) return false
|
||||
if (el.isContentEditable) return (el.textContent || '').length > 0
|
||||
return (el.value || '').length > 0
|
||||
}
|
||||
|
||||
// Prev/next arrow navigation should fire UNLESS focus is in a text entry that
|
||||
// already has content — in that case the arrows belong to the caret so it can
|
||||
// move through the text. An empty (or non-text) target still navigates.
|
||||
export function arrowNavAllowed (target) {
|
||||
return !(isTextEntry(target) && hasText(target))
|
||||
}
|
||||
@@ -20,6 +20,22 @@
|
||||
:last-added="store.lastAdded"
|
||||
/>
|
||||
<v-container fluid class="pt-2 pb-4">
|
||||
<!-- "N new since last visit" banner. Visible only on the initial
|
||||
load that triggered the visit-mark; dismissable via close
|
||||
button or by switching tabs. Re-entry only re-shows if more
|
||||
content has arrived (overview returns 0 immediately after a
|
||||
previous visit). -->
|
||||
<v-alert
|
||||
v-if="unseenBanner"
|
||||
type="info" variant="tonal" density="compact"
|
||||
class="mb-3" closable
|
||||
@click:close="unseenBanner = false"
|
||||
>
|
||||
<span class="fc-artist__unseen-msg">
|
||||
<strong>{{ store.overview.unseen_count_at_visit }}</strong>
|
||||
new since last visit
|
||||
</span>
|
||||
</v-alert>
|
||||
<v-window v-model="tab">
|
||||
<v-window-item value="posts">
|
||||
<ArtistPostsTab
|
||||
@@ -39,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useArtistStore } from '../stores/artist.js'
|
||||
@@ -60,6 +76,10 @@ const { tab, resolve } = useTabQuery(
|
||||
() => ((store.postCount ?? 0) > 0 ? 'posts' : 'gallery'),
|
||||
)
|
||||
|
||||
// One-shot banner — reset on each new artist-slug load so it re-appears
|
||||
// when navigating between artists that each have unseen content.
|
||||
const unseenBanner = ref(false)
|
||||
|
||||
watch(slug, async (s) => {
|
||||
if (!s) return
|
||||
await store.load(s)
|
||||
@@ -67,6 +87,7 @@ watch(slug, async (s) => {
|
||||
? `${store.overview.name} — FabledCurator`
|
||||
: 'FabledCurator'
|
||||
tab.value = resolve()
|
||||
unseenBanner.value = (store.overview?.unseen_count_at_visit || 0) > 0
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -74,4 +95,7 @@ watch(slug, async (s) => {
|
||||
.fc-artist__loading {
|
||||
display: flex; justify-content: center; padding: 64px 0;
|
||||
}
|
||||
.fc-artist__unseen-msg {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -76,7 +76,9 @@ onMounted(async () => {
|
||||
}
|
||||
.fc-artists__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||
/* min(440px, 100%) so a card never exceeds the viewport — on phones the
|
||||
min-track collapses to 100% (single column) instead of overflowing. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.fc-artists__sentinel {
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
<div class="fc-gallery-layout">
|
||||
<div class="fc-gallery-layout__main">
|
||||
<PostInfoHeader />
|
||||
<GalleryFilterBar v-if="store.filter.post_id == null" />
|
||||
<EmptyState v-if="store.isEmpty" />
|
||||
<GalleryGrid v-else @open="openImage" />
|
||||
</div>
|
||||
<TimelineSidebar v-if="store.images.length > 0" class="fc-gallery-layout__sidebar" />
|
||||
<TimelineSidebar
|
||||
v-if="store.images.length > 0 && store.filter.similar_to == null"
|
||||
class="fc-gallery-layout__sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BulkEditorPanel />
|
||||
@@ -24,10 +28,11 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGalleryStore } from '../stores/gallery.js'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
||||
import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue'
|
||||
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
|
||||
import EmptyState from '../components/gallery/EmptyState.vue'
|
||||
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
|
||||
@@ -37,46 +42,19 @@ import { useGallerySelectionStore } from '../stores/gallerySelection.js'
|
||||
const store = useGalleryStore()
|
||||
const modal = useModalStore()
|
||||
const sel = useGallerySelectionStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
const postId = parseInt(route.query.post_id, 10)
|
||||
const tagId = parseInt(route.query.tag_id, 10)
|
||||
if (!isNaN(postId)) store.setPostFilter(postId)
|
||||
else if (!isNaN(tagId)) store.setTagFilter(tagId)
|
||||
await store.loadInitial()
|
||||
await store.loadTimeline()
|
||||
// Open modal if URL has ?image=N
|
||||
const initial = parseInt(route.query.image, 10)
|
||||
if (!isNaN(initial)) modal.open(initial)
|
||||
})
|
||||
// The URL query is the single source of truth for filters. Apply it on
|
||||
// mount and on any query change (filter bar pushes, back button, deep-link).
|
||||
onMounted(() => store.applyFilterFromQuery(route.query))
|
||||
|
||||
watch(() => route.query.tag_id, (q) => {
|
||||
watch(() => route.query, (q) => {
|
||||
sel.clear() // result set changed — selected ids are no longer valid
|
||||
const tagId = parseInt(q, 10)
|
||||
store.setTagFilter(isNaN(tagId) ? null : tagId)
|
||||
})
|
||||
|
||||
watch(() => route.query.post_id, (q) => {
|
||||
sel.clear() // result set changed — selected ids are no longer valid
|
||||
const postId = parseInt(q, 10)
|
||||
store.setPostFilter(isNaN(postId) ? null : postId)
|
||||
})
|
||||
|
||||
watch(() => route.query.image, (q) => {
|
||||
const id = parseInt(q, 10)
|
||||
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
|
||||
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
|
||||
store.applyFilterFromQuery(q)
|
||||
})
|
||||
|
||||
function openImage(id) {
|
||||
router.push({ query: { ...route.query, image: id } })
|
||||
}
|
||||
function closeImage() {
|
||||
const q = { ...route.query }
|
||||
delete q.image
|
||||
router.push({ query: q })
|
||||
modal.open(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -93,4 +71,9 @@ function closeImage() {
|
||||
.fc-gallery-layout { flex-direction: column-reverse; }
|
||||
.fc-gallery-layout__sidebar { width: 100%; max-height: 200px; }
|
||||
}
|
||||
/* Phones: the year/month timeline strip eats vertical space and reads poorly
|
||||
as a horizontal band — drop it; the gallery scroll is the primary nav here. */
|
||||
@media (max-width: 600px) {
|
||||
.fc-gallery-layout__sidebar { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -44,14 +44,15 @@
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="import">
|
||||
<!-- Order: trigger → recent tasks → filters. Tasks sit directly
|
||||
below the trigger so operator sees hit/miss feedback without
|
||||
scrolling past the filter card (operator-flagged 2026-05-25). -->
|
||||
<!-- Order: filters → trigger → recent tasks. Filters hoisted above the
|
||||
trigger (operator-flagged 2026-06-04); the task list stays
|
||||
directly below the trigger so hit/miss feedback is adjacent to the
|
||||
button that produced it (operator-flagged 2026-05-25). -->
|
||||
<ImportFiltersForm />
|
||||
<v-divider class="my-6" />
|
||||
<ImportTriggerPanel />
|
||||
<v-divider class="my-6" />
|
||||
<ImportTaskList />
|
||||
<v-divider class="my-6" />
|
||||
<ImportFiltersForm />
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="cleanup">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
placeholder="Search tags" clearable style="max-width: 320px;"
|
||||
/>
|
||||
<v-chip-group
|
||||
v-model="kind" selected-class="text-accent" mandatory="false"
|
||||
v-model="kind" selected-class="text-accent" :mandatory="false"
|
||||
>
|
||||
<v-chip v-for="k in KINDS" :key="k" :value="k" filter size="small">
|
||||
{{ k }}
|
||||
@@ -28,6 +28,7 @@
|
||||
v-for="c in store.cards" :key="c.id" :card="c"
|
||||
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
|
||||
@merge-with="onMergeWith" @delete="onDeleteTag"
|
||||
@set-fandom="onSetFandom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -85,6 +86,13 @@
|
||||
: ''"
|
||||
@confirm="onDeleteTagConfirm"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="fandomDialogOpen" max-width="460">
|
||||
<FandomSetDialog
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -98,6 +106,7 @@ import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import TagCard from '../components/discovery/TagCard.vue'
|
||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||
import FandomSetDialog from '../components/modal/FandomSetDialog.vue'
|
||||
|
||||
// Must stay a subset of the backend TagKind enum (character, fandom,
|
||||
// general, series, archive, post). 'fandom' is this model's
|
||||
@@ -140,6 +149,18 @@ function openTag(tagId) {
|
||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||
}
|
||||
|
||||
// Character fandom editing (dots-menu → FandomSetDialog).
|
||||
const fandomDialogOpen = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
function onSetFandom(card) {
|
||||
fandomTarget.value = card
|
||||
fandomDialogOpen.value = true
|
||||
}
|
||||
function onFandomUpdated() {
|
||||
fandomDialogOpen.value = false
|
||||
store.reset() // reload so the card reflects the new fandom (or its removal)
|
||||
}
|
||||
|
||||
function onManage(id) {
|
||||
router.push({ name: 'series-manage', params: { tagId: id } })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
import GalleryFacetPanel from '../../src/components/gallery/GalleryFacetPanel.vue'
|
||||
import { useGalleryStore } from '../../src/stores/gallery.js'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
const FACETS = {
|
||||
total: 5,
|
||||
platforms: [
|
||||
{ value: 'patreon', count: 3 },
|
||||
{ value: null, count: 2 }, // unsourced → "No platform" bucket
|
||||
],
|
||||
untagged: 4,
|
||||
no_artist: 1,
|
||||
date_min: '2020-01-01T00:00:00+00:00',
|
||||
date_max: '2026-06-01T00:00:00+00:00',
|
||||
}
|
||||
|
||||
function stubFetchOk(body) {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true, status: 200, statusText: 'OK',
|
||||
text: async () => JSON.stringify(body),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('GalleryFacetPanel', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('renders platform options with counts and the curation flags', () => {
|
||||
const pinia = freshPinia()
|
||||
stubFetchOk(FACETS) // covers the onMounted loadFacets() call
|
||||
const s = useGalleryStore()
|
||||
s.facets = FACETS // seed so counts render synchronously
|
||||
const w = mountComponent(GalleryFacetPanel, { pinia })
|
||||
const t = w.text()
|
||||
expect(t).toContain('patreon')
|
||||
expect(t).toContain('3')
|
||||
expect(t).toContain('No platform')
|
||||
expect(t).toContain('Untagged')
|
||||
expect(t).toContain('4')
|
||||
expect(t).toContain('No artist')
|
||||
})
|
||||
|
||||
it('shows an en-dash placeholder for a flag count before facets load', () => {
|
||||
const pinia = freshPinia()
|
||||
stubFetchOk(FACETS)
|
||||
useGalleryStore().facets = null // not loaded yet
|
||||
const w = mountComponent(GalleryFacetPanel, { pinia })
|
||||
expect(w.text()).toContain('Untagged')
|
||||
expect(w.text()).toContain('–')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import GalleryGrid from '../../src/components/gallery/GalleryGrid.vue'
|
||||
import { useGalleryStore } from '../../src/stores/gallery.js'
|
||||
import { freshPinia } from '../support/mountComponent.js'
|
||||
|
||||
const GIStub = { name: 'GalleryItem', props: ['image'], template: '<div class="gi" />' }
|
||||
|
||||
function mountGrid(pinia) {
|
||||
return mount(GalleryGrid, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: { GalleryItem: GIStub, RouterLink: { template: '<a><slot /></a>' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('GalleryGrid', () => {
|
||||
it('renders a flat ranked list when there are no date groups (similar mode)', () => {
|
||||
const pinia = freshPinia()
|
||||
const store = useGalleryStore()
|
||||
// similar-mode shape: images present, date_groups empty, no cursor.
|
||||
store.images = [
|
||||
{ id: 1, thumbnail_url: '/a' },
|
||||
{ id: 2, thumbnail_url: '/b' },
|
||||
{ id: 3, thumbnail_url: '/c' },
|
||||
]
|
||||
store.dateGroups = []
|
||||
const w = mountGrid(pinia)
|
||||
expect(w.findAll('.gi').length).toBe(3)
|
||||
})
|
||||
|
||||
it('renders grouped by date when date groups are present', () => {
|
||||
const pinia = freshPinia()
|
||||
const store = useGalleryStore()
|
||||
store.images = [
|
||||
{ id: 1, thumbnail_url: '/a' },
|
||||
{ id: 2, thumbnail_url: '/b' },
|
||||
]
|
||||
store.dateGroups = [{ year: 2026, month: 6, image_ids: [1, 2] }]
|
||||
const w = mountGrid(pinia)
|
||||
expect(w.find('.fc-gallery-grid__date-header').exists()).toBe(true)
|
||||
expect(w.findAll('.gi').length).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import GalleryItem from '../../src/components/gallery/GalleryItem.vue'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
describe('GalleryItem', () => {
|
||||
const image = {
|
||||
id: 7, sha256: 'a'.repeat(64), mime: 'image/jpeg',
|
||||
width: 100, height: 100,
|
||||
thumbnail_url: '/images/thumbs/aa/abc.jpg', artist: null,
|
||||
}
|
||||
|
||||
it('reveals the thumbnail only once it has loaded, so tiles fade in individually', async () => {
|
||||
const pinia = freshPinia()
|
||||
const w = mountComponent(GalleryItem, { props: { image }, pinia })
|
||||
const img = w.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
|
||||
// Pre-load: the tile must NOT be revealed, so it can fade in when its
|
||||
// own thumbnail lands rather than pop together with its API batch.
|
||||
expect(img.classes()).not.toContain('is-loaded')
|
||||
|
||||
await img.trigger('load')
|
||||
|
||||
expect(img.classes()).toContain('is-loaded')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
|
||||
import RelatedStrip from '../../src/components/modal/RelatedStrip.vue'
|
||||
import { useModalStore } from '../../src/stores/modal.js'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
const { status, body } = handler(url)
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status, statusText: String(status),
|
||||
text: async () => JSON.stringify(body),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('RelatedStrip (modal "more like this")', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
|
||||
it('stays hidden and never fetches when the image has no embedding', async () => {
|
||||
const pinia = freshPinia()
|
||||
useModalStore().current = { id: 1, has_embedding: false }
|
||||
const fetchSpy = vi.fn()
|
||||
globalThis.fetch = fetchSpy
|
||||
const w = mountComponent(RelatedStrip, { pinia })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(w.find('.fc-related').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('deferred-fetches and renders similar thumbs for an embedded image', async () => {
|
||||
const pinia = freshPinia()
|
||||
useModalStore().current = { id: 1, has_embedding: true }
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { images: [{ id: 2, thumbnail_url: '/t2' }, { id: 3, thumbnail_url: '/t3' }] },
|
||||
}))
|
||||
const w = mountComponent(RelatedStrip, { pinia })
|
||||
// Nothing before the defer elapses (modal image gets priority).
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(w.findAll('.fc-related__item').length).toBe(2)
|
||||
})
|
||||
|
||||
it('collapses silently when the similar fetch returns nothing', async () => {
|
||||
const pinia = freshPinia()
|
||||
useModalStore().current = { id: 1, has_embedding: true }
|
||||
stubFetch(() => ({ status: 200, body: { images: [] } }))
|
||||
const w = mountComponent(RelatedStrip, { pinia })
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
expect(w.find('.fc-related').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('clicking a thumb opens that image in the modal', async () => {
|
||||
const pinia = freshPinia()
|
||||
const modal = useModalStore()
|
||||
modal.current = { id: 1, has_embedding: true }
|
||||
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
|
||||
stubFetch(() => ({ status: 200, body: { images: [{ id: 7, thumbnail_url: '/t7' }] } }))
|
||||
const w = mountComponent(RelatedStrip, { pinia })
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await flushPromises()
|
||||
await w.find('.fc-related__item').trigger('click')
|
||||
expect(openSpy).toHaveBeenCalledWith(7)
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,61 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
|
||||
import PostCard from '../../src/components/posts/PostCard.vue'
|
||||
import { useModalStore } from '../../src/stores/modal.js'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const BASE = {
|
||||
id: 1, external_post_id: 'P1',
|
||||
post_title: '<strong>Hello World</strong>',
|
||||
post_url: 'https://x/1', post_date: now, downloaded_at: now,
|
||||
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
|
||||
source: { id: 1, platform: 'subscribestar' },
|
||||
thumbnails: [], thumbnails_more: 0, attachments: [],
|
||||
}
|
||||
|
||||
describe('PostCard', () => {
|
||||
it('renders the HTML-stripped title and the artist', () => {
|
||||
const pinia = freshPinia()
|
||||
const now = new Date().toISOString()
|
||||
const post = {
|
||||
id: 1, external_post_id: 'P1',
|
||||
post_title: '<strong>Hello World</strong>',
|
||||
post_url: 'https://x/1', post_date: now, downloaded_at: now,
|
||||
description_plain: 'a description',
|
||||
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
|
||||
source: { id: 1, platform: 'subscribestar' },
|
||||
thumbnails: [], thumbnails_more: 0, attachments: [],
|
||||
}
|
||||
const w = mountComponent(PostCard, { props: { post }, pinia })
|
||||
const post = { ...BASE, description_plain: 'a description' }
|
||||
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
||||
const t = w.text()
|
||||
expect(t).toContain('Hello World') // plain title
|
||||
expect(t).not.toContain('<strong>') // tags stripped
|
||||
expect(t).toContain('Sabu') // artist (RouterLink slot)
|
||||
})
|
||||
|
||||
it('shows a "Show more" toggle only when the description is truncated', () => {
|
||||
const truncated = mountComponent(PostCard, {
|
||||
props: { post: { ...BASE, description_plain: 'lots of text…', description_truncated: true } },
|
||||
pinia: freshPinia(),
|
||||
})
|
||||
expect(truncated.text()).toContain('Show more')
|
||||
|
||||
const full = mountComponent(PostCard, {
|
||||
props: { post: { ...BASE, description_plain: 'short', description_truncated: false } },
|
||||
pinia: freshPinia(),
|
||||
})
|
||||
expect(full.text()).not.toContain('Show more')
|
||||
})
|
||||
|
||||
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
|
||||
const pinia = freshPinia()
|
||||
const modal = useModalStore()
|
||||
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
|
||||
const post = {
|
||||
...BASE,
|
||||
description_plain: 'd',
|
||||
thumbnails: [
|
||||
{ image_id: 10, thumbnail_url: '/a' },
|
||||
{ image_id: 11, thumbnail_url: '/b' },
|
||||
],
|
||||
thumbnails_more: 0,
|
||||
}
|
||||
const w = mountComponent(PostCard, { props: { post }, pinia })
|
||||
await w.find('.fc-post-card__hero').trigger('click')
|
||||
await flushPromises()
|
||||
expect(openSpy).toHaveBeenCalledWith(10, { postImageIds: [10, 11] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user