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:
@@ -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"])
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user