Add Discord webhook refresh notifications
This commit is contained in:
+258
-1
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user