6a255482ea
New POST /api/settings/translation/test pings /v1/health for a GIVEN base URL (not the saved one), so the operator can verify a URL before enabling it. TranslationCard gains a Test-connection button that reports reachable/unreachable inline and updates the status dot. Endpoint test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
354 lines
14 KiB
Python
354 lines
14 KiB
Python
"""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
|