Files
FabledCurator/backend/app/api/settings.py
T
bvandeusenandClaude Opus 5 235393c08b
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m3s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m53s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s
feat: link the Patreon teaser to the Discord drop it announced (388 step E5)
The point of the milestone rather than its tail. Two of the operator's artists
post a deliberately cropped fragment on Patreon to signal that the real thing
has landed in their Discord; this proposes those pairs.

Confirm-only, following the FC-6.3 series matcher. A wrongly-asserted
association tells the operator two different pieces are one, which is strictly
worse than no link: no link leaves them where they already were, a wrong one
actively misinforms and then propagates into whatever reads it. So the
matcher's job is a SHORT list worth reading, not a long list worth trusting.

**The threshold sits above every single signal weight, and that is the
design.** Proximity is 0.55, declaration 0.45, the cut 0.60 — so neither
signal can carry a pair alone. That makes "time proximity alone is never
sufficient" an arithmetic property rather than an aspiration: on a busy day an
artist posts several times, and a matcher that could pair on proximity alone
would turn every one of those days into false pairs until the review queue got
abandoned. A guard test asserts the relationship against WEIGHTS directly, so
it survives any refactor of the scorer, and says in its own failure message not
to fix it by lowering the assertion.

**Crop-to-source matching is HELD, on the plan's instruction** — real work with
real false-positive risk, worth building only once signals 1 and 2 are shown
insufficient against the operator's actual artists. Worth stating: a naive
whole-image SigLIP similarity is NOT that signal. A cropped teaser and its full
version are precisely the pair a whole-image comparison handles worst, so
adding one as a "bonus" would mostly add noise while looking like progress.

Two premises in the plan corrected in the building:

* **E4 is not actually a prerequisite.** A Patreon Source and a Discord Source
  the operator has added under one Artist already share `Post.artist_id`, and
  the synthetic grouping inherits it. E4 EXTENDS this to creators FC has to
  learn the association for; it is not needed to represent one FC was told.
  Same-artist is then a hard filter, not a scored signal — two different
  creators posting minutes apart is a coincidence, not evidence.
* **`link_extract` cannot supply the declaration signal.** It exists, but
  `SUPPORTED_HOSTS` is file hosts only and `host_for()` returns None for a
  Discord URL, so no ExternalLink row is ever written for one. The signal
  reads the post body directly instead.

And a bug my own test would have caught: `declared_signal` stripped the HTML
before looking for an invite, but `html_to_plain` discards attributes and
these creators put the invite in an anchor's `href` — so the strongest form of
the signal was being thrown away, leaving only whatever the link text said.
The invite now matches the raw body; the bare mention still matches stripped
text, so `\bdiscord\b` is tested against prose rather than against markup.

Dismissed rows are kept, not deleted: the row is what remembers the rejection,
and re-proposing a rejected pair on every scan is the one behaviour that makes
a review queue get ignored. Both FKs CASCADE, so E3's one-DELETE reversal
cannot leave a proposal pointing at a post that no longer exists.

Only ACCEPTED links reach the post payload. A pending proposal is a question
for the review queue, not a claim to render beside the artwork.

UI (rule 27) follows in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
2026-09-10 11:47:28 -04:00

489 lines
20 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,
TaskRun,
)
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",
# #388 E5 — the announcement matcher (Patreon teaser ↔ Discord drop).
"discord_link_enabled",
"discord_link_threshold",
"discord_link_window_hours",
"extdl_mega_enabled",
"extdl_gdrive_enabled",
"extdl_mediafire_enabled",
"extdl_dropbox_enabled",
"extdl_pixeldrain_enabled",
"translation_enabled",
"interpreter_base_url",
"translation_target_lang",
"translation_min_confidence",
"wip_title_tagging_enabled",
"wip_soft_title_tagging_enabled",
)
# 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)
# Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field
# can't be silently absent from GET.
return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS})
@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
# Acceptance floor (milestone 155): latin-script translations below this
# Interpreter confidence are kept as the original.
if "translation_min_confidence" in body:
v = body["translation_min_confidence"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify(
{"error": "translation_min_confidence must be a number in [0, 1]"}
), 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
if "discord_link_enabled" in body and not isinstance(
body["discord_link_enabled"], bool
):
return jsonify({"error": "discord_link_enabled must be a boolean"}), 400
if "discord_link_threshold" in body:
v = body["discord_link_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
return jsonify(
{"error": "discord_link_threshold must be a number in [0, 1]"}
), 400
if "discord_link_window_hours" in body:
v = body["discord_link_window_hours"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v <= 0:
return jsonify(
{"error": "discord_link_window_hours must be a positive number"}
), 400
if "wip_title_tagging_enabled" in body and not isinstance(
body["wip_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_title_tagging_enabled must be a boolean"}
), 400
if "wip_soft_title_tagging_enabled" in body and not isinstance(
body["wip_soft_title_tagging_enabled"], bool
):
return jsonify(
{"error": "wip_soft_title_tagging_enabled must be a boolean"}
), 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("/settings/wip-title/scan", methods=["POST"])
async def wip_title_scan():
"""Enqueue the back-catalogue WIP-title scan (task #1458 Settings button):
apply the `wip` system tag to EXISTING posts whose title declares
work-in-progress. New imports are tagged live by the importer; this catches
the existing library. Returns the Celery task id (202)."""
from ..tasks.maintenance import backfill_wip_title_tags
r = backfill_wip_title_tags.delay()
return jsonify({"celery_task_id": r.id}), 202
@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."""
translation_tasks = (
"backend.app.tasks.translation.translate_posts",
"backend.app.tasks.translation.retranslate_posts",
)
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()
# Live progress: is a sweep running now, and what did the last one do?
# (run-until-done re-enqueues itself, so `active` stays true across a
# bulk re-translate; `last_run` surfaces a completed run's outcome.)
active = (await session.execute(
select(func.count(TaskRun.id))
.where(TaskRun.task_name.in_(translation_tasks))
.where(TaskRun.status == "running")
)).scalar_one()
last = (await session.execute(
select(TaskRun.task_name, TaskRun.status, TaskRun.finished_at)
.where(TaskRun.task_name.in_(translation_tasks))
.where(TaskRun.finished_at.is_not(None))
.order_by(TaskRun.finished_at.desc())
.limit(1)
)).first()
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),
"active": int(active) > 0,
"last_run": {
"task": last[0].rsplit(".", 1)[-1],
"status": last[1],
"finished_at": last[2].isoformat() if last[2] else None,
} if last else None,
})
@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/probe", methods=["POST"])
async def translation_probe():
"""Diagnostic for the Settings 'Test translation' box: translate a pasted
snippet WITHOUT saving anything, returning what Interpreter *detected*
(language + confidence) alongside the result. Lets the operator see why a
given string was (mis-)detected — e.g. a short English title flagged as
another language — so a detection guard can be tuned from real numbers.
Read-only: no post is touched. Uses the currently-saved base URL + target."""
body = await request.get_json(silent=True)
body = body if isinstance(body, dict) else {}
text = (body.get("text") or "").strip()
if not text:
return jsonify({"error": "provide text to translate"}), 400
async with get_session() as session:
cfg = await ImportSettings.load(session)
base_url = (cfg.interpreter_base_url or "").strip()
if not base_url:
return jsonify({"error": "no Interpreter base URL is set"}), 400
target = (cfg.translation_target_lang or "en").strip() or "en"
try:
res = await asyncio.to_thread(
ic.translate, [text], base_url=base_url, target=target,
)
except ic.InterpreterUnavailable as e:
return jsonify({"error": f"Interpreter unavailable: {e}"}), 503
except ic.InterpreterBadRequest as e:
return jsonify({"error": f"Interpreter rejected the request: {e}"}), 400
translations = res.get("translations") or []
return jsonify({
"target": target,
"detected_lang": res.get("detected_lang"),
"detected_confidence": res.get("detected_confidence"),
"engine": res.get("engine"),
"engine_version": res.get("engine_version"),
"translated": translations[0] if translations else None,
})
@settings_bp.route("/settings/translation/run", methods=["POST"])
async def translation_run():
"""Enqueue the translate sweep now (the Settings 'Translate now' button).
Runs in drain mode — run-until-done — so one press chases the whole
untranslated backlog to zero rather than a single 300-post chunk."""
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(drain=True)
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": <int>}`` 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