Merge pull request 'UI batch + I1–I6 service passes + download-event recovery sweep' (#33) from dev into main

This commit was merged in pull request #33.
This commit is contained in:
2026-05-29 22:46:16 -04:00
73 changed files with 1536 additions and 2604 deletions
+18 -4
View File
@@ -1,7 +1,8 @@
name: CI
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
# - backend-lint-and-test: ruff + `pytest -m "not integration"`, no service containers.
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
# - frontend-build: vitest unit + vite build.
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
@@ -14,6 +15,20 @@ on:
# (single-operator Forgejo repo) so push coverage is complete.
jobs:
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so
# this runs with NO dependency install and surfaces the most common bounce
# class (lint: I001 / UP037 / ASYNC109 / W293 …) in seconds — instead of
# after the backend job's ~30-60s wheel install. ruff is static analysis,
# so no DB/secret env is needed.
lint:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
- name: Ruff lint
run: ruff check backend/ tests/ alembic/
backend-lint-and-test:
runs-on: python-ci
container:
@@ -51,9 +66,8 @@ jobs:
pip install -r requirements.txt pytest pytest-asyncio
fi
- name: Ruff lint
run: ruff check backend/ tests/ alembic/
# Ruff moved to the dedicated fast `lint` job above (fails in seconds,
# no dep install). This job is now unit tests only.
- name: Pytest (unit only — integration runs in the integration job)
run: pytest tests/ -v -m "not integration"
@@ -0,0 +1,50 @@
"""drop migration_run — one-and-done GS/IR migration tooling removed
Revision ID: 0027
Revises: 0026
Create Date: 2026-05-29
The GS/IR migration tooling (services/migrators, /api/migrate, the
run_migration task, LegacyMigrationCard, and the MigrationRun model) was
removed after the migration cutover completed. This drops its now-orphaned
run-log table. Downgrade recreates the table (mirrors the old model) so the
migration is reversible.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "0027"
down_revision: Union[str, None] = "0026"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table("migration_run")
def downgrade() -> None:
op.create_table(
"migration_run",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("dry_run", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column(
"started_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"counts", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb"),
),
sa.Column("error", sa.Text(), nullable=True),
sa.Column(
"metadata", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb"),
),
)
op.create_index("ix_migration_run_kind", "migration_run", ["kind"])
op.create_index("ix_migration_run_status", "migration_run", ["status"])
-6
View File
@@ -33,12 +33,6 @@ def create_app() -> Quart:
app = Quart(__name__)
app.secret_key = cfg.secret_key
# FC-5: legacy IR ingest JSON can run to tens of MB (hundreds of
# thousands of image_tag_associations). Werkzeug's default form
# memory cap is 500KB; raise both ceilings so the multipart upload
# for /api/migrate/ir_ingest doesn't 413.
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 * 1024 # 1 GB
app.config["MAX_FORM_MEMORY_SIZE"] = 1024 * 1024 * 1024 # 1 GB
for bp in all_blueprints():
app.register_blueprint(bp)
-2
View File
@@ -26,7 +26,6 @@ def all_blueprints() -> list[Blueprint]:
from .extension import extension_bp
from .gallery import gallery_bp
from .import_admin import import_admin_bp
from .migrate import migrate_bp
from .ml_admin import ml_admin_bp
from .platforms import platforms_bp
from .posts import posts_bp
@@ -54,7 +53,6 @@ def all_blueprints() -> list[Blueprint]:
admin_bp,
cleanup_bp,
import_admin_bp,
migrate_bp,
suggestions_bp,
allowlist_bp,
aliases_bp,
-107
View File
@@ -1,107 +0,0 @@
"""FC-5: /api/migrate — trigger and poll migration runs.
Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an
`export_file` field. All other kinds accept JSON. Backup + rollback
were retired in FC-3h (2026-05-24); use /api/system/backup/* instead.
"""
import json
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from ..extensions import get_session
from ..models import MigrationRun
from ..tasks.migration import run_migration
from ._responses import error_response as _bad
migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate")
# 'backup' + 'rollback' retired 2026-05-24 (FC-3h); see /api/system/backup/*.
_VALID_KINDS = frozenset({
"gs_ingest", "ir_ingest", "tag_apply",
"ml_queue", "verify", "cleanup",
})
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
def _run_to_dict(run: MigrationRun) -> dict:
return {
"id": run.id,
"kind": run.kind,
"status": run.status,
"dry_run": run.dry_run,
"started_at": run.started_at.isoformat(),
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"counts": run.counts or {},
"error": run.error,
"metadata": run.metadata_ or {},
}
@migrate_bp.route("/<kind>", methods=["POST"])
async def create_run(kind: str):
if kind not in _VALID_KINDS:
return _bad("unknown_kind", detail=f"kind must be one of {sorted(_VALID_KINDS)}")
# Ingest kinds accept multipart/form-data; everything else takes JSON.
if kind in _INGEST_KINDS:
form = await request.form
files = await request.files
if "export_file" not in files:
return _bad("missing_export_file", detail="multipart export_file required")
export_file = files["export_file"]
try:
raw = export_file.read()
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return _bad("invalid_export_file", detail=str(exc))
dry_run = str(form.get("dry_run", "false")).lower() in ("true", "1", "yes")
params: dict = {"data": data, "dry_run": dry_run}
else:
body = await request.get_json()
if body is None:
body = {}
if not isinstance(body, dict):
return _bad("invalid_body")
dry_run = bool(body.get("dry_run", False))
params = dict(body)
async with get_session() as session:
run = MigrationRun(kind=kind, status="pending", dry_run=dry_run)
session.add(run)
await session.commit()
await session.refresh(run)
run_id = run.id
run_migration.delay(run_id, kind, params)
return jsonify({"run_id": run_id, "status": "pending"}), 202
@migrate_bp.route("/runs/<int:run_id>", methods=["GET"])
async def get_run(run_id: int):
async with get_session() as session:
run = (await session.execute(
select(MigrationRun).where(MigrationRun.id == run_id)
)).scalar_one_or_none()
if run is None:
return _bad("not_found", status=404)
return jsonify(_run_to_dict(run))
@migrate_bp.route("/runs", methods=["GET"])
async def list_runs():
try:
limit = int(request.args.get("limit", "10"))
except ValueError:
return _bad("invalid_limit")
if limit < 1 or limit > 100:
return _bad("invalid_limit")
async with get_session() as session:
rows = (await session.execute(
select(MigrationRun)
.order_by(MigrationRun.id.desc())
.limit(limit)
)).scalars().all()
return jsonify([_run_to_dict(r) for r in rows])
+24 -3
View File
@@ -18,6 +18,8 @@ async def list_posts():
artist_id_raw = args.get("artist_id")
platform = args.get("platform") or None
limit_raw = args.get("limit", "24")
direction = args.get("direction", "older")
around_raw = args.get("around")
try:
limit = int(limit_raw)
@@ -26,6 +28,16 @@ async def list_posts():
if limit < 1 or limit > 100:
return _bad("invalid_limit", detail="limit must be between 1 and 100")
if direction not in ("older", "newer"):
return _bad("invalid_direction", detail="direction must be 'older' or 'newer'")
around_id = None
if around_raw is not None:
try:
around_id = int(around_raw)
except ValueError:
return _bad("invalid_around", detail="around must be an integer post id")
artist_id = None
if artist_id_raw is not None:
try:
@@ -40,11 +52,20 @@ async def list_posts():
)
async with get_session() as session:
try:
page = await PostFeedService(session).scroll(
cursor=cursor, artist_id=artist_id,
svc = PostFeedService(session)
if around_id is not None:
result = await svc.around(
post_id=around_id, artist_id=artist_id,
platform=platform, limit=limit,
)
if result is None:
return _bad("not_found", status=404, detail=f"post id={around_id}")
return jsonify(result)
try:
page = await svc.scroll(
cursor=cursor, artist_id=artist_id,
platform=platform, limit=limit, direction=direction,
)
except ValueError as exc:
# Service raises ValueError for malformed cursors only;
# limit bounds are validated above.
+40 -5
View File
@@ -20,6 +20,7 @@ from sqlalchemy import desc, func, select
from ..config import get_config
from ..extensions import get_session
from ..models import TaskRun
from ..services.scheduler_service import scheduler_status
system_activity_bp = Blueprint(
"system_activity", __name__, url_prefix="/api/system/activity",
@@ -81,17 +82,22 @@ def _read_workers_sync() -> dict:
}
async def _queues_cached() -> dict:
"""Per-queue Redis LLEN, cached 2s. Shared by /queues and /summary."""
now = time.time()
if _QUEUE_CACHE["data"] is None or (now - _QUEUE_CACHE["ts"]) > _QUEUE_CACHE_TTL:
_QUEUE_CACHE["data"] = await asyncio.to_thread(_read_queues_sync)
_QUEUE_CACHE["ts"] = now
return _QUEUE_CACHE["data"]
@system_activity_bp.route("/queues", methods=["GET"])
async def get_queues():
"""Per-queue Redis LLEN. Cached 2s.
Response: {queues: {name: depth_or_null}, fetched_at: iso8601}
"""
now = time.time()
if _QUEUE_CACHE["data"] is None or (now - _QUEUE_CACHE["ts"]) > _QUEUE_CACHE_TTL:
_QUEUE_CACHE["data"] = await asyncio.to_thread(_read_queues_sync)
_QUEUE_CACHE["ts"] = now
return jsonify(_QUEUE_CACHE["data"])
return jsonify(await _queues_cached())
@system_activity_bp.route("/workers", methods=["GET"])
@@ -107,6 +113,35 @@ async def get_workers():
return jsonify(_WORKER_CACHE["data"])
@system_activity_bp.route("/summary", methods=["GET"])
async def get_summary():
"""One-call rollup for the always-on TopNav pipeline indicator:
scheduler health, per-queue pending depths, currently-running count, and
recent (24h) failure count. Cheap — cached queue LLENs + two TaskRun
counts — so it's safe to poll app-wide."""
queues_data = await _queues_cached()
depths = queues_data.get("queues", {})
queued_total = sum(v for v in depths.values() if isinstance(v, int))
since = datetime.now(UTC) - timedelta(hours=24)
async with get_session() as session:
scheduler = await scheduler_status(session)
running = (await session.execute(
select(func.count(TaskRun.id)).where(TaskRun.status == "running")
)).scalar_one()
failing = (await session.execute(
select(func.count(TaskRun.id))
.where(TaskRun.status.in_(["error", "timeout"]))
.where(TaskRun.finished_at >= since)
)).scalar_one()
return jsonify({
"scheduler": scheduler,
"queues": depths,
"queued_total": queued_total,
"running": int(running),
"failing": int(failing),
})
@system_activity_bp.route("/runs", methods=["GET"])
async def list_runs():
"""Paginated task_run history. Query params:
+4 -2
View File
@@ -28,7 +28,6 @@ def make_celery() -> Celery:
"backend.app.tasks.import_file",
"backend.app.tasks.thumbnail",
"backend.app.tasks.maintenance",
"backend.app.tasks.migration",
"backend.app.tasks.ml",
"backend.app.tasks.download",
"backend.app.tasks.backup",
@@ -45,7 +44,6 @@ def make_celery() -> Celery:
"backend.app.tasks.download.*": {"queue": "download"},
"backend.app.tasks.scan.*": {"queue": "scan"},
"backend.app.tasks.maintenance.*": {"queue": "maintenance"},
"backend.app.tasks.migration.*": {"queue": "maintenance"},
"backend.app.tasks.backup.*": {"queue": "maintenance"},
"backend.app.tasks.admin.*": {"queue": "maintenance"},
"backend.app.tasks.library_audit.*": {"queue": "maintenance"},
@@ -87,6 +85,10 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.cleanup_old_download_events",
"schedule": 86400.0, # daily
},
"recover-stalled-download-events": {
"task": "backend.app.tasks.maintenance.recover_stalled_download_events",
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
},
"recover-stalled-task-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_task_runs",
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
+1 -4
View File
@@ -66,10 +66,7 @@ def _queue_for(task) -> str:
return "download"
if name.startswith("backend.app.tasks.scan."):
return "scan"
if name.startswith((
"backend.app.tasks.maintenance.",
"backend.app.tasks.migration.",
)):
if name.startswith("backend.app.tasks.maintenance."):
return "maintenance"
return "default"
-2
View File
@@ -12,7 +12,6 @@ from .import_batch import ImportBatch
from .import_settings import ImportSettings
from .import_task import ImportTask
from .library_audit_run import LibraryAuditRun
from .migration_run import MigrationRun
from .ml_settings import MLSettings
from .post import Post
from .post_attachment import PostAttachment
@@ -46,7 +45,6 @@ __all__ = [
"ImportSettings",
"LibraryAuditRun",
"MLSettings",
"MigrationRun",
"TagAlias",
"TagAllowlist",
"TagReferenceEmbedding",
-37
View File
@@ -1,37 +0,0 @@
"""MigrationRun — tracks each FC-5 migration invocation (backup/gs/ir/etc).
kind/status are String(32) not Postgres ENUM so adding kinds later
doesn't need a schema migration. The API layer validates values.
"""
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class MigrationRun(Base):
__tablename__ = "migration_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
counts: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default=sa.text("'{}'::jsonb"),
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_: Mapped[dict] = mapped_column(
"metadata", JSONB, nullable=False, default=dict,
server_default=sa.text("'{}'::jsonb"),
)
+2 -3
View File
@@ -6,9 +6,8 @@ HTTP handlers (small ops) and from Celery tasks in
backend.app.tasks.admin (long ops).
This module is the PERMANENT home of artist-cascade + image-unlink
logic. The legacy copy at backend/app/services/migrators/cleanup.py
stays in place until FC-3j; FC-3j will replace its body with thin
re-exports from this module and then delete the wrapper.
logic. (The legacy migrators/cleanup.py copy was removed with the rest of
the one-and-done GS/IR migration tooling.)
"""
from __future__ import annotations
@@ -1,10 +0,0 @@
"""FC-5 migration tooling.
One module per concern (gs/ir/overlap/ml_queue/verify/cleanup).
Each migrator returns a counts dict; the run_migration task wires
that dict into MigrationRun.counts so the UI polling shows progress.
backup + rollback were retired in FC-3h (2026-05-24); first-class
backup lives at backend/app/services/backup_service.py and exposes
its own /api/system/backup/* surface.
"""
-182
View File
@@ -1,182 +0,0 @@
"""Targeted cleanup migrator: delete every image attributed to one Artist.
Built for the IR-migration rescue case where the filesystem scan derived
a bogus 'imagerepo' artist from a mismatched bind-mount layout. Every
image attributed to that artist (40k+ rows) needs to be removed — DB
rows, original files under `/images/<bucket>/...`, and thumbnails under
`/images/thumbs/...` — before the operator remounts and re-scans.
CASCADE handles image_tag, image_provenance, series_page, and
tag_suggestion_rejection child rows; import_task.result_image_id is
SET NULL by FK. We also delete ImportTask rows whose source_path starts
with the (still-existing) IR scan prefix so the next scan isn't fooled
by them.
"""
from __future__ import annotations
import logging
from pathlib import Path
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Artist, ImageRecord, ImportBatch, ImportTask
log = logging.getLogger(__name__)
_BATCH_SIZE = 500
def _zero_counts() -> dict:
return {
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
}
def _thumb_path(images_root: Path, sha256_hex: str) -> tuple[Path, Path]:
"""Return both possible thumbnail paths (.jpg and .png). We try both
because the extension is chosen at generate-time based on the source
image's mode (alpha → .png, otherwise → .jpg)."""
bucket = sha256_hex[:3]
base = images_root / "thumbs" / bucket / sha256_hex
return base.with_suffix(".jpg"), base.with_suffix(".png")
def _delete_file(path: Path) -> bool:
"""Best-effort unlink; True if the file was actually removed."""
try:
path.unlink(missing_ok=True)
return True
except OSError as exc:
log.warning("cleanup: failed to unlink %s: %s", path, exc)
return False
async def cleanup_artist_async(
db: AsyncSession,
*,
slug: str,
images_root: Path | None = None,
dry_run: bool = False,
source_path_prefix: str | None = None,
) -> dict:
"""Delete every image attributed to the Artist with this slug,
along with the artist row itself and any associated import tasks.
Args:
slug: artist.slug to target (e.g. 'imagerepo').
images_root: defaults to /images.
dry_run: skip filesystem + DB writes; still walk rows for counts.
source_path_prefix: if set, ImportTask rows whose source_path
starts with this string are deleted too (use the IR scan
mount prefix, e.g. '/import/imagerepo').
"""
root = images_root if images_root is not None else Path("/images")
artist = (await db.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
raise ValueError(f"no Artist with slug={slug!r}")
artist_id = artist.id
artist_name = artist.name
total_images = (await db.execute(
select(func.count(ImageRecord.id)).where(ImageRecord.artist_id == artist_id)
)).scalar_one()
counts = _zero_counts()
files_deleted = 0
thumbs_deleted = 0
images_deleted = 0
# Batched delete loop. CASCADE handles image_tag, image_provenance,
# series_page, tag_suggestion_rejection. import_task.result_image_id
# is SET NULL by FK.
while True:
rows = (await db.execute(
select(ImageRecord.id, ImageRecord.path, ImageRecord.sha256)
.where(ImageRecord.artist_id == artist_id)
.limit(_BATCH_SIZE)
)).all()
if not rows:
break
ids = [r.id for r in rows]
counts["rows_processed"] += len(ids)
if not dry_run:
for r in rows:
if r.path:
if _delete_file(Path(r.path)):
files_deleted += 1
if r.sha256:
jpg, png = _thumb_path(root, r.sha256)
if _delete_file(jpg):
thumbs_deleted += 1
if _delete_file(png):
thumbs_deleted += 1
await db.execute(
delete(ImageRecord).where(ImageRecord.id.in_(ids))
)
await db.commit()
images_deleted += len(ids)
if dry_run:
# Nothing was actually deleted from the DB; bail after one
# pass so we don't loop forever.
break
import_tasks_deleted = 0
if source_path_prefix and not dry_run:
# Delete ImportTask rows whose source_path is under the bad mount
# prefix. These are mostly orphaned now (result_image_id was set
# NULL by CASCADE) but their presence still blocks the
# idempotency check in scan_directory if the operator remounts
# the same prefix.
like_pattern = source_path_prefix.rstrip("/") + "/%"
result = await db.execute(
delete(ImportTask).where(ImportTask.source_path.like(like_pattern))
)
import_tasks_deleted = result.rowcount or 0
await db.commit()
# Sweep ImportBatch rows that are now empty.
empty_batches_deleted = 0
if not dry_run:
empty_batch_ids = (await db.execute(
select(ImportBatch.id).where(
~select(ImportTask.id)
.where(ImportTask.batch_id == ImportBatch.id)
.exists()
)
)).scalars().all()
if empty_batch_ids:
result = await db.execute(
delete(ImportBatch).where(ImportBatch.id.in_(empty_batch_ids))
)
empty_batches_deleted = result.rowcount or 0
await db.commit()
# Finally, the artist row.
if not dry_run:
await db.execute(delete(Artist).where(Artist.id == artist_id))
await db.commit()
return {
"counts": counts,
"artist": {"id": artist_id, "name": artist_name, "slug": slug},
"summary": {
"images_targeted": total_images,
"images_deleted": images_deleted,
"files_deleted": files_deleted,
"thumbs_deleted": thumbs_deleted,
"import_tasks_deleted": import_tasks_deleted,
"empty_batches_deleted": empty_batches_deleted,
"dry_run": dry_run,
},
}
-126
View File
@@ -1,126 +0,0 @@
"""GallerySubscriber export → FabledCurator ingest.
Reads a parsed gallerysubscriber-export-v1.json dict (no DB connection
to GS). Creates Artist (from subscriptions) + Source (nested under each
subscription) + Credential (re-encrypted with FC's key). Idempotent on
natural keys: Artist.slug, (artist_id, platform, url), Credential.platform.
Credentials arrive plaintext in the export — GS's export script
decrypts using GS's Fernet key in GS's own process. FC re-encrypts
with FC's CredentialCrypto.
"""
from __future__ import annotations
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Artist, Credential, Source
from ...utils.slug import slugify
from ..credential_crypto import CredentialCrypto
def _zero_counts() -> dict:
return {
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
}
async def migrate_async(
db: AsyncSession,
*,
data: dict,
fc_crypto: CredentialCrypto | None = None,
dry_run: bool = False,
) -> dict:
"""Ingest a parsed gallerysubscriber-export-v1.json dict."""
if data.get("source_app") != "gallerysubscriber":
raise ValueError("export source_app must be 'gallerysubscriber'")
if data.get("schema_version") != 1:
raise ValueError(f"unsupported schema_version: {data.get('schema_version')}")
counts = _zero_counts()
# Phase 1: subscriptions → Artist; nested sources within each.
for sub in data.get("subscriptions", []):
counts["rows_processed"] += 1
slug = slugify(sub["name"])
artist = (await db.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
if dry_run:
counts["rows_inserted"] += 1
# Continue to nested sources, but they can't link without an artist row.
continue
notes = json.dumps(sub.get("metadata"), indent=2) if sub.get("metadata") else None
artist = Artist(
name=sub["name"], slug=slug,
is_subscription=True,
auto_check=bool(sub.get("enabled", True)),
notes=notes,
)
db.add(artist)
await db.flush()
counts["rows_inserted"] += 1
else:
counts["rows_skipped"] += 1
# Nested sources under this subscription.
for src in sub.get("sources", []):
counts["rows_processed"] += 1
existing = (await db.execute(
select(Source).where(
Source.artist_id == artist.id,
Source.platform == src["platform"],
Source.url == src["url"],
)
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
db.add(Source(
artist_id=artist.id,
platform=src["platform"],
url=src["url"],
enabled=bool(src.get("enabled", True)),
check_interval_override=src.get("check_interval"),
config_overrides=src.get("metadata") or {},
))
counts["rows_inserted"] += 1
# Phase 2: credentials.
for cred in data.get("credentials", []):
counts["rows_processed"] += 1
existing = (await db.execute(
select(Credential).where(Credential.platform == cred["platform"])
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
if fc_crypto is None:
# Without a crypto helper we can't encrypt — skip rather than
# store plaintext.
counts["rows_skipped"] += 1
counts["conflicts"] += 1
continue
encrypted = fc_crypto.encrypt(cred["plaintext"])
db.add(Credential(
platform=cred["platform"],
credential_type=cred.get("credential_type") or "cookies",
encrypted_blob=encrypted,
expires_at=cred.get("expires_at"),
))
counts["rows_inserted"] += 1
if not dry_run:
await db.commit()
return counts
-146
View File
@@ -1,146 +0,0 @@
"""ImageRepo export → FabledCurator ingest.
Reads a parsed imagerepo-export-v1.json dict (no DB connection to IR).
Creates Tag rows (skipping artist/post kinds, resolving fandom_name to
FK). Writes the per-image-sha256 artist assignments + tag associations
+ series page assignments to /images/_migration_state/ir_tag_manifest.json
so tag_apply.py can join them to ImageRecord rows AFTER the operator
runs FC's filesystem scan.
"""
from __future__ import annotations
import json
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Tag, TagKind
_SKIP_KINDS = frozenset({"artist", "post"})
_MIGRATION_STATE_DIRNAME = "_migration_state"
_IR_MANIFEST_FILENAME = "ir_tag_manifest.json"
def _zero_counts() -> dict:
return {
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
}
def manifest_path(images_root: Path | None = None) -> Path:
root = images_root if images_root is not None else Path("/images")
p = root / _MIGRATION_STATE_DIRNAME
p.mkdir(parents=True, exist_ok=True)
return p / _IR_MANIFEST_FILENAME
async def _resolve_fandom_id(
db: AsyncSession, fandom_name: str | None, dry_run: bool,
) -> int | None:
"""Find-or-create a fandom-kind Tag by name."""
if not fandom_name:
return None
existing = (await db.execute(
select(Tag).where(Tag.name == fandom_name, Tag.kind == "fandom")
)).scalar_one_or_none()
if existing is not None:
return existing.id
if dry_run:
return None
t = Tag(name=fandom_name, kind=TagKind.fandom)
db.add(t)
await db.flush()
return t.id
async def migrate_async(
db: AsyncSession,
*,
data: dict,
images_root: Path | None = None,
dry_run: bool = False,
) -> dict:
"""Ingest a parsed imagerepo-export-v1.json dict.
Creates Tag rows + writes the IR tag manifest file. Tag-to-image
binding happens later in tag_apply.py (after FC's filesystem scan
populates image_record.sha256 → id).
"""
if data.get("source_app") != "imagerepo":
raise ValueError("export source_app must be 'imagerepo'")
if data.get("schema_version") not in (1, 2):
raise ValueError(f"unsupported schema_version: {data.get('schema_version')}")
counts = _zero_counts()
# Phase 1: tags (skip artist + post kinds; resolve fandom_name → fandom_id).
# First pass: create all fandom-kind tags so they're available for FK resolution.
for tag in data.get("tags", []):
kind = tag.get("kind") or "general"
if kind != "fandom":
continue
counts["rows_processed"] += 1
existing = (await db.execute(
select(Tag).where(Tag.name == tag["name"], Tag.kind == "fandom")
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
db.add(Tag(name=tag["name"], kind=TagKind.fandom))
counts["rows_inserted"] += 1
if not dry_run:
await db.flush()
# Second pass: every other kind.
for tag in data.get("tags", []):
kind_str = tag.get("kind") or "general"
if kind_str in _SKIP_KINDS:
counts["rows_skipped"] += 1
continue
if kind_str == "fandom":
continue # handled above
counts["rows_processed"] += 1
try:
kind = TagKind(kind_str)
except ValueError:
kind = TagKind.general
fandom_id = await _resolve_fandom_id(db, tag.get("fandom_name"), dry_run)
existing = (await db.execute(
select(Tag).where(Tag.name == tag["name"], Tag.kind == kind)
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
db.add(Tag(name=tag["name"], kind=kind, fandom_id=fandom_id))
counts["rows_inserted"] += 1
if not dry_run:
await db.commit()
# Phase 2: write the per-image manifest for tag_apply.py to consume later.
# schema_version 2 (added 2026-05-24) carries `image_posts` for
# Post + Source + ImageProvenance restore; schema 1 manifests
# without it stay valid (tag_apply treats the missing field as []).
manifest = {
"schema_version": data.get("schema_version", 1),
"image_artist_assignments": data.get("image_artist_assignments", []),
"image_tag_associations": data.get("image_tag_associations", []),
"series_pages": data.get("series_pages", []),
"image_posts": data.get("image_posts", []),
}
counts["rows_processed"] += len(manifest["image_artist_assignments"])
counts["rows_processed"] += len(manifest["image_tag_associations"])
counts["rows_processed"] += len(manifest["series_pages"])
counts["rows_processed"] += len(manifest["image_posts"])
if not dry_run:
manifest_path(images_root).write_text(json.dumps(manifest, indent=2))
return counts
@@ -1,21 +0,0 @@
"""Queue every migrated image_record with no embedding for ML re-processing."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ImageRecord
async def queue_all_unprocessed_async(db: AsyncSession) -> int:
"""Find every ImageRecord with siglip_embedding IS NULL, fire
tag_and_embed.delay(id) for each. Returns count queued.
"""
from ...tasks.ml import tag_and_embed
rows = (await db.execute(
select(ImageRecord.id).where(ImageRecord.siglip_embedding.is_(None))
)).scalars().all()
for image_id in rows:
tag_and_embed.delay(image_id)
return len(rows)
-368
View File
@@ -1,368 +0,0 @@
"""Apply the IR tag manifest after FC's filesystem scan.
Reads /images/_migration_state/ir_tag_manifest.json and joins each entry
to an ImageRecord row by sha256 (which exists after the operator runs
FC's filesystem scan over the mounted IR images dir).
- image_artist_assignments → ImageRecord.artist_id (find_or_create Artist by slug).
- image_tag_associations → image_tag insert (idempotent).
- series_pages → series_page insert (idempotent on image_id unique).
- image_posts (schema v2) → Source + Post + ImageProvenance restore.
Unmatched sha256s are logged into the result's `unmatched` list so the
Celery task can drop them into MigrationRun.metadata for the operator
to inspect.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import (
Artist,
ImageProvenance,
ImageRecord,
Post,
SeriesPage,
Source,
Tag,
TagKind,
image_tag,
)
from ...utils.slug import slugify
from .ir_ingest import manifest_path
# Per-platform artist-profile URL — used as Source.url when restoring
# IR PostMetadata into FC. Must cover every platform that
# backend/app/services/extension_service.py:_PLATFORM_PATTERNS
# recognizes; an entry missing here silently drops ALL PostMetadata for
# that platform during phase 4 (operator hit this 2026-05-25:
# DeviantArt + Pixiv posts in the IR migration produced empty
# ImageProvenance because they fell through this table).
#
# Pixiv caveat: the real profile URL takes a numeric user_id
# (https://www.pixiv.net/users/12345), but IR's PostMetadata.artist
# stores the display name not the id. We use the slugified name here
# so we preserve the artist→post→image linkage; the resulting Source.url
# won't resolve in a browser and the operator may want to manually fix
# it via Settings → Subscriptions once the migration lands.
_PLATFORM_PROFILE_URL = {
"patreon": "https://www.patreon.com/{slug}",
"subscribestar": "https://www.subscribestar.com/{slug}",
"hentaifoundry": "https://www.hentai-foundry.com/user/{slug}",
"deviantart": "https://www.deviantart.com/{slug}",
"pixiv": "https://www.pixiv.net/users/{slug}",
}
def _profile_url(platform: str, artist_slug: str) -> str | None:
fmt = _PLATFORM_PROFILE_URL.get(platform)
return fmt.format(slug=artist_slug) if fmt else None
async def _find_or_create_source(
db: AsyncSession, *, artist_id: int, platform: str, url: str, dry_run: bool,
) -> int | None:
existing = (await db.execute(
select(Source.id).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
)).scalar_one_or_none()
if existing is not None:
return existing
if dry_run:
return None
s = Source(artist_id=artist_id, platform=platform, url=url, enabled=False)
db.add(s)
await db.flush()
return s.id
async def _find_or_create_post(
db: AsyncSession, *,
source_id: int, external_post_id: str,
title: str | None, description: str | None, post_url: str | None,
post_date_iso: str | None, attachment_count: int, dry_run: bool,
) -> int | None:
existing = (await db.execute(
select(Post.id).where(
Post.source_id == source_id,
Post.external_post_id == external_post_id,
)
)).scalar_one_or_none()
if existing is not None:
return existing
if dry_run:
return None
post_date = None
if post_date_iso:
post_date = datetime.fromisoformat(post_date_iso)
p = Post(
source_id=source_id,
external_post_id=external_post_id,
post_title=title,
description=description,
post_url=post_url,
post_date=post_date,
attachment_count=attachment_count,
raw_metadata={"migrated_from": "imagerepo"},
)
db.add(p)
await db.flush()
return p.id
async def _ensure_provenance(
db: AsyncSession, *,
image_id: int, post_id: int, source_id: int, dry_run: bool,
) -> bool:
"""Returns True if a new ImageProvenance row was inserted.
Also sets ImageRecord.primary_post_id to this post if the image
doesn't already have one — preserves any primary_post_id already
assigned at download time by the importer (don't clobber). This is
the linkage gallery_service.py uses to surface Post.post_date as
the image's effective date for sort/group/jump/neighbor nav.
"""
existing = (await db.execute(
select(ImageProvenance.id).where(
ImageProvenance.image_record_id == image_id,
ImageProvenance.post_id == post_id,
ImageProvenance.source_id == source_id,
)
)).scalar_one_or_none()
# Whether-or-not the provenance row already exists, ensure the
# image's primary_post_id is set so the gallery date-coalesce works.
# Idempotent: only writes when currently NULL.
if not dry_run:
await db.execute(
ImageRecord.__table__.update()
.where(ImageRecord.id == image_id)
.where(ImageRecord.primary_post_id.is_(None))
.values(primary_post_id=post_id)
)
if existing is not None:
return False
if dry_run:
return True
db.add(ImageProvenance(
image_record_id=image_id, post_id=post_id, source_id=source_id,
))
await db.flush()
return True
def _zero_counts() -> dict:
return {
"rows_processed": 0, "rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0, "conflicts": 0,
}
async def _ensure_artist_id(
db: AsyncSession, artist_name: str, dry_run: bool,
) -> int | None:
if not artist_name or not artist_name.strip():
return None
slug = slugify(artist_name)
existing = (await db.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if existing is not None:
return existing.id
if dry_run:
return None
a = Artist(name=artist_name, slug=slug, is_subscription=False)
db.add(a)
await db.flush()
return a.id
async def _resolve_tag_id(
db: AsyncSession, tag_name: str, tag_kind: str,
) -> int | None:
try:
kind = TagKind(tag_kind)
except ValueError:
kind = TagKind.general
row = (await db.execute(
select(Tag.id).where(Tag.name == tag_name, Tag.kind == kind)
)).scalar_one_or_none()
return row
async def _sha_to_image_id(db: AsyncSession, sha: str) -> int | None:
return (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one_or_none()
async def apply_async(
db: AsyncSession,
*,
images_root: Path | None = None,
dry_run: bool = False,
) -> dict:
"""Apply the manifest. Returns counts + an `unmatched` list of sha256s."""
mf_path = manifest_path(images_root)
if not mf_path.exists():
raise FileNotFoundError(f"no IR tag manifest at {mf_path}")
manifest = json.loads(mf_path.read_text())
counts = _zero_counts()
unmatched: list[dict] = []
# 1. Artist assignments.
for entry in manifest.get("image_artist_assignments", []):
counts["rows_processed"] += 1
img_id = await _sha_to_image_id(db, entry["sha256"])
if img_id is None:
unmatched.append({"kind": "artist", **entry})
counts["rows_skipped"] += 1
continue
aid = await _ensure_artist_id(db, entry["artist_name"], dry_run)
if aid is None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
img = await db.get(ImageRecord, img_id)
if img is not None and img.artist_id != aid:
img.artist_id = aid
counts["rows_inserted"] += 1
else:
counts["rows_skipped"] += 1
# 2. Tag associations.
for entry in manifest.get("image_tag_associations", []):
counts["rows_processed"] += 1
img_id = await _sha_to_image_id(db, entry["sha256"])
if img_id is None:
unmatched.append({"kind": "tag", **entry})
counts["rows_skipped"] += 1
continue
tag_id = await _resolve_tag_id(
db, entry["tag_name"], entry.get("tag_kind") or "general",
)
if tag_id is None:
counts["rows_skipped"] += 1
continue
# Skip if association already exists.
already = (await db.execute(
select(image_tag.c.image_record_id).where(
image_tag.c.image_record_id == img_id,
image_tag.c.tag_id == tag_id,
)
)).first()
if already is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
await db.execute(image_tag.insert().values(
image_record_id=img_id, tag_id=tag_id, source="manual",
))
counts["rows_inserted"] += 1
# 3. Series pages.
for entry in manifest.get("series_pages", []):
counts["rows_processed"] += 1
img_id = await _sha_to_image_id(db, entry["sha256"])
if img_id is None:
unmatched.append({"kind": "series", **entry})
counts["rows_skipped"] += 1
continue
series_tag_id = await _resolve_tag_id(db, entry["series_tag_name"], "series")
if series_tag_id is None:
counts["rows_skipped"] += 1
continue
existing = (await db.execute(
select(SeriesPage).where(SeriesPage.image_id == img_id)
)).scalar_one_or_none()
if existing is not None:
counts["rows_skipped"] += 1
continue
if dry_run:
counts["rows_inserted"] += 1
continue
db.add(SeriesPage(
series_tag_id=series_tag_id,
image_id=img_id,
page_number=entry["page_number"],
))
counts["rows_inserted"] += 1
# 4. Image posts (schema v2) → Source + Post + ImageProvenance.
# Restores IR PostMetadata as FC's downloader-track provenance,
# so the modal's ProvenancePanel surfaces title/description/
# source URL/publish date the same way it does for live
# gallery-dl downloads.
for entry in manifest.get("image_posts", []):
counts["rows_processed"] += 1
platform = entry.get("platform")
artist_name = entry.get("artist")
if not platform or not artist_name:
counts["rows_skipped"] += 1
continue
aid = await _ensure_artist_id(db, artist_name, dry_run)
if aid is None:
counts["rows_skipped"] += 1
continue
url = _profile_url(platform, slugify(artist_name))
if url is None:
counts["rows_skipped"] += 1
continue
source_id = await _find_or_create_source(
db, artist_id=aid, platform=platform, url=url, dry_run=dry_run,
)
if source_id is None:
counts["rows_skipped"] += 1
continue
post_id = await _find_or_create_post(
db, source_id=source_id,
external_post_id=entry.get("post_id") or "",
title=entry.get("title"),
description=entry.get("description"),
post_url=entry.get("source_url"),
post_date_iso=entry.get("published_at"),
attachment_count=entry.get("attachment_count") or 0,
dry_run=dry_run,
)
if post_id is None:
counts["rows_skipped"] += 1
continue
for sha in entry.get("image_sha256s", []):
img_id = await _sha_to_image_id(db, sha)
if img_id is None:
unmatched.append({
"kind": "post", "sha256": sha,
"post_id": entry.get("post_id"),
})
continue
inserted = await _ensure_provenance(
db, image_id=img_id, post_id=post_id,
source_id=source_id, dry_run=dry_run,
)
if inserted:
counts["rows_inserted"] += 1
else:
counts["rows_skipped"] += 1
if not dry_run:
await db.commit()
return {"counts": counts, "unmatched": unmatched}
-80
View File
@@ -1,80 +0,0 @@
"""Post-migration verification: row counts + sha256 sampling."""
from __future__ import annotations
import hashlib
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...models import Artist, Credential, ImageRecord, Source, Tag
async def verify_async(db: AsyncSession, *, expected: dict | None = None) -> dict:
"""Return per-check status dicts. `expected` is optional row-count
assertions; checks default to status='ok' when no expected provided."""
expected = expected or {}
results: dict[str, dict] = {}
checks = {
"artist_subscriptions": (
select(func.count(Artist.id)).where(Artist.is_subscription.is_(True))
),
"source_count": select(func.count(Source.id)),
"credential_count": select(func.count(Credential.id)),
"tag_count": select(func.count(Tag.id)),
"image_record_imported_or_downloaded": (
select(func.count(ImageRecord.id))
.where(ImageRecord.origin.in_(["imported_filesystem", "downloaded"]))
),
}
for name, stmt in checks.items():
actual = (await db.execute(stmt)).scalar_one()
exp = expected.get(name)
status = "ok" if exp is None or exp == actual else "mismatch"
results[name] = {"status": status, "actual": int(actual), "expected": exp}
return results
async def verify_sha256_sample(
db: AsyncSession, *, sample_size: int = 20,
) -> dict:
"""Sample N image_records; verify file exists + sha256 matches."""
rows = (await db.execute(
select(ImageRecord.id, ImageRecord.path, ImageRecord.sha256)
.order_by(func.random()).limit(sample_size)
)).all()
matched = 0
mismatched = 0
missing = 0
samples: list[dict] = []
for img_id, path, expected_sha in rows:
p = Path(path)
# Sync stdlib filesystem ops are intentional: this verify pass runs
# inside a Celery task under asyncio.run; no other awaitables compete
# for the loop. Same pattern as download_service.py.
if not p.exists(): # noqa: ASYNC240
missing += 1
samples.append({"id": img_id, "path": path, "result": "missing"})
continue
h = hashlib.sha256()
with p.open("rb") as f: # noqa: ASYNC230
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
if h.hexdigest() == expected_sha:
matched += 1
samples.append({"id": img_id, "result": "ok"})
else:
mismatched += 1
samples.append({
"id": img_id, "path": path, "result": "mismatch",
"expected_sha": expected_sha, "actual_sha": h.hexdigest(),
})
return {
"sample_size": len(rows),
"matched": matched,
"mismatched": mismatched,
"missing": missing,
"samples": samples,
}
+76 -10
View File
@@ -56,9 +56,17 @@ class PostFeedService:
artist_id: int | None = None,
platform: str | None = None,
limit: int = 24,
direction: str = "older",
) -> dict:
"""Paginate the feed from `cursor`. direction='older' walks back in
time (default, infinite-scroll down); direction='newer' walks forward
(scroll up in an anchored view). Items are always returned in feed
(descending) order; `next_cursor` points to the far edge in the
requested direction (null when exhausted)."""
if limit < 1 or limit > 100:
raise ValueError("limit must be between 1 and 100")
if direction not in ("older", "newer"):
raise ValueError("direction must be 'older' or 'newer'")
sort_key = _sort_key()
stmt = (
@@ -72,22 +80,37 @@ class PostFeedService:
stmt = stmt.where(Source.platform == platform)
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
stmt = stmt.where(
or_(
if direction == "older":
stmt = stmt.where(or_(
sort_key < cur_ts,
and_(sort_key == cur_ts, Post.id < cur_id),
)
)
))
else:
stmt = stmt.where(or_(
sort_key > cur_ts,
and_(sort_key == cur_ts, Post.id > cur_id),
))
stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1)
if direction == "older":
stmt = stmt.order_by(sort_key.desc(), Post.id.desc())
else:
stmt = stmt.order_by(sort_key.asc(), Post.id.asc())
stmt = stmt.limit(limit + 1)
rows = (await self.session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
if direction == "newer":
# Fetched ascending (closest-newer first); flip to feed order.
rows = list(reversed(rows))
next_cursor: str | None = None
if len(rows) > limit:
last_post, _, _ = rows[limit - 1]
last_key = last_post.post_date or last_post.downloaded_at
next_cursor = encode_cursor(last_key, last_post.id)
rows = rows[:limit]
if has_more and rows:
# Far edge in the travel direction: oldest row going older,
# newest row going newer (rows is descending for display).
edge_post = rows[-1][0] if direction == "older" else rows[0][0]
edge_key = edge_post.post_date or edge_post.downloaded_at
next_cursor = encode_cursor(edge_key, edge_post.id)
post_ids = [p.id for p, _, _ in rows]
thumbs_map = await self._thumbnails_for(post_ids)
@@ -99,6 +122,49 @@ class PostFeedService:
]
return {"items": items, "next_cursor": next_cursor}
async def around(
self,
*,
post_id: int,
artist_id: int | None = None,
platform: str | None = None,
limit: int = 12,
) -> dict | None:
"""A window centered on `post_id`: up to `limit` newer posts + the
post + up to `limit` older posts, in feed (descending) order, with a
cursor for each end. Returns None if the post doesn't exist."""
anchor = (await self.session.execute(
select(Post, Artist, Source)
.join(Source, Post.source_id == Source.id)
.join(Artist, Source.artist_id == Artist.id)
.where(Post.id == post_id)
)).one_or_none()
if anchor is None:
return None
anchor_post, anchor_artist, anchor_source = anchor
anchor_key = anchor_post.post_date or anchor_post.downloaded_at
anchor_cursor = encode_cursor(anchor_key, anchor_post.id)
older = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="older",
)
newer = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="newer",
)
thumbs_map = await self._thumbnails_for([anchor_post.id])
atts_map = await self._attachments_for([anchor_post.id])
anchor_item = self._to_dict(
anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map,
)
return {
"items": newer["items"] + [anchor_item] + older["items"],
"cursor_older": older["next_cursor"],
"cursor_newer": newer["next_cursor"],
"anchor_id": anchor_post.id,
}
async def get_post(self, post_id: int) -> dict | None:
row = (await self.session.execute(
select(Post, Artist, Source)
+74 -1
View File
@@ -10,7 +10,14 @@ from PIL import Image
from sqlalchemy import and_, delete, or_, select, update
from ..celery_app import celery
from ..models import DownloadEvent, ImageRecord, ImportSettings, ImportTask, TaskRun
from ..models import (
DownloadEvent,
ImageRecord,
ImportSettings,
ImportTask,
Source,
TaskRun,
)
from ..utils.phash import compute_phash
from ._sync_engine import sync_session_factory as _sync_session_factory
@@ -34,6 +41,13 @@ ARCHIVE_STUCK_THRESHOLD_MINUTES = 40
# flip to terminal 'failed' and never enter this loop.
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.
DOWNLOAD_STALL_THRESHOLD_MINUTES = 30
OLD_TASK_DAYS = 7
PHASH_PAGE = 500
VERIFY_PAGE = 200
@@ -448,6 +462,65 @@ def verify_integrity() -> int:
return total
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_download_events")
def recover_stalled_download_events() -> int:
"""Recover DownloadEvent rows stuck pending/running past the worker hard kill.
The scan tick (scheduler_service.select_due_sources →
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
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.
This sweep flips matching events to 'error', stamps each affected Source's
last_checked_at + last_error and bumps consecutive_failures (once per
source, not per event — backoff is exponential on that count so an N-event
bump would inflate the next interval by 2^N for no reason). The source
becomes re-queueable on the next tick and the health dot goes amber.
Operator-confirmed 2026-05-29 (43-row strand pile in production).
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
cutoff = now - timedelta(minutes=DOWNLOAD_STALL_THRESHOLD_MINUTES)
msg = "stranded by recovery sweep (no terminal status after time_limit)"
with SessionLocal() as session:
# UPDATE...RETURNING the source_ids in one round trip — keeps us off
# the psycopg 65535-param ceiling that SELECT-then-UPDATE-WHERE-IN
# would hit on a large strand pile.
result = session.execute(
update(DownloadEvent)
.where(DownloadEvent.status.in_(["pending", "running"]))
.where(DownloadEvent.started_at < cutoff)
.values(status="error", finished_at=now, error=msg)
.returning(DownloadEvent.source_id)
)
returned = result.all()
if not returned:
session.commit()
return 0
events_recovered = len(returned)
source_ids = list({row.source_id for row in returned})
session.execute(
update(Source)
.where(Source.id.in_(source_ids))
.values(
consecutive_failures=Source.consecutive_failures + 1,
last_error=msg,
last_checked_at=now,
)
)
session.commit()
log.info(
"recover_stalled_download_events: recovered %d events across %d sources",
events_recovered, len(source_ids),
)
return events_recovered
@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
-169
View File
@@ -1,169 +0,0 @@
"""FC-5 run_migration Celery task.
Dispatches to the right migrator based on `kind`. Updates MigrationRun
row's status/counts/finished_at as it runs. Failures set status='error'
with the error message preserved.
kinds: gs_ingest, ir_ingest, tag_apply, ml_queue, verify, cleanup
(backup + rollback retired 2026-05-24 → see /api/system/backup/*)
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..celery_app import celery
from ..models import MigrationRun
from ..services.credential_crypto import CredentialCrypto
from ..services.migrators import cleanup as cleanup_mod
from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify
from ._async_session import async_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")
_KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
async def _update_run(
db: AsyncSession, run_id: int, *,
status: str | None = None, counts: dict | None = None,
error: str | None = None, finished_at: datetime | None = None,
metadata_patch: dict | None = None,
) -> None:
run = (await db.execute(
select(MigrationRun).where(MigrationRun.id == run_id)
)).scalar_one()
if status is not None:
run.status = status
if counts is not None:
run.counts = counts
if error is not None:
run.error = error
if finished_at is not None:
run.finished_at = finished_at
if metadata_patch:
run.metadata_ = {**(run.metadata_ or {}), **metadata_patch}
await db.commit()
async def _run_async(run_id: int, kind: str, params: dict) -> dict:
factory, engine = async_session_factory()
try:
async with factory() as db:
await _update_run(db, run_id, status="running")
try:
if kind in ("backup", "rollback"):
raise ValueError(
f"kind {kind!r} retired in FC-3h; "
"use /api/system/backup/* instead"
)
elif kind == "gs_ingest":
fc_crypto = CredentialCrypto(_KEY_PATH)
counts = await gs_ingest.migrate_async(
db, data=params["data"],
fc_crypto=fc_crypto,
dry_run=params.get("dry_run", False),
)
await _update_run(
db, run_id, status="ok", counts=counts,
finished_at=datetime.now(UTC),
)
return counts
elif kind == "ir_ingest":
counts = await ir_ingest.migrate_async(
db, data=params["data"],
images_root=IMAGES_ROOT,
dry_run=params.get("dry_run", False),
)
await _update_run(
db, run_id, status="ok", counts=counts,
finished_at=datetime.now(UTC),
)
return counts
elif kind == "tag_apply":
result = await tag_apply.apply_async(
db, images_root=IMAGES_ROOT,
dry_run=params.get("dry_run", False),
)
await _update_run(
db, run_id, status="ok",
counts=result["counts"],
finished_at=datetime.now(UTC),
metadata_patch={"unmatched": result["unmatched"]},
)
return result
elif kind == "ml_queue":
count = await ml_queue.queue_all_unprocessed_async(db)
await _update_run(
db, run_id, status="ok",
counts={"rows_processed": count, "rows_inserted": 0,
"rows_skipped": 0, "files_copied": 0,
"bytes_copied": 0, "conflicts": 0},
finished_at=datetime.now(UTC),
)
return {"queued": count}
elif kind == "verify":
checks = await verify.verify_async(db, expected=params.get("expected"))
sample = await verify.verify_sha256_sample(
db, sample_size=params.get("sample_size", 20),
)
await _update_run(
db, run_id, status="ok",
counts={"rows_processed": sample["sample_size"],
"rows_inserted": 0, "rows_skipped": 0,
"files_copied": 0, "bytes_copied": 0,
"conflicts": sample["mismatched"] + sample["missing"]},
finished_at=datetime.now(UTC),
metadata_patch={"checks": checks, "sample": sample},
)
return {"checks": checks, "sample": sample}
elif kind == "cleanup":
slug = params.get("slug")
if not slug:
raise ValueError("cleanup requires params.slug")
result = await cleanup_mod.cleanup_artist_async(
db, slug=slug, images_root=IMAGES_ROOT,
dry_run=params.get("dry_run", False),
source_path_prefix=params.get("source_path_prefix"),
)
await _update_run(
db, run_id, status="ok",
counts=result["counts"],
finished_at=datetime.now(UTC),
metadata_patch={
"artist": result["artist"],
"summary": result["summary"],
},
)
return result
else:
raise ValueError(f"unknown kind: {kind}")
except Exception as exc:
log.exception("migration kind=%s failed", kind)
await _update_run(
db, run_id, status="error", error=str(exc),
finished_at=datetime.now(UTC),
)
raise
finally:
await engine.dispose()
@celery.task(name="backend.app.tasks.migration.run_migration", bind=True, acks_late=True)
def run_migration(self, run_id: int, kind: str, params: dict) -> dict:
"""FC-5: dispatch a migration kind. Updates MigrationRun row as it goes."""
return asyncio.run(_run_async(run_id, kind, params))
+3 -1
View File
@@ -26,6 +26,8 @@
"vue-tsc": "^2.0.0",
"vite-plugin-vuetify": "^2.0.0",
"sass": "^1.71.0",
"vitest": "^2.1.0"
"vitest": "^2.1.0",
"@vue/test-utils": "^2.4.0",
"happy-dom": "^15.0.0"
}
}
@@ -0,0 +1,146 @@
<template>
<v-menu location="bottom start" :close-on-content-click="false">
<template #activator="{ props }">
<button
v-bind="props" type="button"
class="fc-pulse" :class="`fc-pulse--${schedHealth}`"
:aria-label="`pipeline: ${running} running, ${queued} queued, ${failing} failing`"
>
<span class="fc-pulse__dot" />
<span v-if="running" class="fc-pulse__stat">
<v-icon size="13">mdi-progress-download</v-icon>{{ running }}
</span>
<span v-if="queued" class="fc-pulse__stat">
<v-icon size="13">mdi-tray-full</v-icon>{{ queued }}
</span>
<span v-if="failing" class="fc-pulse__stat fc-pulse__stat--err">
<v-icon size="13">mdi-alert-circle</v-icon>{{ failing }}
</span>
<span v-if="!running && !queued && !failing" class="fc-pulse__idle">idle</span>
</button>
</template>
<v-card min-width="300" class="fc-pulse__panel pa-3">
<div class="fc-pulse__row">
<span class="fc-pulse__rowdot" :class="`fc-pulse--${schedHealth}`" />
<strong>Scheduler</strong>
<v-spacer />
<span class="fc-pulse__muted">{{ schedLabel }}</span>
</div>
<div class="fc-pulse__sec">
<div class="fc-pulse__sechead">Queues</div>
<div v-if="busyQueues.length === 0" class="fc-pulse__muted">All idle</div>
<div v-for="q in busyQueues" :key="q.name" class="fc-pulse__qrow">
<span>{{ q.name }}</span><v-spacer /><strong>{{ q.depth }}</strong>
</div>
</div>
<div class="fc-pulse__sec fc-pulse__grid">
<div><span class="fc-pulse__muted">Running</span><div class="fc-pulse__big">{{ running }}</div></div>
<div><span class="fc-pulse__muted">Queued</span><div class="fc-pulse__big">{{ queued }}</div></div>
<div>
<span class="fc-pulse__muted">Failures 24h</span>
<div class="fc-pulse__big" :class="{ 'fc-pulse__big--err': failing }">{{ failing }}</div>
</div>
</div>
<RouterLink
class="fc-pulse__link"
:to="{ path: '/subscriptions', query: { tab: 'downloads' } }"
>Open downloads </RouterLink>
</v-card>
</v-menu>
</template>
<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { useSystemActivityStore } from '../stores/systemActivity.js'
import { formatRelative } from '../utils/date.js'
const store = useSystemActivityStore()
// Beat ticks every 60s; flag the scheduler stale past ~3 min.
const STALE_MS = 180_000
const summary = computed(() => store.summary)
const running = computed(() => summary.value?.running ?? 0)
const queued = computed(() => summary.value?.queued_total ?? 0)
const failing = computed(() => summary.value?.failing ?? 0)
const schedHealth = computed(() => {
const t = summary.value?.scheduler?.last_tick_at
if (!t) return 'unknown'
return (Date.now() - new Date(t).getTime()) <= STALE_MS ? 'ok' : 'stale'
})
const schedLabel = computed(() => {
const s = summary.value?.scheduler
if (!s) return '—'
const ran = formatRelative(s.last_tick_at, { nullText: 'never' })
if (s.due_now > 0) return `ran ${ran} · ${s.due_now} due`
return `ran ${ran}`
})
const busyQueues = computed(() => {
const q = summary.value?.queues || {}
return Object.entries(q)
.filter(([, depth]) => typeof depth === 'number' && depth > 0)
.map(([name, depth]) => ({ name, depth }))
})
const POLL_MS = 8000
let timer = null
onMounted(() => {
store.loadSummary()
timer = setInterval(() => {
if (!document.hidden) store.loadSummary()
}, POLL_MS)
})
onUnmounted(() => { if (timer) clearInterval(timer) })
</script>
<style scoped>
.fc-pulse {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 8px; border-radius: 999px;
background: rgb(var(--v-theme-on-surface) / 0.08);
color: rgb(var(--v-theme-on-surface));
font-size: 0.78rem; font-variant-numeric: tabular-nums;
cursor: pointer; border: 0;
}
.fc-pulse:hover { background: rgb(var(--v-theme-on-surface) / 0.16); }
.fc-pulse__dot {
width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
background: rgb(var(--v-theme-on-surface-variant));
}
.fc-pulse--ok .fc-pulse__dot,
.fc-pulse--ok.fc-pulse__rowdot { background: rgb(var(--v-theme-success)); }
.fc-pulse--stale .fc-pulse__dot,
.fc-pulse--stale.fc-pulse__rowdot { background: rgb(var(--v-theme-error)); }
.fc-pulse__stat { display: inline-flex; align-items: center; gap: 2px; }
.fc-pulse__stat--err { color: rgb(var(--v-theme-error)); }
.fc-pulse__idle {
text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.68rem;
}
.fc-pulse__panel { background: rgb(var(--v-theme-surface)); }
.fc-pulse__row { display: flex; align-items: center; gap: 8px; }
.fc-pulse__rowdot {
width: 9px; height: 9px; border-radius: 50%;
background: rgb(var(--v-theme-on-surface-variant));
}
.fc-pulse__muted { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem; }
.fc-pulse__sec { margin-top: 12px; }
.fc-pulse__sechead {
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em;
color: rgb(var(--v-theme-on-surface-variant)); margin-bottom: 4px;
}
.fc-pulse__qrow { display: flex; align-items: center; font-size: 0.85rem; padding: 2px 0; }
.fc-pulse__grid { display: flex; gap: 16px; }
.fc-pulse__big { font-size: 1.3rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.fc-pulse__big--err { color: rgb(var(--v-theme-error)); }
.fc-pulse__link {
display: inline-block; margin-top: 14px;
color: rgb(var(--v-theme-accent)); text-decoration: none; font-size: 0.85rem;
}
</style>
+2
View File
@@ -8,6 +8,7 @@
<span class="fc-health" :title="health.label">
<v-icon size="x-small" :color="health.color">{{ health.icon }}</v-icon>
</span>
<PipelineStatusChip />
</div>
<nav class="fc-links">
@@ -29,6 +30,7 @@
import { computed, onMounted } from 'vue'
import router, { FRONT_DOOR } from '../router.js'
import { useSystemStore } from '../stores/system.js'
import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore()
onMounted(() => system.refreshHealth())
@@ -35,6 +35,7 @@
<script setup>
import { computed } from 'vue'
import { formatLocalDate } from '../../utils/date.js'
const props = defineProps({
name: { type: String, required: true },
@@ -52,7 +53,7 @@ const stats = computed(() => {
parts.push(`${props.imageCount} image${props.imageCount === 1 ? '' : 's'}`)
}
if (props.lastAdded) {
parts.push(`last added ${props.lastAdded.slice(0, 10)}`)
parts.push(`last added ${formatLocalDate(props.lastAdded)}`)
}
return parts.join(' · ')
})
@@ -28,10 +28,11 @@
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostsStore } from '../../stores/posts.js'
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
import PostCard from '../posts/PostCard.vue'
const props = defineProps({
@@ -42,7 +43,6 @@ defineEmits(['switch-tab'])
const store = usePostsStore()
const sentinel = ref(null)
let observer = null
async function reload () {
await store.loadInitial({ artist_id: props.artistId, platform: null })
@@ -50,19 +50,9 @@ async function reload () {
watch(() => props.artistId, reload)
onMounted(async () => {
await reload()
observer = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
store.loadMore()
}
}, { rootMargin: '400px 0px' })
if (sentinel.value) observer.observe(sentinel.value)
})
useInfiniteScroll(sentinel, () => store.loadMore(), { rootMargin: '400px 0px' })
onUnmounted(() => {
if (observer) observer.disconnect()
})
onMounted(reload)
</script>
<style scoped>
@@ -32,6 +32,8 @@
</template>
<script setup>
import { formatLocalDate } from '../../utils/date.js'
defineProps({
platform: { type: Object, required: true },
credential: { type: Object, default: null },
@@ -39,8 +41,7 @@ defineProps({
defineEmits(['replace', 'remove'])
function fmtDate(iso) {
if (!iso) return '—'
return iso.slice(0, 10)
return iso ? formatLocalDate(iso) : '—'
}
</script>
@@ -30,8 +30,9 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed } from 'vue'
import { usePolyMasonry } from '../../composables/usePolyMasonry.js'
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -78,20 +79,9 @@ function aspectStyle(item) {
return { aspectRatio: `${w} / ${h}` }
}
let observer = null
function attachObserver() {
if (observer) observer.disconnect()
if (!sentinelEl.value) return
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && props.hasMore && !props.loading) {
emit('load-more')
}
}, { rootMargin: '600px' })
observer.observe(sentinelEl.value)
}
watch(sentinelEl, attachObserver)
onMounted(attachObserver)
onUnmounted(() => observer && observer.disconnect())
useInfiniteScroll(sentinelEl, () => {
if (props.hasMore && !props.loading) emit('load-more')
})
</script>
<style scoped>
@@ -100,32 +90,54 @@ onUnmounted(() => observer && observer.disconnect())
.fc-masonry__item {
display: block; padding: 0; border: 0; background: none;
cursor: pointer; width: 100%;
overflow: hidden; border-radius: 4px;
}
.fc-masonry__item img {
width: 100%; height: auto; display: block; border-radius: 4px;
width: 100%; height: auto; display: block;
background: rgb(var(--v-theme-surface-light));
/* IR-parity hover: zoom + brighten the thumbnail (style.css ~1772). */
transition: transform 0.3s ease, filter 0.3s ease;
}
.fc-masonry__item:hover img {
transform: scale(1.03);
filter: brightness(1.1);
}
.fc-masonry__sentinel {
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
}
.fc-masonry__end { text-align: center; padding: 32px 0; }
/* IR-parity stagger fade-in for showcase entry / shuffle. 60ms between
items, 250ms each — matches IR's `itemFadeIn` keyframe (style.css
~line 1834). Honors prefers-reduced-motion. */
/* 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). */
@keyframes fc-masonry-item-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
0% {
opacity: 0;
transform: perspective(1000px) rotateX(-28deg) translateY(26px) scale(0.95);
}
55% { opacity: 1; }
100% {
opacity: 1;
transform: perspective(1000px) rotateX(0deg) translateY(0) scale(1);
}
}
.fc-masonry__item--anim {
animation: fc-masonry-item-in 0.25s ease forwards;
animation-delay: calc(var(--stagger-index, 0) * 60ms);
opacity: 0;
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 {
animation: none;
opacity: 1;
transform: none;
}
.fc-masonry__item img { transition: none; }
.fc-masonry__item:hover img { transform: none; }
}
</style>
@@ -7,7 +7,7 @@
</v-card-title>
<v-card-text>
<p class="text-caption" style="opacity: 0.75">
{{ event.started_at }} {{ event.finished_at || '(running)' }}
{{ formatDateTime(event.started_at) }} {{ event.finished_at ? formatDateTime(event.finished_at) : '(running)' }}
({{ fmtDuration(event.summary?.duration_seconds) }})
</p>
@@ -105,6 +105,7 @@ import { toast } from '../../utils/toast.js'
import { computed } from 'vue'
import { copyText } from '../../utils/clipboard.js'
import { formatDateTime } from '../../utils/date.js'
const props = defineProps({ event: { type: Object, default: null } })
const emit = defineEmits(['close'])
@@ -93,6 +93,7 @@ import { RouterLink } from 'vue-router'
import PlatformChip from '../subscriptions/PlatformChip.vue'
import { useSourcesStore } from '../../stores/sources.js'
import { downloadStatusColor, downloadStatusIcon, downloadStatusLabel } from '../../utils/downloadStatus.js'
import { formatDateTime } from '../../utils/date.js'
const props = defineProps({ event: { type: Object, required: true } })
defineEmits(['open'])
@@ -105,9 +106,7 @@ const statusIcon = computed(() => downloadStatusIcon(props.event.status))
const statusLabel = computed(() => downloadStatusLabel(props.event.status))
function fmtTime(iso) {
if (!iso) return '—'
// 2026-05-27 23:36 — second granularity is in the row's title attr
return iso.slice(0, 16).replace('T', ' ')
return iso ? formatDateTime(iso) : '—'
}
function fmtDuration(sec) {
if (sec == null) return '—'
@@ -32,8 +32,9 @@
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { ref } from 'vue'
import { useGalleryStore } from '../../stores/gallery.js'
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
import GalleryItem from './GalleryItem.vue'
defineEmits(['open'])
@@ -41,22 +42,9 @@ defineEmits(['open'])
const store = useGalleryStore()
const sentinelEl = ref(null)
let observer = null
function attachObserver() {
if (observer) observer.disconnect()
if (!sentinelEl.value) return
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && store.hasMore && !store.loading) {
store.loadMore()
}
}, { rootMargin: '600px' })
observer.observe(sentinelEl.value)
}
watch(sentinelEl, attachObserver)
onMounted(() => attachObserver())
onUnmounted(() => observer && observer.disconnect())
useInfiniteScroll(sentinelEl, () => {
if (store.hasMore && !store.loading) store.loadMore()
})
function dateHeaderId(group) { return `fc-month-${group.year}-${group.month}` }
@@ -19,7 +19,7 @@
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
</div>
<div class="fc-prov__post">
{{ e.post.title || `Post ${e.post.external_post_id}` }}
{{ postTitle(e) }}
</div>
<div class="fc-prov__meta">
<RouterLink :to="`/artist/${e.artist.slug}`">
@@ -30,12 +30,8 @@
</span>
</div>
<div class="fc-prov__actions">
<a
v-if="e.post.url" :href="e.post.url"
target="_blank" rel="noopener noreferrer"
> View original post</a>
<a href="#" @click.prevent="openPost(e.post.id)">
View images from this post
View post
</a>
<a
v-if="e.post.description_html"
@@ -83,6 +79,7 @@ import { useRouter } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { useProvenanceStore } from '../../stores/provenance.js'
import { formatPostDate } from '../../utils/date.js'
import { toPlainText } from '../../utils/htmlSanitize.js'
const modal = useModalStore()
const prov = useProvenanceStore()
@@ -134,9 +131,16 @@ const show = computed(() => {
const attachments = computed(() => state.value?.attachments || [])
function postDate(e) { return formatPostDate(e.post.date) }
function postTitle(e) {
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render
// as plain text (the CSS makes it bold).
return toPlainText(e.post.title) || `Post ${e.post.external_post_id}`
}
function openPost(postId) {
router.push({ path: '/gallery', query: { post_id: postId } })
// Land on the post in the posts feed (in context), not the gallery
// image grid. Operator-flagged 2026-05-28.
router.push({ path: '/posts', query: { post_id: postId } })
modal.close()
}
</script>
@@ -159,7 +163,7 @@ function openPost(postId) {
text-transform: lowercase;
}
.fc-prov__post {
font-weight: 600; margin: 4px 0;
font-weight: 700; margin: 4px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-prov__meta {
+11 -7
View File
@@ -56,8 +56,8 @@
</div>
<div class="fc-post-card__text">
<h3 v-if="post.post_title" class="fc-post-card__title">
{{ post.post_title }}
<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 }}
@@ -80,8 +80,8 @@
<!-- 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="post.post_title" class="fc-post-card__title-full">
{{ post.post_title }}
<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 }}
@@ -125,7 +125,7 @@ import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostsStore } from '../../stores/posts.js'
import { sanitizeHtml } from '../../utils/htmlSanitize.js'
import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostImageGrid from './PostImageGrid.vue'
@@ -149,6 +149,10 @@ 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 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))
@@ -312,7 +316,7 @@ function formatBytes (n) {
.fc-post-card__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px; font-weight: 500;
font-size: 18px; font-weight: 700;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
display: -webkit-box;
@@ -369,7 +373,7 @@ function formatBytes (n) {
.fc-post-card__title-full {
font-family: 'Fraunces', Georgia, serif;
font-size: 22px;
font-weight: 500;
font-weight: 700;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
@@ -11,6 +11,7 @@
<v-icon start>mdi-vector-triangle</v-icon> Recompute centroids
</v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
<QueueStatusBar queue="ml" queue-label="ML" />
</v-card-text>
</v-card>
</template>
@@ -19,6 +20,7 @@
import { toast } from '../../utils/toast.js'
import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js'
import QueueStatusBar from './QueueStatusBar.vue'
const store = useMLStore()
const busy = ref(false)
const done = ref(false)
@@ -1,245 +0,0 @@
<template>
<v-card>
<v-card-title>Legacy migration</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">
Migrate existing ImageRepo and GallerySubscriber data into FabledCurator.
Each app produces a JSON export file via a small script in its own repo
(<code>scripts/export_for_fabledcurator.py</code>); upload both files
here and run the steps in order.
</p>
<ol class="text-body-2 mb-3 fc-migrate__hints">
<li>Backup FC (creates a pre-migration snapshot).</li>
<li>Upload + ingest GS export.</li>
<li>Upload + ingest IR export.</li>
<li>Switch to <strong>Import tab</strong> trigger the existing FC filesystem scan over the bind-mounted IR images dir.</li>
<li>Return here apply IR tag associations (joins by sha256).</li>
<li>Queue ML re-processing.</li>
<li>Verify.</li>
</ol>
<div class="fc-migrate__uploads">
<v-file-input
v-model="gsFile"
label="GallerySubscriber export (gs-export.json)"
accept="application/json,.json"
density="compact"
show-size
prepend-icon="mdi-upload"
/>
<v-file-input
v-model="irFile"
label="ImageRepo export (ir-export.json)"
accept="application/json,.json"
density="compact"
show-size
prepend-icon="mdi-upload"
/>
</div>
<div class="fc-migrate__steps mt-3">
<v-btn
color="accent" variant="tonal" prepend-icon="mdi-content-save"
:disabled="store.isRunning"
@click="onBackup"
>1. Backup FC</v-btn>
<v-btn
color="primary" variant="tonal" prepend-icon="mdi-database-arrow-right"
:disabled="store.isRunning || !gsFile"
@click="onIngestGs"
>2. Ingest GS</v-btn>
<v-btn
color="primary" variant="tonal" prepend-icon="mdi-database-arrow-right"
:disabled="store.isRunning || !irFile"
@click="onIngestIr"
>3. Ingest IR</v-btn>
<v-btn
color="primary" variant="tonal" prepend-icon="mdi-tag-multiple"
:disabled="store.isRunning"
@click="onTagApply"
>5. Apply IR tags</v-btn>
<v-btn
variant="outlined" prepend-icon="mdi-brain"
:disabled="store.isRunning"
@click="onMlQueue"
>6. Queue ML</v-btn>
<v-btn
variant="outlined" prepend-icon="mdi-check-circle"
:disabled="store.isRunning"
@click="onVerify"
>7. Verify</v-btn>
</div>
<v-btn
color="error" variant="outlined" prepend-icon="mdi-restore" class="mt-3"
:disabled="store.isRunning"
@click="onRollback"
>Rollback to pre-migration backup</v-btn>
<v-alert v-if="store.error" type="error" variant="tonal" class="mt-3" closable>
{{ String(store.error) }}
</v-alert>
<v-card v-if="store.activeRun" variant="outlined" class="mt-4">
<v-card-text>
<div class="text-subtitle-2">
#{{ store.activeRun.id }} {{ store.activeRun.kind }}
({{ store.activeRun.status }})
</div>
<v-progress-linear
v-if="store.isRunning" indeterminate color="accent" class="my-2"
/>
<div class="text-caption">
Rows: {{ store.activeRun.counts.rows_processed || 0 }} processed,
{{ store.activeRun.counts.rows_inserted || 0 }} inserted,
{{ store.activeRun.counts.rows_skipped || 0 }} skipped.
Conflicts: {{ store.activeRun.counts.conflicts || 0 }}.
</div>
<div v-if="store.activeRun.error" class="text-error mt-1">
{{ store.activeRun.error }}
</div>
</v-card-text>
</v-card>
<div v-if="store.recentRuns.length" class="mt-4">
<div class="text-subtitle-2 mb-2">Recent runs</div>
<ul class="fc-migrate__history">
<li v-for="r in store.recentRuns" :key="r.id">
#{{ r.id }} {{ r.kind }}
<v-chip
size="x-small"
:color="r.status === 'ok' ? 'success' : (r.status === 'error' ? 'error' : undefined)"
>{{ r.status }}</v-chip>
<span class="text-caption fc-migrate__when">{{ r.started_at }}</span>
</li>
</ul>
</div>
</v-card-text>
<v-dialog v-model="confirmOpen" max-width="520">
<v-card>
<v-card-title>Confirm {{ pendingLabel }}</v-card-title>
<v-card-text>
This will modify FC's database. Make sure you've taken a backup first.
Continue?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
<v-btn color="primary" variant="tonal" @click="onConfirm">Continue</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useMigrationStore } from '../../stores/migration.js'
const store = useMigrationStore()
const gsFile = ref(null)
const irFile = ref(null)
const confirmOpen = ref(false)
const pendingLabel = ref('')
const pendingAction = ref(null)
onMounted(async () => {
await store.loadRecent()
})
function _pick(model) {
if (!model) return null
// v-file-input v-model can be a single File or a [File] depending on Vuetify version.
return Array.isArray(model) ? model[0] : model
}
function requestConfirm(label, action) {
pendingLabel.value = label
pendingAction.value = action
confirmOpen.value = true
}
async function onConfirm() {
confirmOpen.value = false
const action = pendingAction.value
pendingAction.value = null
if (action) await action()
}
async function onBackup() {
await store.trigger('backup', { tag: 'pre_migration' })
}
async function onIngestGs() {
const file = _pick(gsFile.value)
if (!file) return
requestConfirm('Ingest GS', async () => {
await store.trigger('gs_ingest', { dry_run: false }, file)
})
}
async function onIngestIr() {
const file = _pick(irFile.value)
if (!file) return
requestConfirm('Ingest IR', async () => {
await store.trigger('ir_ingest', { dry_run: false }, file)
})
}
async function onTagApply() {
requestConfirm('Apply IR tag associations', async () => {
await store.trigger('tag_apply', { dry_run: false })
})
}
async function onMlQueue() {
await store.trigger('ml_queue', {})
}
async function onVerify() {
await store.trigger('verify', {})
}
async function onRollback() {
requestConfirm('Rollback to pre-migration backup', async () => {
await store.trigger('rollback', {})
})
}
</script>
<style scoped>
.fc-migrate__hints {
padding-left: 1.2rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-migrate__uploads {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.fc-migrate__steps {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fc-migrate__history {
list-style: none;
padding: 0;
margin: 0;
}
.fc-migrate__history li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant), 0.15);
}
.fc-migrate__when {
color: rgb(var(--v-theme-on-surface-variant));
margin-left: auto;
}
</style>
@@ -10,6 +10,7 @@
<v-icon start>mdi-refresh</v-icon> Run backfill now
</v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
<QueueStatusBar queue="ml" queue-label="ML" />
</v-card-text>
</v-card>
</template>
@@ -18,6 +19,7 @@
import { toast } from '../../utils/toast.js'
import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js'
import QueueStatusBar from './QueueStatusBar.vue'
const store = useMLStore()
const busy = ref(false)
const done = ref(false)
@@ -17,12 +17,14 @@
<BackupCard class="mt-6" />
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) it
operates on the existing library which fits the Cleanup-tab
theme, and clusters with the other audit cards. -->
<LegacyMigrationCard class="mt-6" />
theme, and clusters with the other audit cards. LegacyMigrationCard
removed once the one-and-done GS/IR migration cutover completed. -->
</div>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import MLBackfillCard from './MLBackfillCard.vue'
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
@@ -30,7 +32,22 @@ import MLThresholdSliders from './MLThresholdSliders.vue'
import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue'
import BackupCard from './BackupCard.vue'
import LegacyMigrationCard from './LegacyMigrationCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
// Poll queue depths so each card's QueueStatusBar shows live pending
// counts — the operator can see a backfill is already queued/running
// before re-triggering it.
const activity = useSystemActivityStore()
let qTimer = null
onMounted(() => {
activity.loadQueues()
qTimer = setInterval(() => {
if (!document.hidden) activity.loadQueues()
}, 4000)
})
onUnmounted(() => {
if (qTimer) { clearInterval(qTimer); qTimer = null }
})
</script>
<style scoped>
@@ -0,0 +1,69 @@
<template>
<div class="fc-qbar" :class="`fc-qbar--${state}`" :title="hint">
<v-icon size="14" class="fc-qbar__icon">{{ icon }}</v-icon>
<span>{{ label }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
const props = defineProps({
// Celery queue these buttons feed (see celery_app.task_routes).
queue: { type: String, required: true },
queueLabel: { type: String, default: '' },
})
const store = useSystemActivityStore()
// Redis LLEN per queue from /api/system/activity/queues; null when the
// broker read failed or hasn't loaded yet.
const depth = computed(() => {
const q = store.queues?.queues
return q ? (q[props.queue] ?? null) : null
})
const state = computed(() => {
if (depth.value == null) return 'unknown'
return depth.value > 0 ? 'busy' : 'idle'
})
const icon = computed(() => ({
busy: 'mdi-progress-clock',
idle: 'mdi-check-circle-outline',
unknown: 'mdi-help-circle-outline',
}[state.value]))
const qname = computed(() => props.queueLabel || props.queue)
const label = computed(() => {
if (depth.value == null) return `${qname.value} queue · status unavailable`
if (depth.value === 0) return `${qname.value} queue idle — nothing pending`
return `${depth.value} pending in the ${qname.value} queue`
})
const hint = computed(() =>
state.value === 'busy'
? `The ${qname.value} queue already has ${depth.value} task(s) waiting — running again now just adds to that backlog.`
: '',
)
</script>
<style scoped>
.fc-qbar {
display: flex; align-items: center; gap: 6px;
margin-top: 12px;
padding: 5px 10px;
border-radius: 6px;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.fc-qbar__icon { flex: 0 0 auto; }
.fc-qbar--idle,
.fc-qbar--unknown {
color: rgb(var(--v-theme-on-surface-variant));
background: rgb(var(--v-theme-on-surface) / 0.04);
}
.fc-qbar--busy {
color: rgb(var(--v-theme-warning));
background: rgb(var(--v-theme-warning) / 0.12);
font-weight: 600;
}
</style>
@@ -11,6 +11,7 @@
<v-icon start>mdi-image-refresh</v-icon> Run backfill now
</v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
<QueueStatusBar queue="thumbnail" queue-label="Thumbnail" />
</v-card-text>
</v-card>
</template>
@@ -19,6 +20,7 @@
import { toast } from '../../utils/toast.js'
import { ref } from 'vue'
import { useThumbnailsStore } from '../../stores/thumbnails.js'
import QueueStatusBar from './QueueStatusBar.vue'
const store = useThumbnailsStore()
const busy = ref(false)
const done = ref(false)
@@ -0,0 +1,133 @@
<template>
<v-card v-if="active.length" variant="tonal" color="info" class="fc-active mb-4">
<div class="fc-active__head">
<span class="fc-active__pulse" />
<span class="fc-active__title">
<template v-if="running.length">
{{ running.length }} downloading
</template>
<template v-if="running.length && queued.length"> · </template>
<template v-if="queued.length">
{{ queued.length }} queued
</template>
</span>
<v-spacer />
<span class="fc-active__live">live</span>
</div>
<div class="fc-active__body">
<div
v-for="e in running" :key="e.id"
class="fc-active__row fc-active__row--running"
>
<span class="fc-active__dot" />
<PlatformChip :platform="e.platform" size="x-small" />
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
<v-spacer />
<span class="fc-active__timer" :title="`started ${e.started_at}`">
{{ elapsed(e.started_at) }}
</span>
</div>
<div
v-for="e in queued" :key="e.id"
class="fc-active__row fc-active__row--queued"
>
<v-icon size="x-small" class="fc-active__queue-icon">mdi-clock-outline</v-icon>
<PlatformChip :platform="e.platform" size="x-small" />
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
<v-spacer />
<span class="fc-active__queued">queued</span>
</div>
</div>
</v-card>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useDownloadsStore } from '../../stores/downloads.js'
import PlatformChip from './PlatformChip.vue'
const store = useDownloadsStore()
const running = computed(() => store.activeEvents.filter((e) => e.status === 'running'))
const queued = computed(() => store.activeEvents.filter((e) => e.status === 'pending'))
const active = computed(() => [...running.value, ...queued.value])
// Ticking clock so the running timers update every second. started_at is
// tz-aware UTC; Date parses the instant, so (now - start) is correct in
// any viewer timezone.
const now = ref(Date.now())
let timer = null
onMounted(() => { timer = setInterval(() => { now.value = Date.now() }, 1000) })
onUnmounted(() => { if (timer) clearInterval(timer) })
function elapsed (startedIso) {
if (!startedIso) return '—'
const start = new Date(startedIso).getTime()
if (isNaN(start)) return '—'
const secs = Math.max(0, Math.floor((now.value - start) / 1000))
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = secs % 60
const mm = String(m).padStart(2, '0')
const ss = String(s).padStart(2, '0')
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`
}
</script>
<style scoped>
.fc-active { border-radius: 8px; }
.fc-active__head {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px;
}
.fc-active__title {
font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.85rem;
}
.fc-active__pulse {
width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto;
background: rgb(var(--v-theme-info));
animation: fc-active-pulse 1.4s ease-in-out infinite;
}
.fc-active__live {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
color: rgb(var(--v-theme-info));
}
.fc-active__body {
padding: 0 14px 12px;
display: flex; flex-direction: column; gap: 4px;
}
.fc-active__row {
display: flex; align-items: center; gap: 10px;
padding: 7px 10px;
border-radius: 6px;
background: rgb(var(--v-theme-surface) / 0.5);
}
.fc-active__row--queued { opacity: 0.75; }
.fc-active__dot {
width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
background: rgb(var(--v-theme-info));
animation: fc-active-pulse 1.4s ease-in-out infinite;
}
.fc-active__queue-icon { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-active__artist { font-weight: 600; }
.fc-active__timer {
font-variant-numeric: tabular-nums;
font-weight: 700;
color: rgb(var(--v-theme-info));
}
.fc-active__queued {
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
color: rgb(var(--v-theme-on-surface-variant));
}
@keyframes fc-active-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
@media (prefers-reduced-motion: reduce) {
.fc-active__pulse, .fc-active__dot { animation: none; }
}
</style>
@@ -102,6 +102,7 @@ import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue'
import PlatformChip from './PlatformChip.vue'
import { useCredentialsStore } from '../../stores/credentials.js'
import { formatLocalDate } from '../../utils/date.js'
const props = defineProps({
platform: { type: Object, required: true },
@@ -195,8 +196,7 @@ const howToFallback = computed(() => {
})
function fmtDate(iso) {
if (!iso) return '—'
return iso.slice(0, 10)
return iso ? formatLocalDate(iso) : '—'
}
</script>
@@ -43,6 +43,8 @@
</div>
</div>
<ActiveDownloadsPanel />
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
{{ String(store.error) }}
</v-alert>
@@ -124,6 +126,7 @@ import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
import DownloadStatChips from './DownloadStatChips.vue'
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
import FailingSourcesCard from './FailingSourcesCard.vue'
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
import MaintenanceMenu from './MaintenanceMenu.vue'
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
@@ -160,6 +163,7 @@ async function refresh() {
store.loadStats(24),
store.loadActivity(24),
store.loadFailing(),
store.loadActive(),
])
}
@@ -218,9 +222,10 @@ function startPolling() {
if (pollId) return
pollId = setInterval(async () => {
if (document.hidden) return
// Refresh stats + sparkline cheaply every tick; only reload the event
// list + failing rollup when there's active work (keeps idle ticks light).
await Promise.all([store.loadStats(24), store.loadActivity(24)])
// Refresh stats + sparkline + active panel every tick (so new runs
// surface within 4s even from idle); only reload the full event list +
// failing rollup when there's active work (keeps idle ticks light).
await Promise.all([store.loadStats(24), store.loadActivity(24), store.loadActive()])
if (liveActive.value) await Promise.all([store.loadFirst(), store.loadFailing()])
}, POLL_MS)
}
@@ -334,7 +339,7 @@ async function openDetail(id) {
background so rows don't bleed through. */
.fc-dl__sticky {
position: sticky;
top: 48px;
top: 0;
z-index: 3;
background: rgb(var(--v-theme-background));
padding: 8px 0 10px;
@@ -509,11 +509,12 @@ async function bulkDelete() {
.fc-subs__bar {
display: flex; gap: 0.75rem; align-items: center;
flex-wrap: wrap;
/* Sticky below the top nav so the filter/search controls stay
reachable while scrolling a long subscription list. Opaque bg so
table rows don't bleed through. Operator-flagged 2026-05-28. */
/* Sticky to the top of the hub's scroll window so the filter/search
controls stay reachable while scrolling a long subscription list.
Opaque bg so table rows don't bleed through. Operator-flagged
2026-05-28. */
position: sticky;
top: 48px;
top: 0;
z-index: 3;
background: rgb(var(--v-theme-background));
padding: 8px 0 1rem;
@@ -0,0 +1,28 @@
import { onMounted, onUnmounted, watch } from 'vue'
// Shared IntersectionObserver-on-a-sentinel infinite-scroll lifecycle.
// Pass a ref to the sentinel element and a callback to fire when it scrolls
// into view; the callback owns its own guard (e.g. `if (store.hasMore &&
// !store.loading) store.loadMore()`). Re-attaches if the sentinel ref
// changes (e.g. it's behind a v-if) and disconnects on unmount.
export function useInfiniteScroll (sentinelRef, onIntersect, { rootMargin = '600px' } = {}) {
let observer = null
function teardown () {
if (observer) { observer.disconnect(); observer = null }
}
function attach () {
teardown()
if (!sentinelRef.value) return
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) onIntersect()
}, { rootMargin })
observer.observe(sentinelRef.value)
}
onMounted(attach)
watch(sentinelRef, attach)
onUnmounted(teardown)
return { attach, teardown }
}
+15 -2
View File
@@ -18,6 +18,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
const activity = ref({ hours: 24, buckets: [] })
const failing = ref([])
// Running + queued events, fetched independent of the feed's filter so
// the "active now" panel always reflects what's happening regardless of
// how the operator has filtered the historical list below.
const activeEvents = ref([])
function _params(extra = {}) {
const out = { limit: 50, ...extra }
@@ -75,10 +79,19 @@ export const useDownloadsStore = defineStore('downloads', () => {
return failing.value
}
async function loadActive() {
const [running, pending] = await Promise.all([
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
api.get('/api/downloads', { params: { status: 'pending', limit: 50 } }),
])
activeEvents.value = [...running, ...pending]
return activeEvents.value
}
return {
events, cursor, hasMore, filter, selected, loading, error, stats,
activity, failing,
activity, failing, activeEvents,
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
loadActivity, loadFailing,
loadActivity, loadFailing, loadActive,
}
})
-83
View File
@@ -1,83 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
const POLL_MS = 2000
export const useMigrationStore = defineStore('migration', () => {
const api = useApi()
const activeRun = ref(null)
const recentRuns = ref([])
const error = ref(null)
const loading = ref(false)
let pollHandle = null
async function trigger(kind, params, file = null) {
loading.value = true
error.value = null
try {
let body
if (file) {
// Multipart upload for ingest kinds.
const form = new FormData()
form.append('export_file', file)
if (params && params.dry_run) form.append('dry_run', 'true')
if (params && params.force) form.append('force', 'true')
body = await api.post(`/api/migrate/${kind}`, { body: form })
} else {
body = await api.post(`/api/migrate/${kind}`, { body: params || {} })
}
await loadRun(body.run_id)
pollActive(body.run_id)
return body
} catch (e) {
error.value = e
throw e
} finally {
loading.value = false
}
}
async function loadRun(runId) {
activeRun.value = await api.get(`/api/migrate/runs/${runId}`)
}
async function loadRecent() {
recentRuns.value = await api.get('/api/migrate/runs', { params: { limit: 10 } })
}
function pollActive(runId) {
stopPolling()
pollHandle = setInterval(async () => {
try {
const run = await api.get(`/api/migrate/runs/${runId}`)
activeRun.value = run
if (run.status === 'ok' || run.status === 'error') {
stopPolling()
await loadRecent()
}
} catch (e) {
error.value = e
stopPolling()
}
}, POLL_MS)
}
function stopPolling() {
if (pollHandle) {
clearInterval(pollHandle)
pollHandle = null
}
}
const isRunning = computed(
() => activeRun.value && activeRun.value.status === 'running',
)
return {
activeRun, recentRuns, error, loading, isRunning,
trigger, loadRun, loadRecent, pollActive, stopPolling,
}
})
+65
View File
@@ -12,6 +12,14 @@ export const usePostsStore = defineStore('posts', () => {
const done = ref(false)
const filters = ref({ artist_id: null, platform: null })
// In-context (anchored) view: bidirectional cursors so the feed can load
// newer posts on scroll-up and older posts on scroll-down around a post.
const cursorOlder = ref(null)
const cursorNewer = ref(null)
const doneOlder = ref(false)
const doneNewer = ref(false)
const anchorId = ref(null)
function _qs() {
const q = {}
if (cursor.value) q.cursor = cursor.value
@@ -51,8 +59,65 @@ export const usePostsStore = defineStore('posts', () => {
return await api.get(`/api/posts/${id}`)
}
// Load a window centered on `postId`: newer posts above, the post, older
// posts below. Sets both directional cursors for subsequent scrolling.
async function loadAround(postId) {
loading.value = true
error.value = null
anchorId.value = null
try {
const body = await api.get('/api/posts', { params: { around: postId } })
items.value = body.items
cursorOlder.value = body.cursor_older
cursorNewer.value = body.cursor_newer
doneOlder.value = body.cursor_older == null
doneNewer.value = body.cursor_newer == null
anchorId.value = body.anchor_id
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
async function loadOlder() {
if (loading.value || doneOlder.value || cursorOlder.value == null) return
loading.value = true
try {
const body = await api.get('/api/posts', {
params: { cursor: cursorOlder.value, direction: 'older' },
})
items.value.push(...body.items)
cursorOlder.value = body.next_cursor
if (body.next_cursor == null) doneOlder.value = true
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
async function loadNewer() {
if (loading.value || doneNewer.value || cursorNewer.value == null) return
loading.value = true
try {
const body = await api.get('/api/posts', {
params: { cursor: cursorNewer.value, direction: 'newer' },
})
items.value.unshift(...body.items)
cursorNewer.value = body.next_cursor
if (body.next_cursor == null) doneNewer.value = true
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return {
items, cursor, loading, done, error, filters,
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
loadInitial, loadMore, getPostFull,
loadAround, loadOlder, loadNewer,
}
})
+14 -2
View File
@@ -93,11 +93,23 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
runsFilter.value = { ...runsFilter.value, ...filter }
}
// One-call rollup for the always-on TopNav pipeline indicator:
// { scheduler, queues, queued_total, running, failing }.
const summary = ref(null)
async function loadSummary() {
try {
summary.value = await api.get('/api/system/activity/summary')
} catch (e) {
lastError.value = e.message
}
return summary.value
}
return {
queues, workers, recentMinute, failures,
queues, workers, recentMinute, failures, summary,
runs, runsCursor, runsHasMore, runsFilter,
loading, lastError,
loadQueues, loadWorkers, loadRecentMinute,
loadRuns, loadFailures, setFilter,
loadRuns, loadFailures, loadSummary, setFilter,
}
})
+29
View File
@@ -32,3 +32,32 @@ export function formatRelative(iso, opts = {}) {
if (future) return diff <= 0 ? 'imminent' : `in ${body}`
return `${body} ago`
}
// Parse a backend timestamp as a UTC instant. Backend serializes tz-aware
// UTC (…+00:00); if a value ever arrives without a tz designator we treat
// it as UTC so it still converts to the viewer's zone correctly.
function _parseUtc (iso) {
if (typeof iso !== 'string' || !iso) return null
const hasTz = /([zZ])$|([+-]\d{2}:?\d{2})$/.test(iso)
const d = new Date(hasTz ? iso : `${iso}Z`)
return isNaN(d.getTime()) ? null : d
}
// Local-timezone date + time, e.g. "May 28, 7:30 PM". Use for absolute
// timestamps that were previously shown as raw UTC ISO.
export function formatDateTime (iso) {
const d = _parseUtc(iso)
if (!d) return ''
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
// Local-timezone date only, e.g. "May 28, 2026".
export function formatLocalDate (iso) {
const d = _parseUtc(iso)
if (!d) return ''
return d.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
})
}
+2 -1
View File
@@ -1,7 +1,8 @@
// Single source of truth for the download-event status enum -> display
// metadata (label/color/icon). Mirrors the backend ENUM
// (pending|running|ok|error|skipped). Used by the stat chips, the filter
// dropdown, and the event rows so they never drift apart.
// dropdown, and the event rows so they never drift apart. The value set is
// pinned against backend _ALLOWED_STATUSES by tests/test_fe_be_contract.py.
export const DOWNLOAD_STATUSES = [
{ value: 'pending', label: 'Queued', color: 'grey', icon: 'mdi-clock-outline' },
+11
View File
@@ -29,6 +29,17 @@ export function sanitizeHtml (html) {
return doc.body.innerHTML
}
// Strip all tags + decode entities to plain text. Used for titles that
// arrive as stored HTML (e.g. "<strong>Edelgard at the Sex-Arcade</strong>")
// which must render as text, not literal markup. DOMParser yields an inert
// document (no script execution, no resource loads), so reading textContent
// is safe.
export function toPlainText (html) {
if (typeof html !== 'string' || !html) return ''
const doc = new DOMParser().parseFromString(html, 'text/html')
return (doc.body.textContent || '').trim()
}
function _scrubNode (node) {
const children = Array.from(node.children)
for (const child of children) {
+3 -1
View File
@@ -1,7 +1,9 @@
// Single source of truth for platform → color + icon mapping. Used by
// PlatformChip and any other GS-style platform-tagged surface. The six
// platforms FC supports map 1:1 to the GS palette; unknown platforms fall
// back to grey + mdi-web. Operator-confirmed scope 2026-05-27.
// back to grey + mdi-web. Operator-confirmed scope 2026-05-27. The ICONS key
// set is pinned against backend known_platform_keys() by
// tests/test_fe_be_contract.py.
const ICONS = {
patreon: 'mdi-patreon',
+5 -13
View File
@@ -34,9 +34,10 @@
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useArtistDirectoryStore } from '../stores/artistDirectory.js'
import { usePlatformsStore } from '../stores/platforms.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import ArtistCard from '../components/discovery/ArtistCard.vue'
const store = useArtistDirectoryStore()
@@ -55,16 +56,9 @@ watch(search, (v) => {
})
watch(platformModel, (v) => store.setPlatform(v || null))
let observer = null
function attachObserver() {
if (observer) observer.disconnect()
if (!sentinelEl.value) return
observer = new IntersectionObserver(([e]) => {
if (e.isIntersecting && store.hasMore && !store.loading) store.loadMore()
}, { rootMargin: '600px' })
observer.observe(sentinelEl.value)
}
watch(sentinelEl, attachObserver)
useInfiniteScroll(sentinelEl, () => {
if (store.hasMore && !store.loading) store.loadMore()
})
onMounted(async () => {
await platformsStore.loadAll()
@@ -72,9 +66,7 @@ onMounted(async () => {
title: p.key, value: p.key,
}))
store.reset()
attachObserver()
})
onUnmounted(() => observer && observer.disconnect())
</script>
<style scoped>
+150 -40
View File
@@ -1,39 +1,84 @@
<template>
<v-container class="pt-2 pb-6" max-width="900">
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<!-- In-context view: deep-linked to one post, with bidirectional infinite
scroll newer posts load above, older posts below. -->
<template v-if="postIdFilter != null">
<v-btn
variant="text" size="small" prepend-icon="mdi-arrow-left"
:to="{ name: 'posts' }" class="mb-2"
>All posts</v-btn>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
<p>No posts yet. Subscribe to a source on the
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
tab to start capturing posts.
</p>
</div>
<div v-else>
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
<div ref="sentinel" class="fc-posts__sentinel">
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
</div>
<div v-else>
<div ref="topSentinel" class="fc-posts__sentinel">
<v-progress-circular
v-if="store.loading && !store.doneNewer"
indeterminate color="accent" size="20"
/>
<span v-else-if="store.doneNewer" class="fc-posts__end">Top of feed</span>
</div>
<div
v-for="p in store.items" :key="p.id" :id="`fc-post-${p.id}`"
:class="['fc-posts__entry', { 'fc-posts__entry--anchor': p.id === store.anchorId }]"
>
<PostCard :post="p" />
</div>
<div ref="bottomSentinel" class="fc-posts__sentinel">
<v-progress-circular
v-if="store.loading && !store.doneOlder"
indeterminate color="accent" size="24"
/>
<span v-else-if="store.doneOlder" class="fc-posts__end">End of stream</span>
</div>
</div>
</template>
<!-- Normal feed -->
<template v-else>
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
<p>No posts yet. Subscribe to a source on the
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
tab to start capturing posts.
</p>
</div>
<div v-else>
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
<div ref="sentinel" class="fc-posts__sentinel">
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
</div>
</div>
</template>
</v-container>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePostsStore } from '../stores/posts.js'
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
@@ -48,7 +93,12 @@ const artistFilter = computed(() => {
return raw == null ? null : Number(raw)
})
const platformFilter = computed(() => route.query.platform || null)
const postIdFilter = computed(() => {
const raw = route.query.post_id
return raw == null ? null : Number(raw)
})
// --- normal feed (downward infinite scroll) ---
const sentinel = ref(null)
let observer = null
@@ -58,7 +108,6 @@ async function reload() {
platform: platformFilter.value,
})
}
function onFilters({ artist_id, platform }) {
const q = { ...route.query }
if (artist_id == null) delete q.artist_id
@@ -67,22 +116,79 @@ function onFilters({ artist_id, platform }) {
else q.platform = platform
router.replace({ query: q })
}
watch(() => [artistFilter.value, platformFilter.value], reload)
onMounted(async () => {
await reload()
function teardownFeed() {
if (observer) { observer.disconnect(); observer = null }
}
function setupFeedObserver() {
teardownFeed()
if (!sentinel.value) return
observer = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
store.loadMore()
}
if (entries.some((e) => e.isIntersecting)) store.loadMore()
}, { rootMargin: '400px 0px' })
if (sentinel.value) observer.observe(sentinel.value)
observer.observe(sentinel.value)
}
// --- in-context view (bidirectional, anchored on a post) ---
const topSentinel = ref(null)
const bottomSentinel = ref(null)
let topObs = null
let bottomObs = null
function teardownAround() {
if (topObs) { topObs.disconnect(); topObs = null }
if (bottomObs) { bottomObs.disconnect(); bottomObs = null }
}
function setupAroundObservers() {
teardownAround()
bottomObs = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) store.loadOlder()
}, { rootMargin: '500px 0px' })
if (bottomSentinel.value) bottomObs.observe(bottomSentinel.value)
topObs = new IntersectionObserver(async (entries) => {
if (!entries.some((e) => e.isIntersecting)) return
if (store.loading || store.doneNewer) return
// Preserve the viewport position across an upward prepend so the page
// doesn't jump when newer posts insert above the current view.
const beforeH = document.documentElement.scrollHeight
const beforeY = window.scrollY
await store.loadNewer()
await nextTick()
const afterH = document.documentElement.scrollHeight
window.scrollTo(0, beforeY + (afterH - beforeH))
}, { rootMargin: '200px 0px' })
if (topSentinel.value) topObs.observe(topSentinel.value)
}
async function loadAroundAndAnchor() {
teardownFeed()
await store.loadAround(postIdFilter.value)
await nextTick()
const el = document.getElementById(`fc-post-${store.anchorId}`)
if (el) el.scrollIntoView({ block: 'center' })
setupAroundObservers()
}
async function activate() {
if (postIdFilter.value != null) {
await loadAroundAndAnchor()
} else {
teardownAround()
await reload()
await nextTick()
setupFeedObserver()
}
}
watch(postIdFilter, activate)
watch(() => [artistFilter.value, platformFilter.value], async () => {
if (postIdFilter.value != null) return
await reload()
await nextTick()
setupFeedObserver()
})
onUnmounted(() => {
if (observer) observer.disconnect()
})
onMounted(activate)
onUnmounted(() => { teardownFeed(); teardownAround() })
</script>
<style scoped>
@@ -103,4 +209,8 @@ onUnmounted(() => {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem;
}
.fc-posts__entry--anchor :deep(.fc-post-card) {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 3px;
}
</style>
+4 -7
View File
@@ -61,15 +61,15 @@
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
const route = useRoute()
const store = useSeriesManageStore()
const dragFrom = ref(null)
const sentinel = ref(null)
let observer = null
function onDrop(toIdx) {
if (dragFrom.value === null || dragFrom.value === toIdx) return
@@ -80,15 +80,12 @@ function onDrop(toIdx) {
store.reorder(ordered)
}
useInfiniteScroll(sentinel, () => store.loadPicker())
onMounted(async () => {
await store.load(parseInt(route.params.tagId, 10))
await store.loadPicker(true)
observer = new IntersectionObserver(([e]) => {
if (e.isIntersecting) store.loadPicker()
}, { rootMargin: '600px' })
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => observer && observer.disconnect())
</script>
<style scoped>
+15 -1
View File
@@ -21,7 +21,7 @@
</v-tab>
</v-tabs>
<v-window v-model="tab" class="mt-4">
<v-window v-model="tab" class="mt-4 fc-subs-window">
<v-window-item value="subscriptions">
<SubscriptionsTab />
</v-window-item>
@@ -52,8 +52,22 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
.fc-subs-shell {
max-width: 1600px;
margin-inline: auto;
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
put while ONLY the tab content scrolls — previously the whole view
scrolled instead of just the subscription list (operator-flagged
2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.fc-subs-tabs {
flex: 0 0 auto;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
}
.fc-subs-window {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
</style>
+6 -13
View File
@@ -89,11 +89,12 @@
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
import { useAdminStore } from '../stores/admin.js'
import { useApi } from '../composables/useApi.js'
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'
@@ -130,18 +131,10 @@ watch(search, (v) => {
})
watch(kind, (v) => store.setKind(v || null))
let observer = null
function attachObserver() {
if (observer) observer.disconnect()
if (!sentinelEl.value) return
observer = new IntersectionObserver(([e]) => {
if (e.isIntersecting && store.hasMore && !store.loading) store.loadMore()
}, { rootMargin: '600px' })
observer.observe(sentinelEl.value)
}
watch(sentinelEl, attachObserver)
onMounted(() => { store.reset(); attachObserver() })
onUnmounted(() => observer && observer.disconnect())
useInfiniteScroll(sentinelEl, () => {
if (store.hasMore && !store.loading) store.loadMore()
})
onMounted(() => store.reset())
function openTag(tagId) {
router.push({ name: 'gallery', query: { tag_id: tagId } })
@@ -0,0 +1,30 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import ActiveDownloadsPanel from '../../src/components/subscriptions/ActiveDownloadsPanel.vue'
import { useDownloadsStore } from '../../src/stores/downloads.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
describe('ActiveDownloadsPanel', () => {
it('lists a running download with its artist', () => {
const pinia = freshPinia()
useDownloadsStore().activeEvents = [{
id: 1, status: 'running',
started_at: new Date(Date.now() - 65000).toISOString(),
platform: 'patreon', artist_name: 'Alice',
}]
const w = mountComponent(ActiveDownloadsPanel, { pinia })
const t = w.text()
expect(t).toContain('Alice')
expect(t).toContain('downloading')
w.unmount() // clear the 1s elapsed-timer interval
})
it('renders nothing when there is no active work', () => {
const pinia = freshPinia()
useDownloadsStore().activeEvents = []
const w = mountComponent(ActiveDownloadsPanel, { pinia })
expect(w.text()).toBe('')
w.unmount()
})
})
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import CredentialCard from '../../src/components/subscriptions/CredentialCard.vue'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
const platform = { key: 'patreon', name: 'Patreon', auth_type: 'cookies' }
describe('CredentialCard', () => {
it('renders the last-verified row when last_verified is set', () => {
// Regression guard: the field was once read as last_verified_at, so the
// row silently never rendered.
const pinia = freshPinia()
const recent = new Date(Date.now() - 2 * 86400_000).toISOString()
const credential = {
platform: 'patreon', credential_type: 'cookies',
captured_at: recent, expires_at: null, last_verified: recent,
}
const w = mountComponent(CredentialCard, { props: { platform, credential }, pinia })
expect(w.text()).toContain('Last verified')
})
it('renders the empty state with no credential', () => {
const pinia = freshPinia()
const w = mountComponent(CredentialCard, { props: { platform, credential: null }, pinia })
expect(w.text()).toContain('No credential stored')
})
})
@@ -0,0 +1,21 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import DownloadEventRow from '../../src/components/downloads/DownloadEventRow.vue'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
describe('DownloadEventRow', () => {
it('renders the mapped status label and platform', () => {
const pinia = freshPinia()
const now = new Date().toISOString()
const event = {
id: 1, status: 'ok', started_at: now, finished_at: now,
platform: 'patreon', artist_name: 'Alice',
files_count: 3, bytes_downloaded: 1000, error: null, summary: {},
}
const w = mountComponent(DownloadEventRow, { props: { event }, pinia })
const t = w.text()
expect(t).toContain('Completed') // downloadStatusLabel('ok')
expect(t).toContain('Patreon') // platformLabel via PlatformChip
})
})
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import PostCard from '../../src/components/posts/PostCard.vue'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
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 t = w.text()
expect(t).toContain('Hello World') // plain title
expect(t).not.toContain('<strong>') // tags stripped
expect(t).toContain('Sabu') // artist (RouterLink slot)
})
})
@@ -0,0 +1,26 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import QueueStatusBar from '../../src/components/settings/QueueStatusBar.vue'
import { useSystemActivityStore } from '../../src/stores/systemActivity.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
describe('QueueStatusBar', () => {
it('shows the pending count when the queue is busy', () => {
const pinia = freshPinia()
useSystemActivityStore().queues = { queues: { ml: 3 }, fetched_at: '' }
const w = mountComponent(QueueStatusBar, {
props: { queue: 'ml', queueLabel: 'ML' }, pinia,
})
expect(w.text()).toContain('3 pending')
})
it('reads idle when the queue is empty', () => {
const pinia = freshPinia()
useSystemActivityStore().queues = { queues: { ml: 0 }, fetched_at: '' }
const w = mountComponent(QueueStatusBar, {
props: { queue: 'ml', queueLabel: 'ML' }, pinia,
})
expect(w.text().toLowerCase()).toContain('idle')
})
})
+24
View File
@@ -0,0 +1,24 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
// Smoke-test helper. Vuetify components are left unresolved (Vue 3 renders
// unknown elements with their slot children, so text content is still
// present and nothing throws), which avoids standing up the whole Vuetify
// plugin in happy-dom. RouterLink is the one import that needs a router, so
// it's stubbed with a slot-rendering anchor.
export function freshPinia () {
const pinia = createPinia()
setActivePinia(pinia)
return pinia
}
export function mountComponent (Component, { props = {}, pinia } = {}) {
return mount(Component, {
props,
global: {
plugins: pinia ? [pinia] : [],
stubs: { RouterLink: { template: '<a><slot /></a>' } },
},
})
}
-98
View File
@@ -1,98 +0,0 @@
"""FC-5: /api/migrate API tests."""
import io
import json
import pytest
from werkzeug.datastructures import FileStorage
import backend.app.tasks.migration # noqa: F401 — register celery task
from backend.app.celery_app import celery
pytestmark = pytest.mark.integration
def _file_storage(payload: dict, filename: str = "export.json") -> FileStorage:
return FileStorage(
stream=io.BytesIO(json.dumps(payload).encode("utf-8")),
filename=filename,
content_type="application/json",
)
# Retired 2026-05-24 (FC-3h): `test_post_backup_returns_202_and_id`
# asserted the /api/migrate/backup endpoint, which was retired in FC-3h.
# Backup is now a first-class feature at /api/system/backup/*;
# coverage lives in tests/test_api_system_backup.py.
@pytest.mark.asyncio
async def test_post_rejects_unknown_kind(client):
resp = await client.post("/api/migrate/destroy", json={})
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "unknown_kind"
@pytest.mark.asyncio
async def test_post_ingest_rejects_missing_file(client):
resp = await client.post("/api/migrate/gs_ingest", form={})
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "missing_export_file"
@pytest.mark.asyncio
async def test_post_ingest_accepts_multipart_file(client, monkeypatch):
monkeypatch.setattr(
"backend.app.api.migrate.run_migration",
type("F", (), {"delay": lambda self, *a, **k: None})(),
)
export = {
"source_app": "gallerysubscriber", "schema_version": 1,
"subscriptions": [], "credentials": [],
}
resp = await client.post(
"/api/migrate/gs_ingest",
form={"dry_run": "false"},
files={"export_file": _file_storage(export, "gs-export.json")},
)
assert resp.status_code == 202
# Retired 2026-05-24: `test_post_apply_without_backup_rejected` asserted
# the migration backup-gate rejected applies without a recent backup.
# That gate was removed in commit 5535677 (_APPLY_KINDS is now empty,
# FC-3h will own backup as a first-class feature) so the test was
# testing dead behavior. Removed rather than rewritten.
@pytest.mark.asyncio
async def test_post_dry_run_allowed_without_backup(client, monkeypatch):
# FC-3h: the _has_recent_pre_migration_backup gate was retired; dry-run
# ingests were always allowed and now non-dry-run ingests are too.
# This test stays as regression coverage that dry_run ingest still works.
monkeypatch.setattr(
"backend.app.api.migrate.run_migration",
type("F", (), {"delay": lambda self, *a, **k: None})(),
)
export = {
"source_app": "gallerysubscriber", "schema_version": 1,
"subscriptions": [], "credentials": [],
}
resp = await client.post(
"/api/migrate/gs_ingest",
form={"dry_run": "true"},
files={"export_file": _file_storage(export, "gs-export.json")},
)
assert resp.status_code == 202
@pytest.mark.asyncio
async def test_get_run_404_unknown(client):
resp = await client.get("/api/migrate/runs/9999999")
assert resp.status_code == 404
def test_run_migration_task_registered():
assert "backend.app.tasks.migration.run_migration" in celery.tasks
+67 -1
View File
@@ -4,7 +4,7 @@ Validates list shape, cursor handling, filter validation, and detail
endpoint. Service-level fixtures are exercised by test_post_feed_service
— here we focus on the HTTP surface (validation, status codes, dict shape).
"""
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
import pytest
@@ -107,6 +107,72 @@ async def test_detail_404_for_unknown(client):
assert body["error"] == "not_found"
@pytest.fixture
async def post_timeline(db):
"""Five posts on distinct dates: posts[0] oldest … posts[4] newest."""
artist = Artist(name="tl-api", slug="tl-api")
db.add(artist)
await db.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url="https://p/tl-api", enabled=True,
)
db.add(source)
await db.flush()
base = datetime(2026, 1, 1, tzinfo=UTC)
posts = []
for i in range(5):
p = Post(
source_id=source.id, external_post_id=f"TL{i}",
post_title=f"post {i}", post_date=base + timedelta(days=i),
)
db.add(p)
posts.append(p)
await db.commit()
return artist, source, posts
@pytest.mark.asyncio
async def test_around_returns_window_with_anchor(client, post_timeline):
_, _, posts = post_timeline
anchor = posts[2]
resp = await client.get(f"/api/posts?around={anchor.id}&limit=1")
assert resp.status_code == 200
body = await resp.get_json()
assert set(body.keys()) == {"items", "cursor_older", "cursor_newer", "anchor_id"}
assert body["anchor_id"] == anchor.id
# limit=1: one newer + anchor + one older, in feed (desc) order.
assert [it["id"] for it in body["items"]] == [posts[3].id, posts[2].id, posts[1].id]
assert body["cursor_older"] is not None # posts[0] still older
assert body["cursor_newer"] is not None # posts[4] still newer
@pytest.mark.asyncio
async def test_around_404_for_unknown(client):
resp = await client.get("/api/posts?around=999999")
assert resp.status_code == 404
assert (await resp.get_json())["error"] == "not_found"
@pytest.mark.asyncio
async def test_direction_newer_walks_forward(client, post_timeline):
_, _, posts = post_timeline
around = await client.get(f"/api/posts?around={posts[1].id}&limit=1")
cursor_newer = (await around.get_json())["cursor_newer"]
assert cursor_newer is not None
resp = await client.get(f"/api/posts?cursor={cursor_newer}&direction=newer&limit=5")
assert resp.status_code == 200
# Newer than the window's newest (posts[2]) → posts[3], posts[4] in desc order.
assert [it["id"] for it in (await resp.get_json())["items"]] == [posts[4].id, posts[3].id]
@pytest.mark.asyncio
async def test_rejects_bad_direction(client):
resp = await client.get("/api/posts?direction=sideways")
assert resp.status_code == 400
assert (await resp.get_json())["error"] == "invalid_direction"
@pytest.mark.asyncio
async def test_detail_returns_uncapped_thumbnails(client, db):
"""Feed query caps thumbnails at 6 for previews; detail endpoint
+19
View File
@@ -44,6 +44,25 @@ async def test_queues_returns_all_known_queues(client, monkeypatch):
assert set(body["queues"].keys()) == set(activity_module._QUEUE_NAMES)
@pytest.mark.asyncio
async def test_summary_returns_rollup_shape(client, monkeypatch):
def _fake():
return {
"queues": {**dict.fromkeys(activity_module._QUEUE_NAMES, 0), "ml": 3},
"fetched_at": datetime.now(UTC).isoformat(),
}
monkeypatch.setattr(activity_module, "_read_queues_sync", _fake)
resp = await client.get("/api/system/activity/summary")
assert resp.status_code == 200
body = await resp.get_json()
assert set(body.keys()) == {"scheduler", "queues", "queued_total", "running", "failing"}
assert body["queued_total"] == 3 # only ml has a non-zero depth
assert isinstance(body["running"], int)
assert isinstance(body["failing"], int)
assert set(body["scheduler"]) == {"last_tick_at", "next_due_at", "due_now", "auto_sources"}
@pytest.mark.asyncio
async def test_queues_cached_within_ttl(client, monkeypatch):
call_count = {"n": 0}
+44
View File
@@ -0,0 +1,44 @@
"""FE/BE enum contract — guards against the field/enum drift class.
FabledCurator's frontend is plain JS (no TS codegen), so the frontend
re-declares a few backend enum value-sets by hand:
- download-event statuses → frontend/src/utils/downloadStatus.js
- platform keys → frontend/src/utils/platformColor.js
If either side edits its list without the other, the UI silently
mislabels rows or drops a platform (the `last_verified_at`-style drift).
These tests parse the JS mirrors and assert they equal the backend's
canonical definitions, so any divergence fails CI on whichever side moved.
The backend definitions are the source of truth; update the JS to match.
"""
import re
from pathlib import Path
from backend.app.api.downloads import _ALLOWED_STATUSES
from backend.app.services.platforms import known_platform_keys
_FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src" / "utils"
def _read(name: str) -> str:
return (_FRONTEND / name).read_text(encoding="utf-8")
def test_download_status_values_match_backend() -> None:
# downloadStatus.js: each entry is `{ value: 'pending', ... }`
fe_values = set(re.findall(r"value:\s*'([a-z]+)'", _read("downloadStatus.js")))
assert fe_values == set(_ALLOWED_STATUSES), (
"downloadStatus.js DOWNLOAD_STATUSES is out of sync with backend "
"downloads._ALLOWED_STATUSES"
)
def test_platform_keys_match_backend() -> None:
# platformColor.js ICONS: each entry is `patreon: 'mdi-patreon',`
fe_keys = set(re.findall(r"^\s*(\w+):\s*'mdi-", _read("platformColor.js"), re.M))
assert fe_keys == set(known_platform_keys()), (
"platformColor.js ICONS is out of sync with backend "
"platforms.known_platform_keys()"
)
-122
View File
@@ -1,122 +0,0 @@
"""FC-5: gs_ingest unit tests."""
from datetime import UTC, datetime
import pytest
from sqlalchemy import select
from backend.app.models import Artist, Credential, Source
from backend.app.services.credential_crypto import CredentialCrypto
from backend.app.services.migrators import gs_ingest
pytestmark = pytest.mark.integration
def _gs_export(subscriptions=None, credentials=None) -> dict:
return {
"source_app": "gallerysubscriber",
"schema_version": 1,
"exported_at": datetime.now(UTC).isoformat(),
"subscriptions": subscriptions or [],
"credentials": credentials or [],
}
@pytest.mark.asyncio
async def test_subscription_creates_artist_with_slug(db):
data = _gs_export(subscriptions=[
{"name": "Alice Author", "enabled": True, "metadata": {}, "sources": []},
])
counts = await gs_ingest.migrate_async(db, data=data, dry_run=False)
assert counts["rows_inserted"] >= 1
artist = (await db.execute(
select(Artist).where(Artist.name == "Alice Author")
)).scalar_one()
assert artist.slug == "alice-author"
assert artist.is_subscription is True
@pytest.mark.asyncio
async def test_nested_source_attached_to_artist(db):
data = _gs_export(subscriptions=[{
"name": "bob-author",
"enabled": True,
"metadata": {},
"sources": [{
"platform": "patreon",
"url": "https://patreon.com/bob",
"enabled": True,
"check_interval": 28800,
"metadata": {"k": "v"},
}],
}])
await gs_ingest.migrate_async(db, data=data, dry_run=False)
src = (await db.execute(
select(Source).where(Source.url == "https://patreon.com/bob")
)).scalar_one()
artist = (await db.execute(
select(Artist).where(Artist.id == src.artist_id)
)).scalar_one()
assert artist.name == "bob-author"
assert src.platform == "patreon"
assert src.check_interval_override == 28800
assert src.config_overrides == {"k": "v"}
@pytest.mark.asyncio
async def test_credential_re_encrypted_with_fc_key(db, tmp_path):
fc_key_path = tmp_path / "credential_key.b64"
fc_crypto = CredentialCrypto(fc_key_path)
data = _gs_export(credentials=[{
"platform": "patreon",
"credential_type": "cookies",
"plaintext": "session_cookie=abcdef",
"expires_at": None,
}])
await gs_ingest.migrate_async(db, data=data, fc_crypto=fc_crypto, dry_run=False)
cred = (await db.execute(
select(Credential).where(Credential.platform == "patreon")
)).scalar_one()
assert fc_crypto.decrypt(cred.encrypted_blob) == "session_cookie=abcdef"
assert cred.credential_type == "cookies"
@pytest.mark.asyncio
async def test_dry_run_makes_no_changes(db):
data = _gs_export(subscriptions=[
{"name": "dave", "enabled": True, "metadata": {}, "sources": []},
])
counts = await gs_ingest.migrate_async(db, data=data, dry_run=True)
assert counts["rows_processed"] >= 1
after = (await db.execute(
select(Artist).where(Artist.name == "dave")
)).scalar_one_or_none()
assert after is None
@pytest.mark.asyncio
async def test_idempotent_on_rerun(db):
data = _gs_export(subscriptions=[
{"name": "eve", "enabled": True, "metadata": {}, "sources": []},
])
await gs_ingest.migrate_async(db, data=data, dry_run=False)
counts2 = await gs_ingest.migrate_async(db, data=data, dry_run=False)
assert counts2["rows_skipped"] >= 1
@pytest.mark.asyncio
async def test_rejects_wrong_source_app(db):
bad = {"source_app": "wrong", "schema_version": 1, "subscriptions": [], "credentials": []}
with pytest.raises(ValueError):
await gs_ingest.migrate_async(db, data=bad, dry_run=False)
@pytest.mark.asyncio
async def test_rejects_unsupported_schema_version(db):
bad = {"source_app": "gallerysubscriber", "schema_version": 99, "subscriptions": [], "credentials": []}
with pytest.raises(ValueError):
await gs_ingest.migrate_async(db, data=bad, dry_run=False)
-129
View File
@@ -1,129 +0,0 @@
"""FC-5: ir_ingest unit tests."""
import json
from datetime import UTC, datetime
import pytest
from sqlalchemy import select
from backend.app.models import Tag
from backend.app.services.migrators import ir_ingest
pytestmark = pytest.mark.integration
def _ir_export(tags=None, artist_assignments=None, tag_associations=None, series_pages=None):
return {
"source_app": "imagerepo",
"schema_version": 1,
"exported_at": datetime.now(UTC).isoformat(),
"tags": tags or [],
"image_artist_assignments": artist_assignments or [],
"image_tag_associations": tag_associations or [],
"series_pages": series_pages or [],
}
@pytest.mark.asyncio
async def test_general_tag_created(db, tmp_path):
data = _ir_export(tags=[
{"name": "blue_eyes", "kind": "general", "fandom_name": None},
])
await ir_ingest.migrate_async(db, data=data, images_root=tmp_path, dry_run=False)
tag = (await db.execute(
select(Tag).where(Tag.name == "blue_eyes", Tag.kind == "general")
)).scalar_one()
assert tag.fandom_id is None
@pytest.mark.asyncio
async def test_character_tag_resolves_fandom_name(db, tmp_path):
data = _ir_export(tags=[
{"name": "Wonderland", "kind": "fandom", "fandom_name": None},
{"name": "Alice", "kind": "character", "fandom_name": "Wonderland"},
])
await ir_ingest.migrate_async(db, data=data, images_root=tmp_path, dry_run=False)
fandom = (await db.execute(
select(Tag).where(Tag.name == "Wonderland", Tag.kind == "fandom")
)).scalar_one()
alice = (await db.execute(
select(Tag).where(Tag.name == "Alice", Tag.kind == "character")
)).scalar_one()
assert alice.fandom_id == fandom.id
@pytest.mark.asyncio
async def test_artist_and_post_kinds_skipped(db, tmp_path):
data = _ir_export(tags=[
{"name": "BobArtist", "kind": "artist", "fandom_name": None},
{"name": "PostXYZ", "kind": "post", "fandom_name": None},
{"name": "general_one", "kind": "general", "fandom_name": None},
])
counts = await ir_ingest.migrate_async(db, data=data, images_root=tmp_path, dry_run=False)
assert counts["rows_skipped"] >= 2
bob = (await db.execute(
select(Tag).where(Tag.name == "BobArtist")
)).scalar_one_or_none()
post = (await db.execute(
select(Tag).where(Tag.name == "PostXYZ")
)).scalar_one_or_none()
assert bob is None
assert post is None
@pytest.mark.asyncio
async def test_manifest_written_to_disk(db, tmp_path):
data = _ir_export(
artist_assignments=[{"sha256": "a" * 64, "artist_name": "Alice"}],
tag_associations=[
{"sha256": "a" * 64, "tag_name": "blue_eyes", "tag_kind": "general", "fandom_name": None},
],
series_pages=[
{"sha256": "a" * 64, "series_tag_name": "MySeries", "page_number": 1},
],
)
await ir_ingest.migrate_async(db, data=data, images_root=tmp_path, dry_run=False)
manifest_file = tmp_path / "_migration_state" / "ir_tag_manifest.json"
assert manifest_file.exists()
manifest = json.loads(manifest_file.read_text())
assert manifest["schema_version"] == 1
assert len(manifest["image_artist_assignments"]) == 1
assert len(manifest["image_tag_associations"]) == 1
assert len(manifest["series_pages"]) == 1
@pytest.mark.asyncio
async def test_dry_run_writes_no_manifest_and_no_tags(db, tmp_path):
data = _ir_export(
tags=[{"name": "dry", "kind": "general", "fandom_name": None}],
artist_assignments=[{"sha256": "a" * 64, "artist_name": "DryArtist"}],
)
await ir_ingest.migrate_async(db, data=data, images_root=tmp_path, dry_run=True)
tag = (await db.execute(
select(Tag).where(Tag.name == "dry")
)).scalar_one_or_none()
assert tag is None
manifest_file = tmp_path / "_migration_state" / "ir_tag_manifest.json"
assert not manifest_file.exists()
@pytest.mark.asyncio
async def test_idempotent_on_rerun(db, tmp_path):
data = _ir_export(tags=[
{"name": "rerun", "kind": "general", "fandom_name": None},
])
await ir_ingest.migrate_async(db, data=data, images_root=tmp_path, dry_run=False)
counts2 = await ir_ingest.migrate_async(db, data=data, images_root=tmp_path, dry_run=False)
assert counts2["rows_skipped"] >= 1
@pytest.mark.asyncio
async def test_rejects_wrong_source_app(db, tmp_path):
bad = {"source_app": "elsewhere", "schema_version": 1, "tags": [],
"image_artist_assignments": [], "image_tag_associations": [], "series_pages": []}
with pytest.raises(ValueError):
await ir_ingest.migrate_async(db, data=bad, images_root=tmp_path, dry_run=False)
+143
View File
@@ -495,3 +495,146 @@ def test_prune_task_runs_never_deletes_running(db_sync):
select(TaskRun.id).where(TaskRun.id == ancient_id)
).scalar_one_or_none()
assert surviving == ancient_id
# ---- recover_stalled_download_events ----------------------------------
def _make_source(session, *, slug: str) -> int:
"""Create an Artist + Source pair for the download-recovery tests."""
from backend.app.models import Artist, Source
artist = Artist(name=f"Artist {slug}", slug=slug)
session.add(artist)
session.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url=f"https://example.com/{slug}",
)
session.add(source)
session.flush()
return source.id
def test_recover_stalled_download_skips_fresh(db_sync):
"""A pending event whose started_at is under the 30-min threshold is
left alone — the worker may still legitimately be processing it."""
from sqlalchemy import select
from backend.app.models import DownloadEvent, Source
from backend.app.tasks.maintenance import recover_stalled_download_events
sid = _make_source(db_sync, slug="fresh")
now = datetime.now(UTC)
db_sync.add(DownloadEvent(
source_id=sid, status="pending", started_at=now - timedelta(seconds=30),
))
db_sync.commit()
recovered = recover_stalled_download_events.apply().get()
assert recovered == 0
db_sync.expire_all()
status = db_sync.execute(
select(DownloadEvent.status).where(DownloadEvent.source_id == sid)
).scalar_one()
assert status == "pending"
failures = db_sync.execute(
select(Source.consecutive_failures).where(Source.id == sid)
).scalar_one()
assert failures == 0
def test_recover_stalled_download_flips_stale_pending(db_sync):
"""A 2-hour-old pending event flips to error AND the source is bumped
(consecutive_failures, last_error, last_checked_at) so the next scan
tick can re-queue it (the in-flight guard no longer blocks)."""
from sqlalchemy import select
from backend.app.models import DownloadEvent, Source
from backend.app.tasks.maintenance import recover_stalled_download_events
sid = _make_source(db_sync, slug="stale-p")
now = datetime.now(UTC)
db_sync.add(DownloadEvent(
source_id=sid, status="pending", started_at=now - timedelta(hours=2),
))
db_sync.commit()
recovered = recover_stalled_download_events.apply().get()
assert recovered == 1
db_sync.expire_all()
ev_row = db_sync.execute(
select(
DownloadEvent.status, DownloadEvent.finished_at, DownloadEvent.error,
).where(DownloadEvent.source_id == sid)
).one()
assert ev_row.status == "error"
assert ev_row.finished_at is not None
assert "stranded" in ev_row.error
src_row = db_sync.execute(
select(
Source.consecutive_failures, Source.last_error, Source.last_checked_at,
).where(Source.id == sid)
).one()
assert src_row.consecutive_failures == 1
assert "stranded" in src_row.last_error
assert src_row.last_checked_at is not None
def test_recover_stalled_download_flips_stale_running(db_sync):
"""'running' is the other in-flight state — recovery covers it equally."""
from sqlalchemy import select
from backend.app.models import DownloadEvent
from backend.app.tasks.maintenance import recover_stalled_download_events
sid = _make_source(db_sync, slug="stale-r")
now = datetime.now(UTC)
db_sync.add(DownloadEvent(
source_id=sid, status="running", started_at=now - timedelta(hours=2),
))
db_sync.commit()
recovered = recover_stalled_download_events.apply().get()
assert recovered == 1
db_sync.expire_all()
status = db_sync.execute(
select(DownloadEvent.status).where(DownloadEvent.source_id == sid)
).scalar_one()
assert status == "error"
def test_recover_stalled_download_dedupes_per_source(db_sync):
"""Two stale events on one source bump consecutive_failures ONCE.
Backoff is exponential on that counter (2^failures), so per-event bumps
would inflate the next check interval by 2^N for no real reason."""
from sqlalchemy import select
from backend.app.models import DownloadEvent, Source
from backend.app.tasks.maintenance import recover_stalled_download_events
sid = _make_source(db_sync, slug="dedupe")
now = datetime.now(UTC)
db_sync.add_all([
DownloadEvent(
source_id=sid, status="pending",
started_at=now - timedelta(hours=2),
),
DownloadEvent(
source_id=sid, status="running",
started_at=now - timedelta(hours=3),
),
])
db_sync.commit()
recovered = recover_stalled_download_events.apply().get()
assert recovered == 2
db_sync.expire_all()
failures = db_sync.execute(
select(Source.consecutive_failures).where(Source.id == sid)
).scalar_one()
assert failures == 1
-72
View File
@@ -1,72 +0,0 @@
"""FC-5: verify + ml_queue unit tests."""
import hashlib
import pytest
from backend.app.models import Artist, ImageRecord
from backend.app.services.migrators import ml_queue, verify
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_verify_row_counts_match(db):
db.add(Artist(name="vAlpha", slug="valpha", is_subscription=True))
db.add(Artist(name="vBeta", slug="vbeta", is_subscription=False))
await db.commit()
result = await verify.verify_async(
db, expected={"artist_subscriptions": 1},
)
assert result["artist_subscriptions"]["status"] == "ok"
@pytest.mark.asyncio
async def test_verify_sha256_sample_matches_disk(db, tmp_path):
f = tmp_path / "sample.bin"
f.write_bytes(b"hello world")
sha = hashlib.sha256(b"hello world").hexdigest()
db.add(ImageRecord(
path=str(f), sha256=sha, size_bytes=11, mime="application/octet-stream",
origin="imported_filesystem",
))
await db.commit()
result = await verify.verify_sha256_sample(db, sample_size=10)
assert result["matched"] >= 1
assert result["mismatched"] == 0
@pytest.mark.asyncio
async def test_verify_sha256_sample_detects_missing_file(db, tmp_path):
sha = "f" * 64
db.add(ImageRecord(
path=str(tmp_path / "does-not-exist.bin"),
sha256=sha, size_bytes=1, mime="application/octet-stream",
origin="imported_filesystem",
))
await db.commit()
result = await verify.verify_sha256_sample(db, sample_size=10)
assert result["missing"] >= 1
@pytest.mark.asyncio
async def test_ml_queue_returns_count_for_unprocessed(db, tmp_path, monkeypatch):
queued: list[int] = []
class _FakeTask:
def delay(self, image_id):
queued.append(image_id)
monkeypatch.setattr(
"backend.app.tasks.ml.tag_and_embed", _FakeTask(),
)
f = tmp_path / "q.bin"
f.write_bytes(b"x")
db.add(ImageRecord(
path=str(f), sha256="q" * 64, size_bytes=1,
mime="application/octet-stream", origin="imported_filesystem",
))
await db.commit()
count = await ml_queue.queue_all_unprocessed_async(db)
assert count >= 1
-395
View File
@@ -1,395 +0,0 @@
"""FC-5: tag_apply unit tests."""
import json
import pytest
from sqlalchemy import func, select
from backend.app.models import (
Artist,
ImageProvenance,
ImageRecord,
Post,
SeriesPage,
Source,
Tag,
TagKind,
image_tag,
)
from backend.app.services.migrators import tag_apply
pytestmark = pytest.mark.integration
async def _seed_image(db, sha: str, *, suffix="x"):
img = ImageRecord(
path=f"/images/imported/aa/{suffix}.jpg",
sha256=sha, size_bytes=10, mime="image/jpeg",
width=10, height=10, origin="imported_filesystem",
)
db.add(img)
await db.flush()
return img
def _write_manifest(
tmp_path, *,
artist_assignments=None, tag_associations=None,
series_pages=None, image_posts=None, schema_version=2,
):
d = tmp_path / "_migration_state"
d.mkdir(parents=True, exist_ok=True)
(d / "ir_tag_manifest.json").write_text(json.dumps({
"schema_version": schema_version,
"image_artist_assignments": artist_assignments or [],
"image_tag_associations": tag_associations or [],
"series_pages": series_pages or [],
"image_posts": image_posts or [],
}))
@pytest.mark.asyncio
async def test_artist_assignment_sets_image_artist_id(db, tmp_path):
sha = "a" * 64
await _seed_image(db, sha)
await db.commit()
_write_manifest(tmp_path, artist_assignments=[
{"sha256": sha, "artist_name": "BobAuthor"},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result["counts"]["rows_inserted"] >= 1
img = (await db.execute(
select(ImageRecord).where(ImageRecord.sha256 == sha)
)).scalar_one()
artist = (await db.execute(
select(Artist).where(Artist.id == img.artist_id)
)).scalar_one()
assert artist.name == "BobAuthor"
@pytest.mark.asyncio
async def test_tag_association_creates_image_tag(db, tmp_path):
sha = "b" * 64
await _seed_image(db, sha, suffix="b")
# Seed the tag itself (ir_ingest would have created it).
tag = Tag(name="blue_eyes", kind=TagKind.general)
db.add(tag)
await db.commit()
_write_manifest(tmp_path, tag_associations=[
{"sha256": sha, "tag_name": "blue_eyes", "tag_kind": "general", "fandom_name": None},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
row = (await db.execute(
select(image_tag.c.tag_id).where(image_tag.c.image_record_id == img_id)
)).all()
assert len(row) == 1
@pytest.mark.asyncio
async def test_unmatched_sha_appears_in_unmatched_list(db, tmp_path):
_write_manifest(tmp_path, tag_associations=[
{"sha256": "z" * 64, "tag_name": "missing_image", "tag_kind": "general", "fandom_name": None},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert any(e["sha256"] == "z" * 64 for e in result["unmatched"])
@pytest.mark.asyncio
async def test_series_page_created(db, tmp_path):
sha = "c" * 64
await _seed_image(db, sha, suffix="c")
series_tag = Tag(name="MySeries", kind=TagKind.series)
db.add(series_tag)
await db.commit()
_write_manifest(tmp_path, series_pages=[
{"sha256": sha, "series_tag_name": "MySeries", "page_number": 1},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
sp = (await db.execute(
select(SeriesPage).where(SeriesPage.image_id == img_id)
)).scalar_one()
assert sp.page_number == 1
@pytest.mark.asyncio
async def test_idempotent_on_rerun(db, tmp_path):
sha = "d" * 64
await _seed_image(db, sha, suffix="d")
tag = Tag(name="rerun_tag", kind=TagKind.general)
db.add(tag)
await db.commit()
_write_manifest(tmp_path, tag_associations=[
{"sha256": sha, "tag_name": "rerun_tag", "tag_kind": "general", "fandom_name": None},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
result2 = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result2["counts"]["rows_skipped"] >= 1
@pytest.mark.asyncio
async def test_missing_manifest_raises(db, tmp_path):
with pytest.raises(FileNotFoundError):
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
@pytest.mark.asyncio
async def test_dry_run_makes_no_changes(db, tmp_path):
sha = "e" * 64
await _seed_image(db, sha, suffix="e")
tag = Tag(name="dry_tag", kind=TagKind.general)
db.add(tag)
await db.commit()
_write_manifest(tmp_path, tag_associations=[
{"sha256": sha, "tag_name": "dry_tag", "tag_kind": "general", "fandom_name": None},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=True)
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
rows = (await db.execute(
select(image_tag.c.tag_id).where(image_tag.c.image_record_id == img_id)
)).all()
assert len(rows) == 0
# --- Phase 4: image_posts (schema v2) -----------------------------------
_POST_ENTRY = {
"platform": "patreon",
"post_id": "10001",
"artist": "Maewix",
"title": "Test Post Title",
"description": "<p>HTML description</p>",
"source_url": "https://www.patreon.com/posts/test-10001",
"attachment_count": 1,
"published_at": "2026-01-15T12:00:00+00:00",
}
@pytest.mark.asyncio
async def test_image_posts_creates_source_post_provenance(db, tmp_path):
sha = "d" * 64
await _seed_image(db, sha, suffix="d")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": [sha]},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result["counts"]["rows_inserted"] >= 1
# Async Core-DML assertions — column selects, not ORM attribute access.
artist_count = (await db.execute(
select(func.count(Artist.id)).where(Artist.slug == "maewix")
)).scalar_one()
assert artist_count == 1
source_count = (await db.execute(
select(func.count(Source.id)).where(
Source.platform == "patreon",
Source.url == "https://www.patreon.com/maewix",
)
)).scalar_one()
assert source_count == 1
post_count = (await db.execute(
select(func.count(Post.id)).where(Post.external_post_id == "10001")
)).scalar_one()
assert post_count == 1
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
prov_count = (await db.execute(
select(func.count(ImageProvenance.id))
.where(ImageProvenance.image_record_id == img_id)
)).scalar_one()
assert prov_count == 1
# Phase 4 must also set ImageRecord.primary_post_id so the gallery's
# effective_date COALESCE can surface Post.post_date. Operator-flagged
# 2026-05-25: without this, IR-migrated images keep sorting by FC's
# scan date instead of the original publish date.
primary_post_id = (await db.execute(
select(ImageRecord.primary_post_id).where(ImageRecord.id == img_id)
)).scalar_one()
canonical_post_id = (await db.execute(
select(Post.id).where(Post.external_post_id == "10001")
)).scalar_one()
assert primary_post_id == canonical_post_id
@pytest.mark.asyncio
async def test_image_posts_primary_post_id_not_clobbered(db, tmp_path):
"""If the importer already set primary_post_id (e.g. a downloaded
image with a known provenance), phase 4 must NOT overwrite it when
re-running tag_apply against the IR migration. The existing
download-time linkage is the source of truth."""
sha = "9" * 64
await _seed_image(db, sha, suffix="9")
# Pre-set primary_post_id to a sentinel Post so we can detect a clobber.
img_id = (await db.execute(
select(ImageRecord.id).where(ImageRecord.sha256 == sha)
)).scalar_one()
# Build an existing Source + Post for the sentinel.
art = Artist(name="Pre-existing", slug="pre-existing")
db.add(art)
await db.flush()
src = Source(
artist_id=art.id, platform="patreon",
url="https://www.patreon.com/pre-existing",
)
db.add(src)
await db.flush()
sentinel_post = Post(
source_id=src.id, external_post_id="sentinel-99",
post_title="Pre-existing",
)
db.add(sentinel_post)
await db.flush()
await db.execute(
ImageRecord.__table__.update()
.where(ImageRecord.id == img_id)
.values(primary_post_id=sentinel_post.id)
)
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": [sha]},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
# The migration created a NEW Post (external_post_id="10001") and a
# new ImageProvenance, but primary_post_id must still point at the
# original sentinel.
primary_post_id = (await db.execute(
select(ImageRecord.primary_post_id).where(ImageRecord.id == img_id)
)).scalar_one()
assert primary_post_id == sentinel_post.id
@pytest.mark.asyncio
async def test_image_posts_idempotent_on_rerun(db, tmp_path):
sha = "e" * 64
await _seed_image(db, sha, suffix="e")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": [sha]},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
result2 = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
# Re-run: nothing should be newly inserted; all skipped.
assert result2["counts"]["rows_inserted"] == 0
assert result2["counts"]["rows_skipped"] >= 1
prov_count = (await db.execute(
select(func.count(ImageProvenance.id))
)).scalar_one()
assert prov_count == 1
@pytest.mark.asyncio
async def test_image_posts_unknown_sha_unmatched(db, tmp_path):
# No image seeded; sha doesn't exist.
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": ["f" * 64]},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
unmatched_kinds = [u["kind"] for u in result["unmatched"]]
assert "post" in unmatched_kinds
@pytest.mark.asyncio
async def test_image_posts_unknown_platform_skipped(db, tmp_path):
sha = "1" * 64
await _seed_image(db, sha, suffix="1")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "platform": "tumblr", "image_sha256s": [sha]},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result["counts"]["rows_skipped"] >= 1
# No Post / Source / ImageProvenance should have been created.
src_count = (await db.execute(
select(func.count(Source.id)).where(Source.platform == "tumblr")
)).scalar_one()
assert src_count == 0
@pytest.mark.parametrize("platform,expected_url", [
("deviantart", "https://www.deviantart.com/maewix"),
("pixiv", "https://www.pixiv.net/users/maewix"),
])
@pytest.mark.asyncio
async def test_image_posts_extended_platforms_create_source(
db, tmp_path, platform, expected_url,
):
"""Regression for 2026-05-25 operator-reported bug: phase 4's
_PLATFORM_PROFILE_URL had only patreon/subscribestar/hentaifoundry,
silently dropping deviantart + pixiv PostMetadata from the IR migration."""
sha = f"{platform[0]}" * 64
await _seed_image(db, sha, suffix=f"_{platform}")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "platform": platform, "image_sha256s": [sha]},
])
result = await tag_apply.apply_async(db, images_root=tmp_path, dry_run=False)
assert result["counts"]["rows_inserted"] >= 1
src_url = (await db.execute(
select(Source.url).where(Source.platform == platform)
)).scalar_one()
assert src_url == expected_url
# ImageProvenance row was created.
prov_count = (await db.execute(
select(func.count(ImageProvenance.id))
.join(Source, Source.id == ImageProvenance.source_id)
.where(Source.platform == platform)
)).scalar_one()
assert prov_count == 1
@pytest.mark.asyncio
async def test_image_posts_dry_run_makes_no_writes(db, tmp_path):
sha = "2" * 64
await _seed_image(db, sha, suffix="2")
await db.commit()
_write_manifest(tmp_path, image_posts=[
{**_POST_ENTRY, "image_sha256s": [sha]},
])
await tag_apply.apply_async(db, images_root=tmp_path, dry_run=True)
prov_count = (await db.execute(
select(func.count(ImageProvenance.id))
)).scalar_one()
assert prov_count == 0