PWR: add optional polling mode

enable watch_polling/"Use polling" if the usual PWR monitoring doesn't
work for you (likely if you're using a network mount (SMB/NFS) or
anything with FUSE.
This commit is contained in:
Harvey Tindall
2026-03-15 13:46:53 +00:00
parent 5aa640d63d
commit 76878976ee
4 changed files with 97 additions and 46 deletions

View File

@@ -1179,6 +1179,13 @@ sections:
type: text type: text
value: /path/to/jellyfin value: /path/to/jellyfin
description: Path to the folder Jellyfin puts password-reset files. description: Path to the folder Jellyfin puts password-reset files.
- setting: watch_polling
name: Use polling
requires_restart: true
depends_true: watch_directory
type: bool
value: false
description: Use if mounting over network (NFS/SMB/SFTP). Watch the Jellyfin directory by checking periodically, rather than using OS APIs.
- setting: link_reset - setting: link_reset
name: Use reset link instead of PIN (Required for Ombi) name: Use reset link instead of PIN (Required for Ombi)
requires_restart: true requires_restart: true

1
go.mod
View File

@@ -109,6 +109,7 @@ require (
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect
github.com/radovskyb/watcher v1.0.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect github.com/rs/zerolog v1.34.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/swaggo/swag v1.16.6 // indirect github.com/swaggo/swag v1.16.6 // indirect

2
go.sum
View File

@@ -279,6 +279,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc= github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE= github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=

View File

@@ -10,6 +10,12 @@ import (
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
pollingWatcher "github.com/radovskyb/watcher"
)
const (
RetryCount = 2
RetryInterval = time.Second
) )
// GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page. // GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page.
@@ -48,17 +54,35 @@ func (app *appContext) StartPWR() {
return return
} }
watcher, err := fsnotify.NewWatcher() usePolling := app.config.Section("password_resets").Key("watch_polling").MustBool(false)
if err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR", err) if !messagesEnabled {
return return
} }
defer watcher.Close() if usePolling {
watcher := pollingWatcher.New()
watcher.FilterOps(pollingWatcher.Write)
go pwrMonitor(app, watcher) go monitorPolling(app, watcher)
err = watcher.Add(path) if err := watcher.Add(path); err != nil {
if err != nil { app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
app.err.Printf(lm.FailedStartDaemon, "PWR", err) }
if err := watcher.Start(time.Second * 5); err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
}
} else {
watcher, err := fsnotify.NewWatcher()
if err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
return
}
defer watcher.Close()
go monitorFS(app, watcher)
err = watcher.Add(path)
if err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
}
} }
waitForRestart() waitForRestart()
@@ -72,52 +96,55 @@ type PasswordReset struct {
Internal bool `json:"Internal,omitempty"` Internal bool `json:"Internal,omitempty"`
} }
func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) { func validatePWR(app *appContext, fname string, attempt int) {
if !messagesEnabled { currentTime := time.Now()
if !strings.Contains(fname, "passwordreset") {
return return
} }
var pwr PasswordReset
data, err := os.ReadFile(fname)
if err != nil {
app.debug.Printf(lm.FailedReading, fname, err)
return
}
err = json.Unmarshal(data, &pwr)
if len(pwr.Pin) == 0 || err != nil {
app.debug.Printf(lm.FailedReading, fname, err)
return
}
app.info.Printf(lm.NewPWRForUser, pwr.Username)
if pwr.Expiry.Before(currentTime) {
app.err.Printf(lm.PWRExpired, pwr.Username, pwr.Expiry)
return
}
user, err := app.jf.UserByName(pwr.Username, false)
if err != nil || user.ID == "" {
app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err)
return
}
name := app.getAddressOrName(user.ID)
if name != "" {
msg, err := app.email.constructReset(pwr, false)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
} else if err := app.sendByID(msg, user.ID); err != nil {
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err)
} else {
app.err.Printf(lm.SentPWRMessage, pwr.Username, name)
}
}
}
func monitorFS(app *appContext, watcher *fsnotify.Watcher) {
for { for {
select { select {
case event, ok := <-watcher.Events: case event, ok := <-watcher.Events:
if !ok { if !ok {
return return
} }
if event.Op&fsnotify.Write == fsnotify.Write && strings.Contains(event.Name, "passwordreset") { if event.Has(fsnotify.Write) {
var pwr PasswordReset validatePWR(app, event.Name, 0)
data, err := os.ReadFile(event.Name)
if err != nil {
app.debug.Printf(lm.FailedReading, event.Name, err)
return
}
err = json.Unmarshal(data, &pwr)
if len(pwr.Pin) == 0 || err != nil {
app.debug.Printf(lm.FailedReading, event.Name, err)
continue
}
app.info.Printf(lm.NewPWRForUser, pwr.Username)
if currentTime := time.Now(); pwr.Expiry.After(currentTime) {
user, err := app.jf.UserByName(pwr.Username, false)
if err != nil || user.ID == "" {
app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err)
return
}
uid := user.ID
name := app.getAddressOrName(uid)
if name != "" {
msg, err := app.email.constructReset(pwr, false)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
} else if err := app.sendByID(msg, uid); err != nil {
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err)
} else {
app.err.Printf(lm.SentPWRMessage, pwr.Username, name)
}
}
} else {
app.err.Printf(lm.PWRExpired, pwr.Username, pwr.Expiry)
}
} }
case err, ok := <-watcher.Errors: case err, ok := <-watcher.Errors:
if !ok { if !ok {
@@ -127,3 +154,17 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
} }
} }
} }
func monitorPolling(app *appContext, watcher *pollingWatcher.Watcher) {
for {
select {
case event := <-watcher.Event:
validatePWR(app, event.Path, 0)
case err := <-watcher.Error:
app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
return
case <-watcher.Closed:
return
}
}
}