Expand Discord download and Jellyfin notifications

This commit is contained in:
Dymas
2026-05-25 19:40:51 +02:00
parent 75262d261d
commit c006fb2d1d
6 changed files with 306 additions and 10 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.45.1 - 2026-05-25
- Expanded the `download_completed` Discord webhook so it includes the full saved file list instead of only the first few paths, splitting the list across extra embeds when needed.
- Added `jellyfin_sync_success` and `jellyfin_sync_failed` Discord webhook events for finished-library handoffs, with success and actionable failure notifications emitted from Jellyfin moves.
## 0.45.0 - 2026-05-25 ## 0.45.0 - 2026-05-25
- Added an optional per-anime auto-download episode offset for split-part seasons, so queued watchlist downloads can rename files as `S02E13`, `S02E14`, and onward while still downloading the underlying source episodes normally. - Added an optional per-anime auto-download episode offset for split-part seasons, so queued watchlist downloads can rename files as `S02E13`, `S02E14`, and onward while still downloading the underlying source episodes normally.
+8 -1
View File
@@ -2,7 +2,7 @@
Small local web UI for a system-wide `ani-cli` install. Small local web UI for a system-wide `ani-cli` install.
Current version: `0.45.0` Current version: `0.45.1`
## Project Layout ## Project Layout
@@ -210,11 +210,18 @@ Jellyfin handoff:
Discord notification events: Discord notification events:
- Download completed. - Download completed.
- Jellyfin move succeeded.
- Jellyfin move failed.
- Refresh finished and found new episodes. - Refresh finished and found new episodes.
- Refresh failed or finished with per-show refresh errors. - Refresh failed or finished with per-show refresh errors.
- Refresh interrupted by restart or shutdown. - Refresh interrupted by restart or shutdown.
- Unexpected runtime errors. - Unexpected runtime errors.
Webhook details:
- Download-completed notifications include the full saved file list, split across extra Discord embeds when needed.
- Jellyfin handoff notifications fire when a finished library is moved successfully and also on actionable move failures such as missing targets or move errors.
### Watchlist ### Watchlist
Path: `/watchlist` Path: `/watchlist`
+1 -1
View File
@@ -1 +1 @@
0.45.0 0.45.1
+46 -2
View File
@@ -997,6 +997,8 @@ def move_watchlist_item_to_jellyfin(item, config):
except OSError: except OSError:
pass pass
if not source_dir.exists(): if not source_dir.exists():
if target_dir.exists():
return {"moved": False, "reason": "already_moved", "item": item, "source": str(source_dir), "target": str(target_dir)}
return {"moved": False, "reason": "source_missing", "item": item, "source": str(source_dir), "target": str(target_dir)} return {"moved": False, "reason": "source_missing", "item": item, "source": str(source_dir), "target": str(target_dir)}
target_dir.parent.mkdir(parents=True, exist_ok=True) target_dir.parent.mkdir(parents=True, exist_ok=True)
if not target_dir.exists(): if not target_dir.exists():
@@ -1014,6 +1016,29 @@ def move_watchlist_item_to_jellyfin(item, config):
} }
def notify_jellyfin_sync_result(item, result, config, automatic=False):
reason = str(result.get("reason") or "").strip()
if result.get("moved"):
event_name = "jellyfin_sync_success"
elif reason in {"already_moved", "same_path", "disabled", "not_downloaded", "category", "expected_count", "airing_status", "episodes_missing", "ready"}:
return {"sent": False, "reason": "skip"}
else:
event_name = "jellyfin_sync_failed"
payload = {
"show_id": item.get("show_id"),
"title": item.get("title"),
"media_type": normalize_media_type(item.get("media_type")),
"source": "automatic" if automatic else "manual",
"reason": reason or ("moved" if result.get("moved") else "unknown"),
"target": result.get("target"),
"source_path": result.get("source"),
"moved_files": result.get("moved_files") if isinstance(result.get("moved_files"), list) else [],
"error": result.get("error"),
}
send_discord_webhook_event(config or get_config_snapshot(), event_name, payload)
return {"sent": True, "event": event_name}
def episode_specs_for_values(values, available_episodes): def episode_specs_for_values(values, available_episodes):
ordered = [str(value).strip() for value in available_episodes or [] if str(value).strip()] ordered = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
targets = {str(value).strip() for value in values or [] if str(value).strip()} targets = {str(value).strip() for value in values or [] if str(value).strip()}
@@ -2354,8 +2379,27 @@ def trigger_jellyfin_sync_for_item(item, automatic=False, config=None):
media_type = normalize_media_type(item.get("media_type")) media_type = normalize_media_type(item.get("media_type"))
target_key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir" target_key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
if not str(active_config.get(target_key) or "").strip(): if not str(active_config.get(target_key) or "").strip():
return {"moved": False, "reason": "target_missing", "item": item} result = {"moved": False, "reason": "target_missing", "item": item}
result = move_watchlist_item_to_jellyfin(item, active_config) try:
notify_jellyfin_sync_result(item, result, active_config, automatic=automatic)
except Exception as exc:
debug_log("discord_webhook.jellyfin_sync_failed", show_id=item.get("show_id"), reason=result.get("reason"), error=exc)
return result
try:
result = move_watchlist_item_to_jellyfin(item, active_config)
except Exception as exc:
result = {
"moved": False,
"reason": "error",
"item": item,
"source": str(watchlist_source_library_dir(item, active_config)),
"target": str(jellyfin_target_library_dir(item, active_config)),
"error": str(exc),
}
try:
notify_jellyfin_sync_result(item, result, active_config, automatic=automatic)
except Exception as exc:
debug_log("discord_webhook.jellyfin_sync_failed", show_id=item.get("show_id"), reason=result.get("reason"), error=exc)
if result.get("moved"): if result.get("moved"):
debug_log( debug_log(
"jellyfin.sync.moved", "jellyfin.sync.moved",
+117 -6
View File
@@ -20,7 +20,7 @@ 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.45.0" VERSION = "0.45.1"
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"
@@ -55,6 +55,8 @@ WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0
WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300 WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300
DISCORD_WEBHOOK_EVENT_CHOICES = ( DISCORD_WEBHOOK_EVENT_CHOICES = (
"download_completed", "download_completed",
"jellyfin_sync_success",
"jellyfin_sync_failed",
"watchlist_refresh_new_episodes", "watchlist_refresh_new_episodes",
"watchlist_refresh_failed", "watchlist_refresh_failed",
"watchlist_refresh_interrupted", "watchlist_refresh_interrupted",
@@ -62,6 +64,8 @@ DISCORD_WEBHOOK_EVENT_CHOICES = (
) )
DISCORD_WEBHOOK_EVENT_LABELS = { DISCORD_WEBHOOK_EVENT_LABELS = {
"download_completed": "Download completed", "download_completed": "Download completed",
"jellyfin_sync_success": "Jellyfin move succeeded",
"jellyfin_sync_failed": "Jellyfin move failed",
"watchlist_refresh_new_episodes": "Refresh finished with new episodes found", "watchlist_refresh_new_episodes": "Refresh finished with new episodes found",
"watchlist_refresh_failed": "Refresh failed or finished with refresh errors", "watchlist_refresh_failed": "Refresh failed or finished with refresh errors",
"watchlist_refresh_interrupted": "Refresh interrupted by shutdown or restart", "watchlist_refresh_interrupted": "Refresh interrupted by shutdown or restart",
@@ -503,6 +507,61 @@ def _post_discord_webhook(url, payload, attachments=None, timeout=10):
raise RuntimeError("Discord webhook request timed out") from exc raise RuntimeError("Discord webhook request timed out") from exc
def _chunk_discord_lines(lines, max_chars=3800):
chunks = []
current = []
current_length = 0
for raw_line in lines or []:
line = str(raw_line or "").strip()
if not line:
continue
added_length = len(line) + (1 if current else 0)
if current and current_length + added_length > max_chars:
chunks.append(current)
current = [line]
current_length = len(line)
continue
current.append(line)
current_length += added_length
if current:
chunks.append(current)
return chunks
def _post_discord_embeds_with_file_list(webhook_url, content, summary_embed, file_lines, attachments=None):
chunks = _chunk_discord_lines(file_lines)
if not chunks:
_post_discord_webhook(webhook_url, {"content": content, "embeds": [summary_embed]}, attachments=attachments)
return
total_chunks = len(chunks)
first_batch = chunks[:9]
request_embeds = [summary_embed]
for index, chunk in enumerate(first_batch, start=1):
request_embeds.append(
{
"title": f"Saved files ({index}/{total_chunks})",
"description": "\n".join(chunk)[:4096],
"color": summary_embed.get("color", 5763719),
}
)
_post_discord_webhook(webhook_url, {"content": content, "embeds": request_embeds}, attachments=attachments)
sent_chunks = len(first_batch)
while sent_chunks < total_chunks:
batch = chunks[sent_chunks : sent_chunks + 10]
embeds = []
for offset, chunk in enumerate(batch, start=1):
chunk_index = sent_chunks + offset
embeds.append(
{
"title": f"Saved files ({chunk_index}/{total_chunks})",
"description": "\n".join(chunk)[:4096],
"color": summary_embed.get("color", 5763719),
}
)
_post_discord_webhook(webhook_url, {"content": content, "embeds": embeds})
sent_chunks += len(batch)
def send_discord_webhook_event(config, event_name, payload=None, force=False): def send_discord_webhook_event(config, event_name, payload=None, force=False):
normalized = normalize_config(config or {}) normalized = normalize_config(config or {})
event = str(event_name or "").strip() event = str(event_name or "").strip()
@@ -582,7 +641,6 @@ def send_discord_webhook_event(config, event_name, payload=None, force=False):
description_lines.append(f"Watchlist category: {str(payload.get('category')).strip()}") description_lines.append(f"Watchlist category: {str(payload.get('category')).strip()}")
if moved_files: if moved_files:
description_lines.append(f"Saved files: {len(moved_files)}") description_lines.append(f"Saved files: {len(moved_files)}")
description_lines.extend(str(path).strip() for path in moved_files[:5] if str(path).strip())
embed = { embed = {
"title": title[:256], "title": title[:256],
"description": "\n".join(description_lines)[:4096] or "Download completed.", "description": "\n".join(description_lines)[:4096] or "Download completed.",
@@ -594,11 +652,64 @@ def send_discord_webhook_event(config, event_name, payload=None, force=False):
attachment_name = f"download-thumb{thumb_path.suffix or '.jpg'}" attachment_name = f"download-thumb{thumb_path.suffix or '.jpg'}"
attachments.append({"path": thumb_path, "filename": attachment_name}) attachments.append({"path": thumb_path, "filename": attachment_name})
embed["thumbnail"] = {"url": f"attachment://{attachment_name}"} embed["thumbnail"] = {"url": f"attachment://{attachment_name}"}
request_payload = { _post_discord_embeds_with_file_list(
"content": "ani-cli-web finished a download.", webhook_url,
"embeds": [embed], "ani-cli-web finished a download.",
embed,
moved_files,
attachments=attachments,
)
return {"sent": True, "event": event}
if event == "jellyfin_sync_success":
title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip()
moved_files = payload.get("moved_files") if isinstance(payload.get("moved_files"), list) else []
description_lines = [
f"Media type: {str(payload.get('media_type') or 'tv').strip()}",
f"Source: {str(payload.get('source') or 'manual').strip()}",
]
if payload.get("target"):
description_lines.append(f"Target: {str(payload.get('target')).strip()}")
if moved_files:
description_lines.append(f"Moved files: {len(moved_files)}")
embed = {
"title": f"Jellyfin handoff: {title[:236]}",
"description": "\n".join(description_lines)[:4096],
"color": 5763719,
} }
_post_discord_webhook(webhook_url, request_payload, attachments=attachments) _post_discord_embeds_with_file_list(
webhook_url,
"ani-cli-web moved a finished library into Jellyfin.",
embed,
moved_files,
)
return {"sent": True, "event": event}
if event == "jellyfin_sync_failed":
title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip()
reason = str(payload.get("reason") or "unknown").strip()
description_lines = [
f"Reason: {reason}",
f"Media type: {str(payload.get('media_type') or 'tv').strip()}",
f"Source: {str(payload.get('source') or 'manual').strip()}",
]
if payload.get("error"):
description_lines.append(f"Error: {str(payload.get('error')).strip()}")
if payload.get("source_path"):
description_lines.append(f"Source path: {str(payload.get('source_path')).strip()}")
if payload.get("target"):
description_lines.append(f"Target path: {str(payload.get('target')).strip()}")
request_payload = {
"content": "ani-cli-web could not move a finished library into Jellyfin.",
"embeds": [
{
"title": f"Jellyfin handoff failed: {title[:229]}",
"description": "\n".join(description_lines)[:4096],
"color": 15158332,
}
],
}
_post_discord_webhook(webhook_url, request_payload)
return {"sent": True, "event": event} return {"sent": True, "event": event}
if event == "watchlist_refresh_failed": if event == "watchlist_refresh_failed":
+129
View File
@@ -531,6 +531,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
self.assertEqual(send_webhook.call_args.args[1], "download_completed") self.assertEqual(send_webhook.call_args.args[1], "download_completed")
self.assertEqual(send_webhook.call_args.args[2]["title"], "Queue Show") self.assertEqual(send_webhook.call_args.args[2]["title"], "Queue Show")
self.assertEqual(send_webhook.call_args.args[2]["category"], "finished") self.assertEqual(send_webhook.call_args.args[2]["category"], "finished")
self.assertEqual(send_webhook.call_args.args[2]["moved_files"], ["/tmp/example/Queue Show - S01E01.mp4"])
class WatchlistRefreshAllTests(unittest.TestCase): class WatchlistRefreshAllTests(unittest.TestCase):
@@ -1320,6 +1321,82 @@ class JellyfinSyncTests(unittest.TestCase):
self.assertTrue(target_file.exists()) self.assertTrue(target_file.exists())
self.assertFalse((download_root / "tv" / "Jelly Show").exists()) self.assertFalse((download_root / "tv" / "Jelly Show").exists())
def test_trigger_jellyfin_sync_for_item_sends_success_notification(self):
self.seed_watchlist_item()
item = APP.WATCHLIST.get("show-jelly-1")
with tempfile.TemporaryDirectory() as temp_root:
download_root = Path(temp_root) / "downloads"
source_file = download_root / "tv" / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4"
source_file.parent.mkdir(parents=True, exist_ok=True)
source_file.write_bytes(b"episode-data")
jellyfin_tv = Path(temp_root) / "jellyfin-tv"
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
result = APP.trigger_jellyfin_sync_for_item(
item,
automatic=True,
config={
"download_dir": str(download_root),
"jellyfin_sync_enabled": True,
"jellyfin_tv_dir": str(jellyfin_tv),
"jellyfin_movie_dir": "",
"discord_webhook_url": "https://discord.example/webhook",
"discord_webhook_events": ["jellyfin_sync_success"],
},
)
self.assertTrue(result["moved"])
send_webhook.assert_called_once()
self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_success")
self.assertEqual(send_webhook.call_args.args[2]["title"], "Jelly Show")
self.assertEqual(send_webhook.call_args.args[2]["source"], "automatic")
def test_trigger_jellyfin_sync_for_item_sends_failure_notification(self):
self.seed_watchlist_item()
item = APP.WATCHLIST.get("show-jelly-1")
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
result = APP.trigger_jellyfin_sync_for_item(
item,
automatic=True,
config={
"download_dir": "/tmp/example",
"jellyfin_sync_enabled": True,
"jellyfin_tv_dir": "",
"jellyfin_movie_dir": "",
"discord_webhook_url": "https://discord.example/webhook",
"discord_webhook_events": ["jellyfin_sync_failed"],
},
)
self.assertFalse(result["moved"])
self.assertEqual(result["reason"], "target_missing")
send_webhook.assert_called_once()
self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_failed")
self.assertEqual(send_webhook.call_args.args[2]["reason"], "target_missing")
def test_trigger_jellyfin_sync_for_item_skips_already_moved_notification(self):
self.seed_watchlist_item()
item = APP.WATCHLIST.get("show-jelly-1")
with tempfile.TemporaryDirectory() as temp_root:
jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show"
jellyfin_tv.mkdir(parents=True, exist_ok=True)
with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
result = APP.trigger_jellyfin_sync_for_item(
item,
automatic=False,
config={
"download_dir": str(Path(temp_root) / "downloads"),
"jellyfin_sync_enabled": True,
"jellyfin_tv_dir": str(Path(temp_root) / "jellyfin-tv"),
"jellyfin_movie_dir": "",
"discord_webhook_url": "https://discord.example/webhook",
"discord_webhook_events": ["jellyfin_sync_failed", "jellyfin_sync_success"],
},
)
self.assertFalse(result["moved"])
self.assertEqual(result["reason"], "already_moved")
send_webhook.assert_not_called()
def test_trigger_jellyfin_sync_for_item_skips_incomplete_series(self): def test_trigger_jellyfin_sync_for_item_skips_incomplete_series(self):
self.seed_watchlist_item(expected_count=24, downloaded_sub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 13)])) self.seed_watchlist_item(expected_count=24, downloaded_sub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 13)]))
item = APP.WATCHLIST.get("show-jelly-1") item = APP.WATCHLIST.get("show-jelly-1")
@@ -1686,6 +1763,58 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
self.assertEqual(send_webhook.call_args_list[1].args[1], "watchlist_refresh_failed") 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") self.assertEqual(send_webhook.call_args_list[1].args[2]["items"][0]["title"], "Show B")
class DiscordWebhookFormattingTests(unittest.TestCase):
def test_download_completed_webhook_sends_full_file_list_across_embeds(self):
payloads = []
with mock.patch.object(APP.app_support, "_post_discord_webhook", side_effect=lambda url, payload, attachments=None, timeout=10: payloads.append(payload)):
result = APP.app_support.send_discord_webhook_event(
{
"discord_webhook_url": "https://discord.example/webhook",
"discord_webhook_events": ["download_completed"],
},
"download_completed",
{
"title": "Big Download",
"mode": "sub",
"episodes": "1-12",
"category": "finished",
"moved_files": [f"/tmp/downloads/Big Download/File {index:02d}.mp4" for index in range(1, 130)],
},
)
self.assertTrue(result["sent"])
self.assertGreaterEqual(len(payloads), 1)
all_descriptions = "\n".join(
embed.get("description", "")
for payload in payloads
for embed in payload.get("embeds", [])
)
self.assertIn("/tmp/downloads/Big Download/File 01.mp4", all_descriptions)
self.assertIn("/tmp/downloads/Big Download/File 129.mp4", all_descriptions)
def test_jellyfin_success_webhook_includes_moved_files(self):
payloads = []
with mock.patch.object(APP.app_support, "_post_discord_webhook", side_effect=lambda url, payload, attachments=None, timeout=10: payloads.append(payload)):
result = APP.app_support.send_discord_webhook_event(
{
"discord_webhook_url": "https://discord.example/webhook",
"discord_webhook_events": ["jellyfin_sync_success"],
},
"jellyfin_sync_success",
{
"title": "Jelly Show",
"media_type": "tv",
"source": "automatic",
"target": "/srv/jellyfin/Jelly Show",
"moved_files": ["/srv/jellyfin/Jelly Show/Season 01/Jelly Show - S01E01.mp4"],
},
)
self.assertTrue(result["sent"])
self.assertEqual(payloads[0]["content"], "ani-cli-web moved a finished library into Jellyfin.")
self.assertIn("/srv/jellyfin/Jelly Show/Season 01/Jelly Show - S01E01.mp4", payloads[0]["embeds"][1]["description"])
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,