Publish media catalog as markdown attachment
This commit is contained in:
parent
d998896872
commit
08b40b0db0
4 changed files with 140 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
README.md
13
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
|
||||
|
||||
|
|
|
|||
171
status_bot.py
171
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),
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue