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.
jellyseerr
Harvey Tindall 4 months ago
parent 2a6937228c
commit 1fa340f096
No known key found for this signature in database
GPG Key ID: BBC65952848FB1A2

@ -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 {

@ -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
}

@ -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."
}
}
},

@ -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
}

@ -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)
}

@ -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
}

@ -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
}

@ -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()

@ -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() {

Loading…
Cancel
Save