diff --git a/backend/app/api/_responses.py b/backend/app/api/_responses.py new file mode 100644 index 0000000..e935321 --- /dev/null +++ b/backend/app/api/_responses.py @@ -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 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index fd3c5ce..b82aacf 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -6,6 +6,7 @@ Five action surfaces: DELETE /api/admin/tags/ (Tier B) POST /api/admin/tags//merge (Tier B) POST /api/admin/tags/prune-unused (Tier A) + POST /api/admin/tags/purge-legacy (Tier A) GET /api/admin/tags//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) diff --git a/backend/app/api/cleanup.py b/backend/app/api/cleanup.py index 7149525..24853fe 100644 --- a/backend/app/api/cleanup.py +++ b/backend/app/api/cleanup.py @@ -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. diff --git a/backend/app/api/credentials.py b/backend/app/api/credentials.py index 182792a..f719b51 100644 --- a/backend/app/api/credentials.py +++ b/backend/app/api/credentials.py @@ -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("//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}) diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py index e7a86c9..ee648ef 100644 --- a/backend/app/api/downloads.py +++ b/backend/app/api/downloads.py @@ -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("/", methods=["GET"]) async def get_download(event_id: int): async with get_session() as session: diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index 4f4766f..5266d46 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -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[\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 diff --git a/backend/app/api/import_admin.py b/backend/app/api/import_admin.py index 7ad3fa7..36575c3 100644 --- a/backend/app/api/import_admin.py +++ b/backend/app/api/import_admin.py @@ -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)) diff --git a/backend/app/api/migrate.py b/backend/app/api/migrate.py index 8a67fc6..d48e9f4 100644 --- a/backend/app/api/migrate.py +++ b/backend/app/api/migrate.py @@ -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, diff --git a/backend/app/api/posts.py b/backend/app/api/posts.py index 1f9dd3e..d96e523 100644 --- a/backend/app/api/posts.py +++ b/backend/app/api/posts.py @@ -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 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 145890e..fa97bda 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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]) diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index fd2b109..9d7c9b1 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -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("/", methods=["GET"]) async def get_source(source_id: int): async with get_session() as session: diff --git a/backend/app/api/system_backup.py b/backend/app/api/system_backup.py index 24fa94e..a0f9ef2 100644 --- a/backend/app/api/system_backup.py +++ b/backend/app/api/system_backup.py @@ -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]) diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index 323fbf6..e5142ca 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -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() diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 7025b8b..dba9323 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -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. # --------------------------------------------------------------------------- diff --git a/backend/app/services/credential_service.py b/backend/app/services/credential_service.py index f467eb0..a8f6d8c 100644 --- a/backend/app/services/credential_service.py +++ b/backend/app/services/credential_service.py @@ -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 diff --git a/backend/app/services/gallery_dl.py b/backend/app/services/gallery_dl.py index 5e9b17f..e149477 100644 --- a/backend/app/services/gallery_dl.py +++ b/backend/app/services/gallery_dl.py @@ -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 diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index 0e366d9..b722f67 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -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::' 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 diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py index 58071d0..e70c87f 100644 --- a/backend/app/services/scheduler_service.py +++ b/backend/app/services/scheduler_service.py @@ -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), + } diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index df75e3e..85a6564 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -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] diff --git a/backend/app/tasks/_async_session.py b/backend/app/tasks/_async_session.py new file mode 100644 index 0000000..ea7a796 --- /dev/null +++ b/backend/app/tasks/_async_session.py @@ -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 diff --git a/backend/app/tasks/backup.py b/backend/app/tasks/backup.py index 9d2fefb..da7c841 100644 --- a/backend/app/tasks/backup.py +++ b/backend/app/tasks/backup.py @@ -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': ''} or {'dispatched': ''}.""" 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: diff --git a/backend/app/tasks/download.py b/backend/app/tasks/download.py index 8801a56..7cd7c13 100644 --- a/backend/app/tasks/download.py +++ b/backend/app/tasks/download.py @@ -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, diff --git a/backend/app/tasks/import_file.py b/backend/app/tasks/import_file.py index 4a124d1..49c798d 100644 --- a/backend/app/tasks/import_file.py +++ b/backend/app/tasks/import_file.py @@ -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") diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 4584d01..8bd321b 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -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( diff --git a/backend/app/tasks/migration.py b/backend/app/tasks/migration.py index a59c8b2..aec5102 100644 --- a/backend/app/tasks/migration.py +++ b/backend/app/tasks/migration.py @@ -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") diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 0451e49..90398fa 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -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 { diff --git a/frontend/src/components/cleanup/MinDimensionCard.vue b/frontend/src/components/cleanup/MinDimensionCard.vue index 140d549..b018c6b 100644 --- a/frontend/src/components/cleanup/MinDimensionCard.vue +++ b/frontend/src/components/cleanup/MinDimensionCard.vue @@ -62,6 +62,7 @@ diff --git a/frontend/src/components/cleanup/SingleColorAuditCard.vue b/frontend/src/components/cleanup/SingleColorAuditCard.vue index ff2ecbe..08c6476 100644 --- a/frontend/src/components/cleanup/SingleColorAuditCard.vue +++ b/frontend/src/components/cleanup/SingleColorAuditCard.vue @@ -93,6 +93,7 @@ diff --git a/frontend/src/components/cleanup/TransparencyAuditCard.vue b/frontend/src/components/cleanup/TransparencyAuditCard.vue index 4e91ac0..6e26f99 100644 --- a/frontend/src/components/cleanup/TransparencyAuditCard.vue +++ b/frontend/src/components/cleanup/TransparencyAuditCard.vue @@ -80,6 +80,7 @@ diff --git a/frontend/src/components/common/ErrorDetailModal.vue b/frontend/src/components/common/ErrorDetailModal.vue index 021f3da..6c378b3 100644 --- a/frontend/src/components/common/ErrorDetailModal.vue +++ b/frontend/src/components/common/ErrorDetailModal.vue @@ -33,6 +33,7 @@ diff --git a/frontend/src/components/credentials/ExtensionKeyBar.vue b/frontend/src/components/credentials/ExtensionKeyBar.vue index 2563ec6..3453aec 100644 --- a/frontend/src/components/credentials/ExtensionKeyBar.vue +++ b/frontend/src/components/credentials/ExtensionKeyBar.vue @@ -26,6 +26,7 @@ diff --git a/frontend/src/components/downloads/DownloadDetailModal.vue b/frontend/src/components/downloads/DownloadDetailModal.vue index 5ee4823..e8c4697 100644 --- a/frontend/src/components/downloads/DownloadDetailModal.vue +++ b/frontend/src/components/downloads/DownloadDetailModal.vue @@ -35,20 +35,53 @@ + + - Raw stdout + + Raw stdout + + Copy +
{{ event.metadata?.stdout || '(empty)' }}
- Raw stderr + + Raw stderr + + Copy +
{{ event.metadata?.stderr || '(empty)' }}
@@ -56,6 +89,10 @@
+ Copy all diagnostics Close @@ -64,8 +101,11 @@ diff --git a/frontend/src/components/settings/BackupRunsTable.vue b/frontend/src/components/settings/BackupRunsTable.vue index dc15d24..0d12676 100644 --- a/frontend/src/components/settings/BackupRunsTable.vue +++ b/frontend/src/components/settings/BackupRunsTable.vue @@ -59,6 +59,8 @@ diff --git a/frontend/src/components/settings/BrowserExtensionCard.vue b/frontend/src/components/settings/BrowserExtensionCard.vue index 2a0bedb..b3d7a41 100644 --- a/frontend/src/components/settings/BrowserExtensionCard.vue +++ b/frontend/src/components/settings/BrowserExtensionCard.vue @@ -108,6 +108,7 @@ diff --git a/frontend/src/components/settings/CentroidRecomputeCard.vue b/frontend/src/components/settings/CentroidRecomputeCard.vue index 22bf4bd..1f49210 100644 --- a/frontend/src/components/settings/CentroidRecomputeCard.vue +++ b/frontend/src/components/settings/CentroidRecomputeCard.vue @@ -16,6 +16,7 @@ diff --git a/frontend/src/components/settings/ImportTaskList.vue b/frontend/src/components/settings/ImportTaskList.vue index 4eadf3c..a3c78e2 100644 --- a/frontend/src/components/settings/ImportTaskList.vue +++ b/frontend/src/components/settings/ImportTaskList.vue @@ -125,6 +125,7 @@ diff --git a/frontend/src/components/settings/MLThresholdSliders.vue b/frontend/src/components/settings/MLThresholdSliders.vue index 34304d3..8919e3b 100644 --- a/frontend/src/components/settings/MLThresholdSliders.vue +++ b/frontend/src/components/settings/MLThresholdSliders.vue @@ -17,6 +17,7 @@ diff --git a/frontend/src/components/settings/SystemActivityTab.vue b/frontend/src/components/settings/SystemActivityTab.vue index 0d6485c..fe9db71 100644 --- a/frontend/src/components/settings/SystemActivityTab.vue +++ b/frontend/src/components/settings/SystemActivityTab.vue @@ -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: '—' }) } diff --git a/frontend/src/components/settings/TagMaintenanceCard.vue b/frontend/src/components/settings/TagMaintenanceCard.vue index 7d82f0b..6ea69c8 100644 --- a/frontend/src/components/settings/TagMaintenanceCard.vue +++ b/frontend/src/components/settings/TagMaintenanceCard.vue @@ -44,6 +44,51 @@ @click="onCommit" >Delete {{ preview.count }} unused tag(s) + + + +

+ Purge legacy IR-migration tags FC no longer uses: retired/system + kinds (archive, post, + artist — e.g. + BlenderKnight:Hannah_BJ_Loops) plus + source:* tags (ImageRepo's old source + kind, which migrated to general). Provenance and + artists are their own systems now, so these are pure noise. + Removes them from every image. +

+ + Preview legacy tags + +
+

+ {{ kindPreview.count }} legacy tag(s). + + {{ k }}: {{ n }}   + + + {{ p }}: {{ n }}   + +

+
+ + {{ n }} + +
+ Delete {{ kindPreview.count }} legacy tag(s) +
@@ -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 + } +} diff --git a/frontend/src/components/subscriptions/DownloadStatChips.vue b/frontend/src/components/subscriptions/DownloadStatChips.vue index ce5c975..d04387e 100644 --- a/frontend/src/components/subscriptions/DownloadStatChips.vue +++ b/frontend/src/components/subscriptions/DownloadStatChips.vue @@ -1,36 +1,39 @@ diff --git a/frontend/src/components/subscriptions/DownloadsFilterPopover.vue b/frontend/src/components/subscriptions/DownloadsFilterPopover.vue index 57b8253..0bef8d0 100644 --- a/frontend/src/components/subscriptions/DownloadsFilterPopover.vue +++ b/frontend/src/components/subscriptions/DownloadsFilterPopover.vue @@ -9,7 +9,7 @@ - + - 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 }) } diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue index ecaa92b..36c2718 100644 --- a/frontend/src/components/subscriptions/DownloadsTab.vue +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -1,21 +1,60 @@ diff --git a/frontend/src/components/subscriptions/MaintenanceMenu.vue b/frontend/src/components/subscriptions/MaintenanceMenu.vue index dc2d57e..b89d045 100644 --- a/frontend/src/components/subscriptions/MaintenanceMenu.vue +++ b/frontend/src/components/subscriptions/MaintenanceMenu.vue @@ -31,6 +31,7 @@ diff --git a/frontend/src/components/subscriptions/SchedulerStatusBar.vue b/frontend/src/components/subscriptions/SchedulerStatusBar.vue new file mode 100644 index 0000000..59f8bb2 --- /dev/null +++ b/frontend/src/components/subscriptions/SchedulerStatusBar.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/frontend/src/components/subscriptions/SettingsTab.vue b/frontend/src/components/subscriptions/SettingsTab.vue index cc1be56..5b6c3b2 100644 --- a/frontend/src/components/subscriptions/SettingsTab.vue +++ b/frontend/src/components/subscriptions/SettingsTab.vue @@ -18,6 +18,7 @@ :credential="credentialsStore.byPlatform.get(p.key) || null" @replace="openUpload" @remove="confirmRemove" + @verified="onSaved" /> diff --git a/frontend/src/components/subscriptions/SourceHealthDot.vue b/frontend/src/components/subscriptions/SourceHealthDot.vue index 44333ef..145044d 100644 --- a/frontend/src/components/subscriptions/SourceHealthDot.vue +++ b/frontend/src/components/subscriptions/SourceHealthDot.vue @@ -22,6 +22,7 @@