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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 & warnings</h3>
|
||||
<div class="fc-dl-blockhead mt-4">
|
||||
<h3 class="text-subtitle-2">Errors & 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 }}
|
||||
</span>
|
||||
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
|
||||
{{ p }}: {{ n }}
|
||||
</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 }
|
||||
}
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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/*;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user