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_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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
169
status_bot.py
169
status_bot.py
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue