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)
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
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)
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 ..models import Artist
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")
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:
"""Stable 8-hex token derived from the sorted id list. Mutates
when the selection changes; stays the same across modal opens of
@@ -206,3 +202,23 @@ async def tags_prune_unused():
)
)
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 ..models import LibraryAuditRun
from ..services import cleanup_service
from ._responses import error_response as _bad
cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")
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:
# SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both
# sides use SHA-256 truncated to 8 hex chars.
+54 -8
View File
@@ -20,6 +20,7 @@ from ..services.credential_service import (
UnknownPlatformError,
WrongAuthTypeError,
)
from ._responses import error_response as _bad
credentials_bp = Blueprint("credentials", __name__, url_prefix="/api/credentials")
@@ -38,14 +39,6 @@ def _get_crypto() -> CredentialCrypto:
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:
"""If X-Extension-Key is supplied, it must match the stored value.
Missing header → True (browser path; accepted per homelab posture).
@@ -124,3 +117,56 @@ async def delete_credential(platform: str):
except LookupError:
return _bad("not_found", status=404)
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)
@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"])
async def get_download(event_id: int):
async with get_session() as session:
+1 -6
View File
@@ -20,6 +20,7 @@ from ..services.extension_service import (
UnknownPlatformError,
)
from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
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$")
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:
"""Unlike /api/credentials (which accepts the browser path with no
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"}
if task.status != "failed":
return {"status": "not_failed"}
settings = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
settings = ImportSettings.load_sync(session)
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 ..models import MigrationRun
from ..tasks.migration import run_migration
from ._responses import error_response as _bad
migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate")
@@ -24,12 +25,6 @@ _VALID_KINDS = frozenset({
_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:
return {
"id": run.id,
+1 -8
View File
@@ -5,18 +5,11 @@ from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..services.post_feed_service import PostFeedService
from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
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"])
async def list_posts():
args = request.args
+2 -6
View File
@@ -31,9 +31,7 @@ _EDITABLE_FIELDS = (
@settings_bp.route("/settings/import", methods=["GET"])
async def get_import_settings():
async with get_session() as session:
row = (
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
).scalar_one()
row = await ImportSettings.load(session)
return jsonify({
"min_width": row.min_width,
"min_height": row.min_height,
@@ -99,9 +97,7 @@ async def update_import_settings():
return _bad_int("download_failure_warning_threshold", 1, 100)
async with get_session() as session:
row = (
await session.execute(select(ImportSettings).where(ImportSettings.id == 1))
).scalar_one()
row = await ImportSettings.load(session)
for field in _EDITABLE_FIELDS:
if field in body:
setattr(row, field, body[field])
+11 -9
View File
@@ -5,6 +5,7 @@ from sqlalchemy import select
from ..extensions import get_session
from ..models import DownloadEvent, Source
from ..services.scheduler_service import scheduler_status
from ..services.source_service import (
KNOWN_PLATFORMS,
ArtistNotFoundError,
@@ -14,18 +15,11 @@ from ..services.source_service import (
SourceService,
UnknownPlatformError,
)
from ._responses import error_response as _bad
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"])
async def list_sources():
artist_id_raw = request.args.get("artist_id")
@@ -35,11 +29,19 @@ async def list_sources():
artist_id = int(artist_id_raw)
except ValueError:
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:
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])
@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"])
async def get_source(source_id: int):
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 ..models import BackupRun, ImportSettings
from ._responses import error_response as _bad
system_backup_bp = Blueprint(
"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:
return {
"id": r.id,
@@ -232,9 +227,7 @@ async def delete_run(run_id: int):
@system_backup_bp.route("/settings", methods=["GET"])
async def get_settings():
async with get_session() as session:
row = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
row = await ImportSettings.load(session)
return jsonify({
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
@@ -254,9 +247,7 @@ async def patch_settings():
return err
async with get_session() as session:
row = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
row = await ImportSettings.load(session)
for field in _BACKUP_SETTINGS_FIELDS:
if field in body:
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.
"""
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 .base import Base
@@ -63,3 +63,13 @@ class ImportSettings(Base):
backup_images_keep_last_n: Mapped[int] = mapped_column(
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 typing import Any
from sqlalchemy import func, select, update
from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session
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}
# 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.
# ---------------------------------------------------------------------------
+14 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import select
@@ -163,6 +163,19 @@ class CredentialService:
return None
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:
"""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
except Exception:
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)
)
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(
self, *, artist_id: int, platform: str, url: str,
) -> Source:
@@ -222,31 +248,15 @@ class Importer:
and re-select — the concurrent op just created the row we
wanted, so the second select will find it.
"""
existing = self.session.execute(
select(Source).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
).scalar_one_or_none()
if existing is not None:
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()
stmt = select(Source).where(
Source.artist_id == artist_id,
Source.platform == platform,
Source.url == url,
)
return self._get_or_create(
stmt,
lambda: Source(artist_id=artist_id, platform=platform, url=url),
)
def _source_for_sidecar(
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
enabled=False (so the subscription checker doesn't poll it).
"""
existing = self.session.execute(
stmt = (
select(Source)
.where(
Source.artist_id == artist_id,
@@ -276,33 +286,16 @@ class Importer:
)
.order_by(Source.id.asc())
.limit(1)
).scalar_one_or_none()
if existing is not None:
return existing
synthetic_url = f"sidecar:{platform}:{artist_slug}"
sp = self.session.begin_nested()
try:
row = Source(
)
return self._get_or_create(
stmt,
lambda: Source(
artist_id=artist_id,
platform=platform,
url=synthetic_url,
url=f"sidecar:{platform}:{artist_slug}",
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(
self, *, source_id: int, external_post_id: str,
@@ -310,29 +303,14 @@ class Importer:
"""Race-safe find-or-create on `post` keyed by
(source_id, external_post_id). Mirrors `_find_or_create_source`
— same savepoint + IntegrityError-recovery pattern."""
existing = self.session.execute(
select(Post).where(
Post.source_id == source_id,
Post.external_post_id == external_post_id,
)
).scalar_one_or_none()
if existing is not None:
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()
stmt = select(Post).where(
Post.source_id == source_id,
Post.external_post_id == external_post_id,
)
return self._get_or_create(
stmt,
lambda: Post(source_id=source_id, external_post_id=external_post_id),
)
def import_one(self, source: Path) -> ImportResult:
"""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.orm import selectinload
from ..models import Artist, ImportSettings, Source
from ..models import AppSetting, Artist, ImportSettings, Source
MIN_INTERVAL_SECONDS = 60
MAX_INTERVAL_SECONDS = 86400
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(
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))
)).scalars().all()
settings = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
settings = await ImportSettings.load(session)
now = datetime.now(UTC)
due: list[Source] = []
@@ -78,3 +81,62 @@ def compute_next_check_at(
return None
interval = compute_effective_interval(source, artist, settings)
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
async def _load_settings(self) -> ImportSettings:
return (await self.session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
return await ImportSettings.load(self.session)
def _build_record(
self, source: Source, artist: Artist, settings: ImportSettings,
@@ -151,14 +149,19 @@ class SourceService:
settings = await self._load_settings()
return self._build_record(source, artist, settings)
async def list(self, artist_id: int | None = None) -> list[SourceRecord]:
stmt = (
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.order_by(Artist.name.asc(), Source.id.asc())
)
async def list(
self, artist_id: int | None = None, failing: bool = False,
) -> list[SourceRecord]:
stmt = select(Source, Artist).join(Artist, Artist.id == Source.artist_id)
if artist_id is not None:
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()
settings = await self._load_settings()
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()
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
with SessionLocal() as session:
s = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s = ImportSettings.load_sync(session)
for kind, keep in (
("db", s.backup_db_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>'}."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
s = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s = ImportSettings.load_sync(session)
nightly_enabled = s.backup_db_nightly_enabled
configured_hour = s.backup_db_nightly_hour_utc
if not nightly_enabled:
+4 -16
View File
@@ -3,12 +3,9 @@
import asyncio
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from ..celery_app import celery
from ..config import get_config
from ..models import ImportSettings
from ..services.credential_crypto import CredentialCrypto
from ..services.credential_service import CredentialService
@@ -16,18 +13,13 @@ from ..services.download_service import DownloadService
from ..services.gallery_dl import GalleryDLService
from ..services.importer import Importer
from ..services.thumbnailer import Thumbnailer
from ._async_session import async_session_factory
from .import_file import _sync_session_factory
IMAGES_ROOT = Path("/images")
_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(
name="backend.app.tasks.download.download_source",
bind=True,
@@ -44,13 +36,11 @@ def download_source(self, source_id: int) -> int:
"""Returns the DownloadEvent.id."""
async def _run():
async_factory, async_engine = _async_session_factory()
async_factory, async_engine = async_session_factory()
SyncFactory = _sync_session_factory()
try:
with SyncFactory() as sync_session:
settings = sync_session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
settings = ImportSettings.load_sync(sync_session)
rate_limit = settings.download_rate_limit_seconds
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:
cred_service = CredentialService(async_session, crypto)
with SyncFactory() as sync_session:
sync_settings = sync_session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
sync_settings = ImportSettings.load_sync(sync_session)
importer = Importer(
session=sync_session,
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:
"""Actual work, called from inside the resilience wrapper."""
settings = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
settings = ImportSettings.load_sync(session)
import_root = Path(settings.import_scan_path)
batch = session.get(ImportBatch, task.batch_id)
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()
with SessionLocal() as session:
settings = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
settings = ImportSettings.load_sync(session)
retention_days = settings.download_event_retention_days
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
result = session.execute(
+3 -9
View File
@@ -15,14 +15,14 @@ from datetime import UTC, datetime
from pathlib import Path
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 ..config import get_config
from ..models import MigrationRun
from ..services.credential_crypto import CredentialCrypto
from ..services.migrators import cleanup as cleanup_mod
from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify
from ._async_session import async_session_factory
log = logging.getLogger(__name__)
@@ -30,12 +30,6 @@ IMAGES_ROOT = Path("/images")
_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(
db: AsyncSession, run_id: int, *,
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:
factory, engine = _async_session_factory()
factory, engine = async_session_factory()
try:
async with factory() as db:
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 sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from ..celery_app import celery
from ..config import get_config
from ..models import DownloadEvent, ImportBatch, ImportSettings, ImportTask
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
@@ -45,9 +44,7 @@ def scan_directory(self, triggered_by: str = "manual",
batch id."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
settings = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
settings = ImportSettings.load_sync(session)
import_root = Path(settings.import_scan_path)
batch = ImportBatch(
@@ -141,16 +138,12 @@ def scan_directory(self, triggered_by: str = "manual",
# --- 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:
factory, engine = _async_session_factory()
factory, engine = async_session_factory()
try:
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)
if not due:
return {
@@ -62,6 +62,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
@@ -94,7 +95,7 @@ async function onPreview() {
try {
preview.value = await store.previewMinDim(minW.value, minH.value)
} catch (e) {
window.__fcToast?.({ text: `Preview failed: ${e.message}`, type: 'error' })
toast({ text: `Preview failed: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
@@ -108,12 +109,12 @@ function onDeleteClick() {
async function onConfirmedDelete(token) {
try {
const res = await store.deleteMinDim(minW.value, minH.value, token)
window.__fcToast?.({
toast({
text: `Deleted ${res.deleted} image(s)`, type: 'success',
})
preview.value = null
} catch (e) {
window.__fcToast?.({ text: `Delete failed: ${e.message}`, type: 'error' })
toast({ text: `Delete failed: ${e.message}`, type: 'error' })
}
}
</script>
@@ -93,6 +93,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
@@ -124,7 +125,7 @@ function startPoll(id) {
if (fresh.status !== 'running') stopPoll()
} catch (e) {
stopPoll()
window.__fcToast?.({ text: `Audit poll failed: ${e.message}`, type: 'error' })
toast({ text: `Audit poll failed: ${e.message}`, type: 'error' })
}
}, 5000)
}
@@ -142,7 +143,7 @@ async function onStart() {
audit.value = await store.getAudit(res.audit_id)
startPoll(res.audit_id)
} catch (e) {
window.__fcToast?.({ text: `Scan start failed: ${e.message}`, type: 'error' })
toast({ text: `Scan start failed: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
@@ -155,7 +156,7 @@ async function onCancel() {
audit.value = await store.getAudit(audit.value.id)
stopPoll()
} 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) {
try {
const res = await store.applyAudit(audit.value.id, token)
window.__fcToast?.({
toast({
text: `Deleted ${res.deleted} image(s)`, type: 'success',
})
audit.value = await store.getAudit(audit.value.id)
} catch (e) {
window.__fcToast?.({ text: `Apply failed: ${e.message}`, type: 'error' })
toast({ text: `Apply failed: ${e.message}`, type: 'error' })
}
}
</script>
@@ -80,6 +80,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
@@ -109,7 +110,7 @@ function startPoll(id) {
if (fresh.status !== 'running') stopPoll()
} catch (e) {
stopPoll()
window.__fcToast?.({ text: `Audit poll failed: ${e.message}`, type: 'error' })
toast({ text: `Audit poll failed: ${e.message}`, type: 'error' })
}
}, 5000)
}
@@ -125,7 +126,7 @@ async function onStart() {
audit.value = await store.getAudit(res.audit_id)
startPoll(res.audit_id)
} catch (e) {
window.__fcToast?.({ text: `Scan start failed: ${e.message}`, type: 'error' })
toast({ text: `Scan start failed: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
@@ -138,7 +139,7 @@ async function onCancel() {
audit.value = await store.getAudit(audit.value.id)
stopPoll()
} 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) {
try {
const res = await store.applyAudit(audit.value.id, token)
window.__fcToast?.({
toast({
text: `Deleted ${res.deleted} image(s)`, type: 'success',
})
audit.value = await store.getAudit(audit.value.id)
} catch (e) {
window.__fcToast?.({ text: `Apply failed: ${e.message}`, type: 'error' })
toast({ text: `Apply failed: ${e.message}`, type: 'error' })
}
}
</script>
@@ -33,6 +33,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref, watch } from 'vue'
import { copyText } from '../../utils/clipboard.js'
@@ -119,7 +120,7 @@ async function onCopy () {
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => { copied.value = false }, 1500)
} catch (e) {
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
toast({ text: `Copy failed: ${e.message}`, type: 'error' })
}
}
</script>
@@ -26,6 +26,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue'
import { useCredentialsStore } from '../../stores/credentials.js'
import { copyText } from '../../utils/clipboard.js'
@@ -39,9 +40,9 @@ async function copyKey() {
if (!store.extensionKey) return
try {
await copyText(store.extensionKey)
globalThis.window?.__fcToast?.({ text: 'Copied', type: 'success' })
toast({ text: 'Copied', type: 'success' })
} 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() {
showRotateConfirm.value = false
await store.rotateKey()
globalThis.window?.__fcToast?.({ text: 'Key rotated', type: 'success' })
toast({ text: 'Key rotated', type: 'success' })
}
</script>
@@ -35,20 +35,53 @@
</ul>
</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">
<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>
</template>
<v-expansion-panels class="mt-4">
<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>
<pre class="fc-dl-pre">{{ event.metadata?.stdout || '(empty)' }}</pre>
</v-expansion-panel-text>
</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>
<pre class="fc-dl-pre">{{ event.metadata?.stderr || '(empty)' }}</pre>
</v-expansion-panel-text>
@@ -56,6 +89,10 @@
</v-expansion-panels>
</v-card-text>
<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-btn variant="text" @click="onClose(false)">Close</v-btn>
</v-card-actions>
@@ -64,8 +101,11 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed } from 'vue'
import { copyText } from '../../utils/clipboard.js'
const props = defineProps({ event: { type: Object, default: null } })
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 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(() => ({
ok: 'success', error: 'error', running: 'info',
pending: 'secondary', skipped: 'warning',
@@ -114,4 +180,8 @@ function onClose() {
word-break: break-all;
}
.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>
@@ -86,11 +86,13 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import PlatformChip from '../subscriptions/PlatformChip.vue'
import { useSourcesStore } from '../../stores/sources.js'
import { downloadStatusColor, downloadStatusIcon, downloadStatusLabel } from '../../utils/downloadStatus.js'
const props = defineProps({ event: { type: Object, required: true } })
defineEmits(['open'])
@@ -98,16 +100,9 @@ defineEmits(['open'])
const sourcesStore = useSourcesStore()
const retrying = ref(false)
const _STATUS = {
ok: { color: 'success', icon: 'mdi-check-circle', label: 'Completed' },
error: { color: 'error', icon: 'mdi-alert-circle', label: 'Failed' },
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)
const statusColor = computed(() => downloadStatusColor(props.event.status))
const statusIcon = computed(() => downloadStatusIcon(props.event.status))
const statusLabel = computed(() => downloadStatusLabel(props.event.status))
function fmtTime(iso) {
if (!iso) return '—'
@@ -131,12 +126,12 @@ async function onRetry() {
retrying.value = true
try {
await sourcesStore.checkNow(props.event.source_id)
globalThis.window?.__fcToast?.({
toast({
text: `Source check re-queued`, type: 'success',
})
} catch (e) {
const isInFlight = !!e?.body?.download_event_id
globalThis.window?.__fcToast?.({
toast({
text: isInFlight ? 'Already running' : `Retry failed: ${e?.detail || e?.message || e}`,
type: isInFlight ? 'info' : 'error',
})
@@ -37,9 +37,13 @@
<a href="#" @click.prevent="openPost(e.post.id)">
View images from this post
</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
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"
/>
</article>
@@ -74,7 +78,7 @@
</template>
<script setup>
import { computed, watch } from 'vue'
import { computed, reactive, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { useProvenanceStore } from '../../stores/provenance.js'
@@ -84,6 +88,16 @@ const modal = useModalStore()
const prov = useProvenanceStore()
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(
() => modal.currentImageId,
(id) => { if (id != null) prov.loadForImage(id) },
@@ -38,6 +38,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref, watch } from 'vue'
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
@@ -57,7 +58,7 @@ watch(() => props.imageId, (id) => { if (id != null) store.load(id) }, { immedia
async function onAccept(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)
@@ -68,7 +69,7 @@ async function onAliasConfirm(canonicalTagId) {
await store.aliasAccept(aliasTarget.value, canonicalTagId)
aliasDialog.value = false
} catch (e) {
window.__fcToast?.({ text: `Alias failed: ${e.message}`, type: 'error' })
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
}
}
</script>
@@ -59,6 +59,8 @@
</template>
<script setup>
import { formatRelative as fmtRelative } from '../../utils/date.js'
defineProps({ runs: { type: Array, default: () => [] } })
defineEmits(['restore', 'delete', 'tag'])
@@ -92,13 +94,7 @@ function formatBytes(b) {
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function formatRelative(iso) {
if (!iso) return '—'
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`
return fmtRelative(iso, { nullText: '—' })
}
</script>
@@ -108,6 +108,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { copyText } from '../../utils/clipboard.js'
@@ -147,7 +148,7 @@ async function loadKey() {
apiKey.value = key
} catch (e) {
apiKey.value = ''
window.__fcToast?.({
toast({
text: `Failed to load extension API key: ${e.message}`,
type: 'error',
})
@@ -160,9 +161,9 @@ async function rotateKey() {
const { key } = await api.post('/api/settings/extension_api_key/rotate')
apiKey.value = key
keyShown.value = true
window.__fcToast?.({ text: 'Extension API key rotated.', type: 'success' })
toast({ text: 'Extension API key rotated.', type: 'success' })
} catch (e) {
window.__fcToast?.({
toast({
text: `Rotate failed: ${e.message}`,
type: 'error',
})
@@ -174,9 +175,9 @@ async function rotateKey() {
async function copy(text, label) {
try {
await copyText(text)
window.__fcToast?.({ text: `${label} copied.`, type: 'success' })
toast({ text: `${label} copied.`, type: 'success' })
} catch (e) {
window.__fcToast?.({ text: `Copy failed: ${e.message}`, type: 'error' })
toast({ text: `Copy failed: ${e.message}`, type: 'error' })
}
}
</script>
@@ -16,6 +16,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js'
const store = useMLStore()
@@ -24,7 +25,7 @@ const done = ref(false)
async function run() {
busy.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 }
}
</script>
@@ -125,6 +125,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue'
import { useImportStore } from '../../stores/import.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
@@ -177,9 +178,9 @@ async function onRefetch(item) {
try {
const res = await store.refetchTask(item.id)
const msg = _REFETCH_MSG[res.status] || { text: `Re-fetch: ${res.status}`, type: 'info' }
window.__fcToast?.(msg)
toast(msg)
} catch (e) {
window.__fcToast?.({ text: `Re-fetch failed: ${e.message}`, type: 'error' })
toast({ text: `Re-fetch failed: ${e.message}`, type: 'error' })
} finally {
refetching.value = null
}
@@ -15,6 +15,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { ref } from 'vue'
import { useMLStore } from '../../stores/ml.js'
const store = useMLStore()
@@ -23,7 +24,7 @@ const done = ref(false)
async function run() {
busy.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 }
}
</script>
@@ -17,6 +17,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { reactive, watch } from 'vue'
import { useMLStore } from '../../stores/ml.js'
@@ -35,6 +36,6 @@ async function save() {
const patch = {}
for (const f of fields) patch[f.key] = local[f.key]
try { await store.patchSettings(patch) }
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
catch (e) { toast({ text: e.message, type: 'error' }) }
}
</script>
@@ -154,6 +154,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
import { formatRelative as fmtRelative } from '../../utils/date.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import QueuesTable from './QueuesTable.vue'
@@ -277,14 +278,7 @@ function formatDuration(ms) {
return `${(ms / 60_000).toFixed(1)} min`
}
function formatRelative(iso) {
if (!iso) return '—'
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`
return fmtRelative(iso, { nullText: '—' })
}
</script>
@@ -44,6 +44,51 @@
@click="onCommit"
>Delete {{ preview.count }} unused tag(s)</v-btn>
</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>
</template>
@@ -57,6 +102,9 @@ const store = useAdminStore()
const preview = ref(null)
const loadingPreview = ref(false)
const committing = ref(false)
const kindPreview = ref(null)
const loadingKindPreview = ref(false)
const kindCommitting = ref(false)
async function onPreview() {
loadingPreview.value = true
@@ -76,6 +124,25 @@ async function onCommit() {
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>
<style scoped>
@@ -16,6 +16,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { ref } from 'vue'
import { useThumbnailsStore } from '../../stores/thumbnails.js'
const store = useThumbnailsStore()
@@ -24,7 +25,7 @@ const done = ref(false)
async function run () {
busy.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 }
}
</script>
@@ -20,6 +20,7 @@
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { ref, watch } from 'vue'
import { useSourcesStore } from '../../stores/sources.js'
@@ -44,7 +45,7 @@ async function submit() {
busy.value = true
try {
const { artist, created } = await store.findOrCreateArtist(name.value.trim())
globalThis.window?.__fcToast?.({
toast({
text: created ? 'Artist created' : 'Artist already exists',
type: 'success',
})
@@ -24,9 +24,17 @@
</v-icon>
<span>Expires {{ fmtDate(credential.expires_at) }}</span>
</div>
<div v-if="credential.last_verified_at" class="fc-cred-card__row">
<v-icon size="small" color="on-surface-variant">mdi-shield-check</v-icon>
<span>Last verified {{ fmtDate(credential.last_verified_at) }}</span>
<div v-if="credential.last_verified" class="fc-cred-card__row">
<v-icon size="small" :color="verifyStale ? 'warning' : 'success'">
{{ 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>
</template>
<template v-else>
@@ -55,7 +63,21 @@
</v-card-text>
<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-if="hasCredential"
size="small" variant="text" color="error"
@@ -76,14 +98,44 @@
</template>
<script setup>
import { computed } from 'vue'
import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue'
import PlatformChip from './PlatformChip.vue'
import { useCredentialsStore } from '../../stores/credentials.js'
const props = defineProps({
platform: { type: Object, required: true },
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)
@@ -96,14 +148,42 @@ const expiringSoon = computed(() => {
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(() => {
if (!hasCredential.value) return 'Not configured'
if (expiringSoon.value) return 'Expiring soon'
if (verifyStale.value) return 'Verify due'
return 'Active'
})
const statusColor = computed(() => {
if (!hasCredential.value) return 'grey'
if (expiringSoon.value) return 'warning'
if (expiringSoon.value || verifyStale.value) return 'warning'
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>
<div class="fc-dl-stats">
<v-chip
v-for="s in STAT_DEFS" :key="s.key"
v-for="s in STAT_DEFS" :key="s.value"
:color="s.color"
variant="tonal"
:variant="activeStatus === s.value ? 'elevated' : 'tonal'"
:prepend-icon="s.icon"
size="default"
:class="{ 'fc-dl-stats__active': activeStatus === s.value }"
@click="$emit('select', activeStatus === s.value ? null : s.value)"
>
{{ s.label }}
<strong class="ms-1">{{ stats[s.key] ?? 0 }}</strong>
<strong class="ms-1">{{ stats[s.value] ?? 0 }}</strong>
</v-chip>
</div>
</template>
<script setup>
import { DOWNLOAD_STATUSES as STAT_DEFS } from '../../utils/downloadStatus.js'
defineProps({
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 },
})
// 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' },
]
defineEmits(['select'])
</script>
<style scoped>
.fc-dl-stats {
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>
@@ -9,7 +9,7 @@
</v-chip>
</v-btn>
</template>
<v-card min-width="320" class="pa-3">
<v-card min-width="340" class="pa-3">
<v-select
v-model="local.status"
:items="STATUS_OPTIONS"
@@ -17,12 +17,17 @@
density="compact" variant="outlined" hide-details clearable
class="mb-2"
/>
<v-text-field
v-model.number="local.source_id"
label="Source ID"
<v-autocomplete
v-model="local.artist_id"
:items="artistItems"
:loading="artistLoading"
label="Artist"
item-title="name" item-value="id"
density="compact" variant="outlined" hide-details clearable
type="number" min="1"
no-filter
class="mb-2"
@update:search="onArtistSearch"
@update:model-value="onArtistPick"
/>
<v-text-field
v-model="local.from_date"
@@ -62,34 +67,57 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useSourcesStore } from '../../stores/sources.js'
import { DOWNLOAD_STATUSES, downloadStatusLabel } from '../../utils/downloadStatus.js'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
const STATUS_OPTIONS = [
{ title: 'Queued', value: 'pending' },
{ title: 'Running', value: 'running' },
{ 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 sourcesStore = useSourcesStore()
const STATUS_OPTIONS = DOWNLOAD_STATUSES.map((s) => ({ title: s.label, value: s.value }))
const open = ref(false)
const local = reactive({
status: props.modelValue.status ?? null,
source_id: props.modelValue.source_id ?? null,
from_date: props.modelValue.from_date ?? null,
to_date: props.modelValue.to_date ?? null,
status: props.modelValue.status ?? null,
artist_id: props.modelValue.artist_id ?? null,
artist_name: props.modelValue.artist_name ?? 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 })
// 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 out = []
if (local.status) out.push({ key: 'status', label: `Status: ${STATUS_LABEL[local.status] || local.status}` })
if (local.source_id) out.push({ key: 'source_id', label: `Source #${local.source_id}` })
if (local.status) out.push({ key: 'status', label: `Status: ${downloadStatusLabel(local.status)}` })
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.to_date) out.push({ key: 'to_date', label: `To ${local.to_date}` })
return out
@@ -102,13 +130,19 @@ function apply() {
}
function reset() {
local.status = null
local.source_id = null
local.artist_id = null
local.artist_name = null
local.from_date = null
local.to_date = null
emit('update:modelValue', { ...local })
}
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 })
}
</script>
@@ -1,21 +1,60 @@
<template>
<div>
<div class="fc-dl__top">
<DownloadStatChips :stats="store.stats" />
<v-spacer />
<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>
<div class="fc-dl__sticky">
<div class="fc-dl__top">
<DownloadStatChips
:stats="store.stats"
:active-status="filterModel.status"
@select="onStatusChipSelect"
/>
<span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
<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">
{{ String(store.error) }}
</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">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
@@ -74,19 +113,36 @@
</template>
<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 { useDownloadsStore } from '../../stores/downloads.js'
import { useSourcesStore } from '../../stores/sources.js'
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
import DownloadStatChips from './DownloadStatChips.vue'
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
import FailingSourcesCard from './FailingSourcesCard.vue'
import MaintenanceMenu from './MaintenanceMenu.vue'
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
const route = useRoute()
const store = useDownloadsStore()
const sourcesStore = useSourcesStore()
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
// lifetime of the SubscriptionsView (operator-friendly default: all
@@ -102,15 +158,84 @@ async function refresh() {
await Promise.all([
store.loadFirst(),
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(() => {
if (route.query.source_id) {
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
}
refresh()
startPolling()
})
onUnmounted(stopPolling)
// Client-side date filter on the loaded page (avoids a backend round-trip
// 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
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
})
@@ -176,6 +316,7 @@ watch(filterModel, async (m) => {
await store.applyFilter({
status: m.status,
source_id: m.source_id || null,
artist_id: m.artist_id || null,
from_date: m.from_date,
to_date: m.to_date,
})
@@ -188,12 +329,45 @@ async function openDetail(id) {
</script>
<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 {
display: flex; gap: 8px; align-items: center;
margin-bottom: 12px;
margin-bottom: 10px;
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 {
display: flex; justify-content: center; padding: 3rem 0;
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>
<script setup>
import { toast } from '../../utils/toast.js'
import { useImportStore } from '../../stores/import.js'
const emit = defineEmits(['refresh'])
@@ -39,10 +40,10 @@ const importStore = useImportStore()
async function onRetry() {
try {
await importStore.retryFailed()
globalThis.window?.__fcToast?.({ text: 'Retry queued', type: 'success' })
toast({ text: 'Retry queued', type: 'success' })
emit('refresh')
} 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 {
const body = await importStore.clearStuck()
const n = body?.cleared ?? 0
globalThis.window?.__fcToast?.({
toast({
text: n ? `Cleared ${n} stuck task${n === 1 ? '' : 's'}` : 'Nothing stuck',
type: 'success',
})
emit('refresh')
} 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>
@@ -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"
@replace="openUpload"
@remove="confirmRemove"
@verified="onSaved"
/>
</v-col>
</v-row>
@@ -22,6 +22,7 @@
<script setup>
import { computed } from 'vue'
import { formatRelative } from '../../utils/date.js'
const props = defineProps({
source: { type: Object, required: true },
@@ -38,28 +39,13 @@ const level = computed(() => {
const ariaLabel = computed(() => `source health: ${level.value}`)
function relativeTime(iso) {
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(() => formatRelative(props.source.last_checked_at))
const lastCheckedText = computed(() => {
if (!props.source.last_checked_at) return 'Never'
return `${relativeTime(props.source.last_checked_at)} ago`
})
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 nextCheckText = computed(() =>
props.source.next_check_at
? formatRelative(props.source.next_check_at, { future: true })
: null,
)
const truncatedError = computed(() => {
const e = props.source.last_error || ''
@@ -62,6 +62,7 @@
<script setup>
import SourceHealthDot from './SourceHealthDot.vue'
import { formatRelative } from '../../utils/date.js'
const props = defineProps({
source: { type: Object, required: true },
@@ -73,23 +74,6 @@ const emit = defineEmits(['edit', 'remove', 'toggle', 'check'])
function onToggleEnabled(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>
<style scoped>
@@ -1,5 +1,7 @@
<template>
<div>
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
<div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription
@@ -7,10 +9,22 @@
<v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true">
New artist
</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-select
v-model="statusFilter"
:items="STATUS_OPTIONS"
:disabled="needsAttention"
density="compact" variant="outlined" hide-details
style="max-width: 180px"
/>
@@ -185,7 +199,8 @@
</template>
<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 { useSourcesStore } from '../../stores/sources.js'
import { usePlatformsStore } from '../../stores/platforms.js'
@@ -195,6 +210,8 @@ import SourceHealthDot from './SourceHealthDot.vue'
import SourceFormDialog from './SourceFormDialog.vue'
import ArtistCreateDialog from './ArtistCreateDialog.vue'
import PlatformChip from './PlatformChip.vue'
import SchedulerStatusBar from './SchedulerStatusBar.vue'
import { formatRelative } from '../../utils/date.js'
const ITEMS_PER_PAGE_OPTIONS = [
{ value: 25, title: '25' },
@@ -218,6 +235,7 @@ const importStore = useImportStore()
const search = ref('')
const statusFilter = ref('all')
const needsAttention = ref(false)
const expanded = ref([])
const selected = ref([])
const showSourceDialog = ref(false)
@@ -237,14 +255,24 @@ const failureThreshold = computed(() =>
async function refresh() {
await store.loadAll()
await platformsStore.loadAll()
store.loadScheduleStatus()
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(() => {
refresh()
if (artistFilter.value != null) {
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)
@@ -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(() => {
let arr = groups.value
if (artistFilter.value != null) {
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))
}
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])
}
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 }) {
const key = item.key
const idx = expanded.value.indexOf(key)
@@ -382,19 +411,19 @@ function onArtistCreated(artist) {
async function onCheck(source) {
try {
const body = await store.checkNow(source.id)
globalThis.window?.__fcToast?.({
toast({
text: `Check enqueued (event #${body.download_event_id})`,
type: 'success',
})
} catch (e) {
if (e?.body?.download_event_id) {
globalThis.window?.__fcToast?.({
toast({
text: 'Already running — see Downloads',
type: 'info',
})
router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } })
} else {
globalThis.window?.__fcToast?.({
toast({
text: `Check failed: ${e?.detail || e?.message || e}`,
type: 'error',
})
@@ -417,7 +446,7 @@ async function checkAll(group) {
const parts = []
if (ok) parts.push(`${ok} queued`)
if (conflict) parts.push(`${conflict} already running`)
globalThis.window?.__fcToast?.({
toast({
text: parts.join(', ') || 'Nothing to check (no enabled sources)',
type: 'info',
})
@@ -444,7 +473,7 @@ async function bulkSetEnabled(enabled) {
}
}
await refresh()
globalThis.window?.__fcToast?.({
toast({
text: `${changed} source${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}`,
type: 'success',
})
@@ -467,7 +496,7 @@ async function bulkDelete() {
}
}
await refresh()
globalThis.window?.__fcToast?.({
toast({
text: `${deleted} source${deleted === 1 ? '' : 's'} deleted`,
type: 'success',
})
@@ -476,11 +505,20 @@ async function bulkDelete() {
</script>
<style scoped>
.fc-subs__sched { margin-bottom: 10px; }
.fc-subs__bar {
display: flex; gap: 0.75rem; align-items: center;
padding-bottom: 1rem;
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 {
display: flex; justify-content: center; padding: 2rem;
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 'vuetify/styles'
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 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) --------
/**
@@ -148,6 +161,7 @@ export const useAdminStore = defineStore('admin', () => {
mergeTags,
tagUsageCount,
pruneUnusedTags,
purgeLegacyTags,
pollTaskUntilDone,
}
})
+4 -10
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
const PAGE = 60
@@ -8,8 +9,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
const api = useApi()
const cards = ref([])
const nextCursor = ref(null)
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const q = ref('')
const platform = ref(null)
let started = false
@@ -17,9 +17,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
async function loadMore() {
if (loading.value) return
if (started && nextCursor.value === null) return
loading.value = true
error.value = null
try {
await run(async () => {
const params = { limit: PAGE }
if (q.value) params.q = q.value
if (platform.value) params.platform = platform.value
@@ -28,11 +26,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
cards.value.push(...body.cards)
nextCursor.value = body.next_cursor
started = true
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
})
}
async function reset() {
+11 -11
View File
@@ -1,28 +1,22 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useCredentialsStore = defineStore('credentials', () => {
const api = useApi()
const byPlatform = ref(new Map())
const extensionKey = ref(null)
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction()
async function loadAll() {
loading.value = true
error.value = null
try {
await run(async () => {
const arr = await api.get('/api/credentials')
const m = new Map()
for (const c of arr) m.set(c.platform, c)
byPlatform.value = m
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
}
async function upload(platform, credential_type, data) {
@@ -38,6 +32,12 @@ export const useCredentialsStore = defineStore('credentials', () => {
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() {
const body = await api.get('/api/settings/extension_api_key')
extensionKey.value = body.key
@@ -50,6 +50,6 @@ export const useCredentialsStore = defineStore('credentials', () => {
return {
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 { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useDownloadsStore = defineStore('downloads', () => {
const api = useApi()
@@ -13,9 +14,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
from_date: null, to_date: null,
})
const selected = ref(null)
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction()
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 = {}) {
const out = { limit: 50, ...extra }
@@ -26,33 +28,22 @@ export const useDownloadsStore = defineStore('downloads', () => {
}
async function loadFirst() {
loading.value = true
error.value = null
try {
await run(async () => {
const body = await api.get('/api/downloads', { params: _params() })
events.value = body
cursor.value = body.length ? body[body.length - 1].id : null
hasMore.value = body.length === 50
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
}
async function loadMore() {
if (!hasMore.value || cursor.value == null) return
loading.value = true
try {
await run(async () => {
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
events.value.push(...body)
cursor.value = body.length ? body[body.length - 1].id : cursor.value
hasMore.value = body.length === 50
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
}
async function loadOne(id) {
@@ -74,8 +65,20 @@ export const useDownloadsStore = defineStore('downloads', () => {
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 {
events, cursor, hasMore, filter, selected, loading, error, stats,
activity, failing,
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
loadActivity, loadFailing,
}
})
+3 -2
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
@@ -51,7 +52,7 @@ export const useGallerySelectionStore = defineStore('gallerySelection', () => {
body: { image_ids: order.value, tag_id: tagId, source }
})
await refresh()
globalThis.window?.__fcToast?.({
toast({
text: `Added to ${body.added_count} image(s)`, type: 'success'
})
return body
@@ -62,7 +63,7 @@ export const useGallerySelectionStore = defineStore('gallerySelection', () => {
body: { image_ids: order.value, tag_id: tagId }
})
await refresh()
globalThis.window?.__fcToast?.({
toast({
text: `Removed from ${body.removed_count} image(s)`, type: 'success'
})
return body
+7 -6
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue'
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 })
} catch (e) {
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
}
}
@@ -68,7 +69,7 @@ export const useImportStore = defineStore('import', () => {
const label = mode === 'deep'
? 'Deep scan triggered (re-applying sidecar metadata + filling NULL phash/artist on existing rows)'
: mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered'
window.__fcToast?.({ text: label, type: 'success' })
toast({ text: label, type: 'success' })
await refreshStatus()
// Re-poll twice over ~5s and produce an HONEST follow-up toast.
// 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 newImported = sameBatch.filter(t => t.status === 'complete' && t.result_image_id && !t.error).length
if (mode === 'deep' && sameBatch.length > 0) {
window.__fcToast?.({
toast({
text: `Deep scan finished — ${sameBatch.length} file(s) processed`,
type: 'info',
})
} else if (mode === 'quick' && sameBatch.length > 0) {
window.__fcToast?.({
toast({
text: `Quick scan finished — ${newImported} new file(s) queued`,
type: 'info',
})
} else {
window.__fcToast?.({ text: 'Library is up to date', type: 'info' })
toast({ text: 'Library is up to date', type: 'info' })
}
}, 2000)
} catch (e) {
triggerError.value = e.message
window.__fcToast?.({ text: `Scan failed: ${e.message}`, type: 'error' })
toast({ text: `Scan failed: ${e.message}`, type: 'error' })
throw e
}
}
+4 -10
View File
@@ -1,23 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useMLStore = defineStore('ml', () => {
const api = useApi()
const settings = ref(null)
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
async function loadSettings() {
loading.value = true
error.value = null
try {
await run(async () => {
settings.value = await api.get('/api/ml/settings')
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
})
}
async function patchSettings(patch) {
+7 -12
View File
@@ -1,14 +1,15 @@
import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useModalStore = defineStore('modal', () => {
const api = useApi()
const currentImageId = ref(null)
const current = ref(null)
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
// Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
@@ -19,8 +20,7 @@ export const useModalStore = defineStore('modal', () => {
async function open (id, opts = {}) {
currentImageId.value = id
loading.value = true
error.value = null
current.value = null // cleared upfront so it stays null on error
// Update post-scoped state if caller passed it; otherwise clear so
// the next open() from gallery context uses neighbors mode.
if (opts.postImageIds != null) {
@@ -31,14 +31,9 @@ export const useModalStore = defineStore('modal', () => {
postImageIds.value = null
postImageIndex.value = 0
}
try {
await run(async () => {
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 () {
@@ -96,7 +91,7 @@ export const useModalStore = defineStore('modal', () => {
})
} catch (e) {
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
}
}
+4 -10
View File
@@ -1,15 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const usePostsStore = defineStore('posts', () => {
const api = useApi()
const items = ref([])
const cursor = ref(null)
const loading = ref(false)
const { loading, error, run } = useAsyncAction()
const done = ref(false)
const error = ref(null)
const filters = ref({ artist_id: null, platform: null })
function _qs() {
@@ -38,18 +38,12 @@ export const usePostsStore = defineStore('posts', () => {
async function loadMore() {
if (loading.value || done.value) return
loading.value = true
error.value = null
try {
await run(async () => {
const body = await api.get('/api/posts', { params: _qs() })
items.value.push(...body.items)
cursor.value = body.next_cursor
if (body.next_cursor == null) done.value = true
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
}
async function getPostFull(id) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
@@ -59,7 +60,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
})
pickerSelection.value = []
await refresh()
globalThis.window?.__fcToast?.({ text: 'Added to series', type: 'success' })
toast({ text: 'Added to series', type: 'success' })
}
async function remove(imageId) {
+6 -12
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
// IR reader.js "viewport-center-in-third" rule, pure for unit tests.
// metrics: [{page_number, top, height}] in page order.
@@ -33,23 +34,16 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => {
const series = ref(null)
const pages = ref([])
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
async function load(tagId) {
loading.value = true
error.value = null
try {
series.value = null // cleared upfront so they stay reset on error
pages.value = []
await run(async () => {
const body = await api.get(`/api/series/${tagId}/pages`)
series.value = body.series
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 }
+4 -10
View File
@@ -1,32 +1,26 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
const PAGE = 60
export const useShowcaseStore = defineStore('showcase', () => {
const api = useApi()
const images = ref([])
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const exhausted = ref(false)
const seen = new Set()
async function fetchPage() {
if (loading.value) return
loading.value = true
error.value = null
try {
await run(async () => {
const body = await api.get('/api/showcase', { params: { limit: PAGE } })
const fresh = body.images.filter(i => !seen.has(i.id))
if (fresh.length === 0) { exhausted.value = true; return }
for (const i of fresh) seen.add(i.id)
images.value.push(...fresh)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
})
}
async function shuffle() {
+14 -19
View File
@@ -1,41 +1,30 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useSourcesStore = defineStore('sources', () => {
const api = useApi()
// keyed cache: null => all sources, number => artist_id filtered
const byArtist = ref(new Map())
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction()
const scheduleStatus = ref(null)
const allSources = computed(() => byArtist.value.get(null) ?? [])
async function loadAll() {
loading.value = true
error.value = null
try {
await run(async () => {
const body = await api.get('/api/sources')
byArtist.value.set(null, body)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
}
async function loadForArtist(artistId) {
loading.value = true
error.value = null
try {
await run(async () => {
const body = await api.get('/api/sources', { params: { artist_id: artistId } })
byArtist.value.set(artistId, body)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
}
function _invalidate(artistId) {
@@ -70,6 +59,11 @@ export const useSourcesStore = defineStore('sources', () => {
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}.
const checkingIds = ref(new Set())
@@ -104,13 +98,14 @@ export const useSourcesStore = defineStore('sources', () => {
}
return {
byArtist, loading, error,
byArtist, loading, error, scheduleStatus,
allSources,
checkingIds,
loadAll, loadForArtist,
create, update, remove,
checkNow,
findOrCreateArtist, autocompleteArtist,
loadScheduleStatus,
sourcesByArtistGrouped,
}
})
+8 -13
View File
@@ -1,6 +1,8 @@
import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
// Category display order: people/sources first, general last.
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general']
@@ -14,23 +16,16 @@ export const CATEGORY_LABELS = {
export const useSuggestionsStore = defineStore('suggestions', () => {
const api = useApi()
const byCategory = ref({}) // { category: [suggestion, ...] }
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
let currentImageId = null
async function load(imageId) {
currentImageId = imageId
loading.value = true
error.value = null
try {
byCategory.value = {} // cleared upfront so it stays empty on error
await run(async () => {
const body = await api.get(`/api/images/${imageId}/suggestions`)
byCategory.value = body.by_category || {}
} catch (e) {
error.value = e.message
byCategory.value = {}
} finally {
loading.value = false
}
})
}
function _drop(category, predicate) {
@@ -54,7 +49,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
body: { tag_id: tagId }
})
_drop(suggestion.category, s => s === suggestion)
window.__fcToast?.({
toast({
text: `Tagged: ${suggestion.display_name}`,
type: 'success'
})
@@ -69,7 +64,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}
})
_drop(suggestion.category, s => s === suggestion)
window.__fcToast?.({
toast({
text: `Aliased & tagged: ${suggestion.display_name}`,
type: 'success'
})
+4 -10
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
const PAGE = 60
@@ -8,8 +9,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
const api = useApi()
const cards = ref([])
const nextCursor = ref(null)
const loading = ref(false)
const error = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const kind = ref(null)
const q = ref('')
let started = false
@@ -17,9 +17,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
async function loadMore() {
if (loading.value) return
if (started && nextCursor.value === null) return
loading.value = true
error.value = null
try {
await run(async () => {
const params = { limit: PAGE }
if (kind.value) params.kind = kind.value
if (q.value) params.q = q.value
@@ -28,11 +26,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
cards.value.push(...body.cards)
nextCursor.value = body.next_cursor
started = true
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
})
}
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
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>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useArtistStore } from '../stores/artist.js'
import ArtistHeader from '../components/artist/ArtistHeader.vue'
import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue'
import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue'
import ArtistManagementTab from '../components/artist/ArtistManagementTab.vue'
import { useTabQuery } from '../composables/useTabQuery.js'
const VALID_TABS = ['posts', 'gallery', 'management']
const route = useRoute()
const router = useRouter()
const store = useArtistStore()
const slug = computed(() => route.params.slug)
const tab = ref('posts')
function resolveDefaultTab () {
const fromUrl = route.query.tab
if (VALID_TABS.includes(fromUrl)) return fromUrl
if ((store.postCount ?? 0) > 0) return 'posts'
return 'gallery'
}
const { tab, resolve } = useTabQuery(
VALID_TABS,
() => ((store.postCount ?? 0) > 0 ? 'posts' : 'gallery'),
)
watch(slug, async (s) => {
if (!s) return
@@ -70,23 +66,8 @@ watch(slug, async (s) => {
document.title = store.overview
? `${store.overview.name} — FabledCurator`
: 'FabledCurator'
tab.value = resolveDefaultTab()
tab.value = resolve()
}, { 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>
<style scoped>
+9 -21
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="pt-2 pb-6">
<v-container fluid class="pt-2 pb-6 fc-subs-shell">
<v-tabs
v-model="tab"
align-tabs="start"
@@ -36,35 +36,23 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SubscriptionsTab from '../components/subscriptions/SubscriptionsTab.vue'
import DownloadsTab from '../components/subscriptions/DownloadsTab.vue'
import SettingsTab from '../components/subscriptions/SettingsTab.vue'
import { useTabQuery } from '../composables/useTabQuery.js'
const VALID_TABS = ['subscriptions', 'downloads', 'settings']
const route = useRoute()
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
}
})
const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
</script>
<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 {
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 backend.app import create_app
from backend.app.models import Base
@@ -77,6 +78,17 @@ def db_sync(sync_engine):
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 /
# 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
+57 -12
View File
@@ -8,24 +8,12 @@ import hashlib
import pytest
from backend.app import create_app
from backend.app.models import Artist, ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
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 --------------------
@@ -364,3 +352,60 @@ async def test_prune_unused_commit_deletes_and_returns_count(client, db):
body = await resp.get_json()
assert body["deleted"] >= 1
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
from backend.app import create_app
from backend.app.models import TagKind
from backend.app.services.tag_service import TagService
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
async def test_create_list_delete(client, db):
tag = await TagService(db).find_or_create("Canon", TagKind.character)
-12
View File
@@ -1,23 +1,11 @@
import pytest
from backend.app import create_app
from backend.app.models import TagAllowlist, TagKind
from backend.app.services.tag_service import TagService
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
async def test_list_and_patch_and_delete(client, db):
tag = await TagService(db).find_or_create("AL", TagKind.character)
-8
View File
@@ -1,18 +1,10 @@
import pytest
from backend.app import create_app
from backend.app.models import Artist
pytestmark = pytest.mark.integration
@pytest.fixture
async def client():
app = create_app()
async with app.test_client() as c:
yield c
@pytest.mark.asyncio
async def test_artist_404(client):
resp = await client.get("/api/artist/nope")
-13
View File
@@ -1,21 +1,8 @@
import pytest
from backend.app import create_app
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
async def test_create_artist_returns_201(client):
resp = await client.post("/api/artists", json={"name": "Alice"})
-12
View File
@@ -2,23 +2,11 @@
import pytest
from backend.app import create_app
from backend.app.models import Artist, ImageRecord, Source
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
async def seeded(db):
alice = Artist(name="alice-api", slug="alice-api", is_subscription=True)
-12
View File
@@ -1,22 +1,10 @@
import pytest
from backend.app import create_app
from backend.app.models import Artist, PostAttachment
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
async def test_download_streams_with_disposition(client, db, tmp_path):
blob = tmp_path / "pack.zip"
-13
View File
@@ -1,21 +1,8 @@
import pytest
from backend.app import create_app
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"):
r = await client.post("/api/tags", json={"name": name, "kind": kind})
return (await r.get_json())["id"]
-12
View File
@@ -11,7 +11,6 @@ import pytest
from PIL import Image
from sqlalchemy import func, select
from backend.app import create_app
from backend.app.celery_app import celery
from backend.app.models import ImageRecord, LibraryAuditRun
@@ -23,17 +22,6 @@ def disable_celery_eager(monkeypatch):
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:
canon = f"{min_w}x{min_h}"
return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"
+81 -12
View File
@@ -1,6 +1,5 @@
import pytest
from backend.app import create_app
from backend.app.models import AppSetting
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
async def ext_key(db):
# 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"},
)
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
from backend.app import create_app
from backend.app.models import Artist, DownloadEvent, Source
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
async def seed(db):
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
resp = await client.get("/api/downloads/stats?window_hours=bogus")
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
from sqlalchemy import func, select
from backend.app import create_app
from backend.app import frontend as frontend_module
from backend.app.api import extension as extension_module
from backend.app.models import AppSetting, Artist, Source
@@ -14,17 +13,6 @@ from backend.app.models import AppSetting, Artist, Source
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
async def ext_key(db):
db.add(AppSetting(key="extension_api_key", value="test-ext-key"))
-12
View File
@@ -1,23 +1,11 @@
import pytest
from sqlalchemy import select
from backend.app import create_app
from backend.app.models import AppSetting
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
async def test_get_seeds_a_value(client, db):
# First call seeds the row (idempotent on repeat calls).
-12
View File
@@ -2,23 +2,11 @@ from datetime import UTC, datetime, timedelta
import pytest
from backend.app import create_app
from backend.app.models import ImageRecord
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):
base = datetime.now(UTC)
for i in range(count):
-12
View File
@@ -2,7 +2,6 @@ from datetime import UTC, datetime, timedelta
import pytest
from backend.app import create_app
from backend.app.celery_app import celery
from backend.app.models import ImportBatch, ImportTask
@@ -16,17 +15,6 @@ def eager():
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
async def test_status_when_idle(client):
resp = await client.get("/api/import/status")
-12
View File
@@ -6,7 +6,6 @@ import pytest
from werkzeug.datastructures import FileStorage
import backend.app.tasks.migration # noqa: F401 — register celery task
from backend.app import create_app
from backend.app.celery_app import celery
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`
# asserted the /api/migrate/backup endpoint, which was retired in FC-3h.
# Backup is now a first-class feature at /api/system/backup/*;
-12
View File
@@ -1,6 +1,5 @@
import pytest
from backend.app import create_app
from backend.app.celery_app import celery
from backend.app.models import TagKind
from backend.app.services.tag_service import TagService
@@ -15,17 +14,6 @@ def eager():
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
async def test_get_and_patch_settings(client):
resp = await client.get("/api/ml/settings")
-13
View File
@@ -2,22 +2,9 @@ import re
import pytest
from backend.app import create_app
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
async def test_platforms_returns_gs_six(client):
resp = await client.get("/api/platforms")

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