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 } - if u, ok := js.userCache[jfID]; ok { - return u, nil + 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 } - return u, fmt.Errorf("user not found") + 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 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() {