Notify completed downloads and finish watchlist entries
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- Updated the Docker image build to install `yt-dlp` directly from the latest upstream GitHub release and verify the binary during the image build.
|
||||||
|
|||||||
@@ -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.33.1`
|
Current version: `0.34.0`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -123,6 +123,7 @@ Container notes:
|
|||||||
- 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.
|
- Send Discord webhook notifications for refresh discoveries and failures.
|
||||||
|
- Send Discord webhook notifications for completed downloads.
|
||||||
- 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/`.
|
||||||
@@ -165,6 +166,7 @@ Saved settings are written to `.ani-cli-web/config.json`.
|
|||||||
|
|
||||||
Discord notification events:
|
Discord notification events:
|
||||||
|
|
||||||
|
- Download completed.
|
||||||
- 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.
|
||||||
@@ -207,7 +209,7 @@ Status behavior:
|
|||||||
|
|
||||||
Download sync:
|
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`.
|
- 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.
|
- If ani-cli resolves a different `show_id`, the app can reconcile a unique title match instead of creating a duplicate finished row.
|
||||||
|
|
||||||
|
|||||||
@@ -1480,7 +1480,7 @@ class WatchlistStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE watchlist
|
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 = ?
|
WHERE show_id = ?
|
||||||
""",
|
""",
|
||||||
("Queued refresh after successful download.", now, normalized_show_id),
|
("Queued refresh after successful download.", now, normalized_show_id),
|
||||||
@@ -1496,6 +1496,7 @@ class WatchlistStore:
|
|||||||
SET
|
SET
|
||||||
show_id = ?,
|
show_id = ?,
|
||||||
title = ?,
|
title = ?,
|
||||||
|
category = 'finished',
|
||||||
downloaded = 1,
|
downloaded = 1,
|
||||||
status = 'queued',
|
status = 'queued',
|
||||||
status_message = ?,
|
status_message = ?,
|
||||||
|
|||||||
+34
-1
@@ -19,7 +19,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.33.0"
|
VERSION = "0.34.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"
|
||||||
@@ -52,12 +52,14 @@ 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 = (
|
DISCORD_WEBHOOK_EVENT_CHOICES = (
|
||||||
|
"download_completed",
|
||||||
"watchlist_refresh_new_episodes",
|
"watchlist_refresh_new_episodes",
|
||||||
"watchlist_refresh_failed",
|
"watchlist_refresh_failed",
|
||||||
"watchlist_refresh_interrupted",
|
"watchlist_refresh_interrupted",
|
||||||
"runtime_error",
|
"runtime_error",
|
||||||
)
|
)
|
||||||
DISCORD_WEBHOOK_EVENT_LABELS = {
|
DISCORD_WEBHOOK_EVENT_LABELS = {
|
||||||
|
"download_completed": "Download completed",
|
||||||
"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",
|
||||||
@@ -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)
|
_post_discord_webhook(webhook_url, request_payload, attachments=attachments)
|
||||||
return {"sent": True, "event": event, "count": len(items)}
|
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":
|
if event == "watchlist_refresh_failed":
|
||||||
error_lines = []
|
error_lines = []
|
||||||
for item in (payload.get("items") if isinstance(payload.get("items"), list) else [])[:8]:
|
for item in (payload.get("items") if isinstance(payload.get("items"), list) else [])[:8]:
|
||||||
|
|||||||
+25
-1
@@ -1037,6 +1037,7 @@ class DownloadQueue:
|
|||||||
finalize_error = None
|
finalize_error = None
|
||||||
moved_files = []
|
moved_files = []
|
||||||
watchlist_sync_error = None
|
watchlist_sync_error = None
|
||||||
|
synced_watchlist_item = None
|
||||||
cleanup_staging = False
|
cleanup_staging = False
|
||||||
if exit_code == 0 and not job.get("cancel_requested"):
|
if exit_code == 0 and not job.get("cancel_requested"):
|
||||||
try:
|
try:
|
||||||
@@ -1046,7 +1047,7 @@ class DownloadQueue:
|
|||||||
else:
|
else:
|
||||||
if self.watchlist_sync_fn is not None:
|
if self.watchlist_sync_fn is not None:
|
||||||
try:
|
try:
|
||||||
self.watchlist_sync_fn(job)
|
synced_watchlist_item = self.watchlist_sync_fn(job)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
watchlist_sync_error = exc
|
watchlist_sync_error = exc
|
||||||
|
|
||||||
@@ -1083,5 +1084,28 @@ class DownloadQueue:
|
|||||||
self._save_job_locked(job)
|
self._save_job_locked(job)
|
||||||
if cleanup_staging:
|
if cleanup_staging:
|
||||||
self._cleanup_staging_dir(job)
|
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:
|
except Exception as exc:
|
||||||
self._fail_job_runtime(job, exc, process=process)
|
self._fail_job_runtime(job, exc, process=process)
|
||||||
|
|||||||
+44
-4
@@ -350,6 +350,45 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
|||||||
self.assertIsNone(queue.current_job)
|
self.assertIsNone(queue.current_job)
|
||||||
self.assertFalse(staging_dir.exists())
|
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):
|
class WatchlistRefreshAllTests(unittest.TestCase):
|
||||||
def test_refresh_all_iterates_every_show_id(self):
|
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"], "queued")
|
||||||
self.assertEqual(item["status_message"], "Queued from test.")
|
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)
|
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(
|
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")
|
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.assertTrue(item["downloaded"])
|
||||||
self.assertEqual(item["finished_badge"], "")
|
self.assertEqual(item["finished_badge"], "downloaded")
|
||||||
self.assertEqual(item["status"], "queued")
|
self.assertEqual(item["status"], "queued")
|
||||||
|
|
||||||
def test_mark_downloaded_creates_missing_show_in_finished_category(self):
|
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")
|
item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show")
|
||||||
|
|
||||||
self.assertEqual(item["show_id"], "resolved-show-9")
|
self.assertEqual(item["show_id"], "resolved-show-9")
|
||||||
self.assertEqual(item["category"], "watching")
|
self.assertEqual(item["category"], "finished")
|
||||||
self.assertTrue(item["downloaded"])
|
self.assertTrue(item["downloaded"])
|
||||||
|
self.assertEqual(item["finished_badge"], "downloaded")
|
||||||
self.assertEqual(item["status"], "queued")
|
self.assertEqual(item["status"], "queued")
|
||||||
self.assertFalse(APP.WATCHLIST.has_show("legacy-show-9"))
|
self.assertFalse(APP.WATCHLIST.has_show("legacy-show-9"))
|
||||||
self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"])
|
self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"])
|
||||||
|
|||||||
Reference in New Issue
Block a user