Publish media catalog as markdown attachment

This commit is contained in:
MiTHRAL 2026-05-15 15:23:49 -04:00
parent d998896872
commit 08b40b0db0
4 changed files with 140 additions and 46 deletions

View file

@ -5,7 +5,6 @@ ARCHIVE_STATUS_STATE=state/status-message.json
MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_CATALOG_STATE=state/media-catalog.json
MEDIA_LIBRARY_STATE=state/media-library.json MEDIA_LIBRARY_STATE=state/media-library.json
BOT_SETTINGS_STATE=state/bot-settings.json BOT_SETTINGS_STATE=state/bot-settings.json
MEDIA_CATALOG_MAX_ITEMS=240
CHECK_INTERVAL_SECONDS=60 CHECK_INTERVAL_SECONDS=60
HTTP_USER_AGENT=ArchiveStatusBot/1.0 HTTP_USER_AGENT=ArchiveStatusBot/1.0
DISCORD_DRY_RUN=false DISCORD_DRY_RUN=false

View file

@ -5,7 +5,6 @@ ARCHIVE_STATUS_STATE=state/status-message.json
MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_CATALOG_STATE=state/media-catalog.json
MEDIA_LIBRARY_STATE=state/media-library.json MEDIA_LIBRARY_STATE=state/media-library.json
BOT_SETTINGS_STATE=state/bot-settings.json BOT_SETTINGS_STATE=state/bot-settings.json
MEDIA_CATALOG_MAX_ITEMS=240
CHECK_INTERVAL_SECONDS=60 CHECK_INTERVAL_SECONDS=60
HTTP_USER_AGENT=ArchiveStatusBot/1.0 HTTP_USER_AGENT=ArchiveStatusBot/1.0
DISCORD_DRY_RUN=false DISCORD_DRY_RUN=false

View file

@ -23,6 +23,7 @@ Required channel permissions for each dashboard-selected channel:
- View Channel - View Channel
- Send Messages - Send Messages
- Embed Links - Embed Links
- Attach Files
- Read Message History - Read Message History
Channel IDs are configured from the dashboard and stored in `state/bot-settings.json`. Channel IDs are configured from the dashboard and stored in `state/bot-settings.json`.
@ -188,7 +189,7 @@ The dashboard currently supports:
- forcing an immediate check and Discord message update - forcing an immediate check and Discord message update
- uploading `Movies.csv` and `Shows.csv` - uploading `Movies.csv` and `Shows.csv`
- editing imported movies and shows before publishing - editing imported movies and shows before publishing
- publishing a paginated media catalog to a selected Discord channel - publishing a compact Markdown media catalog to a selected Discord channel
The sidebar leaves room for future modules like polls, webhooks, automations, and service integrations without changing the bot shape later. The sidebar leaves room for future modules like polls, webhooks, automations, and service integrations without changing the bot shape later.
@ -198,6 +199,8 @@ Open the dashboard and switch to `Media`. Set the target Discord channel ID, cho
The editor supports adding, editing, and deleting movie/show rows before saving or publishing. Publishing sends the currently edited dashboard library, not the raw uploaded files. The editor supports adding, editing, and deleting movie/show rows before saving or publishing. Publishing sends the currently edited dashboard library, not the raw uploaded files.
Discord publishing uses one message with an attached `media-catalog.md` file so the channel does not get flooded by a long embed wall.
Channel selections are stored in: Channel selections are stored in:
```env ```env
@ -218,13 +221,7 @@ The editable media library is stored in:
MEDIA_LIBRARY_STATE=state/media-library.json MEDIA_LIBRARY_STATE=state/media-library.json
``` ```
Republishing edits the previous catalog messages in place. If the catalog gets shorter, the bot attempts to delete old extra messages. Republishing deletes the previous media catalog message and posts a fresh compact Markdown attachment.
By default, Discord output is capped at 240 movies and 240 shows to avoid flooding a channel. Change this with:
```env
MEDIA_CATALOG_MAX_ITEMS=240
```
## Service Config ## Service Config

View file

@ -39,7 +39,6 @@ MAX_REQUEST_BYTES = 8_000_000
SESSION_COOKIE = "archive_bot_session" SESSION_COOKIE = "archive_bot_session"
PBKDF2_ITERATIONS = 390_000 PBKDF2_ITERATIONS = 390_000
MEDIA_ITEMS_PER_EMBED = 10 MEDIA_ITEMS_PER_EMBED = 10
MAX_MEDIA_ITEMS_PER_CATEGORY = 240
@dataclass(frozen=True) @dataclass(frozen=True)
@ -499,6 +498,53 @@ def discord_request(
raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
def discord_multipart_request(
method: str,
token: str,
path: str,
payload: dict[str, Any],
files: list[tuple[str, str, str, bytes]],
) -> dict[str, Any]:
boundary = f"----ArchiveBot{secrets.token_hex(16)}"
body = bytearray()
def add_part(name: str, content: bytes, content_type: str, filename: str | None = None) -> None:
body.extend(f"--{boundary}\r\n".encode("ascii"))
disposition = f'Content-Disposition: form-data; name="{name}"'
if filename is not None:
disposition += f'; filename="{filename}"'
body.extend(f"{disposition}\r\n".encode("utf-8"))
body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("ascii"))
body.extend(content)
body.extend(b"\r\n")
add_part("payload_json", json.dumps(payload).encode("utf-8"), "application/json")
for field_name, filename, content_type, content in files:
add_part(field_name, content, content_type, filename)
body.extend(f"--{boundary}--\r\n".encode("ascii"))
request = urllib.request.Request(
f"{DISCORD_API}{path}",
data=bytes(body),
headers={
"Authorization": f"Bot {token}",
"User-Agent": "ArchiveStatusBot/1.0",
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method=method,
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = response.read()
if not data:
return {}
return json.loads(data.decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
def discord_delete_message(token: str, channel_id: str, message_id: str) -> None: def discord_delete_message(token: str, channel_id: str, message_id: str) -> None:
try: try:
discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}") discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}")
@ -975,6 +1021,83 @@ def render_media_catalog_payloads(
return payloads return payloads
def media_markdown_line(item: MediaItem) -> str:
title = media_item_heading(item)
details: list[str] = []
if item.genres:
details.append(item.genres)
if item.rating:
details.append(item.rating)
if item.runtime and item.media_type == "movie":
details.append(item.runtime)
if item.media_type == "show":
if item.seasons:
details.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
if item.episodes:
details.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
suffix = f" - {'; '.join(details)}" if details else ""
return f"- **{title}**{suffix}"
def render_media_catalog_markdown(movies: list[MediaItem], shows: list[MediaItem]) -> str:
now = datetime.now(timezone.utc)
lines = [
"# The Mithral Archive Media Catalog",
"",
f"Updated: {now.strftime('%Y-%m-%d %H:%M UTC')}",
f"Movies: {len(movies)}",
f"Shows: {len(shows)}",
"",
]
if movies:
lines.extend(["## Movies", ""])
for item in movies:
lines.append(media_markdown_line(item))
if item.summary:
lines.append(f" - {item.summary}")
lines.append("")
if shows:
lines.extend(["## Shows", ""])
for item in shows:
lines.append(media_markdown_line(item))
if item.summary:
lines.append(f" - {item.summary}")
lines.append("")
return "\n".join(lines).strip() + "\n"
def publish_media_markdown_message(
token: str,
channel_id: str,
movies: list[MediaItem],
shows: list[MediaItem],
) -> str:
markdown = render_media_catalog_markdown(movies, shows)
payload = {
"content": (
"**The Mithral Archive Media Catalog**\n"
f"{len(movies)} movies · {len(shows)} shows\n"
"Attached as a compact Markdown list."
),
"allowed_mentions": {"parse": []},
}
message = discord_multipart_request(
"POST",
token,
f"/channels/{channel_id}/messages",
payload,
[("files[0]", "media-catalog.md", "text/markdown; charset=utf-8", markdown.encode("utf-8"))],
)
message_id = str(message.get("id", "")).strip()
if not message_id:
raise RuntimeError("Discord did not return a message id for the media catalog")
return message_id
def publish_media_items( def publish_media_items(
runtime: BotRuntime, runtime: BotRuntime,
channel_id: str, channel_id: str,
@ -992,18 +1115,6 @@ def publish_media_items(
if not movies_all and not shows_all: if not movies_all and not shows_all:
raise ValueError("Add at least one movie or show before publishing") raise ValueError("Add at least one movie or show before publishing")
limit = int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY)))
movies = movies_all[:limit]
shows = shows_all[:limit]
payloads = render_media_catalog_payloads(
movies=movies,
shows=shows,
movie_total=len(movies_all),
show_total=len(shows_all),
movie_source=movie_source,
show_source=show_source,
)
state = load_state(runtime.media_state_path) state = load_state(runtime.media_state_path)
old_channel = str(state.get("channel_id", "")).strip() old_channel = str(state.get("channel_id", "")).strip()
existing_ids = [ existing_ids = [
@ -1011,33 +1122,20 @@ def publish_media_items(
for message_id in state.get("message_ids", []) for message_id in state.get("message_ids", [])
if str(message_id).strip() if str(message_id).strip()
] ]
if old_channel and old_channel != channel: delete_channel = old_channel or channel
for old_id in existing_ids: for old_id in existing_ids:
discord_delete_message(runtime.token, old_channel, old_id) discord_delete_message(runtime.token, delete_channel, old_id)
existing_ids = []
message_ids: list[str] = [] message_id = publish_media_markdown_message(runtime.token, channel, movies_all, shows_all)
for index, payload in enumerate(payloads):
if index < len(existing_ids):
discord_request("PATCH", runtime.token, f"/channels/{channel}/messages/{existing_ids[index]}", payload)
message_ids.append(existing_ids[index])
continue
message = discord_request("POST", runtime.token, f"/channels/{channel}/messages", payload)
message_id = str(message.get("id", "")).strip()
if not message_id:
raise RuntimeError("Discord did not return a message id for the media catalog")
message_ids.append(message_id)
for old_id in existing_ids[len(payloads) :]:
discord_delete_message(runtime.token, channel, old_id)
save_state( save_state(
runtime.media_state_path, runtime.media_state_path,
{ {
"channel_id": channel, "channel_id": channel,
"message_ids": message_ids, "message_ids": [message_id],
"movie_count": len(movies_all), "movie_count": len(movies_all),
"show_count": len(shows_all), "show_count": len(shows_all),
"format": "markdown",
"published_at": datetime.now(timezone.utc).isoformat(), "published_at": datetime.now(timezone.utc).isoformat(),
}, },
) )
@ -1045,11 +1143,12 @@ def publish_media_items(
return { return {
"channelId": channel, "channelId": channel,
"messageIds": message_ids, "messageIds": [message_id],
"movieCount": len(movies_all), "movieCount": len(movies_all),
"showCount": len(shows_all), "showCount": len(shows_all),
"displayedMovieCount": len(movies), "displayedMovieCount": len(movies_all),
"displayedShowCount": len(shows), "displayedShowCount": len(shows_all),
"format": "markdown",
} }
@ -1083,8 +1182,8 @@ def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]:
"messageIds": state.get("message_ids", []) if isinstance(state.get("message_ids"), list) else [], "messageIds": state.get("message_ids", []) if isinstance(state.get("message_ids"), list) else [],
"movieCount": state.get("movie_count"), "movieCount": state.get("movie_count"),
"showCount": state.get("show_count"), "showCount": state.get("show_count"),
"format": state.get("format", "markdown"),
"publishedAt": state.get("published_at"), "publishedAt": state.get("published_at"),
"maxItemsPerCategory": int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY))),
"library": media_library_to_jsonable(movies, shows), "library": media_library_to_jsonable(movies, shows),
} }