jellyseerr: fix extremely long import, run only once

cache was being invalidated for every user, and on my 5000 user test
instance, this sweated jellyseerr and my computer (audibly). Also, since
this only needs to realistically run once, a flag is set in the database
to indicate it's been done, and unset once the feature is disabled.
It'll only run on boot if the flag is unset, or if triggered by the
/tasks route. Will likely add manual trigger buttons on the web as well.
This commit is contained in:
Harvey Tindall
2025-11-29 14:13:34 +00:00
parent 1a0e32504f
commit 598a389e3d
7 changed files with 116 additions and 20 deletions

View File

@@ -416,6 +416,7 @@ func (config *Config) ReloadDependents(app *appContext) {
} }
app.email = NewEmailer(config, app.storage, app.LoggerSet) app.email = NewEmailer(config, app.storage, app.LoggerSet)
} }
func (app *appContext) ReloadConfig() { func (app *appContext) ReloadConfig() {

View File

@@ -1407,6 +1407,7 @@ sections:
depends_true: enabled depends_true: enabled
description: Existing users (and those created outside jfa-go) will have their description: Existing users (and those created outside jfa-go) will have their
contact info imported to Jellyseerr. contact info imported to Jellyseerr.
deprecated: true
- setting: constraints_note - setting: constraints_note
name: 'Unique Emails:' name: 'Unique Emails:'
type: note type: note

View File

@@ -9,8 +9,13 @@ import (
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
) )
type JellyseerrInitialSyncStatus struct {
Done bool
}
// Ensure the Jellyseerr cache is up to date before calling.
func (app *appContext) SynchronizeJellyseerrUser(jfID string) { func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID) user, imported, err := app.js.GetOrImportUser(jfID, true)
if err != nil { if err != nil {
app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err) app.debug.Printf(lm.FailedImportUser, lm.Jellyseerr, jfID, err)
return return
@@ -63,19 +68,30 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
} }
func (app *appContext) SynchronizeJellyseerrUsers() { func (app *appContext) SynchronizeJellyseerrUsers() {
jsSync := JellyseerrInitialSyncStatus{}
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
if jsSync.Done {
return
}
users, err := app.jf.GetUsers(false) users, err := app.jf.GetUsers(false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
return return
} }
app.js.ReloadCache()
// I'm sure Jellyseerr can handle it, // I'm sure Jellyseerr can handle it,
// but past issues with the Jellyfin db scare me from // but past issues with the Jellyfin db scare me from
// running these concurrently. W/e, its a bg task anyway. // running these concurrently. W/e, its a bg task anyway.
for _, user := range users { for _, user := range users {
app.SynchronizeJellyseerrUser(user.ID) app.SynchronizeJellyseerrUser(user.ID)
} }
// Don't run again until this flag is unset
// Stored in the DB as it's not something the user needs to see.
app.storage.db.Upsert("jellyseerr_inital_sync_status", JellyseerrInitialSyncStatus{true})
} }
// Not really a normal daemon, since it'll only fire once when the feature is enabled.
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon { func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app, d := NewGenericDaemon(interval, app,
func(app *appContext) { func(app *appContext) {
@@ -83,5 +99,12 @@ func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon
}, },
) )
d.Name("Jellyseerr import") d.Name("Jellyseerr import")
jsSync := JellyseerrInitialSyncStatus{}
app.storage.db.Get("jellyseerr_inital_sync_status", &jsSync)
if jsSync.Done {
return nil
}
return d return d
} }

View File

@@ -25,7 +25,9 @@ type Jellyseerr struct {
server, key string server, key string
header map[string]string header map[string]string
httpClient *http.Client httpClient *http.Client
userCache map[string]User // Map of jellyfin IDs to users userCache map[string]User // Map of jellyfin IDs to users
jsToJfID map[int64]string // Map of jellyseerr IDs to jellyfin IDs
invalidatedUsers map[int64]bool // Map of jellyseerr IDs needing a re-caching
cacheExpiry time.Time cacheExpiry time.Time
cacheLength time.Duration cacheLength time.Duration
timeoutHandler co.TimeoutHandler timeoutHandler co.TimeoutHandler
@@ -51,6 +53,8 @@ func NewJellyseerr(server, key string, timeoutHandler co.TimeoutHandler) *Jellys
cacheExpiry: time.Now(), cacheExpiry: time.Now(),
timeoutHandler: timeoutHandler, timeoutHandler: timeoutHandler,
userCache: map[string]User{}, userCache: map[string]User{},
jsToJfID: map[int64]string{},
invalidatedUsers: map[int64]bool{},
LogRequestBodies: false, LogRequestBodies: false,
} }
} }
@@ -158,6 +162,7 @@ func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
for _, u := range data { for _, u := range data {
if u.JellyfinUserID != "" { if u.JellyfinUserID != "" {
js.userCache[u.JellyfinUserID] = u js.userCache[u.JellyfinUserID] = u
js.jsToJfID[u.ID] = u.JellyfinUserID
} }
} }
return data, err return data, err
@@ -166,8 +171,13 @@ func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
func (js *Jellyseerr) getUsers() error { func (js *Jellyseerr) getUsers() error {
if js.cacheExpiry.After(time.Now()) { if js.cacheExpiry.After(time.Now()) {
return nil return nil
if len(js.invalidatedUsers) != 0 {
return js.getInvalidatedUsers()
}
} }
js.cacheExpiry = time.Now().Add(js.cacheLength) js.cacheExpiry = time.Now().Add(js.cacheLength)
userCache := map[string]User{}
jsToJfID := map[int64]string{}
pageCount := 1 pageCount := 1
pageIndex := 0 pageIndex := 0
for { for {
@@ -179,7 +189,8 @@ func (js *Jellyseerr) getUsers() error {
if u.JellyfinUserID == "" { if u.JellyfinUserID == "" {
continue continue
} }
js.userCache[u.JellyfinUserID] = u userCache[u.JellyfinUserID] = u
jsToJfID[u.ID] = u.JellyfinUserID
} }
pageCount = res.Page.Pages pageCount = res.Page.Pages
pageIndex++ pageIndex++
@@ -187,6 +198,10 @@ func (js *Jellyseerr) getUsers() error {
break break
} }
} }
js.userCache = userCache
js.jsToJfID = jsToJfID
js.invalidatedUsers = map[int64]bool{}
return nil return nil
} }
@@ -207,15 +222,15 @@ func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
} }
func (js *Jellyseerr) MustGetUser(jfID string) (User, error) { func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
u, _, err := js.GetOrImportUser(jfID) u, _, err := js.GetOrImportUser(jfID, false)
return u, err return u, err
} }
// GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user, // 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, // 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) { func (js *Jellyseerr) GetOrImportUser(jfID string, fixedCache bool) (u User, imported bool, err error) {
imported = false imported = false
u, err = js.GetExistingUser(jfID) u, err = js.GetExistingUser(jfID, fixedCache)
if err == nil { if err == nil {
return return
} }
@@ -233,15 +248,24 @@ func (js *Jellyseerr) GetOrImportUser(jfID string) (u User, imported bool, err e
return return
} }
func (js *Jellyseerr) GetExistingUser(jfID string) (u User, err error) { func (js *Jellyseerr) GetExistingUser(jfID string, fixedCache bool) (u User, err error) {
js.getUsers() js.getUsers()
ok := false ok := false
err = nil err = nil
if u, ok = js.userCache[jfID]; ok { u, ok = js.userCache[jfID]
_, invalidated := js.invalidatedUsers[u.ID]
if ok && !invalidated {
return return
} }
js.cacheExpiry = time.Now() if invalidated {
js.getUsers() err = js.getInvalidatedUsers()
if err != nil {
return
}
} else if !fixedCache {
js.cacheExpiry = time.Now()
js.getUsers()
}
if u, ok = js.userCache[jfID]; ok { if u, ok = js.userCache[jfID]; ok {
err = nil err = nil
return return
@@ -254,7 +278,7 @@ func (js *Jellyseerr) getUser(jfID string) (User, error) {
if js.AutoImportUsers { if js.AutoImportUsers {
return js.MustGetUser(jfID) return js.MustGetUser(jfID)
} }
return js.GetExistingUser(jfID) return js.GetExistingUser(jfID, false)
} }
func (js *Jellyseerr) Me() (User, error) { func (js *Jellyseerr) Me() (User, error) {
@@ -268,6 +292,25 @@ func (js *Jellyseerr) Me() (User, error) {
return data, err return data, err
} }
func (js *Jellyseerr) getInvalidatedUsers() error {
// FIXME: Collect errors and return
for jellyseerrID, _ := range js.invalidatedUsers {
jfID, ok := js.jsToJfID[jellyseerrID]
if !ok {
continue
}
user, err := js.UserByID(jellyseerrID)
if err != nil {
continue
}
js.userCache[jfID] = user
js.jsToJfID[jellyseerrID] = jfID
delete(js.invalidatedUsers, jellyseerrID)
}
return nil
}
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) { func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
data := permissionsDTO{Permissions: -1} data := permissionsDTO{Permissions: -1}
u, err := js.getUser(jfID) u, err := js.getUser(jfID)
@@ -295,6 +338,7 @@ func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
} }
u.Permissions = perm u.Permissions = perm
js.userCache[jfID] = u js.userCache[jfID] = u
js.jsToJfID[u.ID] = jfID
return nil return nil
} }
@@ -310,6 +354,7 @@ func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error
} }
u.UserTemplate = tmpl u.UserTemplate = tmpl
js.userCache[jfID] = u js.userCache[jfID] = u
js.jsToJfID[u.ID] = jfID
return nil return nil
} }
@@ -326,8 +371,7 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
if err != nil { if err != nil {
return err return err
} }
// Lazily just invalidate the cache. js.invalidatedUsers[u.ID] = true
js.cacheExpiry = time.Now()
return nil return nil
} }
@@ -413,12 +457,19 @@ func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings)
if err != nil { if err != nil {
return err return err
} }
return js.ModifyMainUserSettingsByID(u.ID, conf)
}
_, _, err = js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false) func (js *Jellyseerr) ModifyMainUserSettingsByID(jellyseerrID int64, conf MainUserSettings) error {
_, _, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", jellyseerrID), conf, false)
if err != nil { if err != nil {
return err return err
} }
// Lazily just invalidate the cache. js.invalidatedUsers[jellyseerrID] = true
js.cacheExpiry = time.Now()
return nil return nil
} }
func (js *Jellyseerr) ReloadCache() error {
js.cacheExpiry = time.Now()
return js.getUsers()
}

13
main.go
View File

@@ -516,11 +516,16 @@ func start(asDaemon, firstCall bool) {
go app.userDaemon.run() go app.userDaemon.run()
defer app.userDaemon.Shutdown() defer app.userDaemon.Shutdown()
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) && app.config.Section("jellyseerr").Key("import_existing").MustBool(false) { if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
// import_existing_users setting is deprecated, now it'll run when jellyseerr is enabled, or when triggered manually.
// jellyseerrDaemon = newJellyseerrDaemon(time.Duration(30*time.Second), app) // jellyseerrDaemon = newJellyseerrDaemon(time.Duration(30*time.Second), app)
app.jellyseerrDaemon = newJellyseerrDaemon(time.Duration(10*time.Minute), app) app.jellyseerrDaemon = newJellyseerrDaemon(time.Duration(24*time.Hour), app)
go app.jellyseerrDaemon.run() if app.jellyseerrDaemon != nil {
defer app.jellyseerrDaemon.Shutdown() go app.jellyseerrDaemon.run()
// Run on startup
go app.jellyseerrDaemon.Trigger()
defer app.jellyseerrDaemon.Shutdown()
}
} }
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer { if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {

View File

@@ -22,6 +22,7 @@ func runMigrations(app *appContext) {
// migrateHyphens(app) // migrateHyphens(app)
migrateToBadger(app) migrateToBadger(app)
intialiseCustomContent(app) intialiseCustomContent(app)
migrateJellyseerrImportDaemon(app)
} }
// Migrate pre-0.2.0 user templates to profiles // Migrate pre-0.2.0 user templates to profiles
@@ -505,3 +506,11 @@ func migrateExternalURL(app *appContext) {
return return
} }
} }
// Migrate from use of "Import Existing Users" Jellyseerr import daemon to one-time run when enabling the feature.
func migrateJellyseerrImportDaemon(app *appContext) {
// When Jellyseerr is disabled, set this flag to false so that an initial sync happens the next time it is enabled.
if !(app.config.Section("jellyseerr").Key("enabled").MustBool(false)) {
app.storage.db.Upsert("jellyseerr_inital_sync_status", JellyseerrInitialSyncStatus{false})
}
}

View File

@@ -2190,6 +2190,12 @@ export class accountsList extends PaginatedList {
this.focusAccount(userID); this.focusAccount(userID);
} }
}
// An alternate view showing accounts in sub-lists grouped by group/label.
export class groupedAccountsList {
} }
export const accountURLEvent = (id: string) => { return new CustomEvent(accountsList._accountURLEvent, {"detail": id}) }; export const accountURLEvent = (id: string) => { return new CustomEvent(accountsList._accountURLEvent, {"detail": id}) };