Expand Discord download and Jellyfin notifications
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Small local web UI for a system-wide `ani-cli` install.
|
||||
|
||||
Current version: `0.45.0`
|
||||
Current version: `0.45.1`
|
||||
|
||||
## Project Layout
|
||||
|
||||
@@ -210,11 +210,18 @@ Jellyfin handoff:
|
||||
Discord notification events:
|
||||
|
||||
- Download completed.
|
||||
- Jellyfin move succeeded.
|
||||
- Jellyfin move failed.
|
||||
- Refresh finished and found new episodes.
|
||||
- Refresh failed or finished with per-show refresh errors.
|
||||
- Refresh interrupted by restart or shutdown.
|
||||
- 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
|
||||
|
||||
Path: `/watchlist`
|
||||
|
||||
@@ -997,6 +997,8 @@ def move_watchlist_item_to_jellyfin(item, config):
|
||||
except OSError:
|
||||
pass
|
||||
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)}
|
||||
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
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):
|
||||
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()}
|
||||
@@ -2354,8 +2379,27 @@ def trigger_jellyfin_sync_for_item(item, automatic=False, config=None):
|
||||
media_type = normalize_media_type(item.get("media_type"))
|
||||
target_key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
|
||||
if not str(active_config.get(target_key) or "").strip():
|
||||
return {"moved": False, "reason": "target_missing", "item": item}
|
||||
result = move_watchlist_item_to_jellyfin(item, active_config)
|
||||
result = {"moved": False, "reason": "target_missing", "item": item}
|
||||
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"):
|
||||
debug_log(
|
||||
"jellyfin.sync.moved",
|
||||
|
||||
+117
-6
@@ -20,7 +20,7 @@ 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.45.0"
|
||||
VERSION = "0.45.1"
|
||||
ALLANIME_BASE = "allanime.day"
|
||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||
ALLANIME_REFERER = "https://allmanga.to"
|
||||
@@ -55,6 +55,8 @@ WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0
|
||||
WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300
|
||||
DISCORD_WEBHOOK_EVENT_CHOICES = (
|
||||
"download_completed",
|
||||
"jellyfin_sync_success",
|
||||
"jellyfin_sync_failed",
|
||||
"watchlist_refresh_new_episodes",
|
||||
"watchlist_refresh_failed",
|
||||
"watchlist_refresh_interrupted",
|
||||
@@ -62,6 +64,8 @@ DISCORD_WEBHOOK_EVENT_CHOICES = (
|
||||
)
|
||||
DISCORD_WEBHOOK_EVENT_LABELS = {
|
||||
"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_failed": "Refresh failed or finished with refresh errors",
|
||||
"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
|
||||
|
||||
|
||||
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):
|
||||
normalized = normalize_config(config or {})
|
||||
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()}")
|
||||
if 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 = {
|
||||
"title": title[:256],
|
||||
"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'}"
|
||||
attachments.append({"path": thumb_path, "filename": attachment_name})
|
||||
embed["thumbnail"] = {"url": f"attachment://{attachment_name}"}
|
||||
request_payload = {
|
||||
"content": "ani-cli-web finished a download.",
|
||||
"embeds": [embed],
|
||||
_post_discord_embeds_with_file_list(
|
||||
webhook_url,
|
||||
"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}
|
||||
|
||||
if event == "watchlist_refresh_failed":
|
||||
|
||||
+129
@@ -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[2]["title"], "Queue Show")
|
||||
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):
|
||||
@@ -1320,6 +1321,82 @@ class JellyfinSyncTests(unittest.TestCase):
|
||||
self.assertTrue(target_file.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):
|
||||
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")
|
||||
@@ -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[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):
|
||||
config = {
|
||||
"watchlist_auto_refresh_enabled": True,
|
||||
|
||||
Reference in New Issue
Block a user