diff --git a/CHANGELOG.md b/CHANGELOG.md index 0417f87f..f790fbc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- **Notify on download start** — New `notify_on_download_start` option (disabled by default) emits a `download_start` event when a file enters the `DOWNLOADING` state. Fires through the existing webhook, Discord, and Telegram channels, with a yellow Discord embed color and "Download Started" label. (#486) + ## [0.18.1] - 2026-05-16 ### Fixed diff --git a/src/angular/src/app/models/config.ts b/src/angular/src/app/models/config.ts index 1e7d92ff..933c7cec 100644 --- a/src/angular/src/app/models/config.ts +++ b/src/angular/src/app/models/config.ts @@ -62,6 +62,7 @@ export interface Logging { export interface Notifications { webhook_url: string | null; + notify_on_download_start: boolean | null; notify_on_download_complete: boolean | null; notify_on_extraction_complete: boolean | null; notify_on_extraction_failed: boolean | null; @@ -151,6 +152,7 @@ export const DEFAULT_LOGGING: Logging = { export const DEFAULT_NOTIFICATIONS: Notifications = { webhook_url: null, + notify_on_download_start: null, notify_on_download_complete: null, notify_on_extraction_complete: null, notify_on_extraction_failed: null, diff --git a/src/angular/src/app/pages/settings/options-list.ts b/src/angular/src/app/pages/settings/options-list.ts index c53cf1fc..53caf498 100644 --- a/src/angular/src/app/pages/settings/options-list.ts +++ b/src/angular/src/app/pages/settings/options-list.ts @@ -364,6 +364,12 @@ export const OPTIONS_CONTEXT_NOTIFICATIONS: IOptionsContext = { valuePath: ['notifications', 'webhook_url'], description: 'HTTP(S) URL to POST notifications to. Leave empty to disable.', }, + { + type: OptionType.Checkbox, + label: 'Notify on download start', + valuePath: ['notifications', 'notify_on_download_start'], + description: null, + }, { type: OptionType.Checkbox, label: 'Notify on download complete', diff --git a/src/angular/src/app/services/settings/config.service.spec.ts b/src/angular/src/app/services/settings/config.service.spec.ts index 24255b50..98cd7dd9 100644 --- a/src/angular/src/app/services/settings/config.service.spec.ts +++ b/src/angular/src/app/services/settings/config.service.spec.ts @@ -56,6 +56,7 @@ function makeConfig(overrides: Partial = {}): Config { logging: { log_format: null }, notifications: { webhook_url: null, + notify_on_download_start: false, notify_on_download_complete: false, notify_on_extraction_complete: false, notify_on_extraction_failed: false, diff --git a/src/python/common/config.py b/src/python/common/config.py index 75b07663..d261ea7e 100644 --- a/src/python/common/config.py +++ b/src/python/common/config.py @@ -366,6 +366,7 @@ def __init__(self): class Notifications(IC): webhook_url = PROP("webhook_url", Checkers.string_allow_empty, Converters.null) + notify_on_download_start = PROP("notify_on_download_start", Checkers.null, Converters.bool) notify_on_download_complete = PROP("notify_on_download_complete", Checkers.null, Converters.bool) notify_on_extraction_complete = PROP("notify_on_extraction_complete", Checkers.null, Converters.bool) notify_on_extraction_failed = PROP("notify_on_extraction_failed", Checkers.null, Converters.bool) @@ -377,6 +378,7 @@ class Notifications(IC): def __init__(self): super().__init__() self.webhook_url = "" + self.notify_on_download_start = False self.notify_on_download_complete = True self.notify_on_extraction_complete = True self.notify_on_extraction_failed = True diff --git a/src/python/controller/notification_formatters.py b/src/python/controller/notification_formatters.py index 8f19577f..4039e6ed 100644 --- a/src/python/controller/notification_formatters.py +++ b/src/python/controller/notification_formatters.py @@ -8,6 +8,7 @@ from datetime import UTC, datetime EVENT_LABELS: dict[str, str] = { + "download_start": "Download Started", "download_complete": "Download Complete", "extraction_complete": "Extraction Complete", "extraction_failed": "Extraction Failed", @@ -16,6 +17,7 @@ } DISCORD_COLORS: dict[str, int] = { + "download_start": 0xFEE75C, # yellow "download_complete": 0x57F287, # green "extraction_complete": 0x5865F2, # blurple "extraction_failed": 0xED4245, # red diff --git a/src/python/controller/notifier.py b/src/python/controller/notifier.py index 33534695..5bbf487a 100644 --- a/src/python/controller/notifier.py +++ b/src/python/controller/notifier.py @@ -48,6 +48,8 @@ def file_updated(self, old_file: ModelFile, new_file: ModelFile): def _resolve_event_type(self, state: ModelFile.State) -> str | None: """Map a file state to an event type string, gated by config flags.""" notif = self._config.notifications + if state == ModelFile.State.DOWNLOADING and notif.notify_on_download_start: + return "download_start" if state == ModelFile.State.DOWNLOADED and notif.notify_on_download_complete: return "download_complete" if state == ModelFile.State.EXTRACTED and notif.notify_on_extraction_complete: diff --git a/src/python/tests/unittests/test_common/test_config.py b/src/python/tests/unittests/test_common/test_config.py index 44ae2890..c9e9ee14 100644 --- a/src/python/tests/unittests/test_common/test_config.py +++ b/src/python/tests/unittests/test_common/test_config.py @@ -665,6 +665,7 @@ def test_to_file(self): [Notifications] webhook_url = + notify_on_download_start = False notify_on_download_complete = True notify_on_extraction_complete = True notify_on_extraction_failed = True diff --git a/src/python/tests/unittests/test_controller/test_notification_formatters.py b/src/python/tests/unittests/test_controller/test_notification_formatters.py index 6da90554..45973ee9 100644 --- a/src/python/tests/unittests/test_controller/test_notification_formatters.py +++ b/src/python/tests/unittests/test_controller/test_notification_formatters.py @@ -56,6 +56,10 @@ def test_unknown_event_type_uses_raw_string(self): embed = json.loads(body)["embeds"][0] self.assertIn("custom_event", embed["title"]) + def test_download_start_has_label_and_color(self): + self.assertEqual("Download Started", EVENT_LABELS["download_start"]) + self.assertEqual(0xFEE75C, DISCORD_COLORS["download_start"]) + class TestFormatTelegram(unittest.TestCase): def test_url_contains_bot_token(self): diff --git a/src/python/tests/unittests/test_controller/test_notifier.py b/src/python/tests/unittests/test_controller/test_notifier.py index e6f40bdd..9bd66b27 100644 --- a/src/python/tests/unittests/test_controller/test_notifier.py +++ b/src/python/tests/unittests/test_controller/test_notifier.py @@ -20,6 +20,7 @@ def _make_config( ): config = MagicMock() config.notifications.webhook_url = webhook_url + config.notifications.notify_on_download_start = False config.notifications.notify_on_download_complete = True config.notifications.notify_on_extraction_complete = True config.notifications.notify_on_extraction_failed = True @@ -166,6 +167,7 @@ def _make_config( ): config = MagicMock() config.notifications.webhook_url = webhook_url + config.notifications.notify_on_download_start = False config.notifications.notify_on_download_complete = True config.notifications.notify_on_extraction_complete = True config.notifications.notify_on_extraction_failed = True @@ -258,5 +260,59 @@ def test_nothing_fires_when_no_channels_configured(self): mock.assert_not_called() +class TestWebhookNotifierDownloadStart(unittest.TestCase): + """Tests for the download_start event (state → DOWNLOADING).""" + + def _make_config(self, **flags): + config = MagicMock() + config.notifications.webhook_url = "http://hook.test" + config.notifications.notify_on_download_start = flags.get("notify_on_download_start", True) + config.notifications.notify_on_download_complete = True + config.notifications.notify_on_extraction_complete = True + config.notifications.notify_on_extraction_failed = True + config.notifications.notify_on_delete_complete = True + config.notifications.discord_webhook_url = "" + config.notifications.telegram_bot_token = "" + config.notifications.telegram_chat_id = "" + return config + + def _make_notifier(self, **flags): + return WebhookNotifier(self._make_config(**flags), logging.getLogger("test_download_start")) + + def _transition(self, from_state, to_state): + old = ModelFile("test.mkv", False) + old.state = from_state + new = ModelFile("test.mkv", False) + new.state = to_state + return old, new + + def test_default_to_downloading_fires_when_enabled(self): + notifier = self._make_notifier(notify_on_download_start=True) + old, new = self._transition(ModelFile.State.DEFAULT, ModelFile.State.DOWNLOADING) + with patch.object(notifier, "_fire_raw") as mock: + notifier.file_updated(old, new) + notifier.shutdown(timeout=1) + mock.assert_called_once() + # Payload body carries event_type + body = mock.call_args[0][3] + self.assertIn(b'"event_type": "download_start"', body) + + def test_queued_to_downloading_fires_when_enabled(self): + notifier = self._make_notifier(notify_on_download_start=True) + old, new = self._transition(ModelFile.State.QUEUED, ModelFile.State.DOWNLOADING) + with patch.object(notifier, "_fire_raw") as mock: + notifier.file_updated(old, new) + notifier.shutdown(timeout=1) + mock.assert_called_once() + + def test_does_not_fire_when_disabled(self): + notifier = self._make_notifier(notify_on_download_start=False) + old, new = self._transition(ModelFile.State.DEFAULT, ModelFile.State.DOWNLOADING) + with patch.object(notifier, "_fire_raw") as mock: + notifier.file_updated(old, new) + notifier.shutdown(timeout=1) + mock.assert_not_called() + + if __name__ == "__main__": unittest.main()