From 1fa340f0961c2f1b975f63a78e95cf5523787228 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 31 Jul 2024 14:24:02 +0100 Subject: [PATCH] jellyseerr: add option to auto-import users "import_existing" option in settings enables an every 5-minute daemon which loops through users and imports them to Jellyseerr and copies contact info, if necessary. Also sets new API client flag AutoImportUsers, which decides whether to automatically import non-existent users in it's various methods. also cleaned up the various daemons in the software, most now using the GenericDaemon struct and just providing a new constructor. broken page loop in jellyseerr client also fixed. --- api-users.go | 3 +- backups.go | 15 ++----- config/config-base.json | 9 +++++ daemon.go | 69 ++++++-------------------------- genericdaemon.go | 65 ++++++++++++++++++++++++++++++ jellyseerr/jellyseerr.go | 86 ++++++++++++++++++++++++++++------------ jellyseerrdaemon.go | 81 +++++++++++++++++++++++++++++++++++++ main.go | 15 +++++-- userdaemon.go | 49 ++++------------------- 9 files changed, 252 insertions(+), 140 deletions(-) create mode 100644 genericdaemon.go create mode 100644 jellyseerrdaemon.go diff --git a/api-users.go b/api-users.go index ddf8aa2..f16fe3d 100644 --- a/api-users.go +++ b/api-users.go @@ -4,7 +4,6 @@ import ( "fmt" "net/url" "os" - "strconv" "strings" "time" @@ -529,7 +528,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) } if telegramVerified { u, _ := app.storage.GetTelegramKey(user.ID) - contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(u.ChatID, 10) + contactMethods[jellyseerr.FieldTelegram] = u.ChatID contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact } if emailEnabled || discordVerified || telegramVerified { diff --git a/backups.go b/backups.go index 95e2fad..676bbcb 100644 --- a/backups.go +++ b/backups.go @@ -161,20 +161,13 @@ func (app *appContext) loadPendingBackup() { LOADBAK = "" } -func newBackupDaemon(app *appContext) *housekeepingDaemon { +func newBackupDaemon(app *appContext) *GenericDaemon { interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute - daemon := housekeepingDaemon{ - Stopped: false, - ShutdownChannel: make(chan string), - Interval: interval, - period: interval, - app: app, - } - daemon.jobs = []func(app *appContext){ + d := NewGenericDaemon(interval, app, func(app *appContext) { app.debug.Println("Backups: Creating backup") app.makeBackup() }, - } - return &daemon + ) + return d } diff --git a/config/config-base.json b/config/config-base.json index e5434ac..8c04cc2 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -1620,6 +1620,15 @@ "value": "", "depends_true": "enabled", "description": "API Key. Get this from the first tab in Jellyseerr's settings." + }, + "import_existing": { + "name": "Import existing users to Jellyseerr", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "depends_true": "enabled", + "description": "Existing users (and those created outside jfa-go) will have their contact info imported to Jellyseerr." } } }, diff --git a/daemon.go b/daemon.go index 19a3f97..e7c8efe 100644 --- a/daemon.go +++ b/daemon.go @@ -116,32 +116,16 @@ func (app *appContext) clearActivities() { } } -// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS - -type housekeepingDaemon struct { - Stopped bool - ShutdownChannel chan string - Interval time.Duration - period time.Duration - jobs []func(app *appContext) - app *appContext -} - -func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon { - daemon := housekeepingDaemon{ - Stopped: false, - ShutdownChannel: make(chan string), - Interval: interval, - period: interval, - app: app, - } - daemon.jobs = []func(app *appContext){ +func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon { + d := NewGenericDaemon(interval, app, func(app *appContext) { app.debug.Println("Housekeeping: Checking for expired invites") app.checkInvites() }, func(app *appContext) { app.clearActivities() }, - } + ) + + d.Name("Housekeeping daemon") clearEmail := app.config.Section("email").Key("require_unique").MustBool(false) clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false) @@ -150,53 +134,24 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false) if clearEmail || clearDiscord || clearTelegram || clearMatrix { - daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() }) + d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() }) } if clearEmail { - daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() }) + d.appendJobs(func(app *appContext) { app.clearEmails() }) } if clearDiscord { - daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() }) + d.appendJobs(func(app *appContext) { app.clearDiscord() }) } if clearTelegram { - daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() }) + d.appendJobs(func(app *appContext) { app.clearTelegram() }) } if clearMatrix { - daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() }) + d.appendJobs(func(app *appContext) { app.clearMatrix() }) } if clearPWR { - daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearPWRCaptchas() }) + d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() }) } - return &daemon -} - -func (rt *housekeepingDaemon) run() { - rt.app.info.Println("Invite daemon started") - for { - select { - case <-rt.ShutdownChannel: - rt.ShutdownChannel <- "Down" - return - case <-time.After(rt.period): - break - } - started := time.Now() - - for _, job := range rt.jobs { - job(rt.app) - } - - finished := time.Now() - duration := finished.Sub(started) - rt.period = rt.Interval - duration - } -} - -func (rt *housekeepingDaemon) Shutdown() { - rt.Stopped = true - rt.ShutdownChannel <- "Down" - <-rt.ShutdownChannel - close(rt.ShutdownChannel) + return d } diff --git a/genericdaemon.go b/genericdaemon.go new file mode 100644 index 0000000..f16dd68 --- /dev/null +++ b/genericdaemon.go @@ -0,0 +1,65 @@ +package main + +import "time" + +// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS + +type GenericDaemon struct { + Stopped bool + ShutdownChannel chan string + Interval time.Duration + period time.Duration + jobs []func(app *appContext) + app *appContext + name string +} + +func (d *GenericDaemon) appendJobs(jobs ...func(app *appContext)) { + d.jobs = append(d.jobs, jobs...) +} + +// NewGenericDaemon returns a daemon which can be given jobs that utilize appContext. +func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app *appContext)) *GenericDaemon { + d := GenericDaemon{ + Stopped: false, + ShutdownChannel: make(chan string), + Interval: interval, + period: interval, + app: app, + name: "Generic Daemon", + } + d.jobs = jobs + return &d + +} + +func (d *GenericDaemon) Name(name string) { d.name = name } + +func (d *GenericDaemon) run() { + d.app.info.Printf("%s started", d.name) + for { + select { + case <-d.ShutdownChannel: + d.ShutdownChannel <- "Down" + return + case <-time.After(d.period): + break + } + started := time.Now() + + for _, job := range d.jobs { + job(d.app) + } + + finished := time.Now() + duration := finished.Sub(started) + d.period = d.Interval - duration + } +} + +func (d *GenericDaemon) Shutdown() { + d.Stopped = true + d.ShutdownChannel <- "Down" + <-d.ShutdownChannel + close(d.ShutdownChannel) +} diff --git a/jellyseerr/jellyseerr.go b/jellyseerr/jellyseerr.go index 6161c5b..92063f1 100644 --- a/jellyseerr/jellyseerr.go +++ b/jellyseerr/jellyseerr.go @@ -30,6 +30,7 @@ type Jellyseerr struct { cacheLength time.Duration timeoutHandler common.TimeoutHandler LogRequestBodies bool + AutoImportUsers bool } // NewJellyseerr returns an Ombi object. @@ -63,7 +64,7 @@ func (js *Jellyseerr) getJSON(url string, params map[string]string, queryParams if params != nil { jsonParams, _ := json.Marshal(params) if js.LogRequestBodies { - fmt.Printf("Jellyseerr API Client: Sending Data \"%s\"\n", string(jsonParams)) + fmt.Printf("Jellyseerr API Client: Sending Data \"%s\" to \"%s\"\n", string(jsonParams), url) } req, _ = http.NewRequest("GET", url+"?"+queryParams.Encode(), bytes.NewBuffer(jsonParams)) } else { @@ -101,7 +102,7 @@ func (js *Jellyseerr) send(mode string, url string, data any, response bool, hea responseText := "" params, _ := json.Marshal(data) if js.LogRequestBodies { - fmt.Printf("Jellyseerr API Client: Sending Data \"%s\"\n", string(params)) + fmt.Printf("Jellyseerr API Client: Sending Data \"%s\" to \"%s\"\n", string(params), url) } req, _ := http.NewRequest(mode, url, bytes.NewBuffer(params)) req.Header.Add("Content-Type", "application/json") @@ -175,7 +176,7 @@ func (js *Jellyseerr) getUsers() error { pageCount := 1 pageIndex := 0 for { - res, err := js.getUserPage(0) + res, err := js.getUserPage(pageIndex) if err != nil { return err } @@ -197,8 +198,11 @@ func (js *Jellyseerr) getUsers() error { func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) { params := url.Values{} params.Add("take", "30") - params.Add("skip", strconv.Itoa(page)) + params.Add("skip", strconv.Itoa(page*30)) params.Add("sort", "created") + if js.LogRequestBodies { + fmt.Printf("Jellyseerr API Client: Sending with URL params \"%+v\"\n", params) + } resp, status, err := js.getJSON(js.server+"/user", nil, params) var data GetUsersDTO if status != 200 { @@ -211,25 +215,55 @@ func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) { return data, err } -// MustGetUser provides the same function as ImportFromJellyfin, but will always return the user, -// even if they already existed. func (js *Jellyseerr) MustGetUser(jfID string) (User, error) { - js.getUsers() - if u, ok := js.userCache[jfID]; ok { - return u, nil + u, _, err := js.GetOrImportUser(jfID) + return u, err +} + +// GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user, +// even if they already existed. Also returns whether the user was imported or not, +func (js *Jellyseerr) GetOrImportUser(jfID string) (u User, imported bool, err error) { + imported = false + u, err = js.GetExistingUser(jfID) + if err == nil { + return } - users, err := js.ImportFromJellyfin(jfID) - var u User + var users []User + users, err = js.ImportFromJellyfin(jfID) if err != nil { - return u, err + return } if len(users) != 0 { - return users[0], err + u = users[0] + err = nil + return + } + err = fmt.Errorf("user not found or imported") + return +} + +func (js *Jellyseerr) GetExistingUser(jfID string) (u User, err error) { + js.getUsers() + ok := false + err = nil + if u, ok = js.userCache[jfID]; ok { + return } - if u, ok := js.userCache[jfID]; ok { - return u, nil + js.cacheExpiry = time.Now() + js.getUsers() + if u, ok = js.userCache[jfID]; ok { + err = nil + return + } + err = fmt.Errorf("user not found") + return +} + +func (js *Jellyseerr) getUser(jfID string) (User, error) { + if js.AutoImportUsers { + return js.MustGetUser(jfID) } - return u, fmt.Errorf("user not found") + return js.GetExistingUser(jfID) } func (js *Jellyseerr) Me() (User, error) { @@ -248,7 +282,7 @@ func (js *Jellyseerr) Me() (User, error) { func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) { data := permissionsDTO{Permissions: -1} - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return data.Permissions, err } @@ -265,7 +299,7 @@ func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) { } func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error { - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return err } @@ -283,7 +317,7 @@ func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error { } func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error { - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return err } @@ -304,7 +338,7 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error { if _, ok := conf[FieldEmail]; ok { return fmt.Errorf("email is read only, set with ModifyMainUserSettings instead") } - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return err } @@ -322,7 +356,7 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error { } func (js *Jellyseerr) DeleteUser(jfID string) error { - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return err } @@ -339,7 +373,7 @@ func (js *Jellyseerr) DeleteUser(jfID string) error { } func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) { - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return Notifications{}, err } @@ -364,7 +398,7 @@ func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl Notific /* if tmpl.NotifTypes.Empty() { tmpl.NotifTypes = nil }*/ - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return err } @@ -380,7 +414,7 @@ func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl Notific } func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error { - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return err } @@ -414,12 +448,12 @@ func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) { } func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error { - u, err := js.MustGetUser(jfID) + u, err := js.getUser(jfID) if err != nil { return err } - _, status, err := js.put(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false) + _, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false) if err != nil { return err } diff --git a/jellyseerrdaemon.go b/jellyseerrdaemon.go new file mode 100644 index 0000000..f42bfbc --- /dev/null +++ b/jellyseerrdaemon.go @@ -0,0 +1,81 @@ +package main + +import ( + "strconv" + "time" + + "github.com/hrfee/jfa-go/jellyseerr" +) + +func (app *appContext) SynchronizeJellyseerrUser(jfID string) { + user, imported, err := app.js.GetOrImportUser(jfID) + if err != nil { + app.debug.Printf("Failed to get or trigger import for Jellyseerr (user \"%s\"): %v", jfID, err) + return + } + if imported { + app.debug.Printf("Jellyseerr: Triggered import for Jellyfin user \"%s\" (ID %d)", jfID, user.ID) + } + notif, err := app.js.GetNotificationPreferencesByID(user.ID) + if err != nil { + app.debug.Printf("Failed to get notification prefs for Jellyseerr (user \"%s\"): %v", jfID, err) + return + } + + contactMethods := map[jellyseerr.NotificationsField]any{} + email, ok := app.storage.GetEmailsKey(jfID) + if ok && email.Addr != "" && user.Email != email.Addr { + err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr}) + if err != nil { + app.err.Printf("Failed to set Jellyseerr email address: %v\n", err) + } else { + contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact + } + } + if discordEnabled { + dcUser, ok := app.storage.GetDiscordKey(jfID) + if ok && dcUser.ID != "" && notif.DiscordID != dcUser.ID { + contactMethods[jellyseerr.FieldDiscord] = dcUser.ID + contactMethods[jellyseerr.FieldDiscordEnabled] = dcUser.Contact + } + } + if telegramEnabled { + tgUser, ok := app.storage.GetTelegramKey(jfID) + chatID, _ := strconv.ParseInt(notif.TelegramChatID, 10, 64) + if ok && tgUser.ChatID != 0 && chatID != tgUser.ChatID { + u, _ := app.storage.GetTelegramKey(jfID) + contactMethods[jellyseerr.FieldTelegram] = u.ChatID + contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact + } + } + if len(contactMethods) != 0 { + err := app.js.ModifyNotifications(jfID, contactMethods) + if err != nil { + app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err) + } + } +} + +func (app *appContext) SynchronizeJellyseerrUsers() { + users, status, err := app.jf.GetUsers(false) + if err != nil || status != 200 { + app.err.Printf("Failed to get users (%d): %s", status, err) + return + } + // I'm sure Jellyseerr can handle it, + // but past issues with the Jellyfin db scare me from + // running these concurrently. W/e, its a bg task anyway. + for _, user := range users { + app.SynchronizeJellyseerrUser(user.ID) + } +} + +func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon { + d := NewGenericDaemon(interval, app, + func(app *appContext) { + app.SynchronizeJellyseerrUsers() + }, + ) + d.Name("Jellyseerr import daemon") + return d +} diff --git a/main.go b/main.go index acc739f..3085566 100644 --- a/main.go +++ b/main.go @@ -369,6 +369,7 @@ func start(asDaemon, firstCall bool) { app.config.Section("jellyseerr").Key("api_key").String(), common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true), ) + app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false) // app.js.LogRequestBodies = true } @@ -480,13 +481,21 @@ func start(asDaemon, firstCall bool) { os.Exit(0) } - invDaemon := newInviteDaemon(time.Duration(60*time.Second), app) + invDaemon := newHousekeepingDaemon(time.Duration(60*time.Second), app) go invDaemon.run() defer invDaemon.Shutdown() userDaemon := newUserDaemon(time.Duration(60*time.Second), app) go userDaemon.run() - defer userDaemon.shutdown() + defer userDaemon.Shutdown() + + var jellyseerrDaemon *GenericDaemon + if app.config.Section("jellyseerr").Key("enabled").MustBool(false) && app.config.Section("jellyseerr").Key("import_existing").MustBool(false) { + jellyseerrDaemon = newJellyseerrDaemon(time.Duration(30*time.Second), app) + // jellyseerrDaemon = newJellyseerrDaemon(time.Duration(5*time.Minute), app) + go jellyseerrDaemon.run() + defer jellyseerrDaemon.Shutdown() + } if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer { go app.StartPWR() @@ -496,7 +505,7 @@ func start(asDaemon, firstCall bool) { go app.checkForUpdates() } - var backupDaemon *housekeepingDaemon + var backupDaemon *GenericDaemon if app.config.Section("backups").Key("enabled").MustBool(false) { backupDaemon = newBackupDaemon(app) go backupDaemon.run() diff --git a/userdaemon.go b/userdaemon.go index abe8212..52bcaa8 100644 --- a/userdaemon.go +++ b/userdaemon.go @@ -7,47 +7,14 @@ import ( "github.com/lithammer/shortuuid/v3" ) -type userDaemon struct { - Stopped bool - ShutdownChannel chan string - Interval time.Duration - period time.Duration - app *appContext -} - -func newUserDaemon(interval time.Duration, app *appContext) *userDaemon { - return &userDaemon{ - Stopped: false, - ShutdownChannel: make(chan string), - Interval: interval, - period: interval, - app: app, - } -} - -func (rt *userDaemon) run() { - rt.app.info.Println("User daemon started") - for { - select { - case <-rt.ShutdownChannel: - rt.ShutdownChannel <- "Down" - return - case <-time.After(rt.period): - break - } - started := time.Now() - rt.app.checkUsers() - finished := time.Now() - duration := finished.Sub(started) - rt.period = rt.Interval - duration - } -} - -func (rt *userDaemon) shutdown() { - rt.Stopped = true - rt.ShutdownChannel <- "Down" - <-rt.ShutdownChannel - close(rt.ShutdownChannel) +func newUserDaemon(interval time.Duration, app *appContext) *GenericDaemon { + d := NewGenericDaemon(interval, app, + func(app *appContext) { + app.checkUsers() + }, + ) + d.Name("User daemon") + return d } func (app *appContext) checkUsers() {