Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Comment on lines +3 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace Unreleased with a dated semver release block that includes all required sections.

Line 3 uses an Unreleased heading, but this repository requires a top entry like ## [x.y.z] - YYYY-MM-DD, and the entry must include Changed, Added, Fixed, Removed, and Security sections (even if some are empty placeholders).

As per coding guidelines, "Update CHANGELOG.md with a new version entry at the top following semver (major.minor.patch) with date in YYYY-MM-DD format and sections: Changed, Added, Fixed, Removed, Security".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 3 - 8, Replace the top "## [Unreleased]" heading
with a new release header in the format "## [x.y.z] - YYYY-MM-DD" and move the
existing "### Added" content under that new release; ensure the new release
block contains the required section headings "Changed", "Added", "Fixed",
"Removed", and "Security" (add empty placeholders under any sections without
items). Specifically update the header that currently reads "## [Unreleased]"
and keep the existing bullet "- **Notify on download start** — New
`notify_on_download_start` option..." under the "Added" section while adding the
five required subheadings beneath the new release heading.

## [0.18.1] - 2026-05-16

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions src/angular/src/app/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/angular/src/app/pages/settings/options-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function makeConfig(overrides: Partial<Config> = {}): 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,
Expand Down
2 changes: 2 additions & 0 deletions src/python/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/python/controller/notification_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/python/controller/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/python/tests/unittests/test_common/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
56 changes: 56 additions & 0 deletions src/python/tests/unittests/test_controller/test_notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()