diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a75bc..1dbc859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index ce5b4bc..1f9a242 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/VERSION b/VERSION index 366b834..be386c9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.32.5 +0.33.0 diff --git a/app.py b/app.py index 8beb144..ddc668e 100644 --- a/app.py +++ b/app.py @@ -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, diff --git a/app_support.py b/app_support.py index 4453c15..c1356f5 100644 --- a/app_support.py +++ b/app_support.py @@ -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") diff --git a/config_page.py b/config_page.py index 609aef6..039041d 100644 --- a/config_page.py +++ b/config_page.py @@ -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' " + ) + for event_name in DISCORD_WEBHOOK_EVENT_CHOICES +) + + CONFIG_HTML = r""" @@ -49,7 +59,7 @@ CONFIG_HTML = r""" 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""" 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""" 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""" 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""" .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"""
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.
+
+
+
+

Discord notifications

+

Send webhook notifications for refresh discoveries, refresh problems, and unexpected runtime errors.

+
+ +
+
+ +
The new-episodes notification includes one card per anime, with local cached thumbnails attached to the webhook when available.
+
+
+ +
+""" + WEBHOOK_EVENT_OPTIONS + r""" +
+
+
+
@@ -342,7 +411,9 @@ CONFIG_HTML = r""" 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""" $("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""" } $("saveConfigBtn").addEventListener("click", saveConfig); + $("testWebhookBtn").addEventListener("click", testWebhook); (async function init() { try { diff --git a/http_handler.py b/http_handler.py index e057a20..3bc25dd 100644 --- a/http_handler.py +++ b/http_handler.py @@ -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): diff --git a/queue_jobs.py b/queue_jobs.py index e89c95d..18c28b4 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -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): diff --git a/test_app.py b/test_app.py index f48f4bf..5d90c71 100644 --- a/test_app.py +++ b/test_app.py @@ -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: