diff --git a/.env.deploy.example b/.env.deploy.example index 45f8aa1..b0f6f91 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -5,7 +5,6 @@ ARCHIVE_STATUS_STATE=state/status-message.json MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_LIBRARY_STATE=state/media-library.json BOT_SETTINGS_STATE=state/bot-settings.json -MEDIA_CATALOG_MAX_ITEMS=240 CHECK_INTERVAL_SECONDS=60 HTTP_USER_AGENT=ArchiveStatusBot/1.0 DISCORD_DRY_RUN=false diff --git a/.env.example b/.env.example index 5b3f073..e3cb2b4 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,6 @@ ARCHIVE_STATUS_STATE=state/status-message.json MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_LIBRARY_STATE=state/media-library.json BOT_SETTINGS_STATE=state/bot-settings.json -MEDIA_CATALOG_MAX_ITEMS=240 CHECK_INTERVAL_SECONDS=60 HTTP_USER_AGENT=ArchiveStatusBot/1.0 DISCORD_DRY_RUN=false diff --git a/README.md b/README.md index bf63d51..2a85ee3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Required channel permissions for each dashboard-selected channel: - View Channel - Send Messages - Embed Links +- Attach Files - Read Message History 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 - uploading `Movies.csv` and `Shows.csv` - 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. @@ -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. +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: ```env @@ -218,13 +221,7 @@ The editable media library is stored in: 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. - -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 -``` +Republishing deletes the previous media catalog message and posts a fresh compact Markdown attachment. ## Service Config diff --git a/status_bot.py b/status_bot.py index 0867e42..3c695c4 100644 --- a/status_bot.py +++ b/status_bot.py @@ -39,7 +39,6 @@ MAX_REQUEST_BYTES = 8_000_000 SESSION_COOKIE = "archive_bot_session" PBKDF2_ITERATIONS = 390_000 MEDIA_ITEMS_PER_EMBED = 10 -MAX_MEDIA_ITEMS_PER_CATEGORY = 240 @dataclass(frozen=True) @@ -499,6 +498,53 @@ def discord_request( 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: try: discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}") @@ -975,6 +1021,83 @@ def render_media_catalog_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( runtime: BotRuntime, channel_id: str, @@ -992,18 +1115,6 @@ def publish_media_items( if not movies_all and not shows_all: 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) old_channel = str(state.get("channel_id", "")).strip() existing_ids = [ @@ -1011,33 +1122,20 @@ def publish_media_items( for message_id in state.get("message_ids", []) if str(message_id).strip() ] - if old_channel and old_channel != channel: - for old_id in existing_ids: - discord_delete_message(runtime.token, old_channel, old_id) - existing_ids = [] + delete_channel = old_channel or channel + for old_id in existing_ids: + discord_delete_message(runtime.token, delete_channel, old_id) - message_ids: list[str] = [] - 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) + message_id = publish_media_markdown_message(runtime.token, channel, movies_all, shows_all) save_state( runtime.media_state_path, { "channel_id": channel, - "message_ids": message_ids, + "message_ids": [message_id], "movie_count": len(movies_all), "show_count": len(shows_all), + "format": "markdown", "published_at": datetime.now(timezone.utc).isoformat(), }, ) @@ -1045,11 +1143,12 @@ def publish_media_items( return { "channelId": channel, - "messageIds": message_ids, + "messageIds": [message_id], "movieCount": len(movies_all), "showCount": len(shows_all), - "displayedMovieCount": len(movies), - "displayedShowCount": len(shows), + "displayedMovieCount": len(movies_all), + "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 [], "movieCount": state.get("movie_count"), "showCount": state.get("show_count"), + "format": state.get("format", "markdown"), "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), }