Add Discord webhook refresh notifications

This commit is contained in:
Dymas
2026-05-21 18:56:49 +02:00
parent a95b4c343f
commit 9648b4f6e4
9 changed files with 638 additions and 54 deletions
+258 -1
View File
@@ -5,18 +5,21 @@
import hmac
import ipaddress
import json
import mimetypes
import os
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
import urllib.error
import urllib.request
from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web"
VERSION = "0.32.5"
VERSION = "0.33.0"
ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to"
@@ -48,6 +51,18 @@ WATCHLIST_AUTO_REFRESH_MAX_MINUTES = 7 * 24 * 60
WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS = 5
WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0
WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300
DISCORD_WEBHOOK_EVENT_CHOICES = (
"watchlist_refresh_new_episodes",
"watchlist_refresh_failed",
"watchlist_refresh_interrupted",
"runtime_error",
)
DISCORD_WEBHOOK_EVENT_LABELS = {
"watchlist_refresh_new_episodes": "Refresh finished with new episodes found",
"watchlist_refresh_failed": "Refresh failed or finished with refresh errors",
"watchlist_refresh_interrupted": "Refresh interrupted by shutdown or restart",
"runtime_error": "Unexpected runtime errors",
}
def env_flag(name, default=False):
@@ -123,6 +138,8 @@ DEFAULT_CONFIG = {
"watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
"watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS,
"discord_webhook_url": "",
"discord_webhook_events": [],
}
@@ -266,6 +283,8 @@ def normalize_config(data):
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds")
webhook_url = str(config.get("discord_webhook_url") or "").strip()
webhook_events = config.get("discord_webhook_events")
if isinstance(auto_refresh_enabled, str):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
@@ -295,9 +314,247 @@ def normalize_config(data):
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes
config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds
if isinstance(webhook_events, str):
webhook_events = [part.strip() for part in webhook_events.split(",")]
elif not isinstance(webhook_events, list):
webhook_events = []
normalized_events = []
seen_events = set()
for event_name in webhook_events:
normalized_name = str(event_name or "").strip()
if normalized_name in DISCORD_WEBHOOK_EVENT_CHOICES and normalized_name not in seen_events:
seen_events.add(normalized_name)
normalized_events.append(normalized_name)
config["discord_webhook_url"] = webhook_url
config["discord_webhook_events"] = normalized_events
return config
def discord_webhook_enabled(config, event_name, force=False):
if not isinstance(config, dict):
return False
webhook_url = str(config.get("discord_webhook_url") or "").strip()
if not webhook_url:
return False
if force:
return True
return str(event_name or "").strip() in {
str(name).strip() for name in config.get("discord_webhook_events") or []
}
def thumbnail_file_path(name):
filename = Path(str(name or "")).name
if not filename:
return None
project_path = THUMBNAIL_ROOT / filename
if project_path.exists():
return project_path
legacy_path = LEGACY_THUMBNAIL_ROOT / filename
if legacy_path.exists():
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
try:
shutil.move(str(legacy_path), str(project_path))
debug_log("thumbnail.cache.migrated", from_path=legacy_path, to_path=project_path)
return project_path
except OSError as exc:
debug_log("thumbnail.cache.migrate_failed", from_path=legacy_path, to_path=project_path, error=exc)
return legacy_path
return project_path
def _discord_file_part(boundary, field_name, attachment_name, content_type, payload):
safe_name = str(attachment_name or "attachment.bin").replace('"', "")
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field_name}"; filename="{safe_name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
return header + payload + b"\r\n"
def _discord_text_part(boundary, field_name, value):
return (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field_name}"\r\n\r\n'
f"{value}\r\n"
).encode("utf-8")
def _post_discord_webhook(url, payload, attachments=None, timeout=10):
attachments = attachments or []
headers = {"User-Agent": AGENT}
if attachments:
boundary = f"ani-cli-web-{uuid4().hex}"
body = bytearray()
body.extend(_discord_text_part(boundary, "payload_json", json.dumps(payload)))
for index, attachment in enumerate(attachments):
path = attachment.get("path")
if path is None or not Path(path).exists():
continue
data = Path(path).read_bytes()
if not data:
continue
filename = str(attachment.get("filename") or Path(path).name or f"attachment-{index}")
content_type = str(attachment.get("content_type") or mimetypes.guess_type(filename)[0] or "application/octet-stream")
body.extend(_discord_file_part(boundary, f"files[{index}]", filename, content_type, data))
body.extend(f"--{boundary}--\r\n".encode("utf-8"))
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
request_data = bytes(body)
else:
headers["Content-Type"] = "application/json"
request_data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=request_data, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
response.read()
return getattr(response, "status", 204)
except urllib.error.HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace").strip()
if details:
raise RuntimeError(f"Discord webhook rejected the request: HTTP {exc.code} {details}") from exc
raise RuntimeError(f"Discord webhook rejected the request: HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Could not reach Discord webhook: {exc}") from exc
except TimeoutError as exc:
raise RuntimeError("Discord webhook request timed out") from exc
def send_discord_webhook_event(config, event_name, payload=None, force=False):
normalized = normalize_config(config or {})
event = str(event_name or "").strip()
if not discord_webhook_enabled(normalized, event, force=force):
return {"sent": False, "reason": "disabled"}
webhook_url = normalized["discord_webhook_url"]
payload = payload or {}
if event == "test":
request_payload = {
"content": "ani-cli-web webhook test",
"embeds": [
{
"title": "Webhook test",
"description": "The Discord webhook is configured and reachable from ani-cli-web.",
"color": 5814783,
"footer": {"text": f"{APP_NAME} {VERSION}"},
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
if event == "watchlist_refresh_new_episodes":
items = payload.get("items") if isinstance(payload.get("items"), list) else []
if not items:
return {"sent": False, "reason": "no_items"}
embeds = []
attachments = []
for index, item in enumerate(items[:10]):
title = str(item.get("title") or item.get("show_id") or "Unknown anime").strip()
sub_count = int(item.get("sub_count") or 0)
dub_count = int(item.get("dub_count") or 0)
sub_delta = int(item.get("sub_delta") or 0)
dub_delta = int(item.get("dub_delta") or 0)
expected_count = int(item.get("expected_count") or 0)
airing_status = str(item.get("airing_status") or "").strip()
description_lines = [
f"Sub: {sub_count}" + (f" (+{sub_delta})" if sub_delta > 0 else ""),
f"Dub: {dub_count}" + (f" (+{dub_delta})" if dub_delta > 0 else ""),
]
if expected_count > 0:
description_lines.append(f"Expected episodes: {expected_count}")
if airing_status:
description_lines.append(f"Status: {airing_status}")
embed = {
"title": title[:256],
"description": "\n".join(description_lines)[:4096],
"color": 5763719,
}
thumb_path = thumbnail_file_path(item.get("thumbnail_path"))
if thumb_path and thumb_path.exists():
attachment_name = f"thumb-{index}{thumb_path.suffix or '.jpg'}"
attachments.append({"path": thumb_path, "filename": attachment_name})
embed["thumbnail"] = {"url": f"attachment://{attachment_name}"}
embeds.append(embed)
extra_count = max(0, len(items) - len(embeds))
request_payload = {
"content": (
f"Watchlist refresh found new episodes for {len(items)} anime."
+ (f" Showing the first {len(embeds)} entries." if extra_count else "")
),
"embeds": embeds,
}
_post_discord_webhook(webhook_url, request_payload, attachments=attachments)
return {"sent": True, "event": event, "count": len(items)}
if event == "watchlist_refresh_failed":
error_lines = []
for item in (payload.get("items") if isinstance(payload.get("items"), list) else [])[:8]:
title = str(item.get("title") or item.get("show_id") or "Unknown anime").strip()
error_text = str(item.get("error") or "").strip()
if title and error_text:
error_lines.append(f"{title}: {error_text}")
if payload.get("error") and not error_lines:
error_lines.append(str(payload["error"]).strip())
request_payload = {
"content": "Watchlist refresh reported errors.",
"embeds": [
{
"title": "Refresh errors",
"description": "\n".join(error_lines[:8])[:4096] or "The refresh completed with errors.",
"color": 15158332,
"fields": [
{"name": "Completed", "value": str(int(payload.get("completed") or 0)), "inline": True},
{"name": "Total", "value": str(int(payload.get("total") or 0)), "inline": True},
{"name": "Errors", "value": str(int(payload.get("errors") or 0)), "inline": True},
],
"footer": {"text": f"Source: {str(payload.get('source') or 'manual')}"},
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
if event == "watchlist_refresh_interrupted":
request_payload = {
"content": "Watchlist refresh was interrupted.",
"embeds": [
{
"title": "Refresh interrupted",
"description": str(payload.get("message") or "The watchlist refresh did not finish.").strip()[:4096],
"color": 16763904,
"fields": [
{"name": "Completed", "value": str(int(payload.get("completed") or 0)), "inline": True},
{"name": "Total", "value": str(int(payload.get("total") or 0)), "inline": True},
],
"footer": {"text": f"Source: {str(payload.get('source') or 'manual')}"},
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
if event == "runtime_error":
source = str(payload.get("source") or "runtime").strip()
error_text = str(payload.get("error") or "Unknown error").strip()
details = str(payload.get("details") or "").strip()
description = error_text if not details else f"{error_text}\n{details}"
request_payload = {
"content": "ani-cli-web logged an unexpected error.",
"embeds": [
{
"title": source[:256],
"description": description[:4096],
"color": 15158332,
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event}
raise ValueError(f"Unsupported Discord webhook event: {event}")
def strip_control(value):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n")