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"""