From a0aeb07f0376e1ae2c8b7082d74deb22077a553c Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:35:11 +0800 Subject: [PATCH] Fix notification event toggles and license heartbeat --- README.md | 20 ++++----- README_EN.md | 10 ++--- internal/handler/license.go | 34 ++++++++++++---- internal/service/notify_channels.go | 22 +++++++--- internal/service/notify_channels_test.go | 39 ++++++++++++++++++ scripts/docker-compose-update.sh | 6 +-- web/index.html | 4 +- web/public/brand/mgo-emby-icon.svg | 2 +- web/public/favicon.svg | 13 +++++- web/src/components/Layout.tsx | 2 +- web/src/pages/LoginPage.tsx | 2 +- web/src/pages/NotifyChannelsPage.tsx | 52 +++++++++++++++++++++--- 12 files changed, 164 insertions(+), 42 deletions(-) create mode 100644 internal/service/notify_channels_test.go diff --git a/README.md b/README.md index e1a4f82..a16edcd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MediaStationGo

- MediaStationGo Logo + MediaStationGo Logo

适合 NAS、家庭共享和多端播放的私人媒体中心

@@ -96,11 +96,11 @@ MediaStationGo 推荐按机器资源和用户规模选择部署档位。三挡 第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。 ```bash -# 拉取最新镜像 -docker compose pull +# 只拉取 MediaStationGo 主服务镜像,避免升级时动到 PostgreSQL / Redis +docker compose pull mediastation-go # 启动第一档:MediaStationGo + PostgreSQL -docker compose up -d +docker compose up -d --no-deps mediastation-go ``` 关键数据目录: @@ -238,20 +238,20 @@ environment: 更新镜像: ```bash -docker compose pull -docker compose up -d +docker compose pull mediastation-go +docker compose up -d --no-deps mediastation-go ``` 第二档和第三档更新时继续带上叠加文件: ```bash # 第二档 -docker compose -f docker-compose.yml -f docker-compose.standard.yml pull -docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d +docker compose -f docker-compose.yml -f docker-compose.standard.yml pull mediastation-go +docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d --no-deps mediastation-go # 第三档 -docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml pull -docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d +docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml pull mediastation-go +docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d --no-deps mediastation-go ``` 必须备份: diff --git a/README_EN.md b/README_EN.md index cbd849f..45e95c2 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,7 +1,7 @@ # MediaStationGo

- MediaStationGo Logo + MediaStationGo Logo

A lightweight, polished, NAS-friendly private media center

@@ -151,8 +151,8 @@ environment: Recommended SQLite to PostgreSQL upgrade flow: ```bash -docker compose pull -docker compose up -d +docker compose pull mediastation-go +docker compose up -d --no-deps mediastation-go docker compose logs -f mediastation-go ``` @@ -384,8 +384,8 @@ services: ### Update ```bash -docker compose pull -docker compose up -d +docker compose pull mediastation-go +docker compose up -d --no-deps mediastation-go ``` ### Logs diff --git a/internal/handler/license.go b/internal/handler/license.go index 76b8005..ad9ef31 100644 --- a/internal/handler/license.go +++ b/internal/handler/license.go @@ -113,13 +113,28 @@ func licenseStatusHandler(svc *service.Container) gin.HandlerFunc { if err == nil { deviceID, idErr := ensureLicenseDeviceID(c.Request.Context(), svc, "") if idErr == nil { - var upstream licenseServerStatusResp - if getErr := client.get(c.Request.Context(), "/api/v1/status/"+url.PathEscape(deviceID), &upstream); getErr == nil && upstream.Valid { - applyLicenseStatus(&state, upstream, deviceID) - _ = persistLicenseState(c.Request.Context(), svc, state) - } else if getErr == nil && !upstream.Valid { - state.Valid = false + deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting) + if strings.TrimSpace(deviceName) == "" { + deviceName = defaultLicenseDeviceName() + _ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName) + } + var signed licenseServerSignedResp + if heartbeatErr := client.post(c.Request.Context(), "/api/v1/heartbeat", map[string]any{ + "fingerprint": deviceID, + "instance_id": deviceID, + "device_name": deviceName, + }, &signed); heartbeatErr == nil && client.verifySigned(&signed) == nil { + state = licenseStateFromSigned(signed, deviceID, deviceName) _ = persistLicenseState(c.Request.Context(), svc, state) + } else { + var upstream licenseServerStatusResp + if getErr := client.get(c.Request.Context(), "/api/v1/status/"+url.PathEscape(deviceID), &upstream); getErr == nil && upstream.Valid { + applyLicenseStatus(&state, upstream, deviceID) + _ = persistLicenseState(c.Request.Context(), svc, state) + } else if getErr == nil && !upstream.Valid { + state.Valid = false + _ = persistLicenseState(c.Request.Context(), svc, state) + } } } } @@ -146,10 +161,16 @@ func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting) + if strings.TrimSpace(deviceName) == "" { + deviceName = defaultLicenseDeviceName() + _ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName) + } var upstream licenseServerSignedResp if err := client.post(c.Request.Context(), "/api/v1/heartbeat", map[string]any{ "fingerprint": deviceID, "instance_id": deviceID, + "device_name": deviceName, }, &upstream); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return @@ -158,7 +179,6 @@ func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } - deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting) state := licenseStateFromSigned(upstream, deviceID, deviceName) if err := persistLicenseState(c.Request.Context(), svc, state); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index 50e50ec..8ba3e78 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -182,9 +182,15 @@ func (s *NotifyChannelService) Test(ctx context.Context, id string) error { return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。") } +const ( + NotifyEventAll = "__all__" + NotifyEventNone = "__none__" +) + // Broadcast sends a message to every enabled channel that subscribes to -// `event` (an empty Events slice means "all events"). Failures are -// logged and never abort the loop. +// `event`. Legacy empty Events values mean "all events"; the explicit +// NotifyEventNone sentinel means the channel stays enabled but receives no +// event push. func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) { rows, err := s.repo.NotifyChannel.ListEnabled(ctx) if err != nil { @@ -201,10 +207,10 @@ func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event } } -// channelSubscribes returns true when the channel's Events list is -// empty (= all events) or contains `event`. +// channelSubscribes returns true when the channel's Events list contains the +// event, or when the list is the legacy empty/"all events" value. func channelSubscribes(n model.NotifyChannel, event string) bool { - if event == "" || n.Events == "" || n.Events == "[]" { + if event == "" || n.Events == "" { return true } var ev []string @@ -215,6 +221,12 @@ func channelSubscribes(n model.NotifyChannel, event string) bool { return true } for _, e := range ev { + switch e { + case NotifyEventNone: + return false + case NotifyEventAll: + return true + } if e == event { return true } diff --git a/internal/service/notify_channels_test.go b/internal/service/notify_channels_test.go new file mode 100644 index 0000000..2580f9f --- /dev/null +++ b/internal/service/notify_channels_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestChannelSubscribesCanDisableAllEvents(t *testing.T) { + channel := model.NotifyChannel{Events: `["` + NotifyEventNone + `"]`} + + if channelSubscribes(channel, EventDownloadComplete) { + t.Fatal("explicit none sentinel should disable event pushes") + } +} + +func TestChannelSubscribesKeepsLegacyEmptyAsAllEvents(t *testing.T) { + for _, raw := range []string{"", "[]"} { + channel := model.NotifyChannel{Events: raw} + if !channelSubscribes(channel, EventDownloadComplete) { + t.Fatalf("legacy events %q should still subscribe to all events", raw) + } + } +} + +func TestChannelSubscribesSupportsExplicitAllAndSpecificEvents(t *testing.T) { + all := model.NotifyChannel{Events: `["` + NotifyEventAll + `"]`} + if !channelSubscribes(all, EventScrapeFailed) { + t.Fatal("explicit all sentinel should subscribe to every event") + } + + specific := model.NotifyChannel{Events: `["` + EventDownloadComplete + `"]`} + if !channelSubscribes(specific, EventDownloadComplete) { + t.Fatal("specific event should be subscribed") + } + if channelSubscribes(specific, EventScrapeFailed) { + t.Fatal("unlisted event should not be subscribed") + } +} diff --git a/scripts/docker-compose-update.sh b/scripts/docker-compose-update.sh index a13ecef..b5c2b01 100755 --- a/scripts/docker-compose-update.sh +++ b/scripts/docker-compose-update.sh @@ -16,11 +16,11 @@ else exit 1 fi -echo "==> Pulling latest configured image" +echo "==> Pulling latest $SERVICE image only (PostgreSQL/Redis/OpenSearch are left untouched)" "${COMPOSE[@]}" pull "$SERVICE" -echo "==> Recreating service" -"${COMPOSE[@]}" up -d "$SERVICE" +echo "==> Recreating $SERVICE without dependencies" +"${COMPOSE[@]}" up -d --no-deps "$SERVICE" running_image_id="$(docker inspect -f '{{.Image}}' "$CONTAINER" 2>/dev/null || true)" if [[ -z "$running_image_id" ]]; then diff --git a/web/index.html b/web/index.html index 50e58b9..db5bc97 100644 --- a/web/index.html +++ b/web/index.html @@ -5,8 +5,8 @@ - - + + + diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 1a082dc..83453c6 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,3 +1,12 @@ - - + + + + + + + + + + + diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index ed3bb5d..73d2098 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -192,7 +192,7 @@ export function Layout() {
MediaStationGo diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index a800e67..62b71a9 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -54,7 +54,7 @@ export function LoginPage() { initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.15, type: 'spring', stiffness: 200 }} - src="/brand/mgo-emby-icon.png" + src="/brand/mgo-emby-icon.svg" alt="MediaStationGo" className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm" /> diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx index 47c065c..282279e 100644 --- a/web/src/pages/NotifyChannelsPage.tsx +++ b/web/src/pages/NotifyChannelsPage.tsx @@ -197,6 +197,16 @@ const EVENT_OPTIONS = [ { value: 'system_alert', label: '系统异常通知' }, ] +const EVENT_ALL = '__all__' +const EVENT_NONE = '__none__' +type EventMode = 'all' | 'custom' | 'none' + +function initialEventMode(events: string[] | undefined): EventMode { + if (events?.includes(EVENT_NONE)) return 'none' + if (events?.includes(EVENT_ALL) || !events || events.length === 0) return 'all' + return 'custom' +} + function ChannelCard({ channel, onTest, @@ -252,6 +262,8 @@ function ChannelCard({ } function eventSummary(events: string[] | undefined): string { + if (events?.includes(EVENT_NONE)) return '事件:不推送' + if (events?.includes(EVENT_ALL)) return '事件:全部' if (!events || events.length === 0) return '事件:全部' const labels = events.map((event) => EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event) return `事件:${labels.join('、')}` @@ -324,6 +336,7 @@ function ChannelFormModal({ normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}), ) const [events, setEvents] = useState(editing?.events ?? []) + const [eventMode, setEventMode] = useState(initialEventMode(editing?.events)) const [enabled, setEnabled] = useState(editing?.enabled ?? true) const [saving, setSaving] = useState(false) @@ -344,6 +357,11 @@ function ChannelFormModal({ return } } + const selectedEvents = events.filter((event) => EVENT_OPTIONS.some((item) => item.value === event)) + if (eventMode === 'custom' && selectedEvents.length === 0) { + toast.error('请至少选择一个推送事件,或选择关闭全部推送事件') + return + } setSaving(true) try { const cleanedConfig = Object.fromEntries( @@ -354,7 +372,7 @@ function ChannelFormModal({ name: name.trim(), type: type, config: cleanedConfig, - events, + events: eventMode === 'all' ? [EVENT_ALL] : eventMode === 'none' ? [EVENT_NONE] : selectedEvents, enabled, } if (editing) { @@ -378,7 +396,9 @@ function ChannelFormModal({ const toggleEvent = (event: string) => { setEvents((current) => - current.includes(event) ? current.filter((item) => item !== event) : [...current, event], + current.includes(event) + ? current.filter((item) => item !== event) + : [...current.filter((item) => item !== EVENT_ALL && item !== EVENT_NONE), event], ) } @@ -622,19 +642,41 @@ function ChannelFormModal({
+ +
{EVENT_OPTIONS.map((event) => (