From 76878976ee6f67307754b7f1a2545f84750927d2 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 15 Mar 2026 13:46:53 +0000 Subject: [PATCH] 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. --- config/config-base.yaml | 7 +++ go.mod | 1 + go.sum | 2 + pwreset.go | 133 ++++++++++++++++++++++++++-------------- 4 files changed, 97 insertions(+), 46 deletions(-) diff --git a/config/config-base.yaml b/config/config-base.yaml index 5051920..bc5fcc4 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1179,6 +1179,13 @@ sections: type: text value: /path/to/jellyfin 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 name: Use reset link instead of PIN (Required for Ombi) requires_restart: true diff --git a/go.mod b/go.mod index 6e98864..85bba00 100644 --- a/go.mod +++ b/go.mod @@ -109,6 +109,7 @@ require ( github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect github.com/quic-go/qpack v0.6.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/sirupsen/logrus v1.9.3 // indirect github.com/swaggo/swag v1.16.6 // indirect diff --git a/go.sum b/go.sum index 5ce20a4..172fced 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/pwreset.go b/pwreset.go index 4b06cbb..cc5285c 100644 --- a/pwreset.go +++ b/pwreset.go @@ -10,6 +10,12 @@ import ( "github.com/fsnotify/fsnotify" 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. @@ -48,17 +54,35 @@ func (app *appContext) StartPWR() { return } - watcher, err := fsnotify.NewWatcher() - if err != nil { - app.err.Printf(lm.FailedStartDaemon, "PWR", err) + usePolling := app.config.Section("password_resets").Key("watch_polling").MustBool(false) + + if !messagesEnabled { return } - defer watcher.Close() + if usePolling { + watcher := pollingWatcher.New() + watcher.FilterOps(pollingWatcher.Write) - go pwrMonitor(app, watcher) - err = watcher.Add(path) - if err != nil { - app.err.Printf(lm.FailedStartDaemon, "PWR", err) + go monitorPolling(app, watcher) + if err := watcher.Add(path); err != nil { + app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", 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() @@ -72,52 +96,55 @@ type PasswordReset struct { Internal bool `json:"Internal,omitempty"` } -func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) { - if !messagesEnabled { +func validatePWR(app *appContext, fname string, attempt int) { + currentTime := time.Now() + if !strings.Contains(fname, "passwordreset") { 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 { select { case event, ok := <-watcher.Events: if !ok { return } - if event.Op&fsnotify.Write == fsnotify.Write && strings.Contains(event.Name, "passwordreset") { - var pwr PasswordReset - 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) - } - + if event.Has(fsnotify.Write) { + validatePWR(app, event.Name, 0) } case err, ok := <-watcher.Errors: 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 + } + } +}