Add Discord webhook refresh notifications
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.33.0 - 2026-05-21
|
||||
|
||||
- Added Discord webhook notifications with Config page controls for the webhook URL, selectable notification events, and a one-click test action.
|
||||
- Added refresh result notifications that report each anime with newly found episodes, including cached thumbnails plus current sub and dub counts.
|
||||
- Added Discord notifications for refresh failures, interrupted refresh runs, and unexpected runtime errors so background problems are easier to notice without watching the web UI.
|
||||
|
||||
## 0.32.5 - 2026-05-17
|
||||
|
||||
- Fixed the green ready-border highlight so it applies to any watchlist card with both subbed and dubbed episodes complete, instead of only cards filed under the `Finished` category.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Small local web UI for a system-wide `ani-cli` install.
|
||||
|
||||
Current version: `0.32.5`
|
||||
Current version: `0.33.0`
|
||||
|
||||
## Project Layout
|
||||
|
||||
@@ -121,6 +121,7 @@ Container notes:
|
||||
- Track shows on `/watchlist`.
|
||||
- Refresh watchlist episode counts in the background.
|
||||
- Schedule periodic watchlist refresh jobs.
|
||||
- Send Discord webhook notifications for refresh discoveries and failures.
|
||||
- Cache watchlist thumbnails locally.
|
||||
- Sync successful downloads back into the watchlist.
|
||||
- Keep queue and watchlist data in SQLite under `.ani-cli-web/`.
|
||||
@@ -156,9 +157,18 @@ Use it to set:
|
||||
4. Scheduled watchlist refresh enable or disable.
|
||||
5. Scheduled refresh period in minutes.
|
||||
6. Delay between shows during `Refresh All`.
|
||||
7. Discord webhook URL.
|
||||
8. Discord notification events plus a test action.
|
||||
|
||||
Saved settings are written to `.ani-cli-web/config.json`.
|
||||
|
||||
Discord notification events:
|
||||
|
||||
- Refresh finished and found new episodes.
|
||||
- Refresh failed or finished with per-show refresh errors.
|
||||
- Refresh interrupted by restart or shutdown.
|
||||
- Unexpected runtime errors.
|
||||
|
||||
### Watchlist
|
||||
|
||||
Path: `/watchlist`
|
||||
@@ -215,6 +225,7 @@ Refresh behavior:
|
||||
- Duplicate refresh queueing is avoided.
|
||||
- Scheduled refresh runs log start, stop, success, and failure to stdout.
|
||||
- `Refresh All` works one show at a time with a configurable delay.
|
||||
- When a Discord webhook is configured, new-episode refresh results include one notification card per anime with sub and dub counts and a cached thumbnail attachment when available.
|
||||
|
||||
## Access Guard
|
||||
|
||||
@@ -295,6 +306,8 @@ Environment variables:
|
||||
|
||||
Saved config fields include:
|
||||
|
||||
- `discord_webhook_url`
|
||||
- `discord_webhook_events`
|
||||
- `watchlist_auto_refresh_enabled`
|
||||
- `watchlist_auto_refresh_minutes`
|
||||
- `watchlist_refresh_delay_seconds`
|
||||
|
||||
@@ -34,9 +34,9 @@ from app_support import (
|
||||
APP_NAME,
|
||||
CONFIG_PATH,
|
||||
DEFAULT_CONFIG,
|
||||
LEGACY_THUMBNAIL_ROOT,
|
||||
MAX_JSON_BODY_BYTES,
|
||||
MODE_CHOICES,
|
||||
send_discord_webhook_event,
|
||||
STATE_DB_PATH,
|
||||
THUMBNAIL_RETRY_SECONDS,
|
||||
THUMBNAIL_ROOT,
|
||||
@@ -54,6 +54,7 @@ from app_support import (
|
||||
normalize_config,
|
||||
now_iso,
|
||||
sanitize_path_component,
|
||||
thumbnail_file_path,
|
||||
write_json,
|
||||
)
|
||||
from http_handler import HandlerContext, build_handler_class, server_host, server_port
|
||||
@@ -134,26 +135,6 @@ def thumbnail_retry_due(checked_at):
|
||||
return age.total_seconds() >= THUMBNAIL_RETRY_SECONDS
|
||||
|
||||
|
||||
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 cover_route(show_id):
|
||||
return f"/api/watchlist/thumb/{quote(str(show_id).strip(), safe='')}"
|
||||
|
||||
@@ -1347,6 +1328,8 @@ class WatchlistStore:
|
||||
def refresh(self, show_id, include_thumbnail=True):
|
||||
existing = self.get(show_id)
|
||||
normalized_show_id = existing["show_id"]
|
||||
previous_sub_count = int(existing.get("sub_count") or 0)
|
||||
previous_dub_count = int(existing.get("dub_count") or 0)
|
||||
now = now_iso()
|
||||
request_timeout = self._refresh_request_timeout()
|
||||
try:
|
||||
@@ -1448,7 +1431,13 @@ class WatchlistStore:
|
||||
self.ensure_thumbnail(normalized_show_id)
|
||||
except Exception:
|
||||
pass
|
||||
return self.get(normalized_show_id)
|
||||
item = self.get(normalized_show_id)
|
||||
item["previous_sub_count"] = previous_sub_count
|
||||
item["previous_dub_count"] = previous_dub_count
|
||||
item["sub_delta"] = max(0, int(item.get("sub_count") or 0) - previous_sub_count)
|
||||
item["dub_delta"] = max(0, int(item.get("dub_count") or 0) - previous_dub_count)
|
||||
item["had_new_episodes"] = bool(item["sub_delta"] or item["dub_delta"])
|
||||
return item
|
||||
|
||||
def refresh_all(self):
|
||||
refreshed = []
|
||||
@@ -1842,6 +1831,14 @@ def save_runtime_config(config):
|
||||
return dict(CONFIG)
|
||||
|
||||
|
||||
def test_discord_webhook_config(config):
|
||||
normalized = normalize_config(config)
|
||||
if not normalized.get("discord_webhook_url"):
|
||||
raise ValueError("Discord webhook URL is required for testing.")
|
||||
send_discord_webhook_event(normalized, "test", force=True)
|
||||
return {"message": "Discord webhook test notification sent."}
|
||||
|
||||
|
||||
def runtime_state():
|
||||
return {
|
||||
"config": CONFIG,
|
||||
@@ -1981,6 +1978,7 @@ Handler = build_handler_class(
|
||||
runtime_state=runtime_state,
|
||||
save_runtime_config=save_runtime_config,
|
||||
search_anime=search_anime,
|
||||
test_discord_webhook_config=test_discord_webhook_config,
|
||||
thumbnail_file_path=thumbnail_file_path,
|
||||
update_all_watchlist_statuses=update_all_watchlist_statuses,
|
||||
update_watchlist_category=update_watchlist_category,
|
||||
|
||||
+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")
|
||||
|
||||
|
||||
+125
-29
@@ -2,9 +2,19 @@
|
||||
|
||||
"""Config page template for ani-cli-web."""
|
||||
|
||||
from app_support import DISCORD_WEBHOOK_EVENT_CHOICES, DISCORD_WEBHOOK_EVENT_LABELS
|
||||
from template_helpers import BROWSER_API_HELPER, render_page_links, render_sidebar_brand
|
||||
|
||||
|
||||
WEBHOOK_EVENT_OPTIONS = "\n".join(
|
||||
(
|
||||
f' <label class="check-item"><input class="webhook-event" type="checkbox" value="{event_name}"> '
|
||||
f"<span>{DISCORD_WEBHOOK_EVENT_LABELS[event_name]}</span></label>"
|
||||
)
|
||||
for event_name in DISCORD_WEBHOOK_EVENT_CHOICES
|
||||
)
|
||||
|
||||
|
||||
CONFIG_HTML = r"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -49,7 +59,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
button, input, select {
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
@@ -74,7 +84,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
font-weight: 700;
|
||||
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
|
||||
}
|
||||
input, select {
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 14px;
|
||||
@@ -85,7 +95,12 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
input:focus, select:focus {
|
||||
textarea {
|
||||
min-height: 108px;
|
||||
padding: 12px 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--focus);
|
||||
box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12);
|
||||
}
|
||||
@@ -233,6 +248,38 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.check-item input {
|
||||
width: 18px;
|
||||
min-height: 18px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.settings-main, .deps {
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
@@ -242,7 +289,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
.toolbar, .row { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.form-grid {
|
||||
.form-grid, .check-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -330,6 +377,28 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
<div class="field-hint">When enabled, the server periodically starts the existing background “Refresh All” job using this interval. Bulk refreshes now wait this many seconds between shows to avoid flooding upstream sources.</div>
|
||||
</section>
|
||||
|
||||
<section class="settings settings-main">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>Discord notifications</h2>
|
||||
<p class="muted">Send webhook notifications for refresh discoveries, refresh problems, and unexpected runtime errors.</p>
|
||||
</div>
|
||||
<button id="testWebhookBtn" type="button">Test webhook</button>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<label>Discord webhook URL
|
||||
<textarea id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/..."></textarea>
|
||||
</label>
|
||||
<div class="field-hint">The new-episodes notification includes one card per anime, with local cached thumbnails attached to the webhook when available.</div>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<label>Notify for</label>
|
||||
<div class="check-grid">
|
||||
""" + WEBHOOK_EVENT_OPTIONS + r"""
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="deps" id="deps"></section>
|
||||
</main>
|
||||
</div>
|
||||
@@ -342,7 +411,9 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
download_dir: "",
|
||||
watchlist_auto_refresh_enabled: false,
|
||||
watchlist_auto_refresh_minutes: 60,
|
||||
watchlist_refresh_delay_seconds: 5
|
||||
watchlist_refresh_delay_seconds: 5,
|
||||
discord_webhook_url: "",
|
||||
discord_webhook_events: []
|
||||
}
|
||||
};
|
||||
|
||||
@@ -353,42 +424,66 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
$("notice").textContent = text || "";
|
||||
}
|
||||
|
||||
function selectedWebhookEvents() {
|
||||
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
|
||||
}
|
||||
|
||||
function formConfigPayload() {
|
||||
return {
|
||||
download_dir: $("downloadDir").value,
|
||||
mode: $("configMode").value,
|
||||
quality: $("configQuality").value,
|
||||
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
|
||||
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
|
||||
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value,
|
||||
discord_webhook_url: $("discordWebhookUrl").value,
|
||||
discord_webhook_events: selectedWebhookEvents()
|
||||
};
|
||||
}
|
||||
|
||||
function applyConfig(data) {
|
||||
state.config = data;
|
||||
$("downloadDir").value = data.download_dir;
|
||||
$("configMode").value = data.mode;
|
||||
$("configQuality").value = data.quality;
|
||||
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
|
||||
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
|
||||
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
|
||||
$("discordWebhookUrl").value = data.discord_webhook_url || "";
|
||||
const selected = new Set(data.discord_webhook_events || []);
|
||||
for (const input of document.querySelectorAll(".webhook-event")) {
|
||||
input.checked = selected.has(input.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
state.config = await api("/api/config");
|
||||
$("downloadDir").value = state.config.download_dir;
|
||||
$("configMode").value = state.config.mode;
|
||||
$("configQuality").value = state.config.quality;
|
||||
$("watchlistAutoRefreshEnabled").value = String(Boolean(state.config.watchlist_auto_refresh_enabled));
|
||||
$("watchlistAutoRefreshMinutes").value = state.config.watchlist_auto_refresh_minutes;
|
||||
$("watchlistRefreshDelaySeconds").value = state.config.watchlist_refresh_delay_seconds;
|
||||
applyConfig(await api("/api/config"));
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
const data = await api("/api/config", {
|
||||
applyConfig(await api("/api/config", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
download_dir: $("downloadDir").value,
|
||||
mode: $("configMode").value,
|
||||
quality: $("configQuality").value,
|
||||
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
|
||||
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
|
||||
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value
|
||||
})
|
||||
});
|
||||
state.config = data;
|
||||
$("downloadDir").value = data.download_dir;
|
||||
$("configMode").value = data.mode;
|
||||
$("configQuality").value = data.quality;
|
||||
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
|
||||
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
|
||||
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
|
||||
body: JSON.stringify(formConfigPayload())
|
||||
}));
|
||||
setNotice("Defaults saved.");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebhook() {
|
||||
try {
|
||||
const data = await api("/api/config/webhook/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(formConfigPayload())
|
||||
});
|
||||
setNotice(data.message || "Discord webhook test notification sent.");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeps() {
|
||||
const data = await api("/api/dependencies");
|
||||
const el = $("deps");
|
||||
@@ -410,6 +505,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
}
|
||||
|
||||
$("saveConfigBtn").addEventListener("click", saveConfig);
|
||||
$("testWebhookBtn").addEventListener("click", testWebhook);
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
|
||||
@@ -28,6 +28,7 @@ from app_support import (
|
||||
remote_access_allowed,
|
||||
remote_access_token_configured,
|
||||
remote_access_token_matches,
|
||||
send_discord_webhook_event,
|
||||
)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,6 +46,7 @@ class HandlerContext:
|
||||
runtime_state: object
|
||||
save_runtime_config: object
|
||||
search_anime: object
|
||||
test_discord_webhook_config: object
|
||||
thumbnail_file_path: object
|
||||
update_all_watchlist_statuses: object
|
||||
update_watchlist_category: object
|
||||
@@ -278,6 +280,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.ensure_client_access()
|
||||
if parsed.path == "/api/config":
|
||||
Handler.update_config(self, Handler.require_json_object(self, self.body_json()))
|
||||
elif parsed.path == "/api/config/webhook/test":
|
||||
payload = Handler.require_json_object(self, self.body_json())
|
||||
self.json(Handler._context(self).test_discord_webhook_config(payload))
|
||||
elif parsed.path == "/api/queue":
|
||||
runtime = Handler._runtime(self)
|
||||
self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED)
|
||||
@@ -465,6 +470,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
traceback.print_exc()
|
||||
else:
|
||||
debug_log("handler.exception", path=getattr(self, "path", ""), error=exc)
|
||||
try:
|
||||
send_discord_webhook_event(
|
||||
Handler._context(self).get_config_snapshot(),
|
||||
"runtime_error",
|
||||
{
|
||||
"source": "http_handler",
|
||||
"error": str(exc),
|
||||
"details": f"path={getattr(self, 'path', '')}",
|
||||
},
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
debug_log("discord_webhook.runtime_error_failed", source="http_handler", error=notify_exc)
|
||||
self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.")
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
|
||||
@@ -26,6 +26,7 @@ from app_support import (
|
||||
load_json,
|
||||
now_iso,
|
||||
printable_command,
|
||||
send_discord_webhook_event,
|
||||
strip_control,
|
||||
)
|
||||
|
||||
@@ -262,6 +263,8 @@ class WatchlistRefreshJobs:
|
||||
def _run_job(self, job):
|
||||
interrupted = False
|
||||
source_name = str(job.get("source") or "manual").strip().lower() or "manual"
|
||||
new_episode_items = []
|
||||
refresh_error_items = []
|
||||
try:
|
||||
show_ids = self.store.all_show_ids()
|
||||
delay_seconds = self._refresh_delay_seconds()
|
||||
@@ -295,6 +298,20 @@ class WatchlistRefreshJobs:
|
||||
title = item.get("title") or title
|
||||
errored = item.get("status") == "error"
|
||||
error_message = str(item.get("status_message") or "").strip() if errored else ""
|
||||
if item.get("had_new_episodes"):
|
||||
new_episode_items.append(
|
||||
{
|
||||
"show_id": item.get("show_id"),
|
||||
"title": title,
|
||||
"sub_count": item.get("sub_count"),
|
||||
"dub_count": item.get("dub_count"),
|
||||
"sub_delta": item.get("sub_delta"),
|
||||
"dub_delta": item.get("dub_delta"),
|
||||
"expected_count": item.get("expected_count"),
|
||||
"airing_status": item.get("airing_status"),
|
||||
"thumbnail_path": item.get("thumbnail_path"),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
errored = True
|
||||
error_message = str(exc)
|
||||
@@ -306,6 +323,7 @@ class WatchlistRefreshJobs:
|
||||
job["updated_at"] = now_iso()
|
||||
if errored and error_message:
|
||||
job["last_error"] = error_message
|
||||
refresh_error_items.append({"show_id": show_id, "title": title, "error": error_message})
|
||||
if skipped:
|
||||
job["message"] = f"Refreshing {index}/{len(show_ids)}: removed entry"
|
||||
else:
|
||||
@@ -344,6 +362,42 @@ class WatchlistRefreshJobs:
|
||||
self._stdout_log(
|
||||
f"Scheduled refresh finished successfully (job={job['id']}, completed={job['completed']}/{job['total']})."
|
||||
)
|
||||
config = self.config_getter() or {}
|
||||
if interrupted:
|
||||
send_discord_webhook_event(
|
||||
config,
|
||||
"watchlist_refresh_interrupted",
|
||||
{
|
||||
"source": source_name,
|
||||
"completed": job.get("completed"),
|
||||
"total": job.get("total"),
|
||||
"message": job.get("message"),
|
||||
},
|
||||
)
|
||||
else:
|
||||
if new_episode_items:
|
||||
send_discord_webhook_event(
|
||||
config,
|
||||
"watchlist_refresh_new_episodes",
|
||||
{
|
||||
"source": source_name,
|
||||
"job_id": job.get("id"),
|
||||
"items": new_episode_items,
|
||||
},
|
||||
)
|
||||
if job.get("errors"):
|
||||
send_discord_webhook_event(
|
||||
config,
|
||||
"watchlist_refresh_failed",
|
||||
{
|
||||
"source": source_name,
|
||||
"completed": job.get("completed"),
|
||||
"total": job.get("total"),
|
||||
"errors": job.get("errors"),
|
||||
"items": refresh_error_items,
|
||||
"error": job.get("last_error"),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
finished_at = now_iso()
|
||||
with self.lock:
|
||||
@@ -352,9 +406,22 @@ class WatchlistRefreshJobs:
|
||||
job["updated_at"] = finished_at
|
||||
job["current_title"] = None
|
||||
job["message"] = f"Watchlist refresh failed: {exc}"
|
||||
job["last_error"] = str(exc)
|
||||
self._save_job_locked(job)
|
||||
if source_name == "scheduled":
|
||||
self._stdout_log(f"Scheduled refresh failed (job={job['id']}): {exc}")
|
||||
send_discord_webhook_event(
|
||||
self.config_getter() or {},
|
||||
"watchlist_refresh_failed",
|
||||
{
|
||||
"source": source_name,
|
||||
"completed": job.get("completed"),
|
||||
"total": job.get("total"),
|
||||
"errors": max(1, int(job.get("errors") or 0)),
|
||||
"items": refresh_error_items,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.current_executor = None
|
||||
@@ -427,6 +494,14 @@ class WatchlistAutoRefreshScheduler:
|
||||
debug_log("watchlist.auto_refresh.tick", result=result)
|
||||
except Exception as exc:
|
||||
debug_log("watchlist.auto_refresh.error", error=exc)
|
||||
send_discord_webhook_event(
|
||||
self.config_getter() or {},
|
||||
"runtime_error",
|
||||
{
|
||||
"source": "watchlist.auto_refresh",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
return interval_seconds
|
||||
|
||||
def _run(self):
|
||||
|
||||
+122
@@ -1090,6 +1090,8 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
"watchlist_auto_refresh_enabled": False,
|
||||
"watchlist_auto_refresh_minutes": 60,
|
||||
"watchlist_refresh_delay_seconds": 5,
|
||||
"discord_webhook_url": "",
|
||||
"discord_webhook_events": [],
|
||||
}
|
||||
try:
|
||||
snapshot = APP.get_config_snapshot()
|
||||
@@ -1103,6 +1105,8 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
self.assertFalse(config["watchlist_auto_refresh_enabled"])
|
||||
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
|
||||
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
|
||||
self.assertEqual(config["discord_webhook_url"], "")
|
||||
self.assertEqual(config["discord_webhook_events"], [])
|
||||
|
||||
def test_normalize_config_parses_watchlist_schedule_fields(self):
|
||||
config = APP.normalize_config(
|
||||
@@ -1116,6 +1120,44 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
|
||||
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
|
||||
|
||||
def test_normalize_config_filters_discord_webhook_events(self):
|
||||
config = APP.normalize_config(
|
||||
{
|
||||
"discord_webhook_url": " https://discord.example/webhook ",
|
||||
"discord_webhook_events": [
|
||||
"watchlist_refresh_new_episodes",
|
||||
"runtime_error",
|
||||
"runtime_error",
|
||||
"nope",
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertEqual(config["discord_webhook_url"], "https://discord.example/webhook")
|
||||
self.assertEqual(
|
||||
config["discord_webhook_events"],
|
||||
["watchlist_refresh_new_episodes", "runtime_error"],
|
||||
)
|
||||
|
||||
def test_test_discord_webhook_config_requires_url(self):
|
||||
with self.assertRaises(ValueError):
|
||||
APP.test_discord_webhook_config({"discord_webhook_url": ""})
|
||||
|
||||
def test_test_discord_webhook_config_sends_test_event(self):
|
||||
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
|
||||
result = APP.test_discord_webhook_config(
|
||||
{
|
||||
"discord_webhook_url": "https://discord.example/webhook",
|
||||
"discord_webhook_events": [],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["message"], "Discord webhook test notification sent.")
|
||||
send_webhook.assert_called_once()
|
||||
config, event_name = send_webhook.call_args.args[:2]
|
||||
self.assertEqual(config["discord_webhook_url"], "https://discord.example/webhook")
|
||||
self.assertEqual(event_name, "test")
|
||||
self.assertTrue(send_webhook.call_args.kwargs["force"])
|
||||
|
||||
|
||||
class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
|
||||
def test_tick_waits_then_triggers_refresh(self):
|
||||
@@ -1153,6 +1195,57 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
|
||||
self.assertEqual(wait_mock.call_count, 2)
|
||||
wait_mock.assert_called_with(5)
|
||||
|
||||
def test_refresh_job_sends_new_episode_and_failure_notifications(self):
|
||||
class FakeStore:
|
||||
def all_show_ids(self):
|
||||
return ["show-a", "show-b"]
|
||||
|
||||
def refresh(self, show_id, include_thumbnail=True):
|
||||
if show_id == "show-a":
|
||||
return {
|
||||
"show_id": "show-a",
|
||||
"title": "Show A",
|
||||
"status": "updated",
|
||||
"had_new_episodes": True,
|
||||
"sub_count": 12,
|
||||
"dub_count": 4,
|
||||
"sub_delta": 1,
|
||||
"dub_delta": 0,
|
||||
"expected_count": 12,
|
||||
"airing_status": "Finished",
|
||||
"thumbnail_path": None,
|
||||
}
|
||||
return {
|
||||
"show_id": "show-b",
|
||||
"title": "Show B",
|
||||
"status": "error",
|
||||
"status_message": "Could not refresh episodes: boom",
|
||||
"had_new_episodes": False,
|
||||
}
|
||||
|
||||
service = APP.WatchlistRefreshJobs(
|
||||
FakeStore(),
|
||||
config_getter=lambda: {
|
||||
"discord_webhook_url": "https://discord.example/webhook",
|
||||
"discord_webhook_events": [
|
||||
"watchlist_refresh_new_episodes",
|
||||
"watchlist_refresh_failed",
|
||||
],
|
||||
},
|
||||
start_worker=False,
|
||||
)
|
||||
job = service.start()["job"]
|
||||
job = service._next_pending()
|
||||
|
||||
with mock.patch.object(queue_jobs, "send_discord_webhook_event") as send_webhook:
|
||||
service._run_job(job)
|
||||
|
||||
self.assertEqual(send_webhook.call_count, 2)
|
||||
self.assertEqual(send_webhook.call_args_list[0].args[1], "watchlist_refresh_new_episodes")
|
||||
self.assertEqual(send_webhook.call_args_list[0].args[2]["items"][0]["title"], "Show A")
|
||||
self.assertEqual(send_webhook.call_args_list[1].args[1], "watchlist_refresh_failed")
|
||||
self.assertEqual(send_webhook.call_args_list[1].args[2]["items"][0]["title"], "Show B")
|
||||
|
||||
def test_tick_resets_timer_when_schedule_changes(self):
|
||||
config = {
|
||||
"watchlist_auto_refresh_enabled": True,
|
||||
@@ -1238,6 +1331,8 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
"watchlist_auto_refresh_enabled": False,
|
||||
"watchlist_auto_refresh_minutes": 60,
|
||||
"watchlist_refresh_delay_seconds": 5,
|
||||
"discord_webhook_url": "",
|
||||
"discord_webhook_events": [],
|
||||
}
|
||||
APP.WATCHLIST_AUTO_REFRESH = mock.Mock()
|
||||
saved = APP.save_runtime_config(
|
||||
@@ -1248,6 +1343,8 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
"watchlist_auto_refresh_enabled": True,
|
||||
"watchlist_auto_refresh_minutes": 15,
|
||||
"watchlist_refresh_delay_seconds": 8,
|
||||
"discord_webhook_url": "https://discord.example/webhook",
|
||||
"discord_webhook_events": ["runtime_error"],
|
||||
}
|
||||
)
|
||||
self.assertEqual(saved["mode"], "dub")
|
||||
@@ -1256,6 +1353,8 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"])
|
||||
self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15)
|
||||
self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8)
|
||||
self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.example/webhook")
|
||||
self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"])
|
||||
APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with()
|
||||
finally:
|
||||
APP.CONFIG = original
|
||||
@@ -1299,6 +1398,29 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
self.assertEqual(handler.json_payload["watchlist_auto_refresh_minutes"], 25)
|
||||
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9)
|
||||
|
||||
def test_config_post_accepts_discord_webhook_fields(self):
|
||||
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}'
|
||||
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
||||
APP.Handler.do_POST(handler)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
self.assertEqual(handler.json_payload["discord_webhook_url"], "https://discord.example/webhook")
|
||||
self.assertEqual(
|
||||
handler.json_payload["discord_webhook_events"],
|
||||
["runtime_error", "watchlist_refresh_failed"],
|
||||
)
|
||||
|
||||
def test_config_webhook_test_route_uses_unsaved_payload(self):
|
||||
body = b'{"discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error"]}'
|
||||
handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body))
|
||||
test_webhook = mock.Mock(return_value={"message": "ok"})
|
||||
handler.handler_context = mock.Mock(test_discord_webhook_config=test_webhook)
|
||||
APP.Handler.do_POST(handler)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
self.assertEqual(handler.json_payload["message"], "ok")
|
||||
test_webhook.assert_called_once_with(
|
||||
{"discord_webhook_url": "https://discord.example/webhook", "discord_webhook_events": ["runtime_error"]}
|
||||
)
|
||||
|
||||
def test_body_json_rejects_oversized_request(self):
|
||||
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1)
|
||||
with self.assertRaises(APP.HttpError) as ctx:
|
||||
|
||||
Reference in New Issue
Block a user