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
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
489 lines
20 KiB
Python
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
|