From 9d4baea057c411c943ac2582538231df32ddf316 Mon Sep 17 00:00:00 2001 From: Dymas Date: Thu, 21 May 2026 19:36:39 +0200 Subject: [PATCH] Notify completed downloads and finish watchlist entries --- CHANGELOG.md | 5 +++++ README.md | 6 ++++-- VERSION | 2 +- app.py | 3 ++- app_support.py | 35 ++++++++++++++++++++++++++++++++++- queue_jobs.py | 26 +++++++++++++++++++++++++- test_app.py | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 7 files changed, 115 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd81ba..5f44ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.34.0 - 2026-05-21 + +- Added a Discord webhook notification event for completed downloads so successful queue jobs can report the resolved anime title, saved files, and cached thumbnail. +- Fixed watchlist download sync so successfully downloaded series are moved into the `Finished` category instead of staying in `Watching`, `Planned`, or another earlier category. + ## 0.33.1 - 2026-05-21 - Updated the Docker image build to install `yt-dlp` directly from the latest upstream GitHub release and verify the binary during the image build. diff --git a/README.md b/README.md index fafe6ff..16e77f3 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.33.1` +Current version: `0.34.0` ## Project Layout @@ -123,6 +123,7 @@ Container notes: - Refresh watchlist episode counts in the background. - Schedule periodic watchlist refresh jobs. - Send Discord webhook notifications for refresh discoveries and failures. +- Send Discord webhook notifications for completed downloads. - Cache watchlist thumbnails locally. - Sync successful downloads back into the watchlist. - Keep queue and watchlist data in SQLite under `.ani-cli-web/`. @@ -165,6 +166,7 @@ Saved settings are written to `.ani-cli-web/config.json`. Discord notification events: +- Download completed. - Refresh finished and found new episodes. - Refresh failed or finished with per-show refresh errors. - Refresh interrupted by restart or shutdown. @@ -207,7 +209,7 @@ Status behavior: Download sync: -- Existing watchlist entries stay in their current category when a download succeeds. +- Existing watchlist entries move to `Finished` when a download succeeds. - Missing entries are added to `Finished`. - If ani-cli resolves a different `show_id`, the app can reconcile a unique title match instead of creating a duplicate finished row. diff --git a/VERSION b/VERSION index 8df3f45..85e60ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.33.1 +0.34.0 diff --git a/app.py b/app.py index ddc668e..a13760a 100644 --- a/app.py +++ b/app.py @@ -1480,7 +1480,7 @@ class WatchlistStore: conn.execute( """ UPDATE watchlist - SET downloaded = 1, status = 'queued', status_message = ?, updated_at = ? + SET category = 'finished', downloaded = 1, status = 'queued', status_message = ?, updated_at = ? WHERE show_id = ? """, ("Queued refresh after successful download.", now, normalized_show_id), @@ -1496,6 +1496,7 @@ class WatchlistStore: SET show_id = ?, title = ?, + category = 'finished', downloaded = 1, status = 'queued', status_message = ?, diff --git a/app_support.py b/app_support.py index c1356f5..28da342 100644 --- a/app_support.py +++ b/app_support.py @@ -19,7 +19,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.33.0" +VERSION = "0.34.0" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" @@ -52,12 +52,14 @@ WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS = 5 WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0 WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300 DISCORD_WEBHOOK_EVENT_CHOICES = ( + "download_completed", "watchlist_refresh_new_episodes", "watchlist_refresh_failed", "watchlist_refresh_interrupted", "runtime_error", ) DISCORD_WEBHOOK_EVENT_LABELS = { + "download_completed": "Download completed", "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", @@ -487,6 +489,37 @@ def send_discord_webhook_event(config, event_name, payload=None, force=False): _post_discord_webhook(webhook_url, request_payload, attachments=attachments) return {"sent": True, "event": event, "count": len(items)} + if event == "download_completed": + 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 = [] + if payload.get("mode"): + description_lines.append(f"Mode: {str(payload.get('mode')).strip()}") + if payload.get("episodes"): + description_lines.append(f"Episodes: {str(payload.get('episodes')).strip()}") + if payload.get("category"): + 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.", + "color": 5763719, + } + attachments = [] + thumb_path = thumbnail_file_path(payload.get("thumbnail_path")) + if thumb_path and thumb_path.exists(): + 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_webhook(webhook_url, request_payload, attachments=attachments) + return {"sent": True, "event": event} + if event == "watchlist_refresh_failed": error_lines = [] for item in (payload.get("items") if isinstance(payload.get("items"), list) else [])[:8]: diff --git a/queue_jobs.py b/queue_jobs.py index 18c28b4..b69c9dc 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -1037,6 +1037,7 @@ class DownloadQueue: finalize_error = None moved_files = [] watchlist_sync_error = None + synced_watchlist_item = None cleanup_staging = False if exit_code == 0 and not job.get("cancel_requested"): try: @@ -1046,7 +1047,7 @@ class DownloadQueue: else: if self.watchlist_sync_fn is not None: try: - self.watchlist_sync_fn(job) + synced_watchlist_item = self.watchlist_sync_fn(job) except Exception as exc: watchlist_sync_error = exc @@ -1083,5 +1084,28 @@ class DownloadQueue: self._save_job_locked(job) if cleanup_staging: self._cleanup_staging_dir(job) + elif exit_code == 0 and not finalize_error and not canceled: + download_title = ( + str((synced_watchlist_item or {}).get("title") or "").strip() + or str(job.get("title") or "").strip() + or str(job.get("query") or "").strip() + or "Unknown anime" + ) + try: + send_discord_webhook_event( + self.config_getter() or {}, + "download_completed", + { + "show_id": (synced_watchlist_item or {}).get("show_id") or job.get("show_id"), + "title": download_title, + "mode": job.get("mode"), + "episodes": job.get("episodes"), + "category": (synced_watchlist_item or {}).get("category") or "finished", + "thumbnail_path": (synced_watchlist_item or {}).get("thumbnail_path"), + "moved_files": moved_files, + }, + ) + except Exception as exc: + debug_log("discord_webhook.download_completed_failed", title=download_title, error=exc) except Exception as exc: self._fail_job_runtime(job, exc, process=process) diff --git a/test_app.py b/test_app.py index 5d90c71..0d0f15e 100644 --- a/test_app.py +++ b/test_app.py @@ -350,6 +350,45 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase): self.assertIsNone(queue.current_job) self.assertFalse(staging_dir.exists()) + def test_successful_download_sends_completion_notification(self): + queue = APP.DownloadQueue( + lambda: { + "mode": "sub", + "quality": "best", + "download_dir": "/tmp/example", + "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_events": ["download_completed"], + }, + watchlist_sync_fn=lambda _job: { + "show_id": "show-55", + "title": "Queue Show", + "category": "finished", + "thumbnail_path": None, + }, + start_worker=False, + ) + job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1-2"}) + + class FakeProc: + pid = 4321 + stdout = [] + + def wait(self): + return 0 + + with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), mock.patch.object( + queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"] + ), mock.patch.object(queue_jobs, "send_discord_webhook_event") as send_webhook: + queue._run_job(job) + + stored = queue._find(job["id"]) + self.assertEqual(stored["status"], "done") + self.assertIn("Download completed.", stored["log"]) + send_webhook.assert_called_once() + 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") + class WatchlistRefreshAllTests(unittest.TestCase): def test_refresh_all_iterates_every_show_id(self): @@ -810,7 +849,7 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(item["status"], "queued") self.assertEqual(item["status_message"], "Queued from test.") - def test_mark_downloaded_keeps_existing_category_and_sets_download_flag(self): + def test_mark_downloaded_moves_existing_category_to_finished_and_sets_download_flag(self): self.seed_watchlist_item(show_id="show-9", title="Queue Show", category="planned", downloaded=False) with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object( @@ -818,9 +857,9 @@ class WatchlistCompletionTests(unittest.TestCase): ): item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show") - self.assertEqual(item["category"], "planned") + self.assertEqual(item["category"], "finished") self.assertTrue(item["downloaded"]) - self.assertEqual(item["finished_badge"], "") + self.assertEqual(item["finished_badge"], "downloaded") self.assertEqual(item["status"], "queued") def test_mark_downloaded_creates_missing_show_in_finished_category(self): @@ -843,8 +882,9 @@ class WatchlistCompletionTests(unittest.TestCase): item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show") self.assertEqual(item["show_id"], "resolved-show-9") - self.assertEqual(item["category"], "watching") + self.assertEqual(item["category"], "finished") self.assertTrue(item["downloaded"]) + self.assertEqual(item["finished_badge"], "downloaded") self.assertEqual(item["status"], "queued") self.assertFalse(APP.WATCHLIST.has_show("legacy-show-9")) self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"])