Skip to content
Open
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
20 changes: 12 additions & 8 deletions pkg/apps/apppkg/plex/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,12 +285,16 @@ type Similar struct {

// WebsiteConfig is the website-derived configuration for Plex.
type WebsiteConfig struct {
Interval cnfg.Duration `json:"interval"`
TrackSess bool `json:"trackSessions"`
AccountMap string `json:"accountMap"`
NoActivity bool `json:"noActivity"`
Delay cnfg.Duration `json:"activityDelay"`
Cooldown cnfg.Duration `json:"cooldown"`
SeriesPC uint `json:"seriesPc"`
MoviesPC uint `json:"moviesPc"`
Interval cnfg.Duration `json:"interval"`
TrackSess bool `json:"trackSessions"`
AccountMap string `json:"accountMap"`
NoActivity bool `json:"noActivity"`
Delay cnfg.Duration `json:"activityDelay"`
Cooldown cnfg.Duration `json:"cooldown"`
SeriesPC uint `json:"seriesPc"`
MoviesPC uint `json:"moviesPc"`
WebhookHealthEnabled *bool `json:"webhookHealthEnabled,omitempty"`
WebhookStaleAfter cnfg.Duration `json:"webhookStaleAfter"`
WebhookAlertCooldown cnfg.Duration `json:"webhookAlertCooldown"`
WebhookStartupGrace cnfg.Duration `json:"webhookStartupGrace"`
}
6 changes: 6 additions & 0 deletions pkg/client/handlers_plex.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ func (c *Client) PlexHandler(w http.ResponseWriter, r *http.Request) { //nolint:
mnd.Apps.Add("Plex&&Webhook Errors", 1)
http.Error(w, "payload error", http.StatusBadRequest)
logs.Log.Errorf(mnd.GetID(r.Context()), "Unmarshalling Plex payload: %v", err)
return
default:
c.triggers.PlexCron.RecordWebhook()
}

switch {
case strings.EqualFold(hook.Event, "admin.database.backup"):
fallthrough
case strings.EqualFold(hook.Event, "device.new"):
Expand Down
17 changes: 14 additions & 3 deletions pkg/triggers/plexcron/plexcron.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ type cmd struct {
Plex *apps.Plex
sent map[string]struct{} // Tracks Finished sessions already sent.
sync.Mutex

startAt time.Time
healthLock sync.Mutex
lastWebhookAt time.Time
lastAlertAt time.Time
}

const (
Expand All @@ -51,9 +56,10 @@ const (
func New(config *common.Config, plex *apps.Plex) *Action {
return &Action{
cmd: &cmd{
Config: config,
Plex: plex,
sent: make(map[string]struct{}),
Config: config,
Plex: plex,
sent: make(map[string]struct{}),
startAt: time.Now(),
},
}
}
Expand Down Expand Up @@ -115,6 +121,11 @@ func (a *Action) SendWebhook(hook *plex.IncomingWebhook) {
go a.cmd.sendWebhook(hook)
}

// RecordWebhook marks the current time as the most recent Plex webhook receipt.
func (a *Action) RecordWebhook() {
a.cmd.recordWebhookAt(time.Now())
}

func (c *cmd) sendWebhook(hook *plex.IncomingWebhook) {
mnd.Log.Trace(hook.ReqID, "start: (go) cmd.sendWebhook")
defer mnd.Log.Trace(hook.ReqID, "end: (go) cmd.sendWebhook")
Expand Down
1 change: 1 addition & 0 deletions pkg/triggers/plexcron/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func (c *cmd) getSessions(ctx context.Context, allowedAge time.Duration) (*plex.
}

sessions.Name = c.Plex.Server.Name()
c.evaluateWebhookHealth(ctx, sessions)

return sessions, nil
}
Expand Down
157 changes: 157 additions & 0 deletions pkg/triggers/plexcron/webhook_health.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package plexcron

import (
"context"
"fmt"
"time"

"github.com/Notifiarr/notifiarr/pkg/apps/apppkg/plex"
"github.com/Notifiarr/notifiarr/pkg/mnd"
"github.com/Notifiarr/notifiarr/pkg/website"
"github.com/Notifiarr/notifiarr/pkg/website/clientinfo"
)

const (
defaultWebhookStaleAfter = 20 * time.Minute
defaultWebhookAlertCD = 6 * time.Hour
defaultWebhookStartupGrace = 10 * time.Minute
)

type webhookHealthConfig struct {
Enabled bool
StaleAfter time.Duration
AlertCooldown time.Duration
StartupGrace time.Duration
}

type webhookHealthState struct {
StartAt time.Time
LastWebhookAt time.Time
LastAlertAt time.Time
}

func defaultWebhookHealthConfig() webhookHealthConfig {
return webhookHealthConfig{
Enabled: true,
StaleAfter: defaultWebhookStaleAfter,
AlertCooldown: defaultWebhookAlertCD,
StartupGrace: defaultWebhookStartupGrace,
}
}

func (c *cmd) currentWebhookHealthConfig() webhookHealthConfig {
cfg := defaultWebhookHealthConfig()
ci := clientinfo.Get()
if ci == nil {
return cfg
}

siteCfg := ci.Actions.Plex
if siteCfg.WebhookHealthEnabled != nil {
cfg.Enabled = *siteCfg.WebhookHealthEnabled
}

if siteCfg.WebhookStaleAfter.Duration > 0 {
cfg.StaleAfter = siteCfg.WebhookStaleAfter.Duration
}

if siteCfg.WebhookAlertCooldown.Duration > 0 {
cfg.AlertCooldown = siteCfg.WebhookAlertCooldown.Duration
}

if siteCfg.WebhookStartupGrace.Duration > 0 {
cfg.StartupGrace = siteCfg.WebhookStartupGrace.Duration
}

return cfg
}

func (c *cmd) currentWebhookHealthState() webhookHealthState {
c.healthLock.Lock()
defer c.healthLock.Unlock()

return webhookHealthState{
StartAt: c.startAt,
LastWebhookAt: c.lastWebhookAt,
LastAlertAt: c.lastAlertAt,
}
}

func (c *cmd) recordWebhookAt(t time.Time) {
c.healthLock.Lock()
defer c.healthLock.Unlock()

c.lastWebhookAt = t
}

func (c *cmd) setWebhookAlertAt(t time.Time) {
c.healthLock.Lock()
defer c.healthLock.Unlock()

c.lastAlertAt = t
}

func shouldAlertForStaleWebhook(
now time.Time,
activeSessions int,
cfg webhookHealthConfig,
state webhookHealthState,
) bool {
switch {
case !cfg.Enabled:
return false
case activeSessions < 1:
return false
case cfg.StartupGrace > 0 && now.Sub(state.StartAt) < cfg.StartupGrace:
return false
case !state.LastAlertAt.IsZero() && cfg.AlertCooldown > 0 && now.Sub(state.LastAlertAt) < cfg.AlertCooldown:
return false
case state.LastWebhookAt.IsZero():
return true
default:
return now.Sub(state.LastWebhookAt) > cfg.StaleAfter
}
}

func (c *cmd) evaluateWebhookHealth(ctx context.Context, sessions *plex.Sessions) {
if sessions == nil {
return
}

now := time.Now()
state := c.currentWebhookHealthState()
cfg := c.currentWebhookHealthConfig()
if !shouldAlertForStaleWebhook(now, len(sessions.Sessions), cfg, state) {
return
}

c.setWebhookAlertAt(now)

hook := &plex.IncomingWebhook{ReqID: mnd.GetID(ctx), Event: "notifiarr.webhook.health"}
hook.Server.Title = c.Plex.Name()
hook.Metadata.Type = "healthcheck"
hook.Metadata.Title = "Plex webhook appears inactive"
hook.Metadata.Summary = fmt.Sprintf(
"Detected %d active Plex sessions but no accepted webhook for %s.",
len(sessions.Sessions), now.Sub(state.LastWebhookAt).Round(time.Second))
if state.LastWebhookAt.IsZero() {
hook.Metadata.Summary = fmt.Sprintf(
"Detected %d active Plex sessions but no accepted webhook since startup.",
len(sessions.Sessions))
}

website.SendData(&website.Request{
ReqID: mnd.GetID(ctx),
Route: website.PlexRoute,
Event: website.EventHook,
Payload: &website.Payload{
Snap: c.getMetaSnap(ctx),
Plex: sessions,
Load: hook,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This payload structure you created int's going to work out. I'll figure out what we need...

},
LogMsg: fmt.Sprintf(
"Plex Webhook Health Alert: active sessions=%d, lastWebhook=%s",
len(sessions.Sessions), state.LastWebhookAt.Format(time.RFC3339)),
LogPayload: true,
})
}
102 changes: 102 additions & 0 deletions pkg/triggers/plexcron/webhook_health_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package plexcron

Check failure on line 1 in pkg/triggers/plexcron/webhook_health_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (windows)

package should be `plexcron_test` instead of `plexcron` (testpackage)

Check failure on line 1 in pkg/triggers/plexcron/webhook_health_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (freebsd)

package should be `plexcron_test` instead of `plexcron` (testpackage)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
package plexcron
package plexcron_test

See if you can make the tests work like this. It's not mandatory, but if you don't then you need to make the linter shut up.


import (
"testing"
"time"
)

func TestShouldAlertForStaleWebhook(t *testing.T) {

Check failure on line 8 in pkg/triggers/plexcron/webhook_health_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (windows)

Function 'TestShouldAlertForStaleWebhook' is too long (80 > 60) (funlen)

Check failure on line 8 in pkg/triggers/plexcron/webhook_health_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (freebsd)

Function 'TestShouldAlertForStaleWebhook' is too long (80 > 60) (funlen)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can add an override for test files in .golangci-lint.yml to ignore the funlen linter.

now := time.Date(2026, 2, 16, 12, 0, 0, 0, time.UTC)

cfg := webhookHealthConfig{
Enabled: true,
StaleAfter: 20 * time.Minute,
StartupGrace: 10 * time.Minute,
AlertCooldown: 6 * time.Hour,
}

tests := []struct {
name string
activeSessions int
state webhookHealthState
want bool
}{
{
name: "alerts when sessions active and webhook stale",
activeSessions: 2,
state: webhookHealthState{
StartAt: now.Add(-2 * time.Hour),
LastWebhookAt: now.Add(-25 * time.Minute),
},
want: true,
},
{
name: "does not alert when no sessions",
activeSessions: 0,
state: webhookHealthState{
StartAt: now.Add(-2 * time.Hour),
LastWebhookAt: now.Add(-25 * time.Minute),
},
want: false,
},
{
name: "does not alert when webhook is recent",
activeSessions: 1,
state: webhookHealthState{
StartAt: now.Add(-2 * time.Hour),
LastWebhookAt: now.Add(-5 * time.Minute),
},
want: false,
},
{
name: "does not alert during startup grace",
activeSessions: 3,
state: webhookHealthState{
StartAt: now.Add(-5 * time.Minute),
LastWebhookAt: time.Time{},
},
want: false,
},
{
name: "does not alert during cooldown",
activeSessions: 3,
state: webhookHealthState{
StartAt: now.Add(-2 * time.Hour),
LastWebhookAt: now.Add(-25 * time.Minute),
LastAlertAt: now.Add(-2 * time.Hour),
},
want: false,
},
{
name: "alerts if no webhook was ever seen and grace has passed",
activeSessions: 1,
state: webhookHealthState{
StartAt: now.Add(-2 * time.Hour),
LastWebhookAt: time.Time{},
},
want: true,
},
}

for _, tc := range tests {

Check failure on line 81 in pkg/triggers/plexcron/webhook_health_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (windows)

Range statement for test TestShouldAlertForStaleWebhook missing the call to method parallel in test Run (paralleltest)

Check failure on line 81 in pkg/triggers/plexcron/webhook_health_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (freebsd)

Range statement for test TestShouldAlertForStaleWebhook missing the call to method parallel in test Run (paralleltest)
t.Run(tc.name, func(t *testing.T) {
got := shouldAlertForStaleWebhook(now, tc.activeSessions, cfg, tc.state)
if got != tc.want {
t.Fatalf("shouldAlertForStaleWebhook() = %v, want %v", got, tc.want)
}
})
}
}

func TestDefaultWebhookHealthConfig(t *testing.T) {

Check failure on line 91 in pkg/triggers/plexcron/webhook_health_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (windows)

Function TestDefaultWebhookHealthConfig missing the call to method parallel (paralleltest)

Check failure on line 91 in pkg/triggers/plexcron/webhook_health_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint (freebsd)

Function TestDefaultWebhookHealthConfig missing the call to method parallel (paralleltest)
cfg := defaultWebhookHealthConfig()

if !cfg.Enabled {
t.Fatal("expected default webhook health config to be enabled")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tell your AI to use testify.

}

if cfg.StaleAfter <= 0 || cfg.StartupGrace <= 0 || cfg.AlertCooldown <= 0 {
t.Fatalf("expected positive durations, got stale=%s grace=%s cooldown=%s",
cfg.StaleAfter, cfg.StartupGrace, cfg.AlertCooldown)
}
}
Loading