Add Discord webhook refresh notifications
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
+125
-29
@@ -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,42 +424,66 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
$("notice").textContent = text || "";
|
$("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() {
|
async function loadConfig() {
|
||||||
state.config = await api("/api/config");
|
applyConfig(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/config", {
|
applyConfig(await api("/api/config", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(formConfigPayload())
|
||||||
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;
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user