"""Settings API: import filters, system stats.""" import asyncio import secrets from quart import Blueprint, jsonify, request from sqlalchemy import func, or_, select from ..extensions import get_session from ..models import ( AppSetting, Artist, ImageRecord, ImportBatch, ImportSettings, ImportTask, Post, Tag, ) from ..services import interpreter_client as ic settings_bp = Blueprint("settings", __name__, url_prefix="/api") _EDITABLE_FIELDS = ( "min_width", "min_height", "skip_transparent", "transparency_threshold", "skip_single_color", "single_color_threshold", "single_color_tolerance", "phash_threshold", "download_rate_limit_seconds", "download_validate_files", "download_schedule_default_seconds", "download_event_retention_days", "download_failure_warning_threshold", "series_suggest_enabled", "series_suggest_threshold", "extdl_mega_enabled", "extdl_gdrive_enabled", "extdl_mediafire_enabled", "extdl_dropbox_enabled", "extdl_pixeldrain_enabled", "translation_enabled", "interpreter_base_url", "translation_target_lang", ) # Per-host external-download toggles — all plain booleans, validated uniformly. _EXTDL_TOGGLE_FIELDS = ( "extdl_mega_enabled", "extdl_gdrive_enabled", "extdl_mediafire_enabled", "extdl_dropbox_enabled", "extdl_pixeldrain_enabled", ) @settings_bp.route("/settings/import", methods=["GET"]) async def get_import_settings(): async with get_session() as session: row = await ImportSettings.load(session) return jsonify({ "min_width": row.min_width, "min_height": row.min_height, "skip_transparent": row.skip_transparent, "transparency_threshold": row.transparency_threshold, "skip_single_color": row.skip_single_color, "single_color_threshold": row.single_color_threshold, "single_color_tolerance": row.single_color_tolerance, "phash_threshold": row.phash_threshold, "download_rate_limit_seconds": row.download_rate_limit_seconds, "download_validate_files": row.download_validate_files, "download_schedule_default_seconds": row.download_schedule_default_seconds, "download_event_retention_days": row.download_event_retention_days, "download_failure_warning_threshold": row.download_failure_warning_threshold, "series_suggest_enabled": row.series_suggest_enabled, "series_suggest_threshold": row.series_suggest_threshold, "extdl_mega_enabled": row.extdl_mega_enabled, "extdl_gdrive_enabled": row.extdl_gdrive_enabled, "extdl_mediafire_enabled": row.extdl_mediafire_enabled, "extdl_dropbox_enabled": row.extdl_dropbox_enabled, "extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled, "translation_enabled": row.translation_enabled, "interpreter_base_url": row.interpreter_base_url, "translation_target_lang": row.translation_target_lang, }) @settings_bp.route("/settings/import", methods=["PATCH"]) async def update_import_settings(): body = await request.get_json() if not isinstance(body, dict): return jsonify({"error": "body must be a JSON object"}), 400 if "phash_threshold" in body and ( not isinstance(body["phash_threshold"], int) or isinstance(body["phash_threshold"], bool) or body["phash_threshold"] < 0 ): return jsonify( {"error": "phash_threshold must be a non-negative integer"} ), 400 if "download_rate_limit_seconds" in body: val = body["download_rate_limit_seconds"] if not isinstance(val, (int, float)) or isinstance(val, bool) or val < 0: return jsonify( {"error": "download_rate_limit_seconds must be a non-negative number"} ), 400 if "download_validate_files" in body and not isinstance( body["download_validate_files"], bool ): return jsonify( {"error": "download_validate_files must be a boolean"} ), 400 # FC-3d scheduling knobs — bounds validation. def _bad_int(name: str, lo: int, hi: int): return jsonify( {"error": f"{name} must be an integer in [{lo}, {hi}]"} ), 400 if "download_schedule_default_seconds" in body: v = body["download_schedule_default_seconds"] if not isinstance(v, int) or isinstance(v, bool) or v < 60 or v > 86400: return _bad_int("download_schedule_default_seconds", 60, 86400) if "download_event_retention_days" in body: v = body["download_event_retention_days"] if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 3650: return _bad_int("download_event_retention_days", 1, 3650) if "download_failure_warning_threshold" in body: v = body["download_failure_warning_threshold"] if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 100: return _bad_int("download_failure_warning_threshold", 1, 100) if "series_suggest_enabled" in body and not isinstance( body["series_suggest_enabled"], bool ): return jsonify( {"error": "series_suggest_enabled must be a boolean"} ), 400 for tog in _EXTDL_TOGGLE_FIELDS: if tog in body and not isinstance(body[tog], bool): return jsonify({"error": f"{tog} must be a boolean"}), 400 # Translation (#143): base URL may be empty (feature off until set — no # default host; the operator points it at their own Interpreter proxy). if "translation_enabled" in body and not isinstance( body["translation_enabled"], bool ): return jsonify({"error": "translation_enabled must be a boolean"}), 400 for key in ("interpreter_base_url", "translation_target_lang"): if key in body and not isinstance(body[key], str): return jsonify({"error": f"{key} must be a string"}), 400 if "series_suggest_threshold" in body: v = body["series_suggest_threshold"] if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1: return jsonify( {"error": "series_suggest_threshold must be a number in [0, 1]"} ), 400 async with get_session() as session: row = await ImportSettings.load(session) for field in _EDITABLE_FIELDS: if field in body: setattr(row, field, body[field]) await session.commit() return await get_import_settings() @settings_bp.route("/system/stats", methods=["GET"]) async def system_stats(): async with get_session() as session: total_images = (await session.execute(select(func.count(ImageRecord.id)))).scalar_one() total_tags = (await session.execute(select(func.count(Tag.id)))).scalar_one() storage_bytes = ( (await session.execute(select(func.coalesce(func.sum(ImageRecord.size_bytes), 0)))).scalar_one() ) subscription_count = (await session.execute( select(func.count(Artist.id)).where(Artist.is_subscription.is_(True)) )).scalar_one() # Task counts grouped by status status_rows = ( await session.execute( select(ImportTask.status, func.count(ImportTask.id)).group_by(ImportTask.status) ) ).all() status_counts = {row[0]: row[1] for row in status_rows} # Integrity counts — FC-2e. integrity_rows = ( await session.execute( select( ImageRecord.integrity_status, func.count(ImageRecord.id), ).group_by(ImageRecord.integrity_status) ) ).all() integrity_counts = {row[0]: row[1] for row in integrity_rows} # Active batch = running batch that still has outstanding work. # Plain "most recent running" picks a freshly-created scan that # enqueued zero new files and hides the older batch that's # actually being processed; the EXISTS clause filters those # empty batches out. active_batch_row = ( await session.execute( select(ImportBatch) .where( ImportBatch.status == "running", select(ImportTask.id) .where( ImportTask.batch_id == ImportBatch.id, ImportTask.status.in_(["pending", "queued", "processing"]), ) .exists(), ) .order_by(ImportBatch.started_at.desc()) .limit(1) ) ).scalar_one_or_none() active_batch = None if active_batch_row: active_batch = { "id": active_batch_row.id, "source_path": active_batch_row.source_path, "started_at": active_batch_row.started_at.isoformat(), "total_files": active_batch_row.total_files, "imported": active_batch_row.imported, "skipped": active_batch_row.skipped, "failed": active_batch_row.failed, } return jsonify({ "total_images": total_images, "total_tags": total_tags, "storage_bytes": storage_bytes, "subscription_count": int(subscription_count), "tasks": { "pending": status_counts.get("pending", 0), "queued": status_counts.get("queued", 0), "processing": status_counts.get("processing", 0), "complete": status_counts.get("complete", 0), "skipped": status_counts.get("skipped", 0), "failed": status_counts.get("failed", 0), }, "integrity": { "unknown": int(integrity_counts.get("unknown", 0)), "ok": int(integrity_counts.get("ok", 0)), "corrupt": int(integrity_counts.get("corrupt", 0)), "failed_verification": int( integrity_counts.get("failed_verification", 0) ), }, "active_batch": active_batch, }) # --- FC-3b: extension API key (lives in app_setting) ----------------------- async def _get_or_seed_extension_api_key(session) -> str: row = (await session.execute( select(AppSetting).where(AppSetting.key == "extension_api_key") )).scalar_one_or_none() if row is None: row = AppSetting(key="extension_api_key", value=secrets.token_urlsafe(32)) session.add(row) await session.commit() await session.refresh(row) return row.value @settings_bp.route("/settings/extension_api_key", methods=["GET"]) async def get_extension_api_key(): async with get_session() as session: key = await _get_or_seed_extension_api_key(session) return jsonify({"key": key}) @settings_bp.route("/settings/extension_api_key/rotate", methods=["POST"]) async def rotate_extension_api_key(): async with get_session() as session: row = (await session.execute( select(AppSetting).where(AppSetting.key == "extension_api_key") )).scalar_one_or_none() new_value = secrets.token_urlsafe(32) if row is None: row = AppSetting(key="extension_api_key", value=new_value) session.add(row) else: row.value = new_value await session.commit() return jsonify({"key": new_value}) # --- Translation (#143): live status + manual "Translate now" -------------- @settings_bp.route("/settings/translation/status", methods=["GET"]) async def translation_status(): """For the Settings card: is it on, is a URL set, is the service reachable, and how many posts still await translation. Health runs the sync client in a thread so the event loop isn't blocked.""" async with get_session() as session: cfg = await ImportSettings.load(session) untranslated = (await session.execute( select(func.count(Post.id)) .where(Post.translated_source_lang.is_(None)) .where(or_( Post.post_title.is_not(None), Post.description.is_not(None), )) )).scalar_one() base_url = (cfg.interpreter_base_url or "").strip() healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False return jsonify({ "enabled": cfg.translation_enabled, "base_url_set": bool(base_url), "healthy": healthy, "untranslated_count": int(untranslated), }) @settings_bp.route("/settings/translation/test", methods=["POST"]) async def translation_test(): """On-demand reachability check for a GIVEN Interpreter base URL (the Settings 'Test connection' button) — pings /v1/health without saving, so the operator can verify a URL before enabling. Health runs in a thread (sync client).""" body = await request.get_json() base_url = "" if isinstance(body, dict): base_url = (body.get("base_url") or "").strip() healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False return jsonify({"healthy": healthy}) @settings_bp.route("/settings/translation/run", methods=["POST"]) async def translation_run(): """Enqueue the translate sweep now (the Settings 'Translate now' button).""" async with get_session() as session: cfg = await ImportSettings.load(session) if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip(): return jsonify( {"error": "translation is disabled or no base URL is set"} ), 400 from ..tasks.translation import translate_posts r = translate_posts.delay() return jsonify({"celery_task_id": r.id}), 202 @settings_bp.route("/settings/translation/retranslate", methods=["POST"]) async def translation_retranslate(): """Re-translate stored translations after a model change (m146). Body: ``{"artist_id": }`` aims at one artist; ``{"all": true}`` re-runs every artist. ``all`` must be explicit so an empty/typo body can't wipe everything. Clears the scoped translations and enqueues the run-until-done retranslate sweep (the Interpreter cache re-translates on a changed model, is cache-fast otherwise). Same enabled + base-URL guard as 'Translate now'.""" body = await request.get_json(silent=True) body = body if isinstance(body, dict) else {} artist_id = body.get("artist_id") do_all = bool(body.get("all")) if artist_id is None and not do_all: return jsonify( {"error": "provide artist_id, or all=true to re-translate everything"} ), 400 if artist_id is not None: try: artist_id = int(artist_id) except (TypeError, ValueError): return jsonify({"error": "artist_id must be an integer"}), 400 async with get_session() as session: cfg = await ImportSettings.load(session) if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip(): return jsonify( {"error": "translation is disabled or no base URL is set"} ), 400 from ..tasks.translation import retranslate_posts # artist_id wins when both are sent; otherwise all=true → None (every artist). artist_ids = [artist_id] if artist_id is not None else None r = retranslate_posts.delay(artist_ids=artist_ids) return jsonify({"celery_task_id": r.id}), 202