diff --git a/backend/src/module/api/notification.py b/backend/src/module/api/notification.py index a5963228..3b58e562 100644 --- a/backend/src/module/api/notification.py +++ b/backend/src/module/api/notification.py @@ -48,6 +48,10 @@ class TestProviderConfigRequest(BaseModel): api_token: Optional[str] = Field(default=None, description="API token") template: Optional[str] = Field(default=None, description="Custom template") url: Optional[str] = Field(default=None, description="URL for generic webhook") + sound: Optional[str] = Field(default=None, description="Bark sound") + group: Optional[str] = Field(default=None, description="Bark group") + level: Optional[str] = Field(default=None, description="Bark level") + icon: Optional[str] = Field(default=None, description="Bark icon URL") class TestResponse(BaseModel): @@ -108,6 +112,10 @@ async def test_provider_config(request: TestProviderConfigRequest): api_token=request.api_token or "", template=request.template, url=request.url or "", + sound=request.sound, + group=request.group, + level=request.level, + icon=request.icon, ) success, message = await NotificationManager.test_provider_config(config) diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index ec9ee49f..2465b26a 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -181,7 +181,19 @@ class NotificationProvider(BaseModel): ), ) url_: Optional[str] = Field( - default=None, alias="url", description="URL for generic webhook provider" + default=None, + alias="url", + description="URL for generic webhook provider; tap-to-open URL for bark", + ) + # Bark 的可选推送参数,留空则不发送 + sound: Optional[str] = Field(default=None, description="Bark sound name") + group: Optional[str] = Field(default=None, description="Bark message group") + level: Optional[str] = Field( + default=None, + description="Bark interruption level: active/timeSensitive/passive/critical", + ) + icon: Optional[str] = Field( + default=None, description="Bark icon URL; defaults to the poster URL" ) @property diff --git a/backend/src/module/notification/providers/bark.py b/backend/src/module/notification/providers/bark.py index d2fe5a48..70e0512e 100644 --- a/backend/src/module/notification/providers/bark.py +++ b/backend/src/module/notification/providers/bark.py @@ -22,30 +22,42 @@ class BarkProvider(NotificationProvider): self.device_key = config.device_key server_url = config.server_url or self.DEFAULT_SERVER self.notification_url = f"{server_url.rstrip('/')}/push" + # 可选推送参数:只发送已设置的项,旧配置的请求体不变 + self.options = { + "sound": config.sound, + "group": config.group, + "level": config.level, + "url": config.url, + } + self.icon = config.icon + + def _payload(self, title: str, body: str, icon: str | None = None) -> dict: + data: dict[str, str | None] = { + "title": title, + "body": body, + "device_key": self.device_key, + } + data.update({k: v for k, v in self.options.items() if v}) + # 配置的图标优先,其次是调用方给的(单集通知的海报) + if self.icon or icon: + data["icon"] = self.icon or icon + return data async def send(self, notification: Notification) -> bool: """Send notification via Bark.""" text = self._format_message(notification) - data = { - "title": notification.official_title, - "body": text, - "device_key": self.device_key, - } - poster_url = self._poster_url(notification) - if poster_url: - data["icon"] = poster_url - + data = self._payload( + notification.official_title, text, self._poster_url(notification) + ) resp = await self._post_json(self.notification_url, data) logger.debug("Bark notification: %s", resp.status_code) return resp.status_code == 200 async def test(self) -> tuple[bool, str]: """Test Bark configuration by sending a test notification.""" - data = { - "title": "AutoBangumi", - "body": "通知测试成功!\nNotification test successful!", - "device_key": self.device_key, - } + data = self._payload( + "AutoBangumi", "通知测试成功!\nNotification test successful!" + ) try: resp = await self._post_json(self.notification_url, data) if resp.status_code == 200: @@ -57,6 +69,5 @@ class BarkProvider(NotificationProvider): async def _deliver_text(self, title: str, body: str) -> bool: """Deliver a system event via Bark.""" - data = {"title": title, "body": body, "device_key": self.device_key} - resp = await self._post_json(self.notification_url, data) + resp = await self._post_json(self.notification_url, self._payload(title, body)) return resp.status_code == 200 diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index 3065e5e0..09275b1f 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -354,6 +354,67 @@ class TestBarkProvider: call_args = mock_post.call_args[0] assert "device_key" in call_args[1] + @pytest.mark.parametrize( + ("options", "expected"), + [ + ({}, {}), + ({"sound": "", "level": ""}, {}), + ( + { + "sound": "minuet", + "group": "AB", + "level": "timeSensitive", + "url": "https://ab.local", + }, + { + "sound": "minuet", + "group": "AB", + "level": "timeSensitive", + "url": "https://ab.local", + }, + ), + ], + ) + async def test_deliver_text_sends_only_set_options(self, options, expected): + """#792:sound/group/level/url 只在设置时发送,旧配置的请求体不变。""" + config = ProviderConfig(type="bark", device_key="k", **options) + provider = BarkProvider(config) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_post.return_value = MagicMock(status_code=200) + assert await provider._deliver_text("T", "B") is True + + assert mock_post.call_args[0][1] == { + "title": "T", + "body": "B", + "device_key": "k", + **expected, + } + + @pytest.mark.parametrize( + ("icon", "poster", "expected"), + [ + (None, "https://ab/p.jpg", "https://ab/p.jpg"), + ("https://ab/i.png", "https://ab/p.jpg", "https://ab/i.png"), + (None, None, None), + ], + ) + async def test_send_icon_prefers_configured_icon_over_poster( + self, icon, poster, expected + ): + """配置的 icon 优先;未配置时沿用海报地址;都没有则不发 icon。""" + provider = BarkProvider(ProviderConfig(type="bark", device_key="k", icon=icon)) + notify = Notification(official_title="Test Anime", season=1, episode=5) + + with ( + patch.object(provider, "_poster_url", return_value=poster), + patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post, + ): + mock_post.return_value = MagicMock(status_code=200) + await provider.send(notify) + + assert mock_post.call_args[0][1].get("icon") == expected + class TestWebhookProvider: @pytest.fixture diff --git a/docs/config/notifier.md b/docs/config/notifier.md index 7d0078a6..08d0f822 100644 --- a/docs/config/notifier.md +++ b/docs/config/notifier.md @@ -24,7 +24,7 @@ - Telegram:`Bot Token`、`Chat ID` - Discord:Webhook URL - WeCom:Webhook URL、Key -- Bark:Device Key,可选 Server URL +- Bark:Device Key,可选 Server URL、铃声、分组、中断级别、图标地址、点击打开的地址 - Server Chan:SendKey - Gotify:Server URL、App Token - Pushover:User Key、API Token @@ -53,9 +53,10 @@ Webhook 模板可以使用 `{{title}}`、`{{season}}`、`{{episode}}`、`{{poste | `token` | Telegram(Bot Token)、Server Chan(SendKey)、WeCom(Key)、Gotify(App Token)使用 | | `chat_id` | Telegram 使用 | | `webhook_url` | Discord、WeCom 使用 | -| `url` | Webhook 使用 | +| `url` | Webhook 使用;Bark 中为点击通知后打开的地址(可选) | | `server_url` | Bark(可选)、Gotify 使用 | | `device_key` | Bark 使用 | +| `sound` / `group` / `level` / `icon` | Bark 可选:铃声、分组、中断级别(`active`、`timeSensitive`、`passive`、`critical`)、图标地址。留空则不发送;`icon` 留空时单集通知用海报地址 | | `user_key` / `api_token` | Pushover 使用 | | `template` | 自定义通知模板 | diff --git a/docs/en/config/notifier.md b/docs/en/config/notifier.md index 37a23587..0a167cf5 100644 --- a/docs/en/config/notifier.md +++ b/docs/en/config/notifier.md @@ -24,7 +24,7 @@ Provider-specific fields include: - Telegram: `Bot Token`, `Chat ID` - Discord: Webhook URL - WeCom: Webhook URL and Key -- Bark: Device Key and optional Server URL +- Bark: Device Key. Optional: Server URL, sound, group, interruption level, icon URL, and the URL to open on tap - Server Chan: SendKey - Gotify: Server URL and App Token - Pushover: User Key and API Token @@ -53,9 +53,10 @@ Each object in `providers` usually contains: | `token` | Used by Telegram (Bot Token), Server Chan (SendKey), WeCom (Key) and Gotify (App Token) | | `chat_id` | Used by Telegram | | `webhook_url` | Used by Discord and WeCom | -| `url` | Used by Webhook | +| `url` | Used by Webhook. For Bark, the URL to open on tap (optional) | | `server_url` | Used by Bark (optional) and Gotify | | `device_key` | Used by Bark | +| `sound` / `group` / `level` / `icon` | Optional for Bark: sound, group, interruption level (`active`, `timeSensitive`, `passive`, `critical`), and icon URL. AB does not send an empty field. When `icon` is empty, episode notifications use the poster URL | | `user_key` / `api_token` | Used by Pushover | | `template` | Custom message template | diff --git a/docs/ja/config/notifier.md b/docs/ja/config/notifier.md index 7ca46ae6..f0f8ed42 100644 --- a/docs/ja/config/notifier.md +++ b/docs/ja/config/notifier.md @@ -42,9 +42,10 @@ Webhookテンプレートでは `{{title}}`、`{{season}}`、`{{episode}}`、`{{ | `token` | Telegram(Bot Token)、Server Chan(SendKey)、WeCom(Key)、Gotify(App Token)で使用 | | `chat_id` | Telegram で使用 | | `webhook_url` | Discord、WeCom で使用 | -| `url` | Webhook で使用 | +| `url` | Webhook で使用。Bark ではタップで開く URL(任意) | | `server_url` | Bark(任意)、Gotify で使用 | | `device_key` | Bark で使用 | +| `sound` / `group` / `level` / `icon` | Bark(任意):通知音、グループ、割り込みレベル(`active`、`timeSensitive`、`passive`、`critical`)、アイコン URL。空の項目は送信しません。`icon` が空のとき、エピソード通知はポスターの URL を使います | | `user_key` / `api_token` | Pushover で使用 | | `template` | カスタム通知テンプレート | diff --git a/webui/src/api/notification.ts b/webui/src/api/notification.ts index 8ddf1a70..4dd0d617 100644 --- a/webui/src/api/notification.ts +++ b/webui/src/api/notification.ts @@ -17,6 +17,10 @@ export interface TestProviderConfigRequest { api_token?: string; template?: string; url?: string; + sound?: string; + group?: string; + level?: string; + icon?: string; } export interface TestResponse { diff --git a/webui/src/components/setting/config-notification.vue b/webui/src/components/setting/config-notification.vue index b6464bc5..daa24852 100644 --- a/webui/src/components/setting/config-notification.vue +++ b/webui/src/components/setting/config-notification.vue @@ -42,8 +42,10 @@ const providerFields: Record< string, { key: keyof NotificationProviderConfig; - label: string; + label: string | (() => string); placeholder: string; + // 有选项时渲染为可清空的下拉框 + options?: string[]; }[] > = { telegram: [ @@ -64,6 +66,32 @@ const providerFields: Record< label: 'Server URL (optional)', placeholder: 'https://api.day.app', }, + { + key: 'sound', + label: () => t('config.notification_set.bark_sound'), + placeholder: 'minuet', + }, + { + key: 'group', + label: () => t('config.notification_set.bark_group'), + placeholder: 'AutoBangumi', + }, + { + key: 'level', + label: () => t('config.notification_set.bark_level'), + placeholder: 'active', + options: ['active', 'timeSensitive', 'passive', 'critical'], + }, + { + key: 'icon', + label: () => t('config.notification_set.bark_icon'), + placeholder: 'https://example.com/icon.png', + }, + { + key: 'url', + label: () => t('config.notification_set.bark_url'), + placeholder: 'https://example.com', + }, ], 'server-chan': [{ key: 'token', label: 'SendKey', placeholder: 'sendkey' }], wecom: [ @@ -385,8 +413,15 @@ function getFieldsForType(type: string) { :key="field.key" :label="field.label" > + @@ -457,8 +492,15 @@ function getFieldsForType(type: string) { :key="field.key" :label="field.label" > + diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 1771eae5..40255217 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -119,7 +119,12 @@ "test_success": "Test successful", "test_failed": "Test failed", "plugin_label": "{id} (plugin)", - "plugin_hint": "This channel comes from a plugin and has no fields here. Enter its credentials in the plugin's options in the Plugins section." + "plugin_hint": "This channel comes from a plugin and has no fields here. Enter its credentials in the plugin's options in the Plugins section.", + "bark_sound": "Sound (optional)", + "bark_group": "Group (optional)", + "bark_level": "Interruption level (optional)", + "bark_icon": "Icon URL (optional, defaults to the poster)", + "bark_url": "URL to open on tap (optional)" }, "parser_set": { "enable": "Enable", @@ -245,7 +250,8 @@ "permission_labels": { "fs_write": "Write files", "network": "Network access", - "filesystem": "File system access" + "filesystem": "File system access", + "subprocess": "Run external commands" }, "disable": "Disable", "disable_confirm_title": "Disable {name}?", diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index 9a69a721..294e9970 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -119,7 +119,12 @@ "test_success": "测试成功", "test_failed": "测试失败", "plugin_label": "{id}(插件)", - "plugin_hint": "此渠道由插件提供,这里没有可填字段。请在插件设置中该插件的配置里填写凭据。" + "plugin_hint": "此渠道由插件提供,这里没有可填字段。请在插件设置中该插件的配置里填写凭据。", + "bark_sound": "铃声(可选)", + "bark_group": "分组(可选)", + "bark_level": "中断级别(可选)", + "bark_icon": "图标地址(可选,默认用海报)", + "bark_url": "点击打开的地址(可选)" }, "parser_set": { "enable": "启用", @@ -245,7 +250,8 @@ "permission_labels": { "fs_write": "写入文件", "network": "访问网络", - "filesystem": "访问文件系统" + "filesystem": "访问文件系统", + "subprocess": "运行外部命令" }, "disable": "停用", "disable_confirm_title": "停用 {name}?", diff --git a/webui/types/config.ts b/webui/types/config.ts index f1ab7766..c4f874f5 100644 --- a/webui/types/config.ts +++ b/webui/types/config.ts @@ -95,6 +95,11 @@ export interface NotificationProviderConfig { api_token?: string; template?: string; url?: string; + // Bark 的可选推送参数 + sound?: string; + group?: string; + level?: string; + icon?: string; } export interface Notification {