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
+6
View File
@@ -1,5 +1,11 @@
# Changelog # 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 ## 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. - 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.
+14 -1
View File
@@ -2,7 +2,7 @@
Small local web UI for a system-wide `ani-cli` install. Small local web UI for a system-wide `ani-cli` install.
Current version: `0.32.5` Current version: `0.33.0`
## Project Layout ## Project Layout
@@ -121,6 +121,7 @@ Container notes:
- Track shows on `/watchlist`. - Track shows on `/watchlist`.
- Refresh watchlist episode counts in the background. - Refresh watchlist episode counts in the background.
- Schedule periodic watchlist refresh jobs. - Schedule periodic watchlist refresh jobs.
- Send Discord webhook notifications for refresh discoveries and failures.
- Cache watchlist thumbnails locally. - Cache watchlist thumbnails locally.
- Sync successful downloads back into the watchlist. - Sync successful downloads back into the watchlist.
- Keep queue and watchlist data in SQLite under `.ani-cli-web/`. - 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. 4. Scheduled watchlist refresh enable or disable.
5. Scheduled refresh period in minutes. 5. Scheduled refresh period in minutes.
6. Delay between shows during `Refresh All`. 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`. 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 ### Watchlist
Path: `/watchlist` Path: `/watchlist`
@@ -215,6 +225,7 @@ Refresh behavior:
- Duplicate refresh queueing is avoided. - Duplicate refresh queueing is avoided.
- Scheduled refresh runs log start, stop, success, and failure to stdout. - Scheduled refresh runs log start, stop, success, and failure to stdout.
- `Refresh All` works one show at a time with a configurable delay. - `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 ## Access Guard
@@ -295,6 +306,8 @@ Environment variables:
Saved config fields include: Saved config fields include:
- `discord_webhook_url`
- `discord_webhook_events`
- `watchlist_auto_refresh_enabled` - `watchlist_auto_refresh_enabled`
- `watchlist_auto_refresh_minutes` - `watchlist_auto_refresh_minutes`
- `watchlist_refresh_delay_seconds` - `watchlist_refresh_delay_seconds`
+1 -1
View File
@@ -1 +1 @@
0.32.5 0.33.0
+20 -22
View File
@@ -34,9 +34,9 @@ from app_support import (
APP_NAME, APP_NAME,
CONFIG_PATH, CONFIG_PATH,
DEFAULT_CONFIG, DEFAULT_CONFIG,
LEGACY_THUMBNAIL_ROOT,
MAX_JSON_BODY_BYTES, MAX_JSON_BODY_BYTES,
MODE_CHOICES, MODE_CHOICES,
send_discord_webhook_event,
STATE_DB_PATH, STATE_DB_PATH,
THUMBNAIL_RETRY_SECONDS, THUMBNAIL_RETRY_SECONDS,
THUMBNAIL_ROOT, THUMBNAIL_ROOT,
@@ -54,6 +54,7 @@ from app_support import (
normalize_config, normalize_config,
now_iso, now_iso,
sanitize_path_component, sanitize_path_component,
thumbnail_file_path,
write_json, write_json,
) )
from http_handler import HandlerContext, build_handler_class, server_host, server_port 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 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): def cover_route(show_id):
return f"/api/watchlist/thumb/{quote(str(show_id).strip(), safe='')}" 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): def refresh(self, show_id, include_thumbnail=True):
existing = self.get(show_id) existing = self.get(show_id)
normalized_show_id = existing["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() now = now_iso()
request_timeout = self._refresh_request_timeout() request_timeout = self._refresh_request_timeout()
try: try:
@@ -1448,7 +1431,13 @@ class WatchlistStore:
self.ensure_thumbnail(normalized_show_id) self.ensure_thumbnail(normalized_show_id)
except Exception: except Exception:
pass 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): def refresh_all(self):
refreshed = [] refreshed = []
@@ -1842,6 +1831,14 @@ def save_runtime_config(config):
return dict(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(): def runtime_state():
return { return {
"config": CONFIG, "config": CONFIG,
@@ -1981,6 +1978,7 @@ Handler = build_handler_class(
runtime_state=runtime_state, runtime_state=runtime_state,
save_runtime_config=save_runtime_config, save_runtime_config=save_runtime_config,
search_anime=search_anime, search_anime=search_anime,
test_discord_webhook_config=test_discord_webhook_config,
thumbnail_file_path=thumbnail_file_path, thumbnail_file_path=thumbnail_file_path,
update_all_watchlist_statuses=update_all_watchlist_statuses, update_all_watchlist_statuses=update_all_watchlist_statuses,
update_watchlist_category=update_watchlist_category, update_watchlist_category=update_watchlist_category,
+258 -1
View File
@@ -5,18 +5,21 @@
import hmac import hmac
import ipaddress import ipaddress
import json import json
import mimetypes
import os import os
import re import re
import shutil import shutil
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import urllib.error
import urllib.request
from uuid import uuid4 from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web" APP_NAME = "ani-cli-web"
VERSION = "0.32.5" VERSION = "0.33.0"
ALLANIME_BASE = "allanime.day" ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to" 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_DEFAULT_SECONDS = 5
WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0 WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0
WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300 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): def env_flag(name, default=False):
@@ -123,6 +138,8 @@ DEFAULT_CONFIG = {
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES, "watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
"watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS, "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_enabled = config.get("watchlist_auto_refresh_enabled")
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes") auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds") 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): if isinstance(auto_refresh_enabled, str):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"} 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_enabled"] = auto_refresh_enabled
config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes
config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds 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 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): def strip_control(value):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n") return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n")
+117 -21
View File
@@ -2,9 +2,19 @@
"""Config page template for ani-cli-web.""" """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 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> CONFIG_HTML = r"""<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -49,7 +59,7 @@ CONFIG_HTML = r"""<!doctype html>
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%); background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%);
opacity: 0.5; opacity: 0.5;
} }
button, input, select { button, input, select, textarea {
font: inherit; font: inherit;
} }
button { button {
@@ -74,7 +84,7 @@ CONFIG_HTML = r"""<!doctype html>
font-weight: 700; font-weight: 700;
box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26); box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26);
} }
input, select { input, select, textarea {
width: 100%; width: 100%;
min-height: 42px; min-height: 42px;
border-radius: 14px; border-radius: 14px;
@@ -85,7 +95,12 @@ CONFIG_HTML = r"""<!doctype html>
outline: none; outline: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); 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); border-color: var(--focus);
box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12); 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)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; 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 { .settings-main, .deps {
width: min(760px, 100%); width: min(760px, 100%);
} }
@@ -242,7 +289,7 @@ CONFIG_HTML = r"""<!doctype html>
.toolbar, .row { align-items: stretch; flex-direction: column; } .toolbar, .row { align-items: stretch; flex-direction: column; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.form-grid { .form-grid, .check-grid {
grid-template-columns: 1fr; 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> <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>
<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> <section class="deps" id="deps"></section>
</main> </main>
</div> </div>
@@ -342,7 +411,9 @@ CONFIG_HTML = r"""<!doctype html>
download_dir: "", download_dir: "",
watchlist_auto_refresh_enabled: false, watchlist_auto_refresh_enabled: false,
watchlist_auto_refresh_minutes: 60, watchlist_auto_refresh_minutes: 60,
watchlist_refresh_delay_seconds: 5 watchlist_refresh_delay_seconds: 5,
discord_webhook_url: "",
discord_webhook_events: []
} }
}; };
@@ -353,29 +424,24 @@ CONFIG_HTML = r"""<!doctype html>
$("notice").textContent = text || ""; $("notice").textContent = text || "";
} }
async function loadConfig() { function selectedWebhookEvents() {
state.config = await api("/api/config"); return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
$("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;
} }
async function saveConfig() { function formConfigPayload() {
try { return {
const data = await api("/api/config", {
method: "POST",
body: JSON.stringify({
download_dir: $("downloadDir").value, download_dir: $("downloadDir").value,
mode: $("configMode").value, mode: $("configMode").value,
quality: $("configQuality").value, quality: $("configQuality").value,
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true", watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value, watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value,
}) discord_webhook_url: $("discordWebhookUrl").value,
}); discord_webhook_events: selectedWebhookEvents()
};
}
function applyConfig(data) {
state.config = data; state.config = data;
$("downloadDir").value = data.download_dir; $("downloadDir").value = data.download_dir;
$("configMode").value = data.mode; $("configMode").value = data.mode;
@@ -383,12 +449,41 @@ CONFIG_HTML = r"""<!doctype html>
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled)); $("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes; $("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds; $("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() {
applyConfig(await api("/api/config"));
}
async function saveConfig() {
try {
applyConfig(await api("/api/config", {
method: "POST",
body: JSON.stringify(formConfigPayload())
}));
setNotice("Defaults saved."); setNotice("Defaults saved.");
} catch (error) { } catch (error) {
setNotice(error.message); 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() { async function loadDeps() {
const data = await api("/api/dependencies"); const data = await api("/api/dependencies");
const el = $("deps"); const el = $("deps");
@@ -410,6 +505,7 @@ CONFIG_HTML = r"""<!doctype html>
} }
$("saveConfigBtn").addEventListener("click", saveConfig); $("saveConfigBtn").addEventListener("click", saveConfig);
$("testWebhookBtn").addEventListener("click", testWebhook);
(async function init() { (async function init() {
try { try {
+17
View File
@@ -28,6 +28,7 @@ from app_support import (
remote_access_allowed, remote_access_allowed,
remote_access_token_configured, remote_access_token_configured,
remote_access_token_matches, remote_access_token_matches,
send_discord_webhook_event,
) )
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -45,6 +46,7 @@ class HandlerContext:
runtime_state: object runtime_state: object
save_runtime_config: object save_runtime_config: object
search_anime: object search_anime: object
test_discord_webhook_config: object
thumbnail_file_path: object thumbnail_file_path: object
update_all_watchlist_statuses: object update_all_watchlist_statuses: object
update_watchlist_category: object update_watchlist_category: object
@@ -278,6 +280,9 @@ class Handler(BaseHTTPRequestHandler):
self.ensure_client_access() self.ensure_client_access()
if parsed.path == "/api/config": if parsed.path == "/api/config":
Handler.update_config(self, Handler.require_json_object(self, self.body_json())) 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": elif parsed.path == "/api/queue":
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED) self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED)
@@ -465,6 +470,18 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc() traceback.print_exc()
else: else:
debug_log("handler.exception", path=getattr(self, "path", ""), error=exc) 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.") self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.")
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
+75
View File
@@ -26,6 +26,7 @@ from app_support import (
load_json, load_json,
now_iso, now_iso,
printable_command, printable_command,
send_discord_webhook_event,
strip_control, strip_control,
) )
@@ -262,6 +263,8 @@ class WatchlistRefreshJobs:
def _run_job(self, job): def _run_job(self, job):
interrupted = False interrupted = False
source_name = str(job.get("source") or "manual").strip().lower() or "manual" source_name = str(job.get("source") or "manual").strip().lower() or "manual"
new_episode_items = []
refresh_error_items = []
try: try:
show_ids = self.store.all_show_ids() show_ids = self.store.all_show_ids()
delay_seconds = self._refresh_delay_seconds() delay_seconds = self._refresh_delay_seconds()
@@ -295,6 +298,20 @@ class WatchlistRefreshJobs:
title = item.get("title") or title title = item.get("title") or title
errored = item.get("status") == "error" errored = item.get("status") == "error"
error_message = str(item.get("status_message") or "").strip() if errored else "" 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: except Exception as exc:
errored = True errored = True
error_message = str(exc) error_message = str(exc)
@@ -306,6 +323,7 @@ class WatchlistRefreshJobs:
job["updated_at"] = now_iso() job["updated_at"] = now_iso()
if errored and error_message: if errored and error_message:
job["last_error"] = error_message job["last_error"] = error_message
refresh_error_items.append({"show_id": show_id, "title": title, "error": error_message})
if skipped: if skipped:
job["message"] = f"Refreshing {index}/{len(show_ids)}: removed entry" job["message"] = f"Refreshing {index}/{len(show_ids)}: removed entry"
else: else:
@@ -344,6 +362,42 @@ class WatchlistRefreshJobs:
self._stdout_log( self._stdout_log(
f"Scheduled refresh finished successfully (job={job['id']}, completed={job['completed']}/{job['total']})." 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: except Exception as exc:
finished_at = now_iso() finished_at = now_iso()
with self.lock: with self.lock:
@@ -352,9 +406,22 @@ class WatchlistRefreshJobs:
job["updated_at"] = finished_at job["updated_at"] = finished_at
job["current_title"] = None job["current_title"] = None
job["message"] = f"Watchlist refresh failed: {exc}" job["message"] = f"Watchlist refresh failed: {exc}"
job["last_error"] = str(exc)
self._save_job_locked(job) self._save_job_locked(job)
if source_name == "scheduled": if source_name == "scheduled":
self._stdout_log(f"Scheduled refresh failed (job={job['id']}): {exc}") 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: finally:
with self.lock: with self.lock:
self.current_executor = None self.current_executor = None
@@ -427,6 +494,14 @@ class WatchlistAutoRefreshScheduler:
debug_log("watchlist.auto_refresh.tick", result=result) debug_log("watchlist.auto_refresh.tick", result=result)
except Exception as exc: except Exception as exc:
debug_log("watchlist.auto_refresh.error", error=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 return interval_seconds
def _run(self): def _run(self):
+122
View File
@@ -1090,6 +1090,8 @@ class ConfigSnapshotTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60, "watchlist_auto_refresh_minutes": 60,
"watchlist_refresh_delay_seconds": 5, "watchlist_refresh_delay_seconds": 5,
"discord_webhook_url": "",
"discord_webhook_events": [],
} }
try: try:
snapshot = APP.get_config_snapshot() snapshot = APP.get_config_snapshot()
@@ -1103,6 +1105,8 @@ class ConfigSnapshotTests(unittest.TestCase):
self.assertFalse(config["watchlist_auto_refresh_enabled"]) self.assertFalse(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60) self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5) 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): def test_normalize_config_parses_watchlist_schedule_fields(self):
config = APP.normalize_config( config = APP.normalize_config(
@@ -1116,6 +1120,44 @@ class ConfigSnapshotTests(unittest.TestCase):
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15) self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7) 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): class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
def test_tick_waits_then_triggers_refresh(self): def test_tick_waits_then_triggers_refresh(self):
@@ -1153,6 +1195,57 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
self.assertEqual(wait_mock.call_count, 2) self.assertEqual(wait_mock.call_count, 2)
wait_mock.assert_called_with(5) 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): def test_tick_resets_timer_when_schedule_changes(self):
config = { config = {
"watchlist_auto_refresh_enabled": True, "watchlist_auto_refresh_enabled": True,
@@ -1238,6 +1331,8 @@ class StartupBehaviorTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60, "watchlist_auto_refresh_minutes": 60,
"watchlist_refresh_delay_seconds": 5, "watchlist_refresh_delay_seconds": 5,
"discord_webhook_url": "",
"discord_webhook_events": [],
} }
APP.WATCHLIST_AUTO_REFRESH = mock.Mock() APP.WATCHLIST_AUTO_REFRESH = mock.Mock()
saved = APP.save_runtime_config( saved = APP.save_runtime_config(
@@ -1248,6 +1343,8 @@ class StartupBehaviorTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": True, "watchlist_auto_refresh_enabled": True,
"watchlist_auto_refresh_minutes": 15, "watchlist_auto_refresh_minutes": 15,
"watchlist_refresh_delay_seconds": 8, "watchlist_refresh_delay_seconds": 8,
"discord_webhook_url": "https://discord.example/webhook",
"discord_webhook_events": ["runtime_error"],
} }
) )
self.assertEqual(saved["mode"], "dub") self.assertEqual(saved["mode"], "dub")
@@ -1256,6 +1353,8 @@ class StartupBehaviorTests(unittest.TestCase):
self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"]) self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"])
self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15) self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8) 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() APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with()
finally: finally:
APP.CONFIG = original 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_auto_refresh_minutes"], 25)
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9) 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): def test_body_json_rejects_oversized_request(self):
handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1) handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1)
with self.assertRaises(APP.HttpError) as ctx: with self.assertRaises(APP.HttpError) as ctx: