diff --git a/CHANGELOG.md b/CHANGELOG.md index 150e221..014461b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 6272da4..f9627a4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Small local web UI for a system-wide `ani-cli` install. -Current version: `0.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` diff --git a/VERSION b/VERSION index bcce5d0..ff12df2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.0 +0.45.1 diff --git a/app.py b/app.py index 47e0e52..5f0900b 100644 --- a/app.py +++ b/app.py @@ -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", diff --git a/app_support.py b/app_support.py index a7356ab..9cf052f 100644 --- a/app_support.py +++ b/app_support.py @@ -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": diff --git a/test_app.py b/test_app.py index 0a1d73b..1ca808f 100644 --- a/test_app.py +++ b/test_app.py @@ -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,