Merge pull request 'Dashboard insights + project-wide DRY pass' (#32) from dev into main

This commit was merged in pull request #32.
This commit is contained in:
2026-05-28 15:38:26 -04:00
120 changed files with 1853 additions and 1016 deletions
+16
View File
@@ -0,0 +1,16 @@
"""Shared API response helpers."""
from quart import jsonify
def error_response(
error: str, *, status: int = 400, detail: str | None = None, **extra,
):
"""JSON error body + HTTP status. `detail` is included only when given;
`extra` keys are merged into the body. Returns the (response, status)
tuple Quart expects. Imported as `_bad` by the blueprints."""
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
+22 -6
View File
@@ -6,6 +6,7 @@ Five action surfaces:
DELETE /api/admin/tags/<int:tag_id> (Tier B) DELETE /api/admin/tags/<int:tag_id> (Tier B)
POST /api/admin/tags/<int:dest_id>/merge (Tier B) POST /api/admin/tags/<int:dest_id>/merge (Tier B)
POST /api/admin/tags/prune-unused (Tier A) POST /api/admin/tags/prune-unused (Tier A)
POST /api/admin/tags/purge-legacy (Tier A)
GET /api/admin/tags/<int:tag_id>/usage-count (helper) GET /api/admin/tags/<int:tag_id>/usage-count (helper)
Tier-C ops take a dry_run body flag (returns projection inline, Tier-C ops take a dry_run body flag (returns projection inline,
@@ -23,16 +24,11 @@ from sqlalchemy import select
from ..extensions import get_session from ..extensions import get_session
from ..models import Artist from ..models import Artist
from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete
from ._responses import error_response as _bad
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin") admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _bulk_image_confirm_token(image_ids: list[int]) -> str: def _bulk_image_confirm_token(image_ids: list[int]) -> str:
"""Stable 8-hex token derived from the sorted id list. Mutates """Stable 8-hex token derived from the sorted id list. Mutates
when the selection changes; stays the same across modal opens of when the selection changes; stays the same across modal opens of
@@ -206,3 +202,23 @@ async def tags_prune_unused():
) )
) )
return jsonify(result) return jsonify(result)
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
async def tags_purge_legacy():
"""Tier-A: delete legacy IR-migration tags — archive/post/artist
kinds (e.g. `BlenderKnight:Hannah_BJ_Loops`) PLUS general tags with
a legacy name prefix (`source:*`, from IR's source kind that fell
back to general). dry-run preview returns per-kind + per-prefix
counts + a sample so the UI shows exactly what'll go before the
operator confirms with dry_run=false."""
from ..services.cleanup_service import purge_legacy_tags
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
async with get_session() as session:
result = await session.run_sync(
lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run)
)
return jsonify(result)
+1 -6
View File
@@ -31,18 +31,13 @@ from sqlalchemy import select
from ..extensions import get_session from ..extensions import get_session
from ..models import LibraryAuditRun from ..models import LibraryAuditRun
from ..services import cleanup_service from ..services import cleanup_service
from ._responses import error_response as _bad
cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup") cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")
IMAGES_ROOT = Path("/images") IMAGES_ROOT = Path("/images")
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _min_dim_token(min_w: int, min_h: int) -> str: def _min_dim_token(min_w: int, min_h: int) -> str:
# SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both # SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both
# sides use SHA-256 truncated to 8 hex chars. # sides use SHA-256 truncated to 8 hex chars.
+54 -8
View File
@@ -20,6 +20,7 @@ from ..services.credential_service import (
UnknownPlatformError, UnknownPlatformError,
WrongAuthTypeError, WrongAuthTypeError,
) )
from ._responses import error_response as _bad
credentials_bp = Blueprint("credentials", __name__, url_prefix="/api/credentials") credentials_bp = Blueprint("credentials", __name__, url_prefix="/api/credentials")
@@ -38,14 +39,6 @@ def _get_crypto() -> CredentialCrypto:
return _crypto return _crypto
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
async def _ext_key_ok(session) -> bool: async def _ext_key_ok(session) -> bool:
"""If X-Extension-Key is supplied, it must match the stored value. """If X-Extension-Key is supplied, it must match the stored value.
Missing header → True (browser path; accepted per homelab posture). Missing header → True (browser path; accepted per homelab posture).
@@ -124,3 +117,56 @@ async def delete_credential(platform: str):
except LookupError: except LookupError:
return _bad("not_found", status=404) return _bad("not_found", status=404)
return "", 204 return "", 204
@credentials_bp.route("/<platform>/verify", methods=["POST"])
async def verify_credential(platform: str):
"""Test the stored credential by running gallery-dl --simulate
against one of the platform's enabled sources. On success stamps
last_verified. Returns {valid: bool|null, reason, last_verified?}.
valid=null means "couldn't test" (no credential, or no enabled
source to point at)."""
from ..models import Artist, Source
from ..services.gallery_dl import GalleryDLService, SourceConfig
async with get_session() as session:
if not await _ext_key_ok(session):
return _bad("unauthorized", status=401)
svc = CredentialService(session, _get_crypto())
record = await svc.get(platform)
if record is None:
return jsonify({"valid": None, "reason": "No credential stored for this platform."})
# Pick an enabled source for this platform to point the probe at.
row = (await session.execute(
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.where(Source.platform == platform, Source.enabled.is_(True))
.order_by(Source.id.asc())
)).first()
if row is None:
return jsonify({
"valid": None,
"reason": "No enabled source for this platform to verify against — add a subscription first.",
})
source, artist = row
cookies_path = await svc.get_cookies_path(platform)
auth_token = await svc.get_token(platform)
gdl = GalleryDLService(images_root=Path("/images"))
ok, message = await gdl.verify(
url=source.url,
artist_slug=artist.slug,
platform=platform,
source_config=SourceConfig.from_dict(source.config_overrides or {}),
cookies_path=str(cookies_path) if cookies_path else None,
auth_token=auth_token,
)
last_verified = None
if ok:
async with get_session() as session:
ts = await CredentialService(session, _get_crypto()).mark_verified(platform)
last_verified = ts.isoformat() if ts else None
return jsonify({"valid": ok, "reason": message, "last_verified": last_verified})
+48
View File
@@ -126,6 +126,54 @@ async def downloads_stats():
return jsonify(out) return jsonify(out)
@downloads_bp.route("/activity", methods=["GET"])
async def downloads_activity():
"""Hourly download-event counts over the last `?hours=` (default 24).
Returns a fixed-length, oldest-first bucket array so the UI can render
a sparkline directly. Bucketing is done in Python against UTC to dodge
session-timezone ambiguity in SQL date_trunc.
"""
try:
hours = int(request.args.get("hours", "24"))
except ValueError:
return jsonify({"error": "invalid_hours"}), 400
hours = max(1, min(168, hours))
now = datetime.now(UTC)
end = now.replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=hours - 1)
buckets = [
{"hour": (start + timedelta(hours=i)).isoformat(),
"ok": 0, "error": 0, "other": 0, "total": 0}
for i in range(hours)
]
async with get_session() as session:
rows = (await session.execute(
select(DownloadEvent.started_at, DownloadEvent.status)
.where(DownloadEvent.started_at >= start)
)).all()
for started_at, status in rows:
if started_at is None:
continue
sa = started_at if started_at.tzinfo else started_at.replace(tzinfo=UTC)
idx = int((sa - start).total_seconds() // 3600)
if not (0 <= idx < hours):
continue
b = buckets[idx]
if status == "ok":
b["ok"] += 1
elif status == "error":
b["error"] += 1
else:
b["other"] += 1
b["total"] += 1
return jsonify({"hours": hours, "buckets": buckets})
@downloads_bp.route("/<int:event_id>", methods=["GET"]) @downloads_bp.route("/<int:event_id>", methods=["GET"])
async def get_download(event_id: int): async def get_download(event_id: int):
async with get_session() as session: async with get_session() as session:
+1 -6
View File
@@ -20,6 +20,7 @@ from ..services.extension_service import (
UnknownPlatformError, UnknownPlatformError,
) )
from ..services.source_service import KNOWN_PLATFORMS from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension") extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension")
@@ -30,12 +31,6 @@ XPI_DIR = Path("/app/frontend/dist/extension")
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$") _XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
async def _ext_key_required(session) -> bool: async def _ext_key_required(session) -> bool:
"""Unlike /api/credentials (which accepts the browser path with no """Unlike /api/credentials (which accepts the browser path with no
header), quick-add-source writes server state and must be explicitly header), quick-add-source writes server state and must be explicitly
+1 -3
View File
@@ -159,9 +159,7 @@ def _refetch_task_sync(session, task_id: int) -> dict:
return {"status": "not_found"} return {"status": "not_found"}
if task.status != "failed": if task.status != "failed":
return {"status": "not_failed"} return {"status": "not_failed"}
settings = session.execute( settings = ImportSettings.load_sync(session)
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
return attempt_refetch(session, task, Path(settings.import_scan_path)) return attempt_refetch(session, task, Path(settings.import_scan_path))
+1 -6
View File
@@ -13,6 +13,7 @@ from sqlalchemy import select
from ..extensions import get_session from ..extensions import get_session
from ..models import MigrationRun from ..models import MigrationRun
from ..tasks.migration import run_migration from ..tasks.migration import run_migration
from ._responses import error_response as _bad
migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate") migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate")
@@ -24,12 +25,6 @@ _VALID_KINDS = frozenset({
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"}) _INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _run_to_dict(run: MigrationRun) -> dict: def _run_to_dict(run: MigrationRun) -> dict:
return { return {
"id": run.id, "id": run.id,
+1 -8
View File
@@ -5,18 +5,11 @@ from quart import Blueprint, jsonify, request
from ..extensions import get_session from ..extensions import get_session
from ..services.post_feed_service import PostFeedService from ..services.post_feed_service import PostFeedService
from ..services.source_service import KNOWN_PLATFORMS from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts") posts_bp = Blueprint("posts", __name__, url_prefix="/api/posts")
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
@posts_bp.route("", methods=["GET"]) @posts_bp.route("", methods=["GET"])
async def list_posts(): async def list_posts():
args = request.args args = request.args
+2 -6
View File
@@ -31,9 +31,7 @@ _EDITABLE_FIELDS = (
@settings_bp.route("/settings/import", methods=["GET"]) @settings_bp.route("/settings/import", methods=["GET"])
async def get_import_settings(): async def get_import_settings():
async with get_session() as session: async with get_session() as session:
row = ( row = await ImportSettings.load(session)
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
).scalar_one()
return jsonify({ return jsonify({
"min_width": row.min_width, "min_width": row.min_width,
"min_height": row.min_height, "min_height": row.min_height,
@@ -99,9 +97,7 @@ async def update_import_settings():
return _bad_int("download_failure_warning_threshold", 1, 100) return _bad_int("download_failure_warning_threshold", 1, 100)
async with get_session() as session: async with get_session() as session:
row = ( row = await ImportSettings.load(session)
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
).scalar_one()
for field in _EDITABLE_FIELDS: for field in _EDITABLE_FIELDS:
if field in body: if field in body:
setattr(row, field, body[field]) setattr(row, field, body[field])
+11 -9
View File
@@ -5,6 +5,7 @@ from sqlalchemy import select
from ..extensions import get_session from ..extensions import get_session
from ..models import DownloadEvent, Source from ..models import DownloadEvent, Source
from ..services.scheduler_service import scheduler_status
from ..services.source_service import ( from ..services.source_service import (
KNOWN_PLATFORMS, KNOWN_PLATFORMS,
ArtistNotFoundError, ArtistNotFoundError,
@@ -14,18 +15,11 @@ from ..services.source_service import (
SourceService, SourceService,
UnknownPlatformError, UnknownPlatformError,
) )
from ._responses import error_response as _bad
sources_bp = Blueprint("sources", __name__, url_prefix="/api/sources") sources_bp = Blueprint("sources", __name__, url_prefix="/api/sources")
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
body = {"error": error}
if detail is not None:
body["detail"] = detail
body.update(extra)
return jsonify(body), status
@sources_bp.route("", methods=["GET"]) @sources_bp.route("", methods=["GET"])
async def list_sources(): async def list_sources():
artist_id_raw = request.args.get("artist_id") artist_id_raw = request.args.get("artist_id")
@@ -35,11 +29,19 @@ async def list_sources():
artist_id = int(artist_id_raw) artist_id = int(artist_id_raw)
except ValueError: except ValueError:
return _bad("invalid_artist_id", detail="artist_id must be an integer") return _bad("invalid_artist_id", detail="artist_id must be an integer")
failing = request.args.get("failing", "").lower() in ("1", "true", "yes")
async with get_session() as session: async with get_session() as session:
records = await SourceService(session).list(artist_id=artist_id) records = await SourceService(session).list(artist_id=artist_id, failing=failing)
return jsonify([r.to_dict() for r in records]) return jsonify([r.to_dict() for r in records])
@sources_bp.route("/schedule-status", methods=["GET"])
async def schedule_status():
"""FC-dashboards: scheduler health for the Subscriptions hub."""
async with get_session() as session:
return jsonify(await scheduler_status(session))
@sources_bp.route("/<int:source_id>", methods=["GET"]) @sources_bp.route("/<int:source_id>", methods=["GET"])
async def get_source(source_id: int): async def get_source(source_id: int):
async with get_session() as session: async with get_session() as session:
+3 -12
View File
@@ -14,6 +14,7 @@ from sqlalchemy import desc, select
from ..extensions import get_session from ..extensions import get_session
from ..models import BackupRun, ImportSettings from ..models import BackupRun, ImportSettings
from ._responses import error_response as _bad
system_backup_bp = Blueprint( system_backup_bp = Blueprint(
"system_backup", __name__, url_prefix="/api/system/backup", "system_backup", __name__, url_prefix="/api/system/backup",
@@ -29,12 +30,6 @@ _BACKUP_SETTINGS_FIELDS = (
) )
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _row_to_dict(r: BackupRun) -> dict: def _row_to_dict(r: BackupRun) -> dict:
return { return {
"id": r.id, "id": r.id,
@@ -232,9 +227,7 @@ async def delete_run(run_id: int):
@system_backup_bp.route("/settings", methods=["GET"]) @system_backup_bp.route("/settings", methods=["GET"])
async def get_settings(): async def get_settings():
async with get_session() as session: async with get_session() as session:
row = (await session.execute( row = await ImportSettings.load(session)
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
return jsonify({ return jsonify({
"backup_db_nightly_enabled": row.backup_db_nightly_enabled, "backup_db_nightly_enabled": row.backup_db_nightly_enabled,
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc, "backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
@@ -254,9 +247,7 @@ async def patch_settings():
return err return err
async with get_session() as session: async with get_session() as session:
row = (await session.execute( row = await ImportSettings.load(session)
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
for field in _BACKUP_SETTINGS_FIELDS: for field in _BACKUP_SETTINGS_FIELDS:
if field in body: if field in body:
setattr(row, field, body[field]) setattr(row, field, body[field])
+11 -1
View File
@@ -4,7 +4,7 @@ Enforced as a single row via a CHECK (id = 1) constraint. The application
always SELECTs id=1 and never inserts/deletes after the initial migration. always SELECTs id=1 and never inserts/deletes after the initial migration.
""" """
from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text, select
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@@ -63,3 +63,13 @@ class ImportSettings(Base):
backup_images_keep_last_n: Mapped[int] = mapped_column( backup_images_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=3, Integer, nullable=False, default=3,
) )
@classmethod
async def load(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via an async session."""
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
@classmethod
def load_sync(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via a sync session."""
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
+67 -1
View File
@@ -16,7 +16,7 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from sqlalchemy import func, select, update from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
@@ -369,6 +369,72 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
return {"deleted": len(ids), "sample_names": sample} return {"deleted": len(ids), "sample_names": sample}
# Legacy tags FC no longer uses, in two shapes:
# (1) kinds the tag input never produces — archive/post/artist.
# provenance (post grouping) + archive membership are their own
# systems now, and artists are first-class Artist/Source rows.
# meta/rating were already hard-deleted by alembic 0023.
# (2) name prefixes from IR kinds FC never adopted — `source:*`.
# ImageRepo had a `source` kind; FC's enum doesn't, so ir_ingest
# fell those back to `general` (kind=general, name="source:patreon"
# etc.). They can't be caught by kind, so we match the name prefix.
PURGEABLE_TAG_KINDS = ("archive", "post", "artist")
LEGACY_NAME_PREFIXES = ("source:",)
def _legacy_tag_predicate():
name_clauses = [Tag.name.like(f"{p}%") for p in LEGACY_NAME_PREFIXES]
return or_(Tag.kind.in_(PURGEABLE_TAG_KINDS), *name_clauses)
def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
"""Count (dry_run) or delete legacy IR-migration tags: archive/post/
artist-kind tags PLUS general tags whose name matches a legacy
prefix (source:*).
CASCADE on image_tag / tag_alias / tag_allowlist /
tag_reference_embedding / tag_suggestion_rejection / series_page
clears the related rows on the parent DELETE.
Returns:
{"by_kind": {kind: count, ...}, # kind-matched rows
"by_prefix": {"source:*": count}, # name-prefix-matched rows
"count": total, "sample_names": [first 50],
and on live runs "deleted": total}
"""
predicate = _legacy_tag_predicate()
rows = session.execute(
select(Tag.id, Tag.name, Tag.kind).where(predicate)
).all()
by_kind: dict[str, int] = {}
by_prefix: dict[str, int] = {}
for _id, name, kind in rows:
# Classify by name-prefix first so a source:* row counts once,
# under the prefix bucket, regardless of its (general) kind.
matched_prefix = next(
(p for p in LEGACY_NAME_PREFIXES if name.startswith(p)), None,
)
if matched_prefix is not None:
label = f"{matched_prefix}*"
by_prefix[label] = by_prefix.get(label, 0) + 1
else:
key = kind.value if hasattr(kind, "value") else str(kind)
by_kind[key] = by_kind.get(key, 0) + 1
sample = [name for _id, name, _kind in rows[:50]]
total = len(rows)
result = {
"by_kind": by_kind, "by_prefix": by_prefix,
"count": total, "sample_names": sample,
}
if dry_run:
return result
if total:
session.execute(Tag.__table__.delete().where(predicate))
session.commit()
result["deleted"] = total
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules. # FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+14 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import json import json
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
@@ -163,6 +163,19 @@ class CredentialService:
return None return None
return self.crypto.decrypt(row.encrypted_blob) return self.crypto.decrypt(row.encrypted_blob)
async def mark_verified(self, platform: str) -> datetime | None:
"""Stamp last_verified=now after a successful verify. Returns the
timestamp, or None if the credential is gone."""
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
if row is None:
return None
ts = datetime.now(UTC)
row.last_verified = ts
await self.session.commit()
return ts
def _augment_cookies(platform: str, netscape: str) -> str: def _augment_cookies(platform: str, netscape: str) -> str:
"""Delegate to the platform's `augment_cookies` hook if one is """Delegate to the platform's `augment_cookies` hook if one is
+61
View File
@@ -658,3 +658,64 @@ class GalleryDLService:
Path(temp_config_path).unlink() # noqa: ASYNC240 Path(temp_config_path).unlink() # noqa: ASYNC240
except Exception: except Exception:
pass pass
async def verify(
self,
url: str,
artist_slug: str,
platform: str,
source_config: SourceConfig | None = None,
cookies_path: str | None = None,
auth_token: str | None = None,
timeout: float = 45.0, # noqa: ASYNC109 — subprocess.run timeout, not a coroutine deadline
) -> tuple[bool, str]:
"""Test that credentials authenticate against `url` WITHOUT
downloading anything. Runs gallery-dl in --simulate mode limited
to the first item; if auth is bad the extractor errors before it
can list, which _categorize_error flags as AUTH_ERROR. Returns
(ok, message). Used by the credential Verify button."""
if source_config is None:
source_config = SourceConfig()
config = self._build_config_for_source(platform, source_config, artist_slug)
if cookies_path:
config["extractor"]["cookies"] = cookies_path
if auth_token and platform == "discord":
config["extractor"].setdefault("discord", {})["token"] = auth_token
if auth_token and platform == "pixiv":
config["extractor"].setdefault("pixiv", {})["refresh-token"] = auth_token
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, dir=str(self._config_dir),
) as fh:
json.dump(config, fh, indent=2)
temp_config_path = fh.name
try:
cmd = [
sys.executable, "-m", "gallery_dl",
"--config", temp_config_path,
"--simulate", "--range", "1-1", "--verbose", url,
]
loop = asyncio.get_running_loop()
proc = await loop.run_in_executor(
None,
lambda: subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
),
)
etype, msg = self._categorize_error(proc.returncode, proc.stdout, proc.stderr)
if proc.returncode == 0 or etype == ErrorType.NO_NEW_CONTENT:
return True, "Credentials valid — the feed authenticated."
if etype == ErrorType.AUTH_ERROR:
return False, msg
# Network / not-found / rate-limit / unknown: inconclusive,
# not a definitive credential failure. Surface the reason.
return False, f"Could not confirm ({etype.value}): {msg}"
except subprocess.TimeoutExpired:
return False, f"Verification timed out after {timeout:.0f}s"
except Exception as exc: # noqa: BLE001
return False, f"Verification error: {exc}"
finally:
try:
Path(temp_config_path).unlink() # noqa: ASYNC240
except Exception:
pass
+51 -73
View File
@@ -204,6 +204,32 @@ class Importer:
(phash, width or 0, height or 0, image_id) (phash, width or 0, height or 0, image_id)
) )
def _get_or_create(self, stmt, factory):
"""Race-safe find-or-create. Run `stmt` (scalar_one_or_none); if a
row exists, return it. Otherwise open a savepoint and INSERT
``factory()``; on IntegrityError (a concurrent worker inserted the
same row first) roll the savepoint back — NOT the outer transaction,
which would lose the surrounding scan's progress — and re-run `stmt`
(scalar_one) to return the row the other worker created.
Centralizes the pattern shared by _find_or_create_source,
_source_for_sidecar, and _find_or_create_post. The plain
SELECT-then-INSERT version lost races under the 5-min recovery sweep
(operator-flagged 2026-05-26)."""
existing = self.session.execute(stmt).scalar_one_or_none()
if existing is not None:
return existing
sp = self.session.begin_nested()
try:
row = factory()
self.session.add(row)
self.session.flush()
sp.commit()
return row
except IntegrityError:
sp.rollback()
return self.session.execute(stmt).scalar_one()
def _find_or_create_source( def _find_or_create_source(
self, *, artist_id: int, platform: str, url: str, self, *, artist_id: int, platform: str, url: str,
) -> Source: ) -> Source:
@@ -222,31 +248,15 @@ class Importer:
and re-select — the concurrent op just created the row we and re-select — the concurrent op just created the row we
wanted, so the second select will find it. wanted, so the second select will find it.
""" """
existing = self.session.execute( stmt = select(Source).where(
select(Source).where( Source.artist_id == artist_id,
Source.artist_id == artist_id, Source.platform == platform,
Source.platform == platform, Source.url == url,
Source.url == url, )
) return self._get_or_create(
).scalar_one_or_none() stmt,
if existing is not None: lambda: Source(artist_id=artist_id, platform=platform, url=url),
return existing )
sp = self.session.begin_nested()
try:
row = Source(artist_id=artist_id, platform=platform, url=url)
self.session.add(row)
self.session.flush()
sp.commit()
return row
except IntegrityError:
sp.rollback()
return self.session.execute(
select(Source).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
).scalar_one()
def _source_for_sidecar( def _source_for_sidecar(
self, *, artist_id: int, platform: str, artist_slug: str, self, *, artist_id: int, platform: str, artist_slug: str,
@@ -268,7 +278,7 @@ class Importer:
ONE synthetic anchor with url='sidecar:<platform>:<artist_slug>' and ONE synthetic anchor with url='sidecar:<platform>:<artist_slug>' and
enabled=False (so the subscription checker doesn't poll it). enabled=False (so the subscription checker doesn't poll it).
""" """
existing = self.session.execute( stmt = (
select(Source) select(Source)
.where( .where(
Source.artist_id == artist_id, Source.artist_id == artist_id,
@@ -276,33 +286,16 @@ class Importer:
) )
.order_by(Source.id.asc()) .order_by(Source.id.asc())
.limit(1) .limit(1)
).scalar_one_or_none() )
if existing is not None: return self._get_or_create(
return existing stmt,
synthetic_url = f"sidecar:{platform}:{artist_slug}" lambda: Source(
sp = self.session.begin_nested()
try:
row = Source(
artist_id=artist_id, artist_id=artist_id,
platform=platform, platform=platform,
url=synthetic_url, url=f"sidecar:{platform}:{artist_slug}",
enabled=False, enabled=False,
) ),
self.session.add(row) )
self.session.flush()
sp.commit()
return row
except IntegrityError:
sp.rollback()
return self.session.execute(
select(Source)
.where(
Source.artist_id == artist_id,
Source.platform == platform,
)
.order_by(Source.id.asc())
.limit(1)
).scalar_one()
def _find_or_create_post( def _find_or_create_post(
self, *, source_id: int, external_post_id: str, self, *, source_id: int, external_post_id: str,
@@ -310,29 +303,14 @@ class Importer:
"""Race-safe find-or-create on `post` keyed by """Race-safe find-or-create on `post` keyed by
(source_id, external_post_id). Mirrors `_find_or_create_source` (source_id, external_post_id). Mirrors `_find_or_create_source`
— same savepoint + IntegrityError-recovery pattern.""" — same savepoint + IntegrityError-recovery pattern."""
existing = self.session.execute( stmt = select(Post).where(
select(Post).where( Post.source_id == source_id,
Post.source_id == source_id, Post.external_post_id == external_post_id,
Post.external_post_id == external_post_id, )
) return self._get_or_create(
).scalar_one_or_none() stmt,
if existing is not None: lambda: Post(source_id=source_id, external_post_id=external_post_id),
return existing )
sp = self.session.begin_nested()
try:
row = Post(source_id=source_id, external_post_id=external_post_id)
self.session.add(row)
self.session.flush()
sp.commit()
return row
except IntegrityError:
sp.rollback()
return self.session.execute(
select(Post).where(
Post.source_id == source_id,
Post.external_post_id == external_post_id,
)
).scalar_one()
def import_one(self, source: Path) -> ImportResult: def import_one(self, source: Path) -> ImportResult:
"""Dispatch by kind. Media → normal pipeline. Archive → extract """Dispatch by kind. Media → normal pipeline. Archive → extract
+66 -4
View File
@@ -12,12 +12,17 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from ..models import Artist, ImportSettings, Source from ..models import AppSetting, Artist, ImportSettings, Source
MIN_INTERVAL_SECONDS = 60 MIN_INTERVAL_SECONDS = 60
MAX_INTERVAL_SECONDS = 86400 MAX_INTERVAL_SECONDS = 86400
MAX_BACKOFF_EXPONENT = 6 MAX_BACKOFF_EXPONENT = 6
# AppSetting key stamped every time the Beat tick fires (see scan.py). The
# tick runs every 60s; the UI flags the scheduler as stalled if the last
# stamp is older than a few minutes.
SCHEDULER_LAST_TICK_KEY = "scheduler_last_tick_at"
def compute_effective_interval( def compute_effective_interval(
source: Source, artist: Artist, settings: ImportSettings, source: Source, artist: Artist, settings: ImportSettings,
@@ -53,9 +58,7 @@ async def select_due_sources(session: AsyncSession) -> list[Source]:
.where(Artist.auto_check.is_(True)) .where(Artist.auto_check.is_(True))
)).scalars().all() )).scalars().all()
settings = (await session.execute( settings = await ImportSettings.load(session)
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
now = datetime.now(UTC) now = datetime.now(UTC)
due: list[Source] = [] due: list[Source] = []
@@ -78,3 +81,62 @@ def compute_next_check_at(
return None return None
interval = compute_effective_interval(source, artist, settings) interval = compute_effective_interval(source, artist, settings)
return source.last_checked_at + timedelta(seconds=interval) return source.last_checked_at + timedelta(seconds=interval)
async def record_tick(session: AsyncSession) -> None:
"""Stamp the current time on the SCHEDULER_LAST_TICK_KEY AppSetting.
Called once per Beat tick so the UI can prove the scheduler is alive.
Commits its own write so the stamp survives even if the rest of the
tick errors out.
"""
now_iso = datetime.now(UTC).isoformat()
row = (await session.execute(
select(AppSetting).where(AppSetting.key == SCHEDULER_LAST_TICK_KEY)
)).scalar_one_or_none()
if row is None:
session.add(AppSetting(key=SCHEDULER_LAST_TICK_KEY, value=now_iso))
else:
row.value = now_iso
await session.commit()
async def scheduler_status(session: AsyncSession) -> dict:
"""Summarise scheduler health for the dashboard.
Returns last_tick_at (when Beat last fired), next_due_at (earliest
upcoming scheduled check across enabled auto-check sources), due_now
(how many are due right now), and auto_sources (total under schedule).
"""
last_tick_at = (await session.execute(
select(AppSetting.value).where(AppSetting.key == SCHEDULER_LAST_TICK_KEY)
)).scalar_one_or_none()
rows = (await session.execute(
select(Source)
.options(selectinload(Source.artist))
.join(Artist, Source.artist_id == Artist.id)
.where(Source.enabled.is_(True))
.where(Artist.auto_check.is_(True))
)).scalars().all()
settings = await ImportSettings.load(session)
now = datetime.now(UTC)
due_now = 0
next_due_at: datetime | None = None
for s in rows:
if s.last_checked_at is None:
due_now += 1
continue
nca = compute_next_check_at(s, s.artist, settings)
if nca is None or nca <= now:
due_now += 1
elif next_due_at is None or nca < next_due_at:
next_due_at = nca
return {
"last_tick_at": last_tick_at,
"next_due_at": next_due_at.isoformat() if next_due_at else None,
"due_now": due_now,
"auto_sources": len(rows),
}
+12 -9
View File
@@ -120,9 +120,7 @@ class SourceService:
return config return config
async def _load_settings(self) -> ImportSettings: async def _load_settings(self) -> ImportSettings:
return (await self.session.execute( return await ImportSettings.load(self.session)
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
def _build_record( def _build_record(
self, source: Source, artist: Artist, settings: ImportSettings, self, source: Source, artist: Artist, settings: ImportSettings,
@@ -151,14 +149,19 @@ class SourceService:
settings = await self._load_settings() settings = await self._load_settings()
return self._build_record(source, artist, settings) return self._build_record(source, artist, settings)
async def list(self, artist_id: int | None = None) -> list[SourceRecord]: async def list(
stmt = ( self, artist_id: int | None = None, failing: bool = False,
select(Source, Artist) ) -> list[SourceRecord]:
.join(Artist, Artist.id == Source.artist_id) stmt = select(Source, Artist).join(Artist, Artist.id == Source.artist_id)
.order_by(Artist.name.asc(), Source.id.asc())
)
if artist_id is not None: if artist_id is not None:
stmt = stmt.where(Source.artist_id == artist_id) stmt = stmt.where(Source.artist_id == artist_id)
if failing:
# Worst-first so the rollup card surfaces the loudest failures.
stmt = stmt.where(Source.consecutive_failures > 0).order_by(
Source.consecutive_failures.desc(), Artist.name.asc(),
)
else:
stmt = stmt.order_by(Artist.name.asc(), Source.id.asc())
rows = (await self.session.execute(stmt)).all() rows = (await self.session.execute(stmt)).all()
settings = await self._load_settings() settings = await self._load_settings()
return [self._build_record(s, a, settings) for s, a in rows] return [self._build_record(s, a, settings) for s, a in rows]
+21
View File
@@ -0,0 +1,21 @@
"""Per-invocation async session factory for Celery task modules.
Async engine connections are bound to the event loop. Each Celery task
runs its async body under a fresh ``asyncio.run()`` loop, so it needs its
own engine created (and disposed) within that loop — a process-wide async
engine would reuse loop-bound connections across tasks and raise "attached
to a different loop". So unlike the process-wide sync engine in
``_sync_engine.py``, this returns a fresh engine per call; the caller
disposes it (``await engine.dispose()``) when its loop ends.
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from ..config import get_config
def async_session_factory():
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine."""
cfg = get_config()
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
+2 -6
View File
@@ -246,9 +246,7 @@ def prune_backups() -> dict:
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0} counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
with SessionLocal() as session: with SessionLocal() as session:
s = session.execute( s = ImportSettings.load_sync(session)
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
for kind, keep in ( for kind, keep in (
("db", s.backup_db_keep_last_n), ("db", s.backup_db_keep_last_n),
("images", s.backup_images_keep_last_n), ("images", s.backup_images_keep_last_n),
@@ -286,9 +284,7 @@ def backup_db_nightly() -> dict:
either {'skipped': '<reason>'} or {'dispatched': '<task_id>'}.""" either {'skipped': '<reason>'} or {'dispatched': '<task_id>'}."""
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
with SessionLocal() as session: with SessionLocal() as session:
s = session.execute( s = ImportSettings.load_sync(session)
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
nightly_enabled = s.backup_db_nightly_enabled nightly_enabled = s.backup_db_nightly_enabled
configured_hour = s.backup_db_nightly_hour_utc configured_hour = s.backup_db_nightly_hour_utc
if not nightly_enabled: if not nightly_enabled:
+4 -16
View File
@@ -3,12 +3,9 @@
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError from sqlalchemy.exc import DBAPIError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from ..celery_app import celery from ..celery_app import celery
from ..config import get_config
from ..models import ImportSettings from ..models import ImportSettings
from ..services.credential_crypto import CredentialCrypto from ..services.credential_crypto import CredentialCrypto
from ..services.credential_service import CredentialService from ..services.credential_service import CredentialService
@@ -16,18 +13,13 @@ from ..services.download_service import DownloadService
from ..services.gallery_dl import GalleryDLService from ..services.gallery_dl import GalleryDLService
from ..services.importer import Importer from ..services.importer import Importer
from ..services.thumbnailer import Thumbnailer from ..services.thumbnailer import Thumbnailer
from ._async_session import async_session_factory
from .import_file import _sync_session_factory from .import_file import _sync_session_factory
IMAGES_ROOT = Path("/images") IMAGES_ROOT = Path("/images")
_KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64" _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
def _async_session_factory():
cfg = get_config()
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
@celery.task( @celery.task(
name="backend.app.tasks.download.download_source", name="backend.app.tasks.download.download_source",
bind=True, bind=True,
@@ -44,13 +36,11 @@ def download_source(self, source_id: int) -> int:
"""Returns the DownloadEvent.id.""" """Returns the DownloadEvent.id."""
async def _run(): async def _run():
async_factory, async_engine = _async_session_factory() async_factory, async_engine = async_session_factory()
SyncFactory = _sync_session_factory() SyncFactory = _sync_session_factory()
try: try:
with SyncFactory() as sync_session: with SyncFactory() as sync_session:
settings = sync_session.execute( settings = ImportSettings.load_sync(sync_session)
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
rate_limit = settings.download_rate_limit_seconds rate_limit = settings.download_rate_limit_seconds
validate_files = settings.download_validate_files validate_files = settings.download_validate_files
@@ -64,9 +54,7 @@ def download_source(self, source_id: int) -> int:
async with async_factory() as async_session: async with async_factory() as async_session:
cred_service = CredentialService(async_session, crypto) cred_service = CredentialService(async_session, crypto)
with SyncFactory() as sync_session: with SyncFactory() as sync_session:
sync_settings = sync_session.execute( sync_settings = ImportSettings.load_sync(sync_session)
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer( importer = Importer(
session=sync_session, session=sync_session,
images_root=IMAGES_ROOT, images_root=IMAGES_ROOT,
+1 -3
View File
@@ -167,9 +167,7 @@ def enqueue_import(task_id: int, task_type: str) -> None:
def _do_import(session, task, import_task_id: int) -> dict: def _do_import(session, task, import_task_id: int) -> dict:
"""Actual work, called from inside the resilience wrapper.""" """Actual work, called from inside the resilience wrapper."""
settings = session.execute( settings = ImportSettings.load_sync(session)
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
import_root = Path(settings.import_scan_path) import_root = Path(settings.import_scan_path)
batch = session.get(ImportBatch, task.batch_id) batch = session.get(ImportBatch, task.batch_id)
deep = bool(batch and batch.scan_mode == "deep") deep = bool(batch and batch.scan_mode == "deep")
+1 -3
View File
@@ -460,9 +460,7 @@ def cleanup_old_download_events() -> int:
""" """
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
with SessionLocal() as session: with SessionLocal() as session:
settings = session.execute( settings = ImportSettings.load_sync(session)
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
retention_days = settings.download_event_retention_days retention_days = settings.download_event_retention_days
cutoff = datetime.now(UTC) - timedelta(days=retention_days) cutoff = datetime.now(UTC) - timedelta(days=retention_days)
result = session.execute( result = session.execute(
+3 -9
View File
@@ -15,14 +15,14 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession
from ..celery_app import celery from ..celery_app import celery
from ..config import get_config
from ..models import MigrationRun from ..models import MigrationRun
from ..services.credential_crypto import CredentialCrypto from ..services.credential_crypto import CredentialCrypto
from ..services.migrators import cleanup as cleanup_mod from ..services.migrators import cleanup as cleanup_mod
from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify
from ._async_session import async_session_factory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -30,12 +30,6 @@ IMAGES_ROOT = Path("/images")
_KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64" _KEY_PATH = IMAGES_ROOT / "secrets" / "credential_key.b64"
def _async_session_factory():
cfg = get_config()
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
async def _update_run( async def _update_run(
db: AsyncSession, run_id: int, *, db: AsyncSession, run_id: int, *,
status: str | None = None, counts: dict | None = None, status: str | None = None, counts: dict | None = None,
@@ -59,7 +53,7 @@ async def _update_run(
async def _run_async(run_id: int, kind: str, params: dict) -> dict: async def _run_async(run_id: int, kind: str, params: dict) -> dict:
factory, engine = _async_session_factory() factory, engine = async_session_factory()
try: try:
async with factory() as db: async with factory() as db:
await _update_run(db, run_id, status="running") await _update_run(db, run_id, status="running")
+6 -13
View File
@@ -12,13 +12,12 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from ..celery_app import celery from ..celery_app import celery
from ..config import get_config
from ..models import DownloadEvent, ImportBatch, ImportSettings, ImportTask from ..models import DownloadEvent, ImportBatch, ImportSettings, ImportTask
from ..services.archive_extractor import is_archive from ..services.archive_extractor import is_archive
from ..services.scheduler_service import select_due_sources from ..services.scheduler_service import record_tick, select_due_sources
from ._async_session import async_session_factory
from ._sync_engine import sync_session_factory as _sync_session_factory from ._sync_engine import sync_session_factory as _sync_session_factory
@@ -45,9 +44,7 @@ def scan_directory(self, triggered_by: str = "manual",
batch id.""" batch id."""
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
with SessionLocal() as session: with SessionLocal() as session:
settings = session.execute( settings = ImportSettings.load_sync(session)
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
import_root = Path(settings.import_scan_path) import_root = Path(settings.import_scan_path)
batch = ImportBatch( batch = ImportBatch(
@@ -141,16 +138,12 @@ def scan_directory(self, triggered_by: str = "manual",
# --- FC-3d: periodic source-check tick ------------------------------------ # --- FC-3d: periodic source-check tick ------------------------------------
def _async_session_factory():
cfg = get_config()
engine = create_async_engine(cfg.database_url, future=True, pool_pre_ping=True)
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
async def _tick_due_sources_async() -> dict: async def _tick_due_sources_async() -> dict:
factory, engine = _async_session_factory() factory, engine = async_session_factory()
try: try:
async with factory() as session: async with factory() as session:
# Prove the scheduler is alive even on empty ticks (UI reads this).
await record_tick(session)
due = await select_due_sources(session) due = await select_due_sources(session)
if not due: if not due:
return { return {
@@ -62,6 +62,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
@@ -94,7 +95,7 @@ async function onPreview() {
try { try {
preview.value = await store.previewMinDim(minW.value, minH.value) preview.value = await store.previewMinDim(minW.value, minH.value)
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Preview failed: ${e.message}`, type: 'error' }) toast({ text: `Preview failed: ${e.message}`, type: 'error' })
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -108,12 +109,12 @@ function onDeleteClick() {
async function onConfirmedDelete(token) { async function onConfirmedDelete(token) {
try { try {
const res = await store.deleteMinDim(minW.value, minH.value, token) const res = await store.deleteMinDim(minW.value, minH.value, token)
window.__fcToast?.({ toast({
text: `Deleted ${res.deleted} image(s)`, type: 'success', text: `Deleted ${res.deleted} image(s)`, type: 'success',
}) })
preview.value = null preview.value = null
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Delete failed: ${e.message}`, type: 'error' }) toast({ text: `Delete failed: ${e.message}`, type: 'error' })
} }
} }
</script> </script>
@@ -93,6 +93,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
@@ -124,7 +125,7 @@ function startPoll(id) {
if (fresh.status !== 'running') stopPoll() if (fresh.status !== 'running') stopPoll()
} catch (e) { } catch (e) {
stopPoll() stopPoll()
window.__fcToast?.({ text: `Audit poll failed: ${e.message}`, type: 'error' }) toast({ text: `Audit poll failed: ${e.message}`, type: 'error' })
} }
}, 5000) }, 5000)
} }
@@ -142,7 +143,7 @@ async function onStart() {
audit.value = await store.getAudit(res.audit_id) audit.value = await store.getAudit(res.audit_id)
startPoll(res.audit_id) startPoll(res.audit_id)
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Scan start failed: ${e.message}`, type: 'error' }) toast({ text: `Scan start failed: ${e.message}`, type: 'error' })
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -155,7 +156,7 @@ async function onCancel() {
audit.value = await store.getAudit(audit.value.id) audit.value = await store.getAudit(audit.value.id)
stopPoll() stopPoll()
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Cancel failed: ${e.message}`, type: 'error' }) toast({ text: `Cancel failed: ${e.message}`, type: 'error' })
} }
} }
@@ -167,12 +168,12 @@ function onApplyClick() {
async function onConfirmedApply(token) { async function onConfirmedApply(token) {
try { try {
const res = await store.applyAudit(audit.value.id, token) const res = await store.applyAudit(audit.value.id, token)
window.__fcToast?.({ toast({
text: `Deleted ${res.deleted} image(s)`, type: 'success', text: `Deleted ${res.deleted} image(s)`, type: 'success',
}) })
audit.value = await store.getAudit(audit.value.id) audit.value = await store.getAudit(audit.value.id)
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Apply failed: ${e.message}`, type: 'error' }) toast({ text: `Apply failed: ${e.message}`, type: 'error' })
} }
} }
</script> </script>
@@ -80,6 +80,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
@@ -109,7 +110,7 @@ function startPoll(id) {
if (fresh.status !== 'running') stopPoll() if (fresh.status !== 'running') stopPoll()
} catch (e) { } catch (e) {
stopPoll() stopPoll()
window.__fcToast?.({ text: `Audit poll failed: ${e.message}`, type: 'error' }) toast({ text: `Audit poll failed: ${e.message}`, type: 'error' })
} }
}, 5000) }, 5000)
} }
@@ -125,7 +126,7 @@ async function onStart() {
audit.value = await store.getAudit(res.audit_id) audit.value = await store.getAudit(res.audit_id)
startPoll(res.audit_id) startPoll(res.audit_id)
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Scan start failed: ${e.message}`, type: 'error' }) toast({ text: `Scan start failed: ${e.message}`, type: 'error' })
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -138,7 +139,7 @@ async function onCancel() {
audit.value = await store.getAudit(audit.value.id) audit.value = await store.getAudit(audit.value.id)
stopPoll() stopPoll()
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Cancel failed: ${e.message}`, type: 'error' }) toast({ text: `Cancel failed: ${e.message}`, type: 'error' })
} }
} }
@@ -150,12 +151,12 @@ function onApplyClick() {
async function onConfirmedApply(token) { async function onConfirmedApply(token) {
try { try {
const res = await store.applyAudit(audit.value.id, token) const res = await store.applyAudit(audit.value.id, token)
window.__fcToast?.({ toast({
text: `Deleted ${res.deleted} image(s)`, type: 'success', text: `Deleted ${res.deleted} image(s)`, type: 'success',
}) })
audit.value = await store.getAudit(audit.value.id) audit.value = await store.getAudit(audit.value.id)
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Apply failed: ${e.message}`, type: 'error' }) toast({ text: `Apply failed: ${e.message}`, type: 'error' })
} }
} }
</script> </script>
@@ -33,6 +33,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { copyText } from '../../utils/clipboard.js' import { copyText } from '../../utils/clipboard.js'
@@ -119,7 +120,7 @@ async function onCopy () {
if (copiedTimer) clearTimeout(copiedTimer) if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => { copied.value = false }, 1500) copiedTimer = setTimeout(() => { copied.value = false }, 1500)
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' }) toast({ text: `Copy failed: ${e.message}`, type: 'error' })
} }
} }
</script> </script>
@@ -26,6 +26,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useCredentialsStore } from '../../stores/credentials.js' import { useCredentialsStore } from '../../stores/credentials.js'
import { copyText } from '../../utils/clipboard.js' import { copyText } from '../../utils/clipboard.js'
@@ -39,9 +40,9 @@ async function copyKey() {
if (!store.extensionKey) return if (!store.extensionKey) return
try { try {
await copyText(store.extensionKey) await copyText(store.extensionKey)
globalThis.window?.__fcToast?.({ text: 'Copied', type: 'success' }) toast({ text: 'Copied', type: 'success' })
} catch { } catch {
globalThis.window?.__fcToast?.({ text: 'Copy failed', type: 'error' }) toast({ text: 'Copy failed', type: 'error' })
} }
} }
@@ -50,7 +51,7 @@ function confirmRotate() { showRotateConfirm.value = true }
async function doRotate() { async function doRotate() {
showRotateConfirm.value = false showRotateConfirm.value = false
await store.rotateKey() await store.rotateKey()
globalThis.window?.__fcToast?.({ text: 'Key rotated', type: 'success' }) toast({ text: 'Key rotated', type: 'success' })
} }
</script> </script>
@@ -35,20 +35,53 @@
</ul> </ul>
</template> </template>
<template v-if="event.error">
<div class="fc-dl-blockhead mt-4">
<h3 class="text-subtitle-2">Error</h3>
<v-btn
size="x-small" variant="text" prepend-icon="mdi-content-copy"
@click="onCopy('Error', event.error)"
>Copy</v-btn>
</div>
<pre class="fc-dl-pre">{{ event.error }}</pre>
</template>
<template v-if="errorsWarnings"> <template v-if="errorsWarnings">
<h3 class="text-subtitle-2 mt-4">Errors &amp; warnings</h3> <div class="fc-dl-blockhead mt-4">
<h3 class="text-subtitle-2">Errors &amp; warnings</h3>
<v-btn
size="x-small" variant="text" prepend-icon="mdi-content-copy"
@click="onCopy('Errors & warnings', errorsWarnings)"
>Copy</v-btn>
</div>
<pre class="fc-dl-pre">{{ errorsWarnings }}</pre> <pre class="fc-dl-pre">{{ errorsWarnings }}</pre>
</template> </template>
<v-expansion-panels class="mt-4"> <v-expansion-panels class="mt-4">
<v-expansion-panel> <v-expansion-panel>
<v-expansion-panel-title>Raw stdout</v-expansion-panel-title> <v-expansion-panel-title>
<span>Raw stdout</span>
<v-spacer />
<v-btn
size="x-small" variant="text" prepend-icon="mdi-content-copy"
class="me-2"
@click.stop="onCopy('stdout', event.metadata?.stdout || '')"
>Copy</v-btn>
</v-expansion-panel-title>
<v-expansion-panel-text> <v-expansion-panel-text>
<pre class="fc-dl-pre">{{ event.metadata?.stdout || '(empty)' }}</pre> <pre class="fc-dl-pre">{{ event.metadata?.stdout || '(empty)' }}</pre>
</v-expansion-panel-text> </v-expansion-panel-text>
</v-expansion-panel> </v-expansion-panel>
<v-expansion-panel> <v-expansion-panel>
<v-expansion-panel-title>Raw stderr</v-expansion-panel-title> <v-expansion-panel-title>
<span>Raw stderr</span>
<v-spacer />
<v-btn
size="x-small" variant="text" prepend-icon="mdi-content-copy"
class="me-2"
@click.stop="onCopy('stderr', event.metadata?.stderr || '')"
>Copy</v-btn>
</v-expansion-panel-title>
<v-expansion-panel-text> <v-expansion-panel-text>
<pre class="fc-dl-pre">{{ event.metadata?.stderr || '(empty)' }}</pre> <pre class="fc-dl-pre">{{ event.metadata?.stderr || '(empty)' }}</pre>
</v-expansion-panel-text> </v-expansion-panel-text>
@@ -56,6 +89,10 @@
</v-expansion-panels> </v-expansion-panels>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-btn
variant="text" prepend-icon="mdi-content-copy"
@click="onCopy('All diagnostics', allDiagnostics)"
>Copy all diagnostics</v-btn>
<v-spacer /> <v-spacer />
<v-btn variant="text" @click="onClose(false)">Close</v-btn> <v-btn variant="text" @click="onClose(false)">Close</v-btn>
</v-card-actions> </v-card-actions>
@@ -64,8 +101,11 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { computed } from 'vue' import { computed } from 'vue'
import { copyText } from '../../utils/clipboard.js'
const props = defineProps({ event: { type: Object, default: null } }) const props = defineProps({ event: { type: Object, default: null } })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
@@ -74,6 +114,32 @@ const summary = computed(() => props.event?.metadata?.import_summary || {})
const quarantinedPaths = computed(() => props.event?.metadata?.quarantined_paths || []) const quarantinedPaths = computed(() => props.event?.metadata?.quarantined_paths || [])
const errorsWarnings = computed(() => props.event?.metadata?.stderr_errors_warnings || '') const errorsWarnings = computed(() => props.event?.metadata?.stderr_errors_warnings || '')
// One combined block for "research the issue elsewhere" — header line +
// error + full stdout/stderr. Built lazily from the current event.
const allDiagnostics = computed(() => {
const e = props.event
if (!e) return ''
const md = e.metadata || {}
return [
`event #${e.id} · ${e.platform || '—'} · ${e.artist_name || '—'}`,
`status: ${e.status}`,
`started: ${e.started_at} finished: ${e.finished_at || '(running)'}`,
e.error ? `\n--- error ---\n${e.error}` : '',
errorsWarnings.value ? `\n--- errors & warnings ---\n${errorsWarnings.value}` : '',
`\n--- stdout ---\n${md.stdout || '(empty)'}`,
`\n--- stderr ---\n${md.stderr || '(empty)'}`,
].filter(Boolean).join('\n')
})
async function onCopy(label, text) {
try {
await copyText(text || '')
toast({ text: `${label} copied`, type: 'success' })
} catch (e) {
toast({ text: `Copy failed: ${e.message}`, type: 'error' })
}
}
const statusColor = computed(() => ({ const statusColor = computed(() => ({
ok: 'success', error: 'error', running: 'info', ok: 'success', error: 'error', running: 'info',
pending: 'secondary', skipped: 'warning', pending: 'secondary', skipped: 'warning',
@@ -114,4 +180,8 @@ function onClose() {
word-break: break-all; word-break: break-all;
} }
.fc-dl-quar { padding-left: 1.5rem; font-size: 0.85rem; } .fc-dl-quar { padding-left: 1.5rem; font-size: 0.85rem; }
.fc-dl-blockhead {
display: flex; align-items: center; justify-content: space-between;
gap: 0.5rem;
}
</style> </style>
@@ -86,11 +86,13 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import PlatformChip from '../subscriptions/PlatformChip.vue' import PlatformChip from '../subscriptions/PlatformChip.vue'
import { useSourcesStore } from '../../stores/sources.js' import { useSourcesStore } from '../../stores/sources.js'
import { downloadStatusColor, downloadStatusIcon, downloadStatusLabel } from '../../utils/downloadStatus.js'
const props = defineProps({ event: { type: Object, required: true } }) const props = defineProps({ event: { type: Object, required: true } })
defineEmits(['open']) defineEmits(['open'])
@@ -98,16 +100,9 @@ defineEmits(['open'])
const sourcesStore = useSourcesStore() const sourcesStore = useSourcesStore()
const retrying = ref(false) const retrying = ref(false)
const _STATUS = { const statusColor = computed(() => downloadStatusColor(props.event.status))
ok: { color: 'success', icon: 'mdi-check-circle', label: 'Completed' }, const statusIcon = computed(() => downloadStatusIcon(props.event.status))
error: { color: 'error', icon: 'mdi-alert-circle', label: 'Failed' }, const statusLabel = computed(() => downloadStatusLabel(props.event.status))
running: { color: 'info', icon: 'mdi-progress-clock', label: 'Running' },
pending: { color: 'grey', icon: 'mdi-clock-outline', label: 'Queued' },
skipped: { color: 'warning', icon: 'mdi-skip-next', label: 'Skipped' },
}
const statusColor = computed(() => _STATUS[props.event.status]?.color || 'grey')
const statusIcon = computed(() => _STATUS[props.event.status]?.icon || 'mdi-help-circle')
const statusLabel = computed(() => _STATUS[props.event.status]?.label || props.event.status)
function fmtTime(iso) { function fmtTime(iso) {
if (!iso) return '—' if (!iso) return '—'
@@ -131,12 +126,12 @@ async function onRetry() {
retrying.value = true retrying.value = true
try { try {
await sourcesStore.checkNow(props.event.source_id) await sourcesStore.checkNow(props.event.source_id)
globalThis.window?.__fcToast?.({ toast({
text: `Source check re-queued`, type: 'success', text: `Source check re-queued`, type: 'success',
}) })
} catch (e) { } catch (e) {
const isInFlight = !!e?.body?.download_event_id const isInFlight = !!e?.body?.download_event_id
globalThis.window?.__fcToast?.({ toast({
text: isInFlight ? 'Already running' : `Retry failed: ${e?.detail || e?.message || e}`, text: isInFlight ? 'Already running' : `Retry failed: ${e?.detail || e?.message || e}`,
type: isInFlight ? 'info' : 'error', type: isInFlight ? 'info' : 'error',
}) })
@@ -37,9 +37,13 @@
<a href="#" @click.prevent="openPost(e.post.id)"> <a href="#" @click.prevent="openPost(e.post.id)">
View images from this post View images from this post
</a> </a>
<a
v-if="e.post.description_html"
href="#" @click.prevent="toggleDesc(e.provenance_id)"
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
</div> </div>
<div <div
v-if="e.post.description_html" v-if="e.post.description_html && expanded[e.provenance_id]"
class="fc-prov__desc" v-html="e.post.description_html" class="fc-prov__desc" v-html="e.post.description_html"
/> />
</article> </article>
@@ -74,7 +78,7 @@
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed, reactive, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useModalStore } from '../../stores/modal.js' import { useModalStore } from '../../stores/modal.js'
import { useProvenanceStore } from '../../stores/provenance.js' import { useProvenanceStore } from '../../stores/provenance.js'
@@ -84,6 +88,16 @@ const modal = useModalStore()
const prov = useProvenanceStore() const prov = useProvenanceStore()
const router = useRouter() const router = useRouter()
// Per-post description collapse state (keyed by provenance_id). Default
// collapsed so multiple posts don't each eat ~180px of the panel — the
// operator flagged the descriptions consuming a lot of real estate
// 2026-05-28. Reset when the viewed image changes.
const expanded = reactive({})
function toggleDesc(id) { expanded[id] = !expanded[id] }
watch(() => modal.currentImageId, () => {
for (const k of Object.keys(expanded)) delete expanded[k]
})
watch( watch(
() => modal.currentImageId, () => modal.currentImageId,
(id) => { if (id != null) prov.loadForImage(id) }, (id) => { if (id != null) prov.loadForImage(id) },
@@ -38,6 +38,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js' import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue' import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
@@ -57,7 +58,7 @@ watch(() => props.imageId, (id) => { if (id != null) store.load(id) }, { immedia
async function onAccept(s) { async function onAccept(s) {
try { await store.accept(s) } try { await store.accept(s) }
catch (e) { window.__fcToast?.({ text: `Accept failed: ${e.message}`, type: 'error' }) } catch (e) { toast({ text: `Accept failed: ${e.message}`, type: 'error' }) }
} }
const aliasDialog = ref(false) const aliasDialog = ref(false)
@@ -68,7 +69,7 @@ async function onAliasConfirm(canonicalTagId) {
await store.aliasAccept(aliasTarget.value, canonicalTagId) await store.aliasAccept(aliasTarget.value, canonicalTagId)
aliasDialog.value = false aliasDialog.value = false
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Alias failed: ${e.message}`, type: 'error' }) toast({ text: `Alias failed: ${e.message}`, type: 'error' })
} }
} }
</script> </script>
@@ -59,6 +59,8 @@
</template> </template>
<script setup> <script setup>
import { formatRelative as fmtRelative } from '../../utils/date.js'
defineProps({ runs: { type: Array, default: () => [] } }) defineProps({ runs: { type: Array, default: () => [] } })
defineEmits(['restore', 'delete', 'tag']) defineEmits(['restore', 'delete', 'tag'])
@@ -92,13 +94,7 @@ function formatBytes(b) {
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}` return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
} }
function formatRelative(iso) { function formatRelative(iso) {
if (!iso) return '—' return fmtRelative(iso, { nullText: '—' })
const then = new Date(iso).getTime()
const diff = Math.max(0, (Date.now() - then) / 1000)
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
} }
</script> </script>
@@ -108,6 +108,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import { copyText } from '../../utils/clipboard.js' import { copyText } from '../../utils/clipboard.js'
@@ -147,7 +148,7 @@ async function loadKey() {
apiKey.value = key apiKey.value = key
} catch (e) { } catch (e) {
apiKey.value = '' apiKey.value = ''
window.__fcToast?.({ toast({
text: `Failed to load extension API key: ${e.message}`, text: `Failed to load extension API key: ${e.message}`,
type: 'error', type: 'error',
}) })
@@ -160,9 +161,9 @@ async function rotateKey() {
const { key } = await api.post('/api/settings/extension_api_key/rotate') const { key } = await api.post('/api/settings/extension_api_key/rotate')
apiKey.value = key apiKey.value = key
keyShown.value = true keyShown.value = true
window.__fcToast?.({ text: 'Extension API key rotated.', type: 'success' }) toast({ text: 'Extension API key rotated.', type: 'success' })
} catch (e) { } catch (e) {
window.__fcToast?.({ toast({
text: `Rotate failed: ${e.message}`, text: `Rotate failed: ${e.message}`,
type: 'error', type: 'error',
}) })
@@ -174,9 +175,9 @@ async function rotateKey() {
async function copy(text, label) { async function copy(text, label) {
try { try {
await copyText(text) await copyText(text)
window.__fcToast?.({ text: `${label} copied.`, type: 'success' }) toast({ text: `${label} copied.`, type: 'success' })
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' }) toast({ text: `Copy failed: ${e.message}`, type: 'error' })
} }
} }
</script> </script>
@@ -16,6 +16,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
const store = useMLStore() const store = useMLStore()
@@ -24,7 +25,7 @@ const done = ref(false)
async function run() { async function run() {
busy.value = true busy.value = true
try { await store.triggerRecomputeCentroids(); done.value = true } try { await store.triggerRecomputeCentroids(); done.value = true }
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) } catch (e) { toast({ text: e.message, type: 'error' }) }
finally { busy.value = false } finally { busy.value = false }
} }
</script> </script>
@@ -125,6 +125,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue' import ErrorDetailModal from '../common/ErrorDetailModal.vue'
@@ -177,9 +178,9 @@ async function onRefetch(item) {
try { try {
const res = await store.refetchTask(item.id) const res = await store.refetchTask(item.id)
const msg = _REFETCH_MSG[res.status] || { text: `Re-fetch: ${res.status}`, type: 'info' } const msg = _REFETCH_MSG[res.status] || { text: `Re-fetch: ${res.status}`, type: 'info' }
window.__fcToast?.(msg) toast(msg)
} catch (e) { } catch (e) {
window.__fcToast?.({ text: `Re-fetch failed: ${e.message}`, type: 'error' }) toast({ text: `Re-fetch failed: ${e.message}`, type: 'error' })
} finally { } finally {
refetching.value = null refetching.value = null
} }
@@ -15,6 +15,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
const store = useMLStore() const store = useMLStore()
@@ -23,7 +24,7 @@ const done = ref(false)
async function run() { async function run() {
busy.value = true busy.value = true
try { await store.triggerBackfill(); done.value = true } try { await store.triggerBackfill(); done.value = true }
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) } catch (e) { toast({ text: e.message, type: 'error' }) }
finally { busy.value = false } finally { busy.value = false }
} }
</script> </script>
@@ -17,6 +17,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
@@ -35,6 +36,6 @@ async function save() {
const patch = {} const patch = {}
for (const f of fields) patch[f.key] = local[f.key] for (const f of fields) patch[f.key] = local[f.key]
try { await store.patchSettings(patch) } try { await store.patchSettings(patch) }
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) } catch (e) { toast({ text: e.message, type: 'error' }) }
} }
</script> </script>
@@ -154,6 +154,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js' import { useSystemActivityStore } from '../../stores/systemActivity.js'
import { formatRelative as fmtRelative } from '../../utils/date.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue' import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import QueuesTable from './QueuesTable.vue' import QueuesTable from './QueuesTable.vue'
@@ -277,14 +278,7 @@ function formatDuration(ms) {
return `${(ms / 60_000).toFixed(1)} min` return `${(ms / 60_000).toFixed(1)} min`
} }
function formatRelative(iso) { function formatRelative(iso) {
if (!iso) return '—' return fmtRelative(iso, { nullText: '—' })
const then = new Date(iso).getTime()
const now = Date.now()
const diff = Math.max(0, (now - then) / 1000)
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
} }
</script> </script>
@@ -44,6 +44,51 @@
@click="onCommit" @click="onCommit"
>Delete {{ preview.count }} unused tag(s)</v-btn> >Delete {{ preview.count }} unused tag(s)</v-btn>
</div> </div>
<v-divider class="my-5" />
<p class="fc-muted text-body-2 mb-4">
Purge legacy IR-migration tags FC no longer uses: retired/system
kinds (<code>archive</code>, <code>post</code>,
<code>artist</code> e.g.
<code>BlenderKnight:Hannah_BJ_Loops</code>) plus
<code>source:*</code> tags (ImageRepo's old <code>source</code>
kind, which migrated to <code>general</code>). Provenance and
artists are their own systems now, so these are pure noise.
Removes them from every image.
</p>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingKindPreview"
class="mb-3"
@click="onKindPreview"
>Preview legacy tags</v-btn>
<div v-if="kindPreview">
<p class="text-body-2 mb-2">
<strong>{{ kindPreview.count }}</strong> legacy tag(s).
<span v-for="(n, k) in kindPreview.by_kind" :key="k" class="fc-muted">
{{ k }}: {{ n }}&nbsp;&nbsp;
</span>
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
{{ p }}: {{ n }}&nbsp;&nbsp;
</span>
</p>
<div v-if="kindPreview.sample_names?.length" class="fc-name-grid mb-3">
<span v-for="n in kindPreview.sample_names" :key="n" class="fc-name">
{{ n }}
</span>
</div>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep"
:disabled="!kindPreview.count"
:loading="kindCommitting"
@click="onKindCommit"
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</template> </template>
@@ -57,6 +102,9 @@ const store = useAdminStore()
const preview = ref(null) const preview = ref(null)
const loadingPreview = ref(false) const loadingPreview = ref(false)
const committing = ref(false) const committing = ref(false)
const kindPreview = ref(null)
const loadingKindPreview = ref(false)
const kindCommitting = ref(false)
async function onPreview() { async function onPreview() {
loadingPreview.value = true loadingPreview.value = true
@@ -76,6 +124,25 @@ async function onCommit() {
committing.value = false committing.value = false
} }
} }
async function onKindPreview() {
loadingKindPreview.value = true
try {
kindPreview.value = await store.purgeLegacyTags({ dryRun: true })
} finally {
loadingKindPreview.value = false
}
}
async function onKindCommit() {
kindCommitting.value = true
try {
await store.purgeLegacyTags({ dryRun: false })
kindPreview.value = { count: 0, by_kind: {}, by_prefix: {}, sample_names: [] }
} finally {
kindCommitting.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@@ -16,6 +16,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useThumbnailsStore } from '../../stores/thumbnails.js' import { useThumbnailsStore } from '../../stores/thumbnails.js'
const store = useThumbnailsStore() const store = useThumbnailsStore()
@@ -24,7 +25,7 @@ const done = ref(false)
async function run () { async function run () {
busy.value = true busy.value = true
try { await store.triggerBackfill(); done.value = true } try { await store.triggerBackfill(); done.value = true }
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) } catch (e) { toast({ text: e.message, type: 'error' }) }
finally { busy.value = false } finally { busy.value = false }
} }
</script> </script>
@@ -20,6 +20,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useSourcesStore } from '../../stores/sources.js' import { useSourcesStore } from '../../stores/sources.js'
@@ -44,7 +45,7 @@ async function submit() {
busy.value = true busy.value = true
try { try {
const { artist, created } = await store.findOrCreateArtist(name.value.trim()) const { artist, created } = await store.findOrCreateArtist(name.value.trim())
globalThis.window?.__fcToast?.({ toast({
text: created ? 'Artist created' : 'Artist already exists', text: created ? 'Artist created' : 'Artist already exists',
type: 'success', type: 'success',
}) })
@@ -24,9 +24,17 @@
</v-icon> </v-icon>
<span>Expires {{ fmtDate(credential.expires_at) }}</span> <span>Expires {{ fmtDate(credential.expires_at) }}</span>
</div> </div>
<div v-if="credential.last_verified_at" class="fc-cred-card__row"> <div v-if="credential.last_verified" class="fc-cred-card__row">
<v-icon size="small" color="on-surface-variant">mdi-shield-check</v-icon> <v-icon size="small" :color="verifyStale ? 'warning' : 'success'">
<span>Last verified {{ fmtDate(credential.last_verified_at) }}</span> {{ verifyStale ? 'mdi-shield-alert' : 'mdi-shield-check' }}
</v-icon>
<span :class="verifyStale ? 'text-warning' : undefined">
Last verified {{ verifiedRelative }}<template v-if="verifyStale"> re-verify recommended</template>
</span>
</div>
<div v-else-if="!freshlyVerified" class="fc-cred-card__row">
<v-icon size="small" color="warning">mdi-shield-alert</v-icon>
<span class="text-warning">Never verified click Verify to confirm</span>
</div> </div>
</template> </template>
<template v-else> <template v-else>
@@ -55,7 +63,21 @@
</v-card-text> </v-card-text>
<v-card-actions class="px-3 pb-3 pt-0"> <v-card-actions class="px-3 pb-3 pt-0">
<v-spacer /> <v-chip
v-if="verifyResult"
:color="verifyResult.valid === true ? 'success' : (verifyResult.valid === false ? 'error' : 'grey')"
size="x-small" variant="tonal" class="me-auto"
:title="verifyResult.reason"
>{{ verifyChipLabel }}</v-chip>
<v-spacer v-else />
<v-btn
v-if="hasCredential"
size="small" variant="text"
:loading="verifying"
@click="onVerify"
>
Verify
</v-btn>
<v-btn <v-btn
v-if="hasCredential" v-if="hasCredential"
size="small" variant="text" color="error" size="small" variant="text" color="error"
@@ -76,14 +98,44 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue'
import PlatformChip from './PlatformChip.vue' import PlatformChip from './PlatformChip.vue'
import { useCredentialsStore } from '../../stores/credentials.js'
const props = defineProps({ const props = defineProps({
platform: { type: Object, required: true }, platform: { type: Object, required: true },
credential: { type: Object, default: null }, credential: { type: Object, default: null },
}) })
defineEmits(['replace', 'remove']) const emit = defineEmits(['replace', 'remove', 'verified'])
const credsStore = useCredentialsStore()
const verifying = ref(false)
const verifyResult = ref(null)
const verifyChipLabel = computed(() => {
if (!verifyResult.value) return ''
if (verifyResult.value.valid === true) return 'Verified ✓'
if (verifyResult.value.valid === false) return 'Failed'
return 'Untestable'
})
async function onVerify() {
verifying.value = true
verifyResult.value = null
try {
const res = await credsStore.verify(props.platform.key)
verifyResult.value = res
const type = res.valid === true ? 'success' : (res.valid === false ? 'error' : 'info')
toast({ text: `${props.platform.name}: ${res.reason}`, type })
if (res.valid === true) emit('verified')
} catch (e) {
verifyResult.value = { valid: false, reason: e.message }
toast({ text: `Verify failed: ${e.message}`, type: 'error' })
} finally {
verifying.value = false
}
}
const hasCredential = computed(() => !!props.credential) const hasCredential = computed(() => !!props.credential)
@@ -96,14 +148,42 @@ const expiringSoon = computed(() => {
return diff > 0 && diff < 7 return diff > 0 && diff < 7
}) })
// Verification staleness: a stored cookie/token that hasn't been confirmed
// in a month is the silent-failure trap (subscribestar/HF). Nudge the
// operator to re-verify before a scheduled download dies on bad auth.
const STALE_DAYS = 30
// A successful Verify in this session clears the warning even before the
// parent reloads the credential list (last_verified prop is still old).
const freshlyVerified = computed(() => verifyResult.value?.valid === true)
const verifiedAgeDays = computed(() => {
const v = props.credential?.last_verified
if (!v) return null
return (Date.now() - new Date(v).getTime()) / 86400_000
})
const verifyStale = computed(() => {
if (!hasCredential.value || freshlyVerified.value) return false
const age = verifiedAgeDays.value
return age == null || age > STALE_DAYS
})
const verifiedRelative = computed(() => {
const age = verifiedAgeDays.value
if (age == null) return '—'
if (age < 1) return 'today'
if (age < 2) return 'yesterday'
if (age < 30) return `${Math.floor(age)}d ago`
if (age < 365) return `${Math.floor(age / 30)}mo ago`
return `${Math.floor(age / 365)}y ago`
})
const statusLabel = computed(() => { const statusLabel = computed(() => {
if (!hasCredential.value) return 'Not configured' if (!hasCredential.value) return 'Not configured'
if (expiringSoon.value) return 'Expiring soon' if (expiringSoon.value) return 'Expiring soon'
if (verifyStale.value) return 'Verify due'
return 'Active' return 'Active'
}) })
const statusColor = computed(() => { const statusColor = computed(() => {
if (!hasCredential.value) return 'grey' if (!hasCredential.value) return 'grey'
if (expiringSoon.value) return 'warning' if (expiringSoon.value || verifyStale.value) return 'warning'
return 'success' return 'success'
}) })
@@ -0,0 +1,92 @@
<template>
<div class="fc-spark" :title="summaryTip">
<div class="fc-spark__bars">
<div
v-for="(b, i) in buckets" :key="i"
class="fc-spark__bar"
:title="barTip(b)"
>
<div class="fc-spark__fill" :style="{ height: fillPct(b) + '%' }">
<div
v-if="b.error > 0"
class="fc-spark__err"
:style="{ height: errPct(b) + '%' }"
/>
</div>
</div>
</div>
<div class="fc-spark__caption">
{{ hours }}h · {{ totalEvents }} events
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
// [{ hour, ok, error, other, total }] oldest-first
buckets: { type: Array, default: () => [] },
hours: { type: Number, default: 24 },
})
const maxTotal = computed(() =>
Math.max(1, ...props.buckets.map((b) => b.total || 0)),
)
const totalEvents = computed(() =>
props.buckets.reduce((n, b) => n + (b.total || 0), 0),
)
function fillPct(b) {
return Math.round(((b.total || 0) / maxTotal.value) * 100)
}
function errPct(b) {
if (!b.total) return 0
return Math.round((b.error / b.total) * 100)
}
function barTip(b) {
const t = new Date(b.hour)
const label = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `${label}${b.total} events (${b.ok} ok, ${b.error} failed)`
}
const summaryTip = computed(
() => `Download activity, last ${props.hours}h (red = failures)`,
)
</script>
<style scoped>
.fc-spark {
display: flex; flex-direction: column; gap: 2px;
min-width: 160px;
}
.fc-spark__bars {
display: flex; align-items: flex-end; gap: 2px;
height: 36px;
}
.fc-spark__bar {
flex: 1 1 0;
height: 100%;
display: flex; align-items: flex-end;
min-width: 2px;
}
.fc-spark__fill {
width: 100%;
min-height: 1px;
position: relative;
border-radius: 2px 2px 0 0;
background: rgb(var(--v-theme-accent) / 0.7);
}
.fc-spark__err {
position: absolute; bottom: 0; left: 0;
width: 100%;
border-radius: 0 0 2px 2px;
background: rgb(var(--v-theme-error));
}
.fc-spark__caption {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgb(var(--v-theme-on-surface-variant));
text-align: center;
}
</style>
@@ -1,36 +1,39 @@
<template> <template>
<div class="fc-dl-stats"> <div class="fc-dl-stats">
<v-chip <v-chip
v-for="s in STAT_DEFS" :key="s.key" v-for="s in STAT_DEFS" :key="s.value"
:color="s.color" :color="s.color"
variant="tonal" :variant="activeStatus === s.value ? 'elevated' : 'tonal'"
:prepend-icon="s.icon" :prepend-icon="s.icon"
size="default" size="default"
:class="{ 'fc-dl-stats__active': activeStatus === s.value }"
@click="$emit('select', activeStatus === s.value ? null : s.value)"
> >
{{ s.label }} {{ s.label }}
<strong class="ms-1">{{ stats[s.key] ?? 0 }}</strong> <strong class="ms-1">{{ stats[s.value] ?? 0 }}</strong>
</v-chip> </v-chip>
</div> </div>
</template> </template>
<script setup> <script setup>
import { DOWNLOAD_STATUSES as STAT_DEFS } from '../../utils/downloadStatus.js'
defineProps({ defineProps({
stats: { type: Object, required: true }, stats: { type: Object, required: true },
// The currently-active status filter value (pending|running|ok|error|
// skipped) or null. Highlights the matching chip.
activeStatus: { type: String, default: null },
}) })
defineEmits(['select'])
// status keys come straight from the backend ENUM
// (pending|running|ok|error|skipped); display order + icons are UI-only.
const STAT_DEFS = [
{ key: 'pending', label: 'Queued', color: 'grey', icon: 'mdi-clock-outline' },
{ key: 'running', label: 'Running', color: 'info', icon: 'mdi-progress-clock' },
{ key: 'ok', label: 'Completed', color: 'success', icon: 'mdi-check-circle' },
{ key: 'error', label: 'Failed', color: 'error', icon: 'mdi-alert-circle' },
{ key: 'skipped', label: 'Skipped', color: 'warning', icon: 'mdi-skip-next' },
]
</script> </script>
<style scoped> <style scoped>
.fc-dl-stats { .fc-dl-stats {
display: flex; gap: 8px; flex-wrap: wrap; display: flex; gap: 8px; flex-wrap: wrap;
} }
.fc-dl-stats .v-chip { cursor: pointer; }
.fc-dl-stats__active {
outline: 2px solid rgb(var(--v-theme-on-surface) / 0.5);
outline-offset: 1px;
}
</style> </style>
@@ -9,7 +9,7 @@
</v-chip> </v-chip>
</v-btn> </v-btn>
</template> </template>
<v-card min-width="320" class="pa-3"> <v-card min-width="340" class="pa-3">
<v-select <v-select
v-model="local.status" v-model="local.status"
:items="STATUS_OPTIONS" :items="STATUS_OPTIONS"
@@ -17,12 +17,17 @@
density="compact" variant="outlined" hide-details clearable density="compact" variant="outlined" hide-details clearable
class="mb-2" class="mb-2"
/> />
<v-text-field <v-autocomplete
v-model.number="local.source_id" v-model="local.artist_id"
label="Source ID" :items="artistItems"
:loading="artistLoading"
label="Artist"
item-title="name" item-value="id"
density="compact" variant="outlined" hide-details clearable density="compact" variant="outlined" hide-details clearable
type="number" min="1" no-filter
class="mb-2" class="mb-2"
@update:search="onArtistSearch"
@update:model-value="onArtistPick"
/> />
<v-text-field <v-text-field
v-model="local.from_date" v-model="local.from_date"
@@ -62,34 +67,57 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useSourcesStore } from '../../stores/sources.js'
import { DOWNLOAD_STATUSES, downloadStatusLabel } from '../../utils/downloadStatus.js'
const props = defineProps({ const props = defineProps({
modelValue: { type: Object, required: true }, modelValue: { type: Object, required: true },
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const STATUS_OPTIONS = [ const sourcesStore = useSourcesStore()
{ title: 'Queued', value: 'pending' },
{ title: 'Running', value: 'running' }, const STATUS_OPTIONS = DOWNLOAD_STATUSES.map((s) => ({ title: s.label, value: s.value }))
{ title: 'Completed', value: 'ok' },
{ title: 'Failed', value: 'error' },
{ title: 'Skipped', value: 'skipped' },
]
const STATUS_LABEL = Object.fromEntries(STATUS_OPTIONS.map((o) => [o.value, o.title]))
const open = ref(false) const open = ref(false)
const local = reactive({ const local = reactive({
status: props.modelValue.status ?? null, status: props.modelValue.status ?? null,
source_id: props.modelValue.source_id ?? null, artist_id: props.modelValue.artist_id ?? null,
from_date: props.modelValue.from_date ?? null, artist_name: props.modelValue.artist_name ?? null,
to_date: props.modelValue.to_date ?? null, from_date: props.modelValue.from_date ?? null,
to_date: props.modelValue.to_date ?? null,
}) })
watch(() => props.modelValue, (v) => Object.assign(local, v), { deep: true }) watch(() => props.modelValue, (v) => Object.assign(local, v), { deep: true })
// Artist autocomplete for the filter (replaces the old numeric Source-ID
// field — operator-flagged 2026-05-28). Keeps the currently-selected
// artist in the items list so the chip label resolves even before a
// search runs.
const artistItems = ref([])
const artistLoading = ref(false)
let artistDebounce = null
function onArtistSearch(q) {
if (artistDebounce) clearTimeout(artistDebounce)
if (!q || !q.trim()) { return }
artistDebounce = setTimeout(async () => {
artistLoading.value = true
try {
artistItems.value = await sourcesStore.autocompleteArtist(q.trim(), 20)
} finally {
artistLoading.value = false
}
}, 250)
}
function onArtistPick(id) {
const hit = artistItems.value.find((a) => a.id === id)
local.artist_name = hit ? hit.name : null
}
const activePills = computed(() => { const activePills = computed(() => {
const out = [] const out = []
if (local.status) out.push({ key: 'status', label: `Status: ${STATUS_LABEL[local.status] || local.status}` }) if (local.status) out.push({ key: 'status', label: `Status: ${downloadStatusLabel(local.status)}` })
if (local.source_id) out.push({ key: 'source_id', label: `Source #${local.source_id}` }) if (local.artist_id) out.push({ key: 'artist_id', label: `Artist: ${local.artist_name || `#${local.artist_id}`}` })
if (local.from_date) out.push({ key: 'from_date', label: `From ${local.from_date}` }) if (local.from_date) out.push({ key: 'from_date', label: `From ${local.from_date}` })
if (local.to_date) out.push({ key: 'to_date', label: `To ${local.to_date}` }) if (local.to_date) out.push({ key: 'to_date', label: `To ${local.to_date}` })
return out return out
@@ -102,13 +130,19 @@ function apply() {
} }
function reset() { function reset() {
local.status = null local.status = null
local.source_id = null local.artist_id = null
local.artist_name = null
local.from_date = null local.from_date = null
local.to_date = null local.to_date = null
emit('update:modelValue', { ...local }) emit('update:modelValue', { ...local })
} }
function clearOne(key) { function clearOne(key) {
local[key] = null if (key === 'artist_id') {
local.artist_id = null
local.artist_name = null
} else {
local[key] = null
}
emit('update:modelValue', { ...local }) emit('update:modelValue', { ...local })
} }
</script> </script>
@@ -1,21 +1,60 @@
<template> <template>
<div> <div>
<div class="fc-dl__top"> <div class="fc-dl__sticky">
<DownloadStatChips :stats="store.stats" /> <div class="fc-dl__top">
<v-spacer /> <DownloadStatChips
<v-btn variant="text" icon @click="refresh"> :stats="store.stats"
<v-icon>mdi-refresh</v-icon> :active-status="filterModel.status"
<v-tooltip activator="parent" location="top">Refresh</v-tooltip> @select="onStatusChipSelect"
</v-btn> />
<MaintenanceMenu @refresh="refresh" /> <span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
</div> <span class="fc-dl__live-dot" />
live
</span>
<v-spacer />
<DownloadActivitySparkline
v-if="store.activity.buckets.length"
:buckets="store.activity.buckets"
:hours="store.activity.hours"
class="fc-dl__spark"
/>
<v-btn variant="text" icon @click="refresh">
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Refresh</v-tooltip>
</v-btn>
<MaintenanceMenu @refresh="refresh" />
</div>
<DownloadsFilterPopover v-model="filterModel" class="fc-dl__filter" /> <div class="fc-dl__controls">
<v-text-field
v-model="search"
density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify"
placeholder="Search artist / platform / error"
style="max-width: 340px"
/>
<DownloadsFilterPopover v-model="filterModel" />
<v-switch
v-model="showNoChange"
label="Show no-change scans"
density="compact" hide-details color="accent"
class="fc-dl__nochange"
/>
</div>
</div>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4"> <v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
{{ String(store.error) }} {{ String(store.error) }}
</v-alert> </v-alert>
<FailingSourcesCard
:sources="store.failing"
:retrying-ids="sourcesStore.checkingIds"
:retrying-all="retryingAll"
@retry="onRetrySource"
@retry-all="onRetryAll"
/>
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading"> <div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
<v-progress-circular indeterminate color="accent" size="36" /> <v-progress-circular indeterminate color="accent" size="36" />
</div> </div>
@@ -74,19 +113,36 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useDownloadsStore } from '../../stores/downloads.js' import { useDownloadsStore } from '../../stores/downloads.js'
import { useSourcesStore } from '../../stores/sources.js'
import DownloadEventRow from '../downloads/DownloadEventRow.vue' import DownloadEventRow from '../downloads/DownloadEventRow.vue'
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue' import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
import DownloadStatChips from './DownloadStatChips.vue' import DownloadStatChips from './DownloadStatChips.vue'
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
import FailingSourcesCard from './FailingSourcesCard.vue'
import MaintenanceMenu from './MaintenanceMenu.vue' import MaintenanceMenu from './MaintenanceMenu.vue'
import DownloadsFilterPopover from './DownloadsFilterPopover.vue' import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
const route = useRoute() const route = useRoute()
const store = useDownloadsStore() const store = useDownloadsStore()
const sourcesStore = useSourcesStore()
const filterModel = ref({ ...store.filter }) const filterModel = ref({ ...store.filter })
const retryingAll = ref(false)
// Free-text search (client-side over the loaded events) + a toggle to
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
// real downloads + failures stand out. Operator-flagged 2026-05-28.
const search = ref('')
const showNoChange = ref(false)
// Clicking a stat chip toggles a status filter (re-click clears it).
function onStatusChipSelect(statusKey) {
filterModel.value = { ...filterModel.value, status: statusKey }
}
// Each group's collapsed state persists across refreshes for the // Each group's collapsed state persists across refreshes for the
// lifetime of the SubscriptionsView (operator-friendly default: all // lifetime of the SubscriptionsView (operator-friendly default: all
@@ -102,15 +158,84 @@ async function refresh() {
await Promise.all([ await Promise.all([
store.loadFirst(), store.loadFirst(),
store.loadStats(24), store.loadStats(24),
store.loadActivity(24),
store.loadFailing(),
]) ])
} }
// Retry a single failing source (re-runs its whole feed) then refresh the
// rollup + stats so the operator sees it move.
async function onRetrySource(source) {
try {
await sourcesStore.checkNow(source.id)
toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' })
} catch (e) {
if (e?.body?.download_event_id) {
toast({ text: 'Already running — see below', type: 'info' })
} else {
toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
}
} finally {
await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
}
}
async function onRetryAll(sources) {
retryingAll.value = true
let ok = 0
let conflict = 0
try {
for (const s of sources) {
try {
await sourcesStore.checkNow(s.id)
ok += 1
} catch (e) {
if (e?.body?.download_event_id) conflict += 1
}
}
} finally {
retryingAll.value = false
await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
}
const parts = []
if (ok) parts.push(`${ok} queued`)
if (conflict) parts.push(`${conflict} already running`)
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
}
// Live auto-refresh: while any download is queued or running, poll the
// stats + first page every 4s so the operator can watch events succeed/
// fail in real time without hitting Refresh. Polling stops automatically
// when the queue drains (nothing pending/running), and is paused while
// the tab is backgrounded. Operator-flagged 2026-05-28: couldn't tell if
// downloads were succeeding because the view was static.
const POLL_MS = 4000
const liveActive = computed(
() => (store.stats.pending || 0) + (store.stats.running || 0) > 0,
)
let pollId = null
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)])
if (liveActive.value) await Promise.all([store.loadFirst(), store.loadFailing()])
}, POLL_MS)
}
function stopPolling() {
if (pollId) { clearInterval(pollId); pollId = null }
}
onMounted(() => { onMounted(() => {
if (route.query.source_id) { if (route.query.source_id) {
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) } filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
} }
refresh() refresh()
startPolling()
}) })
onUnmounted(stopPolling)
// Client-side date filter on the loaded page (avoids a backend round-trip // Client-side date filter on the loaded page (avoids a backend round-trip
// for the date pickers; the existing /api/downloads endpoint can grow // for the date pickers; the existing /api/downloads endpoint can grow
@@ -127,6 +252,21 @@ const filteredEvents = computed(() => {
const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1 const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1
arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs) arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs)
} }
// Hide no-change scheduled scans (succeeded/skipped, downloaded
// nothing) unless the operator opts to show them.
if (!showNoChange.value) {
arr = arr.filter(
(e) => !((e.status === 'ok' || e.status === 'skipped') && !(e.files_count > 0)),
)
}
const q = search.value?.trim().toLowerCase()
if (q) {
arr = arr.filter((e) =>
(e.artist_name || '').toLowerCase().includes(q)
|| (e.platform || '').toLowerCase().includes(q)
|| (e.error || '').toLowerCase().includes(q),
)
}
return arr return arr
}) })
@@ -176,6 +316,7 @@ watch(filterModel, async (m) => {
await store.applyFilter({ await store.applyFilter({
status: m.status, status: m.status,
source_id: m.source_id || null, source_id: m.source_id || null,
artist_id: m.artist_id || null,
from_date: m.from_date, from_date: m.from_date,
to_date: m.to_date, to_date: m.to_date,
}) })
@@ -188,12 +329,45 @@ async function openDetail(id) {
</script> </script>
<style scoped> <style scoped>
/* Sticky control header: stat chips + search + filter + toggle stay
pinned below the top nav while the event feed scrolls. Opaque
background so rows don't bleed through. */
.fc-dl__sticky {
position: sticky;
top: 48px;
z-index: 3;
background: rgb(var(--v-theme-background));
padding: 8px 0 10px;
margin-bottom: 4px;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.12);
}
.fc-dl__top { .fc-dl__top {
display: flex; gap: 8px; align-items: center; display: flex; gap: 8px; align-items: center;
margin-bottom: 12px; margin-bottom: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.fc-dl__filter { margin-bottom: 12px; } .fc-dl__controls {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.fc-dl__nochange { flex: 0 0 auto; }
.fc-dl__spark { flex: 0 0 auto; margin-right: 4px; max-width: 240px; }
.fc-dl__live {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-info));
}
.fc-dl__live-dot {
width: 7px; height: 7px; border-radius: 50%;
background: rgb(var(--v-theme-info));
animation: fc-dl-pulse 1.4s ease-in-out infinite;
}
@keyframes fc-dl-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
@media (prefers-reduced-motion: reduce) {
.fc-dl__live-dot { animation: none; }
}
.fc-dl__loading, .fc-dl__empty { .fc-dl__loading, .fc-dl__empty {
display: flex; justify-content: center; padding: 3rem 0; display: flex; justify-content: center; padding: 3rem 0;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
@@ -0,0 +1,87 @@
<template>
<v-card v-if="sources.length" variant="tonal" color="error" class="fc-fail mb-4">
<div class="fc-fail__head" @click="open = !open">
<v-icon size="small">mdi-alert-circle</v-icon>
<span class="fc-fail__title">
{{ sources.length }}
{{ sources.length === 1 ? 'source is' : 'sources are' }} failing
</span>
<v-spacer />
<v-btn
size="small" variant="text" prepend-icon="mdi-refresh"
:loading="retryingAll"
@click.stop="$emit('retry-all', sources)"
>
Retry all
</v-btn>
<v-icon size="small">{{ open ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</div>
<v-expand-transition>
<div v-if="open" class="fc-fail__body">
<div v-for="s in sources" :key="s.id" class="fc-fail__row">
<PlatformChip :platform="s.platform" size="x-small" />
<span class="fc-fail__artist">{{ s.artist_name || `#${s.artist_id}` }}</span>
<v-chip size="x-small" color="error" variant="flat" label class="fc-fail__count">
{{ s.consecutive_failures }}× failed
</v-chip>
<span class="fc-fail__err" :title="s.last_error || ''">
{{ s.last_error || 'no error message recorded' }}
</span>
<v-spacer />
<v-btn
size="x-small" variant="text" prepend-icon="mdi-refresh"
:loading="retryingIds.has(s.id)"
@click="$emit('retry', s)"
>
Retry
</v-btn>
</div>
</div>
</v-expand-transition>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import PlatformChip from './PlatformChip.vue'
defineProps({
// source records from /api/sources?failing=true
sources: { type: Array, default: () => [] },
retryingIds: { type: Set, default: () => new Set() },
retryingAll: { type: Boolean, default: false },
})
defineEmits(['retry', 'retry-all'])
const open = ref(true)
</script>
<style scoped>
.fc-fail { border-radius: 8px; }
.fc-fail__head {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
}
.fc-fail__title { font-weight: 600; }
.fc-fail__body {
padding: 0 14px 10px;
display: flex; flex-direction: column; gap: 2px;
}
.fc-fail__row {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px;
border-radius: 4px;
background: rgb(var(--v-theme-surface) / 0.4);
}
.fc-fail__artist { font-weight: 600; white-space: nowrap; }
.fc-fail__count { flex: 0 0 auto; }
.fc-fail__err {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.8rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 46ch;
}
</style>
@@ -31,6 +31,7 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
const emit = defineEmits(['refresh']) const emit = defineEmits(['refresh'])
@@ -39,10 +40,10 @@ const importStore = useImportStore()
async function onRetry() { async function onRetry() {
try { try {
await importStore.retryFailed() await importStore.retryFailed()
globalThis.window?.__fcToast?.({ text: 'Retry queued', type: 'success' }) toast({ text: 'Retry queued', type: 'success' })
emit('refresh') emit('refresh')
} catch (e) { } catch (e) {
globalThis.window?.__fcToast?.({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' }) toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
} }
} }
@@ -50,13 +51,13 @@ async function onClear() {
try { try {
const body = await importStore.clearStuck() const body = await importStore.clearStuck()
const n = body?.cleared ?? 0 const n = body?.cleared ?? 0
globalThis.window?.__fcToast?.({ toast({
text: n ? `Cleared ${n} stuck task${n === 1 ? '' : 's'}` : 'Nothing stuck', text: n ? `Cleared ${n} stuck task${n === 1 ? '' : 's'}` : 'Nothing stuck',
type: 'success', type: 'success',
}) })
emit('refresh') emit('refresh')
} catch (e) { } catch (e) {
globalThis.window?.__fcToast?.({ text: `Clear failed: ${e?.detail || e?.message || e}`, type: 'error' }) toast({ text: `Clear failed: ${e?.detail || e?.message || e}`, type: 'error' })
} }
} }
</script> </script>
@@ -0,0 +1,103 @@
<template>
<div v-if="status" class="fc-sched" :class="`fc-sched--${health}`">
<span class="fc-sched__dot" />
<span class="fc-sched__label">Scheduler</span>
<span class="fc-sched__sep">·</span>
<span class="fc-sched__item">
<v-icon size="x-small">mdi-clock-outline</v-icon>
last ran {{ lastRanLabel }}
</span>
<span class="fc-sched__sep">·</span>
<span class="fc-sched__item">
<v-icon size="x-small">mdi-calendar-clock</v-icon>
{{ nextLabel }}
</span>
<span class="fc-sched__sep">·</span>
<span class="fc-sched__item">
{{ status.auto_sources }} on schedule
</span>
<v-tooltip activator="parent" location="bottom">
{{ tooltip }}
</v-tooltip>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatRelative } from '../../utils/date.js'
const props = defineProps({
// { last_tick_at, next_due_at, due_now, auto_sources } | null
status: { type: Object, default: null },
})
// The Beat tick fires every 60s; if the last stamp is older than ~3
// minutes the scheduler (Beat) is almost certainly down.
const STALE_MS = 180_000
const tickAgeMs = computed(() => {
const t = props.status?.last_tick_at
if (!t) return null
return Date.now() - new Date(t).getTime()
})
const health = computed(() => {
if (tickAgeMs.value == null) return 'unknown'
return tickAgeMs.value <= STALE_MS ? 'ok' : 'stale'
})
const lastRanLabel = computed(() =>
formatRelative(props.status?.last_tick_at, { nullText: 'never' }),
)
const nextLabel = computed(() => {
const due = props.status?.due_now || 0
if (due > 0) return `${due} due now`
const n = props.status?.next_due_at
if (!n) return 'nothing scheduled'
const diff = new Date(n).getTime() - Date.now()
if (diff <= 0) return 'check due'
const s = Math.floor(diff / 1000)
if (s < 60) return `next in ${s}s`
if (s < 3600) return `next in ${Math.floor(s / 60)}m`
if (s < 86400) return `next in ${Math.floor(s / 3600)}h`
return `next in ${Math.floor(s / 86400)}d`
})
const tooltip = computed(() => {
if (health.value === 'unknown') return 'The scheduler has not ticked yet (or the worker just started).'
if (health.value === 'stale') return 'The scheduler tick is overdue — check that Celery Beat is running.'
return 'Celery Beat is firing the periodic source-check tick on schedule.'
})
</script>
<style scoped>
.fc-sched {
display: inline-flex; align-items: center; gap: 8px;
flex-wrap: wrap;
font-size: 0.78rem;
color: rgb(var(--v-theme-on-surface-variant));
padding: 4px 10px;
border-radius: 6px;
background: rgb(var(--v-theme-on-surface) / 0.04);
}
.fc-sched__dot {
width: 8px; height: 8px; border-radius: 50%;
flex: 0 0 auto;
background: rgb(var(--v-theme-on-surface-variant));
}
.fc-sched--ok .fc-sched__dot { background: rgb(var(--v-theme-success)); }
.fc-sched--stale .fc-sched__dot { background: rgb(var(--v-theme-error)); }
.fc-sched--unknown .fc-sched__dot { background: rgb(var(--v-theme-on-surface-variant)); }
.fc-sched--stale { color: rgb(var(--v-theme-error)); }
.fc-sched__label {
font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
color: rgb(var(--v-theme-on-surface));
}
.fc-sched--stale .fc-sched__label { color: rgb(var(--v-theme-error)); }
.fc-sched__sep { opacity: 0.4; }
.fc-sched__item {
display: inline-flex; align-items: center; gap: 3px;
white-space: nowrap;
}
</style>
@@ -18,6 +18,7 @@
:credential="credentialsStore.byPlatform.get(p.key) || null" :credential="credentialsStore.byPlatform.get(p.key) || null"
@replace="openUpload" @replace="openUpload"
@remove="confirmRemove" @remove="confirmRemove"
@verified="onSaved"
/> />
</v-col> </v-col>
</v-row> </v-row>
@@ -22,6 +22,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { formatRelative } from '../../utils/date.js'
const props = defineProps({ const props = defineProps({
source: { type: Object, required: true }, source: { type: Object, required: true },
@@ -38,28 +39,13 @@ const level = computed(() => {
const ariaLabel = computed(() => `source health: ${level.value}`) const ariaLabel = computed(() => `source health: ${level.value}`)
function relativeTime(iso) { const lastCheckedText = computed(() => formatRelative(props.source.last_checked_at))
if (!iso) return 'Never'
const then = new Date(iso).getTime()
const now = Date.now()
const diff = Math.abs(now - then) / 1000
if (diff < 60) return `${Math.floor(diff)}s`
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
}
const lastCheckedText = computed(() => { const nextCheckText = computed(() =>
if (!props.source.last_checked_at) return 'Never' props.source.next_check_at
return `${relativeTime(props.source.last_checked_at)} ago` ? formatRelative(props.source.next_check_at, { future: true })
}) : null,
)
const nextCheckText = computed(() => {
if (!props.source.next_check_at) return null
const due = new Date(props.source.next_check_at).getTime()
if (due <= Date.now()) return 'imminent'
return `in ~${relativeTime(props.source.next_check_at)}`
})
const truncatedError = computed(() => { const truncatedError = computed(() => {
const e = props.source.last_error || '' const e = props.source.last_error || ''
@@ -62,6 +62,7 @@
<script setup> <script setup>
import SourceHealthDot from './SourceHealthDot.vue' import SourceHealthDot from './SourceHealthDot.vue'
import { formatRelative } from '../../utils/date.js'
const props = defineProps({ const props = defineProps({
source: { type: Object, required: true }, source: { type: Object, required: true },
@@ -73,23 +74,6 @@ const emit = defineEmits(['edit', 'remove', 'toggle', 'check'])
function onToggleEnabled(value) { function onToggleEnabled(value) {
emit('toggle', { source: props.source, enabled: value }) emit('toggle', { source: props.source, enabled: value })
} }
function formatRelative(iso, opts = {}) {
if (!iso) return opts.future ? '—' : 'Never'
const then = new Date(iso).getTime()
const now = Date.now()
const diff = (then - now) / 1000 // seconds; positive = future, negative = past
const abs = Math.abs(diff)
let body
if (abs < 60) body = `${Math.floor(abs)}s`
else if (abs < 3600) body = `${Math.floor(abs / 60)}m`
else if (abs < 86400) body = `${Math.floor(abs / 3600)}h`
else body = `${Math.floor(abs / 86400)}d`
if (opts.future) return diff <= 0 ? 'imminent' : `in ${body}`
return `${body} ago`
}
</script> </script>
<style scoped> <style scoped>
@@ -1,5 +1,7 @@
<template> <template>
<div> <div>
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
<div class="fc-subs__bar"> <div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)"> <v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription Add subscription
@@ -7,10 +9,22 @@
<v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true"> <v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true">
New artist New artist
</v-btn> </v-btn>
<v-chip
:color="needsAttention ? 'error' : undefined"
:variant="needsAttention ? 'flat' : 'outlined'"
prepend-icon="mdi-alert-circle-outline"
@click="needsAttention = !needsAttention"
>
Needs attention
<v-chip v-if="attentionCount" size="x-small" class="ms-2" variant="tonal">
{{ attentionCount }}
</v-chip>
</v-chip>
<v-spacer /> <v-spacer />
<v-select <v-select
v-model="statusFilter" v-model="statusFilter"
:items="STATUS_OPTIONS" :items="STATUS_OPTIONS"
:disabled="needsAttention"
density="compact" variant="outlined" hide-details density="compact" variant="outlined" hide-details
style="max-width: 180px" style="max-width: 180px"
/> />
@@ -185,7 +199,8 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useSourcesStore } from '../../stores/sources.js' import { useSourcesStore } from '../../stores/sources.js'
import { usePlatformsStore } from '../../stores/platforms.js' import { usePlatformsStore } from '../../stores/platforms.js'
@@ -195,6 +210,8 @@ import SourceHealthDot from './SourceHealthDot.vue'
import SourceFormDialog from './SourceFormDialog.vue' import SourceFormDialog from './SourceFormDialog.vue'
import ArtistCreateDialog from './ArtistCreateDialog.vue' import ArtistCreateDialog from './ArtistCreateDialog.vue'
import PlatformChip from './PlatformChip.vue' import PlatformChip from './PlatformChip.vue'
import SchedulerStatusBar from './SchedulerStatusBar.vue'
import { formatRelative } from '../../utils/date.js'
const ITEMS_PER_PAGE_OPTIONS = [ const ITEMS_PER_PAGE_OPTIONS = [
{ value: 25, title: '25' }, { value: 25, title: '25' },
@@ -218,6 +235,7 @@ const importStore = useImportStore()
const search = ref('') const search = ref('')
const statusFilter = ref('all') const statusFilter = ref('all')
const needsAttention = ref(false)
const expanded = ref([]) const expanded = ref([])
const selected = ref([]) const selected = ref([])
const showSourceDialog = ref(false) const showSourceDialog = ref(false)
@@ -237,14 +255,24 @@ const failureThreshold = computed(() =>
async function refresh() { async function refresh() {
await store.loadAll() await store.loadAll()
await platformsStore.loadAll() await platformsStore.loadAll()
store.loadScheduleStatus()
if (!importStore.settings) await importStore.loadSettings() if (!importStore.settings) await importStore.loadSettings()
} }
// Re-poll scheduler status every 30s so the "last ran" reading + health
// dot stay honest (the Beat tick fires every 60s).
let schedId = null
onMounted(() => { onMounted(() => {
refresh() refresh()
if (artistFilter.value != null) { if (artistFilter.value != null) {
expanded.value = [`artist-${artistFilter.value}`] expanded.value = [`artist-${artistFilter.value}`]
} }
schedId = setInterval(() => {
if (!document.hidden) store.loadScheduleStatus()
}, 30000)
})
onUnmounted(() => {
if (schedId) { clearInterval(schedId); schedId = null }
}) })
watch(() => route.query.artist_id, refresh) watch(() => route.query.artist_id, refresh)
@@ -277,12 +305,23 @@ const groups = computed(() => {
}) })
}) })
// A group "needs attention" if any of its sources has accumulated
// failures OR has never been checked — the ones worth acting on.
function groupNeedsAttention(g) {
return groupMatchesStatus(g, 'errors') || groupMatchesStatus(g, 'stale')
}
const attentionCount = computed(
() => groups.value.filter(groupNeedsAttention).length,
)
const filteredGroups = computed(() => { const filteredGroups = computed(() => {
let arr = groups.value let arr = groups.value
if (artistFilter.value != null) { if (artistFilter.value != null) {
arr = arr.filter((g) => g.artist.id === artistFilter.value) arr = arr.filter((g) => g.artist.id === artistFilter.value)
} }
if (statusFilter.value !== 'all') { if (needsAttention.value) {
arr = arr.filter(groupNeedsAttention)
} else if (statusFilter.value !== 'all') {
arr = arr.filter((g) => groupMatchesStatus(g, statusFilter.value)) arr = arr.filter((g) => groupMatchesStatus(g, statusFilter.value))
} }
const q = search.value?.trim().toLowerCase() const q = search.value?.trim().toLowerCase()
@@ -328,16 +367,6 @@ function pickWorstSource(sources, threshold) {
return sources.reduce((worst, s) => (level(s) > level(worst) ? s : worst), sources[0]) return sources.reduce((worst, s) => (level(s) > level(worst) ? s : worst), sources[0])
} }
function formatRelative(iso) {
if (!iso) return 'Never'
const then = new Date(iso).getTime()
const diff = (Date.now() - then) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
function onRowClick(_evt, { item }) { function onRowClick(_evt, { item }) {
const key = item.key const key = item.key
const idx = expanded.value.indexOf(key) const idx = expanded.value.indexOf(key)
@@ -382,19 +411,19 @@ function onArtistCreated(artist) {
async function onCheck(source) { async function onCheck(source) {
try { try {
const body = await store.checkNow(source.id) const body = await store.checkNow(source.id)
globalThis.window?.__fcToast?.({ toast({
text: `Check enqueued (event #${body.download_event_id})`, text: `Check enqueued (event #${body.download_event_id})`,
type: 'success', type: 'success',
}) })
} catch (e) { } catch (e) {
if (e?.body?.download_event_id) { if (e?.body?.download_event_id) {
globalThis.window?.__fcToast?.({ toast({
text: 'Already running — see Downloads', text: 'Already running — see Downloads',
type: 'info', type: 'info',
}) })
router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } }) router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } })
} else { } else {
globalThis.window?.__fcToast?.({ toast({
text: `Check failed: ${e?.detail || e?.message || e}`, text: `Check failed: ${e?.detail || e?.message || e}`,
type: 'error', type: 'error',
}) })
@@ -417,7 +446,7 @@ async function checkAll(group) {
const parts = [] const parts = []
if (ok) parts.push(`${ok} queued`) if (ok) parts.push(`${ok} queued`)
if (conflict) parts.push(`${conflict} already running`) if (conflict) parts.push(`${conflict} already running`)
globalThis.window?.__fcToast?.({ toast({
text: parts.join(', ') || 'Nothing to check (no enabled sources)', text: parts.join(', ') || 'Nothing to check (no enabled sources)',
type: 'info', type: 'info',
}) })
@@ -444,7 +473,7 @@ async function bulkSetEnabled(enabled) {
} }
} }
await refresh() await refresh()
globalThis.window?.__fcToast?.({ toast({
text: `${changed} source${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}`, text: `${changed} source${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}`,
type: 'success', type: 'success',
}) })
@@ -467,7 +496,7 @@ async function bulkDelete() {
} }
} }
await refresh() await refresh()
globalThis.window?.__fcToast?.({ toast({
text: `${deleted} source${deleted === 1 ? '' : 's'} deleted`, text: `${deleted} source${deleted === 1 ? '' : 's'} deleted`,
type: 'success', type: 'success',
}) })
@@ -476,11 +505,20 @@ async function bulkDelete() {
</script> </script>
<style scoped> <style scoped>
.fc-subs__sched { margin-bottom: 10px; }
.fc-subs__bar { .fc-subs__bar {
display: flex; gap: 0.75rem; align-items: center; display: flex; gap: 0.75rem; align-items: center;
padding-bottom: 1rem;
flex-wrap: wrap; 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. */
position: sticky;
top: 48px;
z-index: 3;
background: rgb(var(--v-theme-background));
padding: 8px 0 1rem;
} }
.fc-subs__bar .v-chip { cursor: pointer; }
.fc-subs__loading, .fc-subs__empty { .fc-subs__loading, .fc-subs__empty {
display: flex; justify-content: center; padding: 2rem; display: flex; justify-content: center; padding: 2rem;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
@@ -0,0 +1,28 @@
import { ref } from 'vue'
// Owns the loading/error lifecycle shared by most Pinia stores. `run`
// flips loading on, clears error, runs `fn`, captures any throw into error
// (swallowed so callers can read `store.error`), and always resets loading.
// Returns fn's resolved value on success, or undefined if it threw.
//
// `errorAs` matches each store's existing convention so migrations don't
// change what `store.error` holds: 'raw' keeps the thrown object (the
// ApiError, with .status/.body), 'message' keeps just the string.
export function useAsyncAction({ errorAs = 'raw' } = {}) {
const loading = ref(false)
const error = ref(null)
async function run(fn) {
loading.value = true
error.value = null
try {
return await fn()
} catch (e) {
error.value = errorAs === 'message' ? (e?.message ?? String(e)) : e
} finally {
loading.value = false
}
}
return { loading, error, run }
}
+30
View File
@@ -0,0 +1,30 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// Two-way bind a tab ref with the ?tab= query param: the initial value comes
// from the URL (when it names a valid tab) else `defaultTab` — a string, or a
// function for a state-dependent default. Tab changes push to the URL via
// router.replace (no history spam); URL changes (back/forward) flow back into
// the ref. Returns { tab, resolve } where resolve() re-applies the URL-or-
// default rule (callers re-run it when their default inputs change).
export function useTabQuery(validTabs, defaultTab) {
const route = useRoute()
const router = useRouter()
const resolveDefault = () =>
(typeof defaultTab === 'function' ? defaultTab() : defaultTab)
const resolve = () =>
(validTabs.includes(route.query.tab) ? route.query.tab : resolveDefault())
const tab = ref(resolve())
watch(tab, (t) => {
if (route.query.tab === t) return
router.replace({ query: { ...route.query, tab: t } })
})
watch(() => route.query.tab, (q) => {
if (q && validTabs.includes(q) && tab.value !== q) tab.value = q
})
return { tab, resolve }
}
+3
View File
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
import { createVuetify } from 'vuetify' import { createVuetify } from 'vuetify'
import 'vuetify/styles' import 'vuetify/styles'
import '@mdi/font/css/materialdesignicons.css' import '@mdi/font/css/materialdesignicons.css'
// App-global CSS overrides — imported AFTER vuetify/styles so equal-
// specificity rules (e.g. the tooltip readability fix) win by source order.
import './styles/app.css'
import App from './App.vue' import App from './App.vue'
import router from './router.js' import router from './router.js'
+14
View File
@@ -114,6 +114,19 @@ export const useAdminStore = defineStore('admin', () => {
} }
} }
async function purgeLegacyTags({ dryRun = true } = {}) {
lastError.value = null
try {
return await api.post(
'/api/admin/tags/purge-legacy',
{ body: { dry_run: dryRun } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
// --- Task progress polling (taps FC-3i activity dashboard) -------- // --- Task progress polling (taps FC-3i activity dashboard) --------
/** /**
@@ -148,6 +161,7 @@ export const useAdminStore = defineStore('admin', () => {
mergeTags, mergeTags,
tagUsageCount, tagUsageCount,
pruneUnusedTags, pruneUnusedTags,
purgeLegacyTags,
pollTaskUntilDone, pollTaskUntilDone,
} }
}) })
+4 -10
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
const PAGE = 60 const PAGE = 60
@@ -8,8 +9,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
const api = useApi() const api = useApi()
const cards = ref([]) const cards = ref([])
const nextCursor = ref(null) const nextCursor = ref(null)
const loading = ref(false) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const error = ref(null)
const q = ref('') const q = ref('')
const platform = ref(null) const platform = ref(null)
let started = false let started = false
@@ -17,9 +17,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
async function loadMore() { async function loadMore() {
if (loading.value) return if (loading.value) return
if (started && nextCursor.value === null) return if (started && nextCursor.value === null) return
loading.value = true await run(async () => {
error.value = null
try {
const params = { limit: PAGE } const params = { limit: PAGE }
if (q.value) params.q = q.value if (q.value) params.q = q.value
if (platform.value) params.platform = platform.value if (platform.value) params.platform = platform.value
@@ -28,11 +26,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
cards.value.push(...body.cards) cards.value.push(...body.cards)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
started = true started = true
} catch (e) { })
error.value = e.message
} finally {
loading.value = false
}
} }
async function reset() { async function reset() {
+11 -11
View File
@@ -1,28 +1,22 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useCredentialsStore = defineStore('credentials', () => { export const useCredentialsStore = defineStore('credentials', () => {
const api = useApi() const api = useApi()
const byPlatform = ref(new Map()) const byPlatform = ref(new Map())
const extensionKey = ref(null) const extensionKey = ref(null)
const loading = ref(false) const { loading, error, run } = useAsyncAction()
const error = ref(null)
async function loadAll() { async function loadAll() {
loading.value = true await run(async () => {
error.value = null
try {
const arr = await api.get('/api/credentials') const arr = await api.get('/api/credentials')
const m = new Map() const m = new Map()
for (const c of arr) m.set(c.platform, c) for (const c of arr) m.set(c.platform, c)
byPlatform.value = m byPlatform.value = m
} catch (e) { })
error.value = e
} finally {
loading.value = false
}
} }
async function upload(platform, credential_type, data) { async function upload(platform, credential_type, data) {
@@ -38,6 +32,12 @@ export const useCredentialsStore = defineStore('credentials', () => {
byPlatform.value.delete(platform) byPlatform.value.delete(platform)
} }
// Runs gallery-dl --simulate against an enabled source for the
// platform. Returns {valid: bool|null, reason, last_verified?}.
async function verify(platform) {
return await api.post(`/api/credentials/${platform}/verify`)
}
async function loadKey() { async function loadKey() {
const body = await api.get('/api/settings/extension_api_key') const body = await api.get('/api/settings/extension_api_key')
extensionKey.value = body.key extensionKey.value = body.key
@@ -50,6 +50,6 @@ export const useCredentialsStore = defineStore('credentials', () => {
return { return {
byPlatform, extensionKey, loading, error, byPlatform, extensionKey, loading, error,
loadAll, upload, remove, loadKey, rotateKey, loadAll, upload, remove, verify, loadKey, rotateKey,
} }
}) })
+20 -17
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useDownloadsStore = defineStore('downloads', () => { export const useDownloadsStore = defineStore('downloads', () => {
const api = useApi() const api = useApi()
@@ -13,9 +14,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
from_date: null, to_date: null, from_date: null, to_date: null,
}) })
const selected = ref(null) const selected = ref(null)
const loading = ref(false) const { loading, error, run } = useAsyncAction()
const error = ref(null)
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 }) const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
const activity = ref({ hours: 24, buckets: [] })
const failing = ref([])
function _params(extra = {}) { function _params(extra = {}) {
const out = { limit: 50, ...extra } const out = { limit: 50, ...extra }
@@ -26,33 +28,22 @@ export const useDownloadsStore = defineStore('downloads', () => {
} }
async function loadFirst() { async function loadFirst() {
loading.value = true await run(async () => {
error.value = null
try {
const body = await api.get('/api/downloads', { params: _params() }) const body = await api.get('/api/downloads', { params: _params() })
events.value = body events.value = body
cursor.value = body.length ? body[body.length - 1].id : null cursor.value = body.length ? body[body.length - 1].id : null
hasMore.value = body.length === 50 hasMore.value = body.length === 50
} catch (e) { })
error.value = e
} finally {
loading.value = false
}
} }
async function loadMore() { async function loadMore() {
if (!hasMore.value || cursor.value == null) return if (!hasMore.value || cursor.value == null) return
loading.value = true await run(async () => {
try {
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) }) const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
events.value.push(...body) events.value.push(...body)
cursor.value = body.length ? body[body.length - 1].id : cursor.value cursor.value = body.length ? body[body.length - 1].id : cursor.value
hasMore.value = body.length === 50 hasMore.value = body.length === 50
} catch (e) { })
error.value = e
} finally {
loading.value = false
}
} }
async function loadOne(id) { async function loadOne(id) {
@@ -74,8 +65,20 @@ export const useDownloadsStore = defineStore('downloads', () => {
return stats.value return stats.value
} }
async function loadActivity(hours = 24) {
activity.value = await api.get('/api/downloads/activity', { params: { hours } })
return activity.value
}
async function loadFailing() {
failing.value = await api.get('/api/sources', { params: { failing: true } })
return failing.value
}
return { return {
events, cursor, hasMore, filter, selected, loading, error, stats, events, cursor, hasMore, filter, selected, loading, error, stats,
activity, failing,
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats, loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
loadActivity, loadFailing,
} }
}) })
+3 -2
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
@@ -51,7 +52,7 @@ export const useGallerySelectionStore = defineStore('gallerySelection', () => {
body: { image_ids: order.value, tag_id: tagId, source } body: { image_ids: order.value, tag_id: tagId, source }
}) })
await refresh() await refresh()
globalThis.window?.__fcToast?.({ toast({
text: `Added to ${body.added_count} image(s)`, type: 'success' text: `Added to ${body.added_count} image(s)`, type: 'success'
}) })
return body return body
@@ -62,7 +63,7 @@ export const useGallerySelectionStore = defineStore('gallerySelection', () => {
body: { image_ids: order.value, tag_id: tagId } body: { image_ids: order.value, tag_id: tagId }
}) })
await refresh() await refresh()
globalThis.window?.__fcToast?.({ toast({
text: `Removed from ${body.removed_count} image(s)`, type: 'success' text: `Removed from ${body.removed_count} image(s)`, type: 'success'
}) })
return body return body
+7 -6
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
@@ -37,7 +38,7 @@ export const useImportStore = defineStore('import', () => {
settings.value = await api.patch('/api/settings/import', { body: patch }) settings.value = await api.patch('/api/settings/import', { body: patch })
} catch (e) { } catch (e) {
settingsError.value = e.message settingsError.value = e.message
window.__fcToast?.({ text: `Settings save failed: ${e.message}`, type: 'error' }) toast({ text: `Settings save failed: ${e.message}`, type: 'error' })
throw e throw e
} }
} }
@@ -68,7 +69,7 @@ export const useImportStore = defineStore('import', () => {
const label = mode === 'deep' const label = mode === 'deep'
? 'Deep scan triggered (re-applying sidecar metadata + filling NULL phash/artist on existing rows)' ? 'Deep scan triggered (re-applying sidecar metadata + filling NULL phash/artist on existing rows)'
: mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered' : mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered'
window.__fcToast?.({ text: label, type: 'success' }) toast({ text: label, type: 'success' })
await refreshStatus() await refreshStatus()
// Re-poll twice over ~5s and produce an HONEST follow-up toast. // Re-poll twice over ~5s and produce an HONEST follow-up toast.
// Operator-flagged 2026-05-25: the prior "no new files" message was // Operator-flagged 2026-05-25: the prior "no new files" message was
@@ -91,22 +92,22 @@ export const useImportStore = defineStore('import', () => {
const refreshedCount = sameBatch.filter(t => t.status === 'complete' && t.result_image_id).length const refreshedCount = sameBatch.filter(t => t.status === 'complete' && t.result_image_id).length
const newImported = sameBatch.filter(t => t.status === 'complete' && t.result_image_id && !t.error).length const newImported = sameBatch.filter(t => t.status === 'complete' && t.result_image_id && !t.error).length
if (mode === 'deep' && sameBatch.length > 0) { if (mode === 'deep' && sameBatch.length > 0) {
window.__fcToast?.({ toast({
text: `Deep scan finished — ${sameBatch.length} file(s) processed`, text: `Deep scan finished — ${sameBatch.length} file(s) processed`,
type: 'info', type: 'info',
}) })
} else if (mode === 'quick' && sameBatch.length > 0) { } else if (mode === 'quick' && sameBatch.length > 0) {
window.__fcToast?.({ toast({
text: `Quick scan finished — ${newImported} new file(s) queued`, text: `Quick scan finished — ${newImported} new file(s) queued`,
type: 'info', type: 'info',
}) })
} else { } else {
window.__fcToast?.({ text: 'Library is up to date', type: 'info' }) toast({ text: 'Library is up to date', type: 'info' })
} }
}, 2000) }, 2000)
} catch (e) { } catch (e) {
triggerError.value = e.message triggerError.value = e.message
window.__fcToast?.({ text: `Scan failed: ${e.message}`, type: 'error' }) toast({ text: `Scan failed: ${e.message}`, type: 'error' })
throw e throw e
} }
} }
+4 -10
View File
@@ -1,23 +1,17 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useMLStore = defineStore('ml', () => { export const useMLStore = defineStore('ml', () => {
const api = useApi() const api = useApi()
const settings = ref(null) const settings = ref(null)
const loading = ref(false) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const error = ref(null)
async function loadSettings() { async function loadSettings() {
loading.value = true await run(async () => {
error.value = null
try {
settings.value = await api.get('/api/ml/settings') settings.value = await api.get('/api/ml/settings')
} catch (e) { })
error.value = e.message
} finally {
loading.value = false
}
} }
async function patchSettings(patch) { async function patchSettings(patch) {
+7 -12
View File
@@ -1,14 +1,15 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useModalStore = defineStore('modal', () => { export const useModalStore = defineStore('modal', () => {
const api = useApi() const api = useApi()
const currentImageId = ref(null) const currentImageId = ref(null)
const current = ref(null) const current = ref(null)
const loading = ref(false) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const error = ref(null)
// Post-scoped cycle. When set, prev/next cycles within this array // Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When // (used by PostCard's expanded-mosaic PostImageGrid clicks). When
@@ -19,8 +20,7 @@ export const useModalStore = defineStore('modal', () => {
async function open (id, opts = {}) { async function open (id, opts = {}) {
currentImageId.value = id currentImageId.value = id
loading.value = true current.value = null // cleared upfront so it stays null on error
error.value = null
// Update post-scoped state if caller passed it; otherwise clear so // Update post-scoped state if caller passed it; otherwise clear so
// the next open() from gallery context uses neighbors mode. // the next open() from gallery context uses neighbors mode.
if (opts.postImageIds != null) { if (opts.postImageIds != null) {
@@ -31,14 +31,9 @@ export const useModalStore = defineStore('modal', () => {
postImageIds.value = null postImageIds.value = null
postImageIndex.value = 0 postImageIndex.value = 0
} }
try { await run(async () => {
current.value = await api.get(`/api/gallery/image/${id}`) current.value = await api.get(`/api/gallery/image/${id}`)
} catch (e) { })
error.value = e.message
current.value = null
} finally {
loading.value = false
}
} }
async function close () { async function close () {
@@ -96,7 +91,7 @@ export const useModalStore = defineStore('modal', () => {
}) })
} catch (e) { } catch (e) {
current.value.tags = prev current.value.tags = prev
window.__fcToast?.({ text: `Failed to remove tag: ${e.message}`, type: 'error' }) toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
throw e throw e
} }
} }
+4 -10
View File
@@ -1,15 +1,15 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const usePostsStore = defineStore('posts', () => { export const usePostsStore = defineStore('posts', () => {
const api = useApi() const api = useApi()
const items = ref([]) const items = ref([])
const cursor = ref(null) const cursor = ref(null)
const loading = ref(false) const { loading, error, run } = useAsyncAction()
const done = ref(false) const done = ref(false)
const error = ref(null)
const filters = ref({ artist_id: null, platform: null }) const filters = ref({ artist_id: null, platform: null })
function _qs() { function _qs() {
@@ -38,18 +38,12 @@ export const usePostsStore = defineStore('posts', () => {
async function loadMore() { async function loadMore() {
if (loading.value || done.value) return if (loading.value || done.value) return
loading.value = true await run(async () => {
error.value = null
try {
const body = await api.get('/api/posts', { params: _qs() }) const body = await api.get('/api/posts', { params: _qs() })
items.value.push(...body.items) items.value.push(...body.items)
cursor.value = body.next_cursor cursor.value = body.next_cursor
if (body.next_cursor == null) done.value = true if (body.next_cursor == null) done.value = true
} catch (e) { })
error.value = e
} finally {
loading.value = false
}
} }
async function getPostFull(id) { async function getPostFull(id) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
@@ -59,7 +60,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
}) })
pickerSelection.value = [] pickerSelection.value = []
await refresh() await refresh()
globalThis.window?.__fcToast?.({ text: 'Added to series', type: 'success' }) toast({ text: 'Added to series', type: 'success' })
} }
async function remove(imageId) { async function remove(imageId) {
+6 -12
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
// IR reader.js "viewport-center-in-third" rule, pure for unit tests. // IR reader.js "viewport-center-in-third" rule, pure for unit tests.
// metrics: [{page_number, top, height}] in page order. // metrics: [{page_number, top, height}] in page order.
@@ -33,23 +34,16 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => {
const series = ref(null) const series = ref(null)
const pages = ref([]) const pages = ref([])
const loading = ref(false) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const error = ref(null)
async function load(tagId) { async function load(tagId) {
loading.value = true series.value = null // cleared upfront so they stay reset on error
error.value = null pages.value = []
try { await run(async () => {
const body = await api.get(`/api/series/${tagId}/pages`) const body = await api.get(`/api/series/${tagId}/pages`)
series.value = body.series series.value = body.series
pages.value = body.pages pages.value = body.pages
} catch (e) { })
error.value = e.message
series.value = null
pages.value = []
} finally {
loading.value = false
}
} }
return { series, pages, loading, error, load } return { series, pages, loading, error, load }
+4 -10
View File
@@ -1,32 +1,26 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
const PAGE = 60 const PAGE = 60
export const useShowcaseStore = defineStore('showcase', () => { export const useShowcaseStore = defineStore('showcase', () => {
const api = useApi() const api = useApi()
const images = ref([]) const images = ref([])
const loading = ref(false) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const error = ref(null)
const exhausted = ref(false) const exhausted = ref(false)
const seen = new Set() const seen = new Set()
async function fetchPage() { async function fetchPage() {
if (loading.value) return if (loading.value) return
loading.value = true await run(async () => {
error.value = null
try {
const body = await api.get('/api/showcase', { params: { limit: PAGE } }) const body = await api.get('/api/showcase', { params: { limit: PAGE } })
const fresh = body.images.filter(i => !seen.has(i.id)) const fresh = body.images.filter(i => !seen.has(i.id))
if (fresh.length === 0) { exhausted.value = true; return } if (fresh.length === 0) { exhausted.value = true; return }
for (const i of fresh) seen.add(i.id) for (const i of fresh) seen.add(i.id)
images.value.push(...fresh) images.value.push(...fresh)
} catch (e) { })
error.value = e.message
} finally {
loading.value = false
}
} }
async function shuffle() { async function shuffle() {
+14 -19
View File
@@ -1,41 +1,30 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useSourcesStore = defineStore('sources', () => { export const useSourcesStore = defineStore('sources', () => {
const api = useApi() const api = useApi()
// keyed cache: null => all sources, number => artist_id filtered // keyed cache: null => all sources, number => artist_id filtered
const byArtist = ref(new Map()) const byArtist = ref(new Map())
const loading = ref(false) const { loading, error, run } = useAsyncAction()
const error = ref(null) const scheduleStatus = ref(null)
const allSources = computed(() => byArtist.value.get(null) ?? []) const allSources = computed(() => byArtist.value.get(null) ?? [])
async function loadAll() { async function loadAll() {
loading.value = true await run(async () => {
error.value = null
try {
const body = await api.get('/api/sources') const body = await api.get('/api/sources')
byArtist.value.set(null, body) byArtist.value.set(null, body)
} catch (e) { })
error.value = e
} finally {
loading.value = false
}
} }
async function loadForArtist(artistId) { async function loadForArtist(artistId) {
loading.value = true await run(async () => {
error.value = null
try {
const body = await api.get('/api/sources', { params: { artist_id: artistId } }) const body = await api.get('/api/sources', { params: { artist_id: artistId } })
byArtist.value.set(artistId, body) byArtist.value.set(artistId, body)
} catch (e) { })
error.value = e
} finally {
loading.value = false
}
} }
function _invalidate(artistId) { function _invalidate(artistId) {
@@ -70,6 +59,11 @@ export const useSourcesStore = defineStore('sources', () => {
return await api.get('/api/artists/autocomplete', { params: { q: query, limit } }) return await api.get('/api/artists/autocomplete', { params: { q: query, limit } })
} }
async function loadScheduleStatus() {
scheduleStatus.value = await api.get('/api/sources/schedule-status')
return scheduleStatus.value
}
// FC-3c: trigger a download for one source. Returns {download_event_id,status}. // FC-3c: trigger a download for one source. Returns {download_event_id,status}.
const checkingIds = ref(new Set()) const checkingIds = ref(new Set())
@@ -104,13 +98,14 @@ export const useSourcesStore = defineStore('sources', () => {
} }
return { return {
byArtist, loading, error, byArtist, loading, error, scheduleStatus,
allSources, allSources,
checkingIds, checkingIds,
loadAll, loadForArtist, loadAll, loadForArtist,
create, update, remove, create, update, remove,
checkNow, checkNow,
findOrCreateArtist, autocompleteArtist, findOrCreateArtist, autocompleteArtist,
loadScheduleStatus,
sourcesByArtistGrouped, sourcesByArtistGrouped,
} }
}) })
+8 -13
View File
@@ -1,6 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
// Category display order: people/sources first, general last. // Category display order: people/sources first, general last.
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general'] export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general']
@@ -14,23 +16,16 @@ export const CATEGORY_LABELS = {
export const useSuggestionsStore = defineStore('suggestions', () => { export const useSuggestionsStore = defineStore('suggestions', () => {
const api = useApi() const api = useApi()
const byCategory = ref({}) // { category: [suggestion, ...] } const byCategory = ref({}) // { category: [suggestion, ...] }
const loading = ref(false) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const error = ref(null)
let currentImageId = null let currentImageId = null
async function load(imageId) { async function load(imageId) {
currentImageId = imageId currentImageId = imageId
loading.value = true byCategory.value = {} // cleared upfront so it stays empty on error
error.value = null await run(async () => {
try {
const body = await api.get(`/api/images/${imageId}/suggestions`) const body = await api.get(`/api/images/${imageId}/suggestions`)
byCategory.value = body.by_category || {} byCategory.value = body.by_category || {}
} catch (e) { })
error.value = e.message
byCategory.value = {}
} finally {
loading.value = false
}
} }
function _drop(category, predicate) { function _drop(category, predicate) {
@@ -54,7 +49,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
body: { tag_id: tagId } body: { tag_id: tagId }
}) })
_drop(suggestion.category, s => s === suggestion) _drop(suggestion.category, s => s === suggestion)
window.__fcToast?.({ toast({
text: `Tagged: ${suggestion.display_name}`, text: `Tagged: ${suggestion.display_name}`,
type: 'success' type: 'success'
}) })
@@ -69,7 +64,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
}) })
_drop(suggestion.category, s => s === suggestion) _drop(suggestion.category, s => s === suggestion)
window.__fcToast?.({ toast({
text: `Aliased & tagged: ${suggestion.display_name}`, text: `Aliased & tagged: ${suggestion.display_name}`,
type: 'success' type: 'success'
}) })
+4 -10
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
const PAGE = 60 const PAGE = 60
@@ -8,8 +9,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
const api = useApi() const api = useApi()
const cards = ref([]) const cards = ref([])
const nextCursor = ref(null) const nextCursor = ref(null)
const loading = ref(false) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const error = ref(null)
const kind = ref(null) const kind = ref(null)
const q = ref('') const q = ref('')
let started = false let started = false
@@ -17,9 +17,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
async function loadMore() { async function loadMore() {
if (loading.value) return if (loading.value) return
if (started && nextCursor.value === null) return if (started && nextCursor.value === null) return
loading.value = true await run(async () => {
error.value = null
try {
const params = { limit: PAGE } const params = { limit: PAGE }
if (kind.value) params.kind = kind.value if (kind.value) params.kind = kind.value
if (q.value) params.q = q.value if (q.value) params.q = q.value
@@ -28,11 +26,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
cards.value.push(...body.cards) cards.value.push(...body.cards)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
started = true started = true
} catch (e) { })
error.value = e.message
} finally {
loading.value = false
}
} }
async function reset() { async function reset() {
+21
View File
@@ -0,0 +1,21 @@
/* App-global overrides. Imported in main.js AFTER 'vuetify/styles' so
these win on equal selector specificity by source order. */
/* Tooltip readability fix (operator-flagged 2026-05-28).
Vuetify's default v-tooltip pairs `on-surface-variant` TEXT with an
`surface-variant` BACKGROUND. FC's theme deliberately maps
`on-surface-variant` to vellum (#C2BFB4 — a light cream, correct for
muted captions/hints on the dark page) but never defines
`surface-variant`, so Vuetify auto-generates a light-ish background:
light text on light bg → near-white-on-near-white, unreadable.
Tooltips want the inverse of muted body text — a dark, slightly
elevated panel with the HIGH-contrast parchment text. Fixing it here
(not by changing on-surface-variant) keeps captions/empty-states
correct while making every tooltip in the app legible. */
.v-tooltip > .v-overlay__content {
background: rgb(var(--v-theme-surface-bright)); /* slate #2C313A */
color: rgb(var(--v-theme-on-surface)); /* parchment #E8E4D8 */
border: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.25);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
+19
View File
@@ -13,3 +13,22 @@ export function formatPostDate(iso) {
if (isNaN(d.getTime())) return null if (isNaN(d.getTime())) return null
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}` return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`
} }
// Compact relative formatter: "5m ago" (past) or "in 3h" (future). Null /
// invalid input returns `nullText`. Pass { future: true } for upcoming
// timestamps; a future time already elapsed reads as "imminent".
export function formatRelative(iso, opts = {}) {
const { future = false, nullText = future ? '—' : 'Never' } = opts
if (!iso) return nullText
const then = new Date(iso).getTime()
if (isNaN(then)) return nullText
const diff = (then - Date.now()) / 1000 // +ve = future
const abs = Math.abs(diff)
let body
if (abs < 60) body = `${Math.floor(abs)}s`
else if (abs < 3600) body = `${Math.floor(abs / 60)}m`
else if (abs < 86400) body = `${Math.floor(abs / 3600)}h`
else body = `${Math.floor(abs / 86400)}d`
if (future) return diff <= 0 ? 'imminent' : `in ${body}`
return `${body} ago`
}
+21
View File
@@ -0,0 +1,21 @@
// 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.
export const DOWNLOAD_STATUSES = [
{ value: 'pending', label: 'Queued', color: 'grey', icon: 'mdi-clock-outline' },
{ value: 'running', label: 'Running', color: 'info', icon: 'mdi-progress-clock' },
{ value: 'ok', label: 'Completed', color: 'success', icon: 'mdi-check-circle' },
{ value: 'error', label: 'Failed', color: 'error', icon: 'mdi-alert-circle' },
{ value: 'skipped', label: 'Skipped', color: 'warning', icon: 'mdi-skip-next' },
]
const _BY_VALUE = Object.fromEntries(DOWNLOAD_STATUSES.map((s) => [s.value, s]))
export function downloadStatusMeta(value) {
return _BY_VALUE[value] || { value, label: value, color: 'grey', icon: 'mdi-help-circle' }
}
export const downloadStatusLabel = (v) => downloadStatusMeta(v).label
export const downloadStatusColor = (v) => downloadStatusMeta(v).color
export const downloadStatusIcon = (v) => downloadStatusMeta(v).icon
+8
View File
@@ -0,0 +1,8 @@
// Centralized toast trigger. App.vue registers the actual snackbar on
// window.__fcToast; this wraps the optional-chaining lookup so call sites
// don't repeat `globalThis.window?.__fcToast?.(...)` everywhere.
//
// opts: { text: string, type?: 'success' | 'error' | 'info' | 'warning' }
export function toast(opts) {
globalThis.window?.__fcToast?.(opts)
}
+8 -27
View File
@@ -39,30 +39,26 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { useArtistStore } from '../stores/artist.js' import { useArtistStore } from '../stores/artist.js'
import ArtistHeader from '../components/artist/ArtistHeader.vue' import ArtistHeader from '../components/artist/ArtistHeader.vue'
import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue' import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue'
import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue' import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue'
import ArtistManagementTab from '../components/artist/ArtistManagementTab.vue' import ArtistManagementTab from '../components/artist/ArtistManagementTab.vue'
import { useTabQuery } from '../composables/useTabQuery.js'
const VALID_TABS = ['posts', 'gallery', 'management'] const VALID_TABS = ['posts', 'gallery', 'management']
const route = useRoute() const route = useRoute()
const router = useRouter()
const store = useArtistStore() const store = useArtistStore()
const slug = computed(() => route.params.slug) const slug = computed(() => route.params.slug)
const tab = ref('posts') const { tab, resolve } = useTabQuery(
VALID_TABS,
function resolveDefaultTab () { () => ((store.postCount ?? 0) > 0 ? 'posts' : 'gallery'),
const fromUrl = route.query.tab )
if (VALID_TABS.includes(fromUrl)) return fromUrl
if ((store.postCount ?? 0) > 0) return 'posts'
return 'gallery'
}
watch(slug, async (s) => { watch(slug, async (s) => {
if (!s) return if (!s) return
@@ -70,23 +66,8 @@ watch(slug, async (s) => {
document.title = store.overview document.title = store.overview
? `${store.overview.name} — FabledCurator` ? `${store.overview.name} — FabledCurator`
: 'FabledCurator' : 'FabledCurator'
tab.value = resolveDefaultTab() tab.value = resolve()
}, { immediate: true }) }, { immediate: true })
// Reflect tab changes back into the URL so refresh/back/forward work.
watch(tab, (newTab) => {
if (route.query.tab === newTab) return
router.replace({
query: { ...route.query, tab: newTab },
})
})
// React to URL-tab changes (e.g., back/forward).
watch(() => route.query.tab, (q) => {
if (q && VALID_TABS.includes(q) && tab.value !== q) {
tab.value = q
}
})
</script> </script>
<style scoped> <style scoped>
+9 -21
View File
@@ -1,5 +1,5 @@
<template> <template>
<v-container fluid class="pt-2 pb-6"> <v-container fluid class="pt-2 pb-6 fc-subs-shell">
<v-tabs <v-tabs
v-model="tab" v-model="tab"
align-tabs="start" align-tabs="start"
@@ -36,35 +36,23 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SubscriptionsTab from '../components/subscriptions/SubscriptionsTab.vue' import SubscriptionsTab from '../components/subscriptions/SubscriptionsTab.vue'
import DownloadsTab from '../components/subscriptions/DownloadsTab.vue' import DownloadsTab from '../components/subscriptions/DownloadsTab.vue'
import SettingsTab from '../components/subscriptions/SettingsTab.vue' import SettingsTab from '../components/subscriptions/SettingsTab.vue'
import { useTabQuery } from '../composables/useTabQuery.js'
const VALID_TABS = ['subscriptions', 'downloads', 'settings'] const VALID_TABS = ['subscriptions', 'downloads', 'settings']
const route = useRoute() const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
const router = useRouter()
const tab = ref(
VALID_TABS.includes(route.query.tab) ? route.query.tab : 'subscriptions',
)
watch(tab, (t) => {
if (route.query.tab === t) return
router.replace({ query: { ...route.query, tab: t } })
})
watch(() => route.query.tab, (q) => {
if (q && VALID_TABS.includes(q) && tab.value !== q) {
tab.value = q
}
})
</script> </script>
<style scoped> <style scoped>
/* Cap the dashboards at a centered 1600px so rows aren't a mile wide on
wide/ultrawide monitors (operator-flagged 2026-05-28). */
.fc-subs-shell {
max-width: 1600px;
margin-inline: auto;
}
.fc-subs-tabs { .fc-subs-tabs {
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18); border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
} }
+12
View File
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import (
) )
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from backend.app import create_app
from backend.app.models import Base from backend.app.models import Base
@@ -77,6 +78,17 @@ def db_sync(sync_engine):
session.rollback() session.rollback()
@pytest_asyncio.fixture
async def app():
return create_app()
@pytest_asyncio.fixture
async def client(app):
async with app.test_client() as c:
yield c
# Migration-seeded baseline (singleton config like import_settings / # Migration-seeded baseline (singleton config like import_settings /
# ml_settings), captured lazily at the FIRST integration test's setup — # ml_settings), captured lazily at the FIRST integration test's setup —
# when the CI integration job has just run `alembic upgrade head` so the DB # when the CI integration job has just run `alembic upgrade head` so the DB
+57 -12
View File
@@ -8,24 +8,12 @@ import hashlib
import pytest import pytest
from backend.app import create_app
from backend.app.models import Artist, ImageRecord, Tag, TagKind from backend.app.models import Artist, ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag from backend.app.models.tag import image_tag
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
# --- Tier-C: POST /artists/<slug>/cascade-delete -------------------- # --- Tier-C: POST /artists/<slug>/cascade-delete --------------------
@@ -364,3 +352,60 @@ async def test_prune_unused_commit_deletes_and_returns_count(client, db):
body = await resp.get_json() body = await resp.get_json()
assert body["deleted"] >= 1 assert body["deleted"] >= 1
assert "byebye" in body["sample_names"] assert "byebye" in body["sample_names"]
@pytest.mark.asyncio
async def test_purge_legacy_dry_run_counts_by_kind_and_prefix(client, db):
db.add_all([
Tag(name="BlenderKnight:Hannah_BJ_Loops", kind=TagKind.archive),
Tag(name="BlenderKnight:May Animation", kind=TagKind.post),
Tag(name="SomeArtist", kind=TagKind.artist),
# IR `source` kind fell back to general during migration —
# caught by the source:* name prefix, not by kind.
Tag(name="source:patreon", kind=TagKind.general),
Tag(name="blonde hair", kind=TagKind.general), # must survive
])
await db.commit()
resp = await client.post(
"/api/admin/tags/purge-legacy", json={"dry_run": True},
)
body = await resp.get_json()
assert body["count"] == 4
assert body["by_kind"] == {"archive": 1, "post": 1, "artist": 1}
assert body["by_prefix"] == {"source:*": 1}
assert "blonde hair" not in body["sample_names"]
assert "source:patreon" in body["sample_names"]
@pytest.mark.asyncio
async def test_purge_legacy_commit_deletes_only_legacy(client, db):
from sqlalchemy import func, select
db.add_all([
Tag(name="arch1", kind=TagKind.archive),
Tag(name="source:fanbox", kind=TagKind.general),
Tag(name="keepme", kind=TagKind.general),
Tag(name="char1", kind=TagKind.character),
])
await db.commit()
resp = await client.post(
"/api/admin/tags/purge-legacy", json={"dry_run": False},
)
body = await resp.get_json()
assert body["deleted"] == 2 # arch1 + source:fanbox
# The plain general + character tags survive.
keep = (await db.execute(
select(Tag.name).where(Tag.name.in_(["keepme", "char1"]))
)).scalars().all()
assert set(keep) == {"keepme", "char1"}
# No archive/post/artist or source:* left.
gone = (await db.execute(
select(func.count()).select_from(Tag).where(
(Tag.kind.in_([TagKind.archive, TagKind.post, TagKind.artist]))
| (Tag.name.like("source:%"))
)
)).scalar_one()
assert gone == 0
-12
View File
@@ -1,23 +1,11 @@
import pytest import pytest
from backend.app import create_app
from backend.app.models import TagKind from backend.app.models import TagKind
from backend.app.services.tag_service import TagService from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_list_delete(client, db): async def test_create_list_delete(client, db):
tag = await TagService(db).find_or_create("Canon", TagKind.character) tag = await TagService(db).find_or_create("Canon", TagKind.character)
-12
View File
@@ -1,23 +1,11 @@
import pytest import pytest
from backend.app import create_app
from backend.app.models import TagAllowlist, TagKind from backend.app.models import TagAllowlist, TagKind
from backend.app.services.tag_service import TagService from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_and_patch_and_delete(client, db): async def test_list_and_patch_and_delete(client, db):
tag = await TagService(db).find_or_create("AL", TagKind.character) tag = await TagService(db).find_or_create("AL", TagKind.character)
-8
View File
@@ -1,18 +1,10 @@
import pytest import pytest
from backend.app import create_app
from backend.app.models import Artist from backend.app.models import Artist
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def client():
app = create_app()
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_artist_404(client): async def test_artist_404(client):
resp = await client.get("/api/artist/nope") resp = await client.get("/api/artist/nope")
-13
View File
@@ -1,21 +1,8 @@
import pytest import pytest
from backend.app import create_app
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_artist_returns_201(client): async def test_create_artist_returns_201(client):
resp = await client.post("/api/artists", json={"name": "Alice"}) resp = await client.post("/api/artists", json={"name": "Alice"})
-12
View File
@@ -2,23 +2,11 @@
import pytest import pytest
from backend.app import create_app
from backend.app.models import Artist, ImageRecord, Source from backend.app.models import Artist, ImageRecord, Source
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.fixture @pytest.fixture
async def seeded(db): async def seeded(db):
alice = Artist(name="alice-api", slug="alice-api", is_subscription=True) alice = Artist(name="alice-api", slug="alice-api", is_subscription=True)
-12
View File
@@ -1,22 +1,10 @@
import pytest import pytest
from backend.app import create_app
from backend.app.models import Artist, PostAttachment from backend.app.models import Artist, PostAttachment
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_streams_with_disposition(client, db, tmp_path): async def test_download_streams_with_disposition(client, db, tmp_path):
blob = tmp_path / "pack.zip" blob = tmp_path / "pack.zip"
-13
View File
@@ -1,21 +1,8 @@
import pytest import pytest
from backend.app import create_app
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
async def _mk_tag(client, name, kind="general"): async def _mk_tag(client, name, kind="general"):
r = await client.post("/api/tags", json={"name": name, "kind": kind}) r = await client.post("/api/tags", json={"name": name, "kind": kind})
return (await r.get_json())["id"] return (await r.get_json())["id"]
-12
View File
@@ -11,7 +11,6 @@ import pytest
from PIL import Image from PIL import Image
from sqlalchemy import func, select from sqlalchemy import func, select
from backend.app import create_app
from backend.app.celery_app import celery from backend.app.celery_app import celery
from backend.app.models import ImageRecord, LibraryAuditRun from backend.app.models import ImageRecord, LibraryAuditRun
@@ -23,17 +22,6 @@ def disable_celery_eager(monkeypatch):
monkeypatch.setattr(celery.conf, "task_always_eager", False) monkeypatch.setattr(celery.conf, "task_always_eager", False)
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
def _sha256_min_dim_token(min_w: int, min_h: int) -> str: def _sha256_min_dim_token(min_w: int, min_h: int) -> str:
canon = f"{min_w}x{min_h}" canon = f"{min_w}x{min_h}"
return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}" return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"
+81 -12
View File
@@ -1,6 +1,5 @@
import pytest import pytest
from backend.app import create_app
from backend.app.models import AppSetting from backend.app.models import AppSetting
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -12,17 +11,6 @@ _NETSCAPE = (
) )
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.fixture @pytest.fixture
async def ext_key(db): async def ext_key(db):
# Seed an extension API key for tests that want to assert the # Seed an extension API key for tests that want to assert the
@@ -143,3 +131,84 @@ async def test_extension_key_wrong_rejects(client, ext_key):
headers={"X-Extension-Key": "WRONG"}, headers={"X-Extension-Key": "WRONG"},
) )
assert resp.status_code == 401 assert resp.status_code == 401
@pytest.mark.asyncio
async def test_verify_no_credential_is_untestable(client):
resp = await client.post("/api/credentials/patreon/verify")
assert resp.status_code == 200
body = await resp.get_json()
assert body["valid"] is None
assert "No credential" in body["reason"]
@pytest.mark.asyncio
async def test_verify_no_enabled_source_is_untestable(client):
await client.post("/api/credentials", json={
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
})
resp = await client.post("/api/credentials/patreon/verify")
body = await resp.get_json()
assert body["valid"] is None
assert "no enabled source" in body["reason"].lower()
@pytest.mark.asyncio
async def test_verify_runs_gallery_dl_and_stamps_on_success(client, db, monkeypatch):
from backend.app.models import Artist, Source
from backend.app.services import gallery_dl as gdl_mod
# Stub the gallery-dl probe so the test doesn't shell out / hit network.
async def _fake_verify(self, *args, **kwargs):
return (True, "Credentials valid — the feed authenticated.")
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
await client.post("/api/credentials", json={
"platform": "patreon", "credential_type": "cookies", "data": _NETSCAPE,
})
artist = Artist(name="Maewix", slug="maewix")
db.add(artist)
await db.flush()
db.add(Source(
artist_id=artist.id, platform="patreon",
url="https://www.patreon.com/maewix", enabled=True, config_overrides={},
))
await db.commit()
resp = await client.post("/api/credentials/patreon/verify")
body = await resp.get_json()
assert body["valid"] is True
assert body["last_verified"] is not None
# The stamp is persisted on the credential record.
rec = await (await client.get("/api/credentials/patreon")).get_json()
assert rec["last_verified"] is not None
@pytest.mark.asyncio
async def test_verify_reports_auth_failure(client, db, monkeypatch):
from backend.app.models import Artist, Source
from backend.app.services import gallery_dl as gdl_mod
async def _fake_verify(self, *args, **kwargs):
return (False, "Authentication failed — cookies may be expired or invalid")
monkeypatch.setattr(gdl_mod.GalleryDLService, "verify", _fake_verify)
await client.post("/api/credentials", json={
"platform": "hentaifoundry", "credential_type": "cookies", "data": _NETSCAPE,
})
artist = Artist(name="HolyMeh", slug="holymeh")
db.add(artist)
await db.flush()
db.add(Source(
artist_id=artist.id, platform="hentaifoundry",
url="https://www.hentai-foundry.com/user/HolyMeh", enabled=True,
config_overrides={},
))
await db.commit()
resp = await client.post("/api/credentials/hentaifoundry/verify")
body = await resp.get_json()
assert body["valid"] is False
assert "auth" in body["reason"].lower()
assert body["last_verified"] is None
+20 -12
View File
@@ -1,22 +1,10 @@
import pytest import pytest
from backend.app import create_app
from backend.app.models import Artist, DownloadEvent, Source from backend.app.models import Artist, DownloadEvent, Source
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.fixture @pytest.fixture
async def seed(db): async def seed(db):
artist = Artist(name="Alice", slug="alice") artist = Artist(name="Alice", slug="alice")
@@ -126,3 +114,23 @@ async def test_stats_window_hours_rejects_out_of_range(client):
assert resp.status_code == 400 assert resp.status_code == 400
resp = await client.get("/api/downloads/stats?window_hours=bogus") resp = await client.get("/api/downloads/stats?window_hours=bogus")
assert resp.status_code == 400 assert resp.status_code == 400
@pytest.mark.asyncio
async def test_activity_returns_fixed_bucket_array(client, seed):
resp = await client.get("/api/downloads/activity")
assert resp.status_code == 200
body = await resp.get_json()
assert body["hours"] == 24
assert len(body["buckets"]) == 24
assert sum(b["total"] for b in body["buckets"]) == 2
# Both seed events were created "now" → land in the newest bucket.
newest = body["buckets"][-1]
assert newest["ok"] == 1
assert newest["error"] == 1
@pytest.mark.asyncio
async def test_activity_rejects_bogus_hours(client):
resp = await client.get("/api/downloads/activity?hours=bogus")
assert resp.status_code == 400
-12
View File
@@ -6,7 +6,6 @@ import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy import func, select from sqlalchemy import func, select
from backend.app import create_app
from backend.app import frontend as frontend_module from backend.app import frontend as frontend_module
from backend.app.api import extension as extension_module from backend.app.api import extension as extension_module
from backend.app.models import AppSetting, Artist, Source from backend.app.models import AppSetting, Artist, Source
@@ -14,17 +13,6 @@ from backend.app.models import AppSetting, Artist, Source
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def ext_key(db): async def ext_key(db):
db.add(AppSetting(key="extension_api_key", value="test-ext-key")) db.add(AppSetting(key="extension_api_key", value="test-ext-key"))
-12
View File
@@ -1,23 +1,11 @@
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
from backend.app import create_app
from backend.app.models import AppSetting from backend.app.models import AppSetting
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_seeds_a_value(client, db): async def test_get_seeds_a_value(client, db):
# First call seeds the row (idempotent on repeat calls). # First call seeds the row (idempotent on repeat calls).
-12
View File
@@ -2,23 +2,11 @@ from datetime import UTC, datetime, timedelta
import pytest import pytest
from backend.app import create_app
from backend.app.models import ImageRecord from backend.app.models import ImageRecord
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
async def _seed(db, count: int = 3): async def _seed(db, count: int = 3):
base = datetime.now(UTC) base = datetime.now(UTC)
for i in range(count): for i in range(count):
-12
View File
@@ -2,7 +2,6 @@ from datetime import UTC, datetime, timedelta
import pytest import pytest
from backend.app import create_app
from backend.app.celery_app import celery from backend.app.celery_app import celery
from backend.app.models import ImportBatch, ImportTask from backend.app.models import ImportBatch, ImportTask
@@ -16,17 +15,6 @@ def eager():
celery.conf.task_always_eager = False celery.conf.task_always_eager = False
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_when_idle(client): async def test_status_when_idle(client):
resp = await client.get("/api/import/status") resp = await client.get("/api/import/status")
-12
View File
@@ -6,7 +6,6 @@ import pytest
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
import backend.app.tasks.migration # noqa: F401 — register celery task import backend.app.tasks.migration # noqa: F401 — register celery task
from backend.app import create_app
from backend.app.celery_app import celery from backend.app.celery_app import celery
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -20,17 +19,6 @@ def _file_storage(payload: dict, filename: str = "export.json") -> FileStorage:
) )
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
# Retired 2026-05-24 (FC-3h): `test_post_backup_returns_202_and_id` # 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. # asserted the /api/migrate/backup endpoint, which was retired in FC-3h.
# Backup is now a first-class feature at /api/system/backup/*; # Backup is now a first-class feature at /api/system/backup/*;
-12
View File
@@ -1,6 +1,5 @@
import pytest import pytest
from backend.app import create_app
from backend.app.celery_app import celery from backend.app.celery_app import celery
from backend.app.models import TagKind from backend.app.models import TagKind
from backend.app.services.tag_service import TagService from backend.app.services.tag_service import TagService
@@ -15,17 +14,6 @@ def eager():
celery.conf.task_always_eager = False celery.conf.task_always_eager = False
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_and_patch_settings(client): async def test_get_and_patch_settings(client):
resp = await client.get("/api/ml/settings") resp = await client.get("/api/ml/settings")
-13
View File
@@ -2,22 +2,9 @@ import re
import pytest import pytest
from backend.app import create_app
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_platforms_returns_gs_six(client): async def test_platforms_returns_gs_six(client):
resp = await client.get("/api/platforms") resp = await client.get("/api/platforms")

Some files were not shown because too many files have changed in this diff Show More