feat: the extension picks a Discord source's posters from who posts in the channel, stored by id (#4488)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 18s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m24s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 6m13s
CI and images / build-web (push) Successful in 1m38s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 2s

On a Discord channel page the Add panel lists who posted in the newest 200
messages, the creator the server is named for (or owned by) ticked. On a
channel FabledCurator already follows, the chip opens the same list with Save
and Open artist. The list is kept as user ids, so a rename never quietly stops
it matching; the name each was picked under rides beside it for display, and
the Subscriptions dialog shows it.

- DiscordClient.recent_posters + rank_posters (owner / name-matches-server;
  bots never suggested; it suggests, the operator ticks).
- GET/POST /api/extension/discord/posters; quick-add takes discord_authors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 09:47:27 -04:00
co-authored by Claude Opus 5.5
parent 1efece0f21
commit ef591edf10
11 changed files with 593 additions and 2 deletions
+60
View File
@@ -19,8 +19,10 @@ from ..models import AppSetting
from ..services.extension_service import (
ExtensionService,
InvalidUrlError,
PosterLookupError,
UnknownArtistError,
UnknownPlatformError,
UnknownSourceError,
)
from ..services.source_service import KNOWN_PLATFORMS
from ._responses import error_response as _bad
@@ -121,6 +123,10 @@ async def quick_add_source():
# Patreon is canon: adding a Patreon source to an existing artist can take
# the creator's Patreon display name (name only; the slug never moves).
use_platform_name = body.get("use_platform_name") is True
# #4488: a Discord add can carry the posters picked in the panel.
discord_authors = body.get("discord_authors")
if discord_authors is not None and not _valid_authors(discord_authors):
return _bad("invalid_body", detail="discord_authors must be a list of {id, name}")
from .credentials import _get_crypto
@@ -133,6 +139,7 @@ async def quick_add_source():
result = await ExtensionService(session, _get_crypto()).quick_add_source(
url, artist_id=artist_id, artist_name=artist_name,
use_platform_name=use_platform_name,
discord_authors=discord_authors,
)
except UnknownArtistError as exc:
return _bad("not_found", detail=str(exc), status=404)
@@ -147,6 +154,59 @@ async def quick_add_source():
return jsonify(result), (201 if result["created_source"] else 200)
def _valid_authors(value) -> bool:
"""[{id, name}]: the id is what the list keys on, the name only shows it."""
return isinstance(value, list) and all(
isinstance(a, dict) and isinstance(a.get("id"), str | int)
and not isinstance(a.get("id"), bool)
and (a.get("name") is None or isinstance(a.get("name"), str))
for a in value
)
@extension_bp.route("/discord/posters", methods=["GET"])
async def discord_posters():
"""Who posts in the Discord channel `url` shows, lately, with the likely
creator marked (#4488). Reads the channel with the stored token; writes
nothing."""
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
from .credentials import _get_crypto
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
try:
result = await ExtensionService(session, _get_crypto()).discord_posters(url)
except (InvalidUrlError, UnknownPlatformError) as exc:
return _bad("invalid_url", detail=str(exc))
except PosterLookupError as exc:
return _bad("posters_unavailable", detail=str(exc), status=409)
return jsonify(result)
@extension_bp.route("/discord/posters", methods=["POST"])
async def set_discord_posters():
"""Set the poster list of the source following `url`'s channel."""
body = await request.get_json(silent=True)
if not isinstance(body, dict) or not isinstance(body.get("url"), str):
return _bad("invalid_body", detail="url is required")
authors = body.get("authors")
if not _valid_authors(authors):
return _bad("invalid_body", detail="authors must be a list of {id, name}")
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
try:
result = await ExtensionService(session).set_discord_posters(body["url"], authors)
except (InvalidUrlError, UnknownPlatformError, PosterLookupError) as exc:
return _bad("invalid_url", detail=str(exc))
except UnknownSourceError as exc:
return _bad("not_found", detail=str(exc), status=404)
return jsonify(result)
def _read_manifest_sync() -> dict | None:
"""All the filesystem-touching work for /api/extension/manifest,
in a sync helper so the async route can dispatch it via