From c21df253a13c4adaadefc573be051f0d409946ae Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 16 Dec 2025 18:15:39 +0000 Subject: [PATCH 1/2] jf-activity: initial changes functionality mostly there but needs a UI. --- Makefile | 4 +- api-activities.go | 6 +- api-users.go | 23 ++++ go.mod | 6 +- go.sum | 4 - jf_activity.go | 246 +++++++++++++++++++++++++++++++++++++ jf_activity_test.go | 136 ++++++++++++++++++++ logmessages/logmessages.go | 2 + main.go | 15 ++- models.go | 6 + router.go | 1 + 11 files changed, 435 insertions(+), 14 deletions(-) create mode 100644 jf_activity.go create mode 100644 jf_activity_test.go diff --git a/Makefile b/Makefile index 8e9097e..1df417c 100644 --- a/Makefile +++ b/Makefile @@ -162,7 +162,7 @@ $(SWAGGER_TARGET): $(SWAGGER_SRC) $(SWAGINSTALL) swag init --parseDependency --parseInternal -g main.go -VARIANTS_SRC = $(wildcard html/*.html) +VARIANTS_SRC = $(wildcard html/*.html) $(wildcard html/*.txt) VARIANTS_TARGET = $(DATA)/html/admin.html $(VARIANTS_TARGET): $(VARIANTS_SRC) $(info copying html) @@ -184,7 +184,7 @@ CSS_FULLTARGET = $(CSS_BUNDLE) ALL_CSS_SRC = $(ICON_SRC) $(CSS_SRC) $(SYNTAX_LIGHT_SRC) $(SYNTAX_DARK_SRC) ALL_CSS_TARGET = $(ICON_TARGET) -$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html) +$(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wildcard html/*.html) $(wildcard html.*.txt) $(info copying fonts) cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/ cp -r $(SYNTAX_LIGHT_SRC) $(SYNTAX_LIGHT_TARGET) diff --git a/api-activities.go b/api-activities.go index df797ab..a903aa2 100644 --- a/api-activities.go +++ b/api-activities.go @@ -99,7 +99,7 @@ func (app *appContext) generateActivitiesQuery(req ServerFilterReqDTO) *badgerho for _, q := range req.Queries { nq := q.AsDBQuery(query) if nq == nil { - nq = ActivityDBQueryFromSpecialField(app.jf, query, q) + nq = ActivityDBQueryFromSpecialField(app.jf.MediaBrowser, query, q) } query = nq } @@ -156,8 +156,8 @@ func (app *appContext) GetActivities(gc *gin.Context) { Value: act.Value, Time: act.Time.Unix(), IP: act.IP, - Username: act.MustGetUsername(app.jf), - SourceUsername: act.MustGetSourceUsername(app.jf), + Username: act.MustGetUsername(app.jf.MediaBrowser), + SourceUsername: act.MustGetSourceUsername(app.jf.MediaBrowser), } if act.Type == ActivityDeletion || act.Type == ActivityCreation { // Username would've been in here, clear it to avoid confusion to the consumer diff --git a/api-users.go b/api-users.go index 8a18c7c..6fad4ed 100644 --- a/api-users.go +++ b/api-users.go @@ -1418,3 +1418,26 @@ func (app *appContext) ApplySettings(gc *gin.Context) { app.InvalidateUserCaches() gc.JSON(code, errors) } + +// @Summary Get the latest Jellyfin/Emby activities related to the given user ID. Returns as many as the server has recorded. +// @Produce json +// @Success 200 {object} ActivityLogEntriesDTO +// @Failure 400 {object} boolResponse +// @Param id path string true "id of user to fetch activities of." +// @Router /users/{id}/activities/jellyfin [get] +// @Security Bearer +// @tags Users +func (app *appContext) GetJFActivitesForUser(gc *gin.Context) { + userId := gc.Param("id") + if userId == "" { + respondBool(400, false, gc) + return + } + activities, err := app.jf.activity.ByUserID(userId) + if err != nil { + app.err.Printf(lm.FailedGetJFActivities, err) + respondBool(400, false, gc) + return + } + gc.JSON(200, ActivityLogEntriesDTO{Entries: activities}) +} diff --git a/go.mod b/go.mod index db775cb..0309db2 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr -// replace github.com/hrfee/mediabrowser => ../mediabrowser +replace github.com/hrfee/mediabrowser => ../mediabrowser require ( github.com/bwmarrin/discordgo v0.29.0 @@ -42,10 +42,11 @@ require ( github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460 github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460 github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460 - github.com/hrfee/mediabrowser v0.3.33 + github.com/hrfee/mediabrowser v0.0.0-00010101000000-000000000000 github.com/hrfee/simple-template v1.1.0 github.com/itchyny/timefmt-go v0.1.7 github.com/lithammer/shortuuid/v3 v3.0.7 + github.com/lutischan-ferenc/systray v1.2.1 github.com/mailgun/mailgun-go/v4 v4.23.0 github.com/mattn/go-sqlite3 v1.14.32 github.com/robert-nix/ansihtml v1.0.1 @@ -98,7 +99,6 @@ require ( github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lutischan-ferenc/systray v1.2.1 // indirect github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect github.com/mailgun/errors v0.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/go.sum b/go.sum index d1d319c..f0004cb 100644 --- a/go.sum +++ b/go.sum @@ -194,8 +194,6 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hrfee/mediabrowser v0.3.33 h1:kjUFZc46hNhbOEU4xZNyhGVNjfZ5lENmX95Md1thxiA= -github.com/hrfee/mediabrowser v0.3.33/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M= github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -459,8 +457,6 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/jf_activity.go b/jf_activity.go new file mode 100644 index 0000000..c52c609 --- /dev/null +++ b/jf_activity.go @@ -0,0 +1,246 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/hrfee/mediabrowser" +) + +const ( + // ActivityLimit is the maximum number of ActivityLogEntries to fetch at once. + ActivityLimit = 5000 + // If ByUserLimitLength is true, ByUserLengthOrBaseLength is the maximum number of records attached + // to a user. + // If false, it is the base amount of entries to allocate for for each user ID, and more will be allocayted as needed. + ByUserLengthOrBaseLength = 128 + ByUserLimitLength = false +) + +type activityLogEntrySource interface { + GetActivityLog(skip, limit int, since time.Time, hasUserID bool) (mediabrowser.ActivityLog, error) +} + +// JFActivityCache is a cache for Jellyfin ActivityLogEntries, intended to be refreshed frequently +// and suited to it by only querying for changes since the last refresh. +type JFActivityCache struct { + jf activityLogEntrySource + cache [ActivityLimit]mediabrowser.ActivityLogEntry + // index into Cache of the entry that should be considered the start (i.e. most recent), and end (i.e. oldest). + start, end int + // Map of activity entry IDs to their index. + byEntryID map[int64]int + // Map of user IDs to a slice of entry indexes they are referenced in, chronologically ordered. + byUserID map[string][]int + LastSync, LastYieldingSync time.Time + // Age of cache before it should be refreshed. + WaitForSyncTimeout time.Duration + syncLock sync.Mutex + syncing bool + // Total number of entries. + Total int + dupesInLastSync int +} + +func (c *JFActivityCache) debugString() string { + var b strings.Builder + places := len(strconv.Itoa(ActivityLimit - 1)) + b.Grow((ActivityLimit * (places + 1) * 2) + 1) + for i := range c.cache { + fmt.Fprintf(&b, "%0"+strconv.Itoa(places)+"d|", i) + } + b.WriteByte('\n') + for i := range c.cache { + fmt.Fprintf(&b, "%0"+strconv.Itoa(places)+"d|", c.cache[i].ID) + } + return b.String() +} + +// NewJFActivityCache returns a Jellyfin ActivityLogEntry cache. +// You should set the timeout low, as events are likely to happen frequently, +// and refreshing should be quick anyway +func NewJFActivityCache(jf activityLogEntrySource, waitForSyncTimeout time.Duration) *JFActivityCache { + c := &JFActivityCache{ + jf: jf, + WaitForSyncTimeout: waitForSyncTimeout, + start: -1, + end: -1, + byEntryID: map[int64]int{}, + byUserID: map[string][]int{}, + Total: 0, + dupesInLastSync: 0, + } + for i := range ActivityLimit { + c.cache[i].ID = -1 + } + return c +} + +// ByUserID returns a slice of ActivitLogEntries with the given jellyfin ID attached. +func (c *JFActivityCache) ByUserID(jellyfinID string) ([]mediabrowser.ActivityLogEntry, error) { + if err := c.MaybeSync(); err != nil { + return nil, err + } + arr, ok := c.byUserID[jellyfinID] + if !ok { + return nil, nil + } + out := make([]mediabrowser.ActivityLogEntry, len(arr)) + for i, aleIdx := range arr { + out[i] = c.cache[aleIdx] + } + return out, nil +} + +// ByEntryID returns the ActivityLogEntry with the corresponding ID. +func (c *JFActivityCache) ByEntryID(entryID int64) (entry mediabrowser.ActivityLogEntry, ok bool, err error) { + err = c.MaybeSync() + if err != nil { + return + } + var idx int + idx, ok = c.byEntryID[entryID] + if !ok { + return + } + entry = c.cache[idx] + return +} + +// MaybeSync returns once the cache is in a suitable state to read: +// return if cache is fresh, sync if not, or wait if another sync is happening already. +func (c *JFActivityCache) MaybeSync() error { + shouldWaitForSync := time.Now().After(c.LastSync.Add(c.WaitForSyncTimeout)) + + if !shouldWaitForSync { + return nil + } + + syncStatus := make(chan error) + + go func(status chan error, c *JFActivityCache) { + c.syncLock.Lock() + alreadySyncing := c.syncing + // We're either already syncing or will be + c.syncing = true + c.syncLock.Unlock() + if !alreadySyncing { + // If we haven't synced, this'll just get max (ActivityLimit), + // If we have, it'll get anything that's happened since then + thisSync := time.Now() + al, err := c.jf.GetActivityLog(-1, ActivityLimit, c.LastYieldingSync, true) + if err != nil { + c.syncLock.Lock() + c.syncing = false + c.syncLock.Unlock() + status <- err + return + } + + // Can't trust the source fully, so we need to check for anything we've already got stored + // -before- we decide where the data should go. + recvLength := len(al.Items) + c.dupesInLastSync = 0 + for i, ale := range al.Items { + if _, ok := c.byEntryID[ale.ID]; ok { + c.dupesInLastSync = len(al.Items) - i + // If we got the same as before, everything after it we'll also have. + recvLength = i + break + } + } + if recvLength > 0 { + // Lazy strategy: for user ID maps, each refresh we'll rebuild them. + // Wipe them, and then append each new refresh element as we process them. + // Then loop through all the old entries and append them too. + for uid := range c.byUserID { + c.byUserID[uid] = c.byUserID[uid][:0] + } + + previousStart := c.start + + if c.start == -1 { + c.start = 0 + c.end = recvLength - 1 + } else { + c.start = ((c.start-recvLength)%ActivityLimit + ActivityLimit) % ActivityLimit + } + if c.cache[c.start].ID != -1 { + c.end = ((c.end-1)%ActivityLimit + ActivityLimit) % ActivityLimit + } + for i := range recvLength { + ale := al.Items[i] + ci := (c.start + i) % ActivityLimit + if c.cache[ci].ID != -1 { + // Since we're overwriting it, remove it from index + delete(c.byEntryID, c.cache[ci].ID) + // don't increment total since we're adding and removing + } else { + c.Total++ + } + if ale.UserID != "" { + arr, ok := c.byUserID[ale.UserID] + if !ok { + arr = make([]int, 0, ByUserLengthOrBaseLength) + } + if !ByUserLimitLength || len(arr) < ByUserLengthOrBaseLength { + arr = append(arr, ci) + c.byUserID[ale.UserID] = arr + } + } + + c.cache[ci] = ale + c.byEntryID[ale.ID] = ci + } + // If this was the first sync, everything has already been processed in the previous loop. + if previousStart != -1 { + i := previousStart + for { + if c.cache[i].UserID != "" { + arr, ok := c.byUserID[c.cache[i].UserID] + if !ok { + arr = make([]int, 0, ByUserLengthOrBaseLength) + } + if !ByUserLimitLength || len(arr) < ByUserLengthOrBaseLength { + arr = append(arr, i) + c.byUserID[c.cache[i].UserID] = arr + } + } + + if i == c.end { + break + } + i = (i + 1) % ActivityLimit + } + } + } + + // for i := range c.cache { + // fmt.Printf("%04d|", i) + // } + // fmt.Print("\n") + // for i := range c.cache { + // fmt.Printf("%04d|", c.cache[i].ID) + // } + // fmt.Print("\n") + + c.syncLock.Lock() + c.LastSync = thisSync + if recvLength > 0 { + c.LastYieldingSync = thisSync + } + c.syncing = false + c.syncLock.Unlock() + } else { + for c.syncing { + continue + } + } + status <- nil + }(syncStatus, c) + err := <-syncStatus + return err +} diff --git a/jf_activity_test.go b/jf_activity_test.go new file mode 100644 index 0000000..3ee17af --- /dev/null +++ b/jf_activity_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "sync" + "testing" + "time" + + "github.com/hrfee/mediabrowser" +) + +type MockActivityLogSource struct { + logs []mediabrowser.ActivityLogEntry + lock sync.Mutex + i int +} + +func (m *MockActivityLogSource) run(size int, delay time.Duration, finished *bool) { + m.logs = make([]mediabrowser.ActivityLogEntry, size) + for i := range len(m.logs) { + m.logs[i].ID = -1 + } + m.i = 0 + for i := range len(m.logs) { + m.lock.Lock() + log := mediabrowser.ActivityLogEntry{ + ID: int64(i), + Date: mediabrowser.Time{time.Now()}, + } + m.logs[i] = log + m.i = i + 1 + m.lock.Unlock() + time.Sleep(delay) + } + *finished = true + time.Sleep(delay) +} + +func (m *MockActivityLogSource) GetActivityLog(skip, limit int, since time.Time, hasUserID bool) (mediabrowser.ActivityLog, error) { + // This may introduce duplicates, but those are handled fine. + // If we don't do this, things go wrong in a way that seems + // very specific to this test setup, and (imo) is not necessarily + // applicable to a real scenario. + // since = since.Add(-time.Millisecond) + out := make([]mediabrowser.ActivityLogEntry, 0, limit) + count := 0 + loopCount := 0 + m.lock.Lock() + for i := m.i - 1; count < limit && i >= 0; i-- { + loopCount++ + if m.logs[i].Date.After(since) { + out = append(out, m.logs[i]) + count++ + } + } + m.lock.Unlock() + return mediabrowser.ActivityLog{Items: out}, nil +} + +func TestJFActivityLog(t *testing.T) { + t.Parallel() + // FIXME: This test is failing + t.Run("Completeness", func(t *testing.T) { + mock := MockActivityLogSource{} + waitForSync := time.Microsecond + cache := NewJFActivityCache(&mock, waitForSync) + finished := false + count := len(cache.cache) - 10 + go mock.run(count, time.Millisecond, &finished) + for { + if err := cache.MaybeSync(); err != nil { + t.Errorf("sync failed: %v", err) + return + } + + if cache.dupesInLastSync > 1 { + t.Logf("got %d dupes in last sync\n", cache.dupesInLastSync) + } + + if finished { + // Make sure we got everything + time.Sleep(30 * waitForSync) + if err := cache.MaybeSync(); err != nil { + t.Errorf("sync failed: %v", err) + return + } + break + } + } + t.Log(">-\n" + cache.debugString()) + if cache.Total != count { + t.Errorf("not all collected: %d < %d", cache.Total, count) + } + }) + t.Run("Ordering", func(t *testing.T) { + mock := MockActivityLogSource{} + waitForSync := 5 * time.Millisecond + cache := NewJFActivityCache(&mock, waitForSync) + finished := false + count := len(cache.cache) * 10 + go mock.run(count, time.Second/100, &finished) + for { + if err := cache.MaybeSync(); err != nil { + t.Errorf("sync failed: %v", err) + return + } + + if finished { + // Make sure we got everything + time.Sleep(waitForSync) + if err := cache.MaybeSync(); err != nil { + t.Errorf("sync failed: %v", err) + return + } + break + } + } + t.Log(">-\n" + cache.debugString()) + i := cache.start + lastID := int64(-1) + t.Logf("cache start=%d, end=%d, total=%d\n", cache.start, cache.end, cache.Total) + for { + if i != cache.start { + if cache.cache[i].ID != lastID-1 { + t.Errorf("next was not previous ID: %d != %d-1 = %d", cache.cache[i].ID, lastID, lastID-1) + return + } + } + lastID = cache.cache[i].ID + + if i == cache.end { + break + } + i = (i + 1) % len(cache.cache) + } + }) +} diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index 4d537e2..6c9da27 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -198,6 +198,8 @@ const ( UserAdminAdjusted = "Admin state for user \"%s\" set to %t" UserLabelAdjusted = "Label for user \"%s\" set to \"%s\"" + FailedGetJFActivities = "Failed to get ActivityLog entries: %v" + // api.go ApplyUpdate = "Applied update" FailedApplyUpdate = "Failed to apply update: %v" diff --git a/main.go b/main.go index 14eed8a..90c6d5d 100644 --- a/main.go +++ b/main.go @@ -115,7 +115,10 @@ type appContext struct { adminUsers []User invalidTokens []string // Keeping jf name because I can't think of a better one - jf *mediabrowser.MediaBrowser + jf struct { + *mediabrowser.MediaBrowser + activity *JFActivityCache + } authJf *mediabrowser.MediaBrowser ombi *OmbiWrapper js *JellyseerrWrapper @@ -154,6 +157,14 @@ func generateSecret(length int) (string, error) { } func test(app *appContext) { + app.jf.activity = NewJFActivityCache(app.jf, 10*time.Second) + for { + c, _ := app.jf.activity.ByUserID("9d8c71d1bac04c4c8e69ce3446c61652") + v, _ := app.jf.GetActivityLog(-1, 1, time.Time{}, true) + fmt.Printf("From the source: %+v\nFrom the cache: %+v\nequal: %t\n", v.Items[0], c[0], v.Items[0].ID == c[0].ID) + time.Sleep(5 * time.Second) + } + fmt.Printf("\n\n----\n\n") settings := map[string]any{ "server": app.jf.Server, @@ -429,7 +440,7 @@ func start(asDaemon, firstCall bool) { app.info.Println(lm.UsingJellyfin) } - app.jf, err = mediabrowser.NewServer( + app.jf.MediaBrowser, err = mediabrowser.NewServer( serverType, server, app.config.Section("jellyfin").Key("client").String(), diff --git a/models.go b/models.go index 215dbb2..6defa10 100644 --- a/models.go +++ b/models.go @@ -2,6 +2,8 @@ package main import ( "time" + + "github.com/hrfee/mediabrowser" ) type stringResponse struct { @@ -513,3 +515,7 @@ type TaskDTO struct { type LabelsDTO struct { Labels []string `json:'labels"` } + +type ActivityLogEntriesDTO struct { + Entries []mediabrowser.ActivityLogEntry `json:"entries"` +} diff --git a/router.go b/router.go index 6095021..cb658b0 100644 --- a/router.go +++ b/router.go @@ -206,6 +206,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.POST(p+"/user", app.NewUserFromAdmin) api.POST(p+"/users/extend", app.ExtendExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) + api.GET(p+"/users/:id/activities/jellyfin", app.GetJFActivitesForUser) api.POST(p+"/users/enable", app.EnableDisableUsers) api.POST(p+"/invites", app.GenerateInvite) api.GET(p+"/invites", app.GetInvites) From d7bad69d40324ccd1f3d2e7daf8a17545bc78f37 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 20 Dec 2025 11:21:13 +0000 Subject: [PATCH 2/2] jf-actvitity: functioning route, ombi fixes forgot to switch branches before doing a fix for #455, so it's in here too. OmbiUserByJfID/getOmbiUser takes an optional email *string, to optionally pass an override email address to search with, used when changing it. --- Makefile | 2 +- api-ombi.go | 19 ++++++++++++------- api-userpage.go | 10 +++++----- api-users.go | 13 +++++++++++-- api.go | 2 +- config.go | 1 + config/config-base.yaml | 7 +++++++ jf_activity.go | 12 ++++++++---- logmessages/logmessages.go | 3 +++ main.go | 12 ++++-------- migrations.go | 2 +- models.go | 7 ++++++- ts/modules/accounts.ts | 38 ++++++++++++++++++++++++++------------ users.go | 3 ++- views.go | 2 +- 15 files changed, 89 insertions(+), 44 deletions(-) diff --git a/Makefile b/Makefile index 1df417c..d8c347f 100644 --- a/Makefile +++ b/Makefile @@ -248,7 +248,7 @@ $(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum $(GOBINARY) mod download $(info Building) mkdir -p build - $(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET) + $(GOBINARY) build $(RACEDETECTOR) -ldflags="$(LDFLAGS)" $(TAGS) -o $(GO_TARGET) $(GOBUILDFLAGS) test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum $(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1 diff --git a/api-ombi.go b/api-ombi.go index 76b3828..ed56c3e 100644 --- a/api-ombi.go +++ b/api-ombi.go @@ -12,17 +12,22 @@ import ( "github.com/hrfee/mediabrowser" ) -func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, error) { +// getOmbiUser searches for an ombi user given a Jellyfin user ID. It looks for matching username or matching email address. +// If "email"=nil, an email address will be acquired from the DB instead. Passing it manually is useful when changing email address. +func (app *appContext) getOmbiUser(jfID string, email *string) (map[string]interface{}, error) { jfUser, err := app.jf.UserByID(jfID, false) if err != nil { return nil, err } username := jfUser.Name - email := "" - if e, ok := app.storage.GetEmailsKey(jfID); ok { - email = e.Addr + if email == nil { + addr := "" + if e, ok := app.storage.GetEmailsKey(jfID); ok { + addr = e.Addr + } + email = &addr } - user, err := app.ombi.getUser(username, email) + user, err := app.ombi.getUser(username, *email) return user, err } @@ -147,7 +152,7 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) { } type OmbiWrapper struct { - OmbiUserByJfID func(jfID string) (map[string]interface{}, error) + OmbiUserByJfID func(jfID string, email *string) (map[string]interface{}, error) *ombiLib.Ombi } @@ -191,7 +196,7 @@ func (ombi *OmbiWrapper) ImportUser(jellyfinID string, req newUserDTO, profile P } func (ombi *OmbiWrapper) SetContactMethods(jellyfinID string, email *string, discord *DiscordUser, telegram *TelegramUser, contactPrefs *common.ContactPreferences) (err error) { - ombiUser, err := ombi.OmbiUserByJfID(jellyfinID) + ombiUser, err := ombi.OmbiUserByJfID(jellyfinID, email) if err != nil { return } diff --git a/api-userpage.go b/api-userpage.go index 39307f1..89bcaf5 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -206,14 +206,14 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) { app.storage.SetActivityKey(shortuuid.New(), Activity{ Type: ActivityContactLinked, - UserID: gc.GetString("jfId"), + UserID: id, SourceType: ActivityUser, - Source: gc.GetString("jfId"), + Source: id, Value: "email", Time: time.Now(), }, gc, true) - app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId")) + app.info.Printf(lm.UserEmailAdjusted, id) gc.Redirect(http.StatusSeeOther, PAGES.MyAccount) return } @@ -270,7 +270,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) { } else if err := app.email.send(msg, req.Email); err != nil { app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err) } else { - app.err.Printf(lm.SentConfirmationEmail, id, req.Email) + app.info.Printf(lm.SentConfirmationEmail, id, req.Email) } return } @@ -716,7 +716,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) { if app.config.Section("ombi").Key("enabled").MustBool(false) { func() { - ombiUser, err := app.getOmbiUser(gc.GetString("jfId")) + ombiUser, err := app.getOmbiUser(gc.GetString("jfId"), nil) if err != nil { app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err) return diff --git a/api-users.go b/api-users.go index 6fad4ed..570af70 100644 --- a/api-users.go +++ b/api-users.go @@ -294,6 +294,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey } contactPrefs.Email = &(emailStore.Contact) if profile != nil { + // FIXME: Why? profile.ReferralTemplateKey = profile.ReferralTemplateKey } /// Ensures at least one contact method is enabled. @@ -1369,7 +1370,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) { } if ombi != nil { errorString := "" - user, err := app.getOmbiUser(id) + user, err := app.getOmbiUser(id, nil) if err != nil { errorString += fmt.Sprintf("Ombi GetUser: %v ", err) } else { @@ -1439,5 +1440,13 @@ func (app *appContext) GetJFActivitesForUser(gc *gin.Context) { respondBool(400, false, gc) return } - gc.JSON(200, ActivityLogEntriesDTO{Entries: activities}) + out := ActivityLogEntriesDTO{ + Entries: make([]ActivityLogEntryDTO, len(activities)), + } + for i := range activities { + out.Entries[i].ActivityLogEntry = activities[i] + out.Entries[i].Date = activities[i].Date.Unix() + } + app.debug.Printf(lm.GotNEntries, len(activities)) + gc.JSON(200, out) } diff --git a/api.go b/api.go index 53097df..004c796 100644 --- a/api.go +++ b/api.go @@ -201,7 +201,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) { respondBool(200, true, gc) return } */ - ombiUser, err := app.getOmbiUser(user.ID) + ombiUser, err := app.getOmbiUser(user.ID, nil) if err != nil { app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err) respondBool(200, true, gc) diff --git a/config.go b/config.go index 300fc86..b58f278 100644 --- a/config.go +++ b/config.go @@ -291,6 +291,7 @@ func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Conf config.MustSetValue("jellyfin", "cache_timeout", "30") config.MustSetValue("jellyfin", "web_cache_async_timeout", "1") config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10") + config.MustSetValue("jellyfin", "activity_cache_sync_timeout_seconds", "20") LOGIP = config.Section("advanced").Key("log_ips").MustBool(false) LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false) diff --git a/config/config-base.yaml b/config/config-base.yaml index e523da6..5051920 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -136,6 +136,13 @@ sections: type: number value: 10 description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does." + - setting: activity_cache_sync_timeout + name: "Activity cache timeout (minutes)" + requires_restart: true + advanced: true + type: number + value: 0.1 + description: "Synchronise Jellyfin's activity log after cache is this old. It can be pretty low as syncing only pulls new records and so is quick. Note this is unrelated to jfa-go's activity log." - setting: type name: Server type requires_restart: true diff --git a/jf_activity.go b/jf_activity.go index c52c609..2c2c010 100644 --- a/jf_activity.go +++ b/jf_activity.go @@ -11,11 +11,13 @@ import ( ) const ( - // ActivityLimit is the maximum number of ActivityLogEntries to fetch at once. - ActivityLimit = 5000 + // ActivityLimit is the maximum number of ActivityLogEntries to keep in memory. + // The array they are stored in is fixed, so (ActivityLimit*unsafe.Sizeof(mediabrowser.ActivityLogEntry)) + // At writing ActivityLogEntries take up ~160 bytes each, so 1M of memory gives us room for ~6250 records + ActivityLimit int = 1e6 / 160 // If ByUserLimitLength is true, ByUserLengthOrBaseLength is the maximum number of records attached // to a user. - // If false, it is the base amount of entries to allocate for for each user ID, and more will be allocayted as needed. + // If false, it is the base amount of entries to allocate for for each user ID, and more will be allocated as needed. ByUserLengthOrBaseLength = 128 ByUserLimitLength = false ) @@ -113,11 +115,13 @@ func (c *JFActivityCache) ByEntryID(entryID int64) (entry mediabrowser.ActivityL // MaybeSync returns once the cache is in a suitable state to read: // return if cache is fresh, sync if not, or wait if another sync is happening already. func (c *JFActivityCache) MaybeSync() error { + syncTime := time.Now() shouldWaitForSync := time.Now().After(c.LastSync.Add(c.WaitForSyncTimeout)) if !shouldWaitForSync { return nil } + defer func() { fmt.Printf("sync took %v", time.Since(syncTime)) }() syncStatus := make(chan error) @@ -153,7 +157,7 @@ func (c *JFActivityCache) MaybeSync() error { } } if recvLength > 0 { - // Lazy strategy: for user ID maps, each refresh we'll rebuild them. + // Lazy strategy: rebuild user ID maps each time. // Wipe them, and then append each new refresh element as we process them. // Then loop through all the old entries and append them too. for uid := range c.byUserID { diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index 6c9da27..6aea578 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -333,6 +333,9 @@ const ( // usercache.go CacheRefreshCompleted = "Usercache refreshed, %d in %.2fs (%f.2u/sec)" + + // Other + GotNEntries = "got %d entries" ) const ( diff --git a/main.go b/main.go index 90c6d5d..07444b0 100644 --- a/main.go +++ b/main.go @@ -157,14 +157,6 @@ func generateSecret(length int) (string, error) { } func test(app *appContext) { - app.jf.activity = NewJFActivityCache(app.jf, 10*time.Second) - for { - c, _ := app.jf.activity.ByUserID("9d8c71d1bac04c4c8e69ce3446c61652") - v, _ := app.jf.GetActivityLog(-1, 1, time.Time{}, true) - fmt.Printf("From the source: %+v\nFrom the cache: %+v\nequal: %t\n", v.Items[0], c[0], v.Items[0].ID == c[0].ID) - time.Sleep(5 * time.Second) - } - fmt.Printf("\n\n----\n\n") settings := map[string]any{ "server": app.jf.Server, @@ -453,6 +445,10 @@ func start(asDaemon, firstCall bool) { if err != nil { app.err.Fatalf(lm.FailedAuthJellyfin, server, -1, err) } + app.jf.activity = NewJFActivityCache( + app.jf, + time.Duration(app.config.Section("jellyfin").Key("activity_cache_sync_timeout_seconds").MustInt(20))*time.Second, + ) /*if debugMode { app.jf.Verbose = true }*/ diff --git a/migrations.go b/migrations.go index 6e883b0..692c47b 100644 --- a/migrations.go +++ b/migrations.go @@ -189,7 +189,7 @@ func linkExistingOmbiDiscordTelegram(app *appContext) error { idList[user.JellyfinID] = vals } for jfID, ids := range idList { - ombiUser, err := app.getOmbiUser(jfID) + ombiUser, err := app.getOmbiUser(jfID, nil) if err != nil { app.debug.Printf("Failed to get Ombi user with Discord/Telegram \"%s\"/\"%s\": %v", ids[0], ids[1], err) continue diff --git a/models.go b/models.go index 6defa10..897229d 100644 --- a/models.go +++ b/models.go @@ -517,5 +517,10 @@ type LabelsDTO struct { } type ActivityLogEntriesDTO struct { - Entries []mediabrowser.ActivityLogEntry `json:"entries"` + Entries []ActivityLogEntryDTO `json:"entries"` +} + +type ActivityLogEntryDTO struct { + mediabrowser.ActivityLogEntry + Date int64 `json:"Date"` } diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index ccde9c9..c216363 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -31,7 +31,7 @@ enum SelectAllState { All = 1, } -interface User { +interface UserDTO { id: string; name: string; email: string | undefined; @@ -177,7 +177,7 @@ const queries = (): { [field: string]: QueryType } => { }; }; -class user implements User, SearchableItem { +class User implements UserDTO, SearchableItem { private _id = ""; private _row: HTMLTableRowElement; private _check: HTMLInputElement; @@ -691,7 +691,7 @@ class user implements User, SearchableItem { private _checkEvent = () => new CustomEvent("accountCheckEvent", { detail: this.id }); private _uncheckEvent = () => new CustomEvent("accountUncheckEvent", { detail: this.id }); - constructor(user: User) { + constructor(user: UserDTO) { this._row = document.createElement("tr") as HTMLTableRowElement; this._row.classList.add("border-b", "border-dashed", "dark:border-dotted", "dark:border-stone-700"); let innerHTML = ` @@ -897,7 +897,7 @@ class user implements User, SearchableItem { this._row.setAttribute(SearchableItemDataAttribute, v); } - update = (user: User) => { + update = (user: UserDTO) => { this.id = user.id; this.name = user.name; this.email = user.email || ""; @@ -934,7 +934,7 @@ class user implements User, SearchableItem { } interface UsersDTO extends paginatedDTO { - users: User[]; + users: UserDTO[]; } declare interface ExtendExpiryDTO { @@ -1000,8 +1000,8 @@ export class accountsList extends PaginatedList { private _selectAllState: SelectAllState = SelectAllState.None; // private _users: { [id: string]: user }; // private _ordering: string[] = []; - get users(): { [id: string]: user } { - return this._search.items as { [id: string]: user }; + get users(): { [id: string]: User } { + return this._search.items as { [id: string]: User }; } // set users(v: { [id: string]: user }) { this._search.items = v as SearchableItems; } @@ -1362,7 +1362,7 @@ export class accountsList extends PaginatedList { this._columns[headerGetters[i]] = new Column( header, headerGetters[i], - Object.getOwnPropertyDescriptor(user.prototype, headerGetters[i]).get, + Object.getOwnPropertyDescriptor(User.prototype, headerGetters[i]).get, ); } } @@ -1501,8 +1501,8 @@ export class accountsList extends PaginatedList { } }; - add = (u: User) => { - let domAccount = new user(u); + add = (u: UserDTO) => { + let domAccount = new User(u); this.users[u.id] = domAccount; // console.log("after appending lengths:", Object.keys(this.users).length, Object.keys(this._search.items).length); }; @@ -1991,7 +1991,7 @@ export class accountsList extends PaginatedList { sendPWR = () => { addLoader(this._sendPWR); let list = this._collectUsers(); - let manualUser: user; + let manualUser: User; for (let id of list) { let user = this.users[id]; if (!user.lastNotifyMethod() && !user.email) { @@ -2545,7 +2545,7 @@ class Column { } // Sorts the user list. previouslyActive is whether this column was previously sorted by, indicating that the direction should change. - sort = (users: { [id: string]: user }): string[] => { + sort = (users: { [id: string]: User }): string[] => { let userIDs = Object.keys(users); userIDs.sort((a: string, b: string): number => { let av: GetterReturnType = this._getter.call(users[a]); @@ -2560,3 +2560,17 @@ class Column { return userIDs; }; } + +type ActivitySeverity = "Info" | "Debug" | "Warn" | "Error" | "Fatal"; +interface ActivityLogEntryDTO { + Id: number; + Name: string; + Overview: string; + ShortOverview: string; + Type: string; + ItemId: string; + Date: number; + UserId: string; + UserPrimaryImageTag: string; + Severity: ActivitySeverity; +} diff --git a/users.go b/users.go index 772a8a0..eec7c93 100644 --- a/users.go +++ b/users.go @@ -207,9 +207,10 @@ func (app *appContext) SetUserDisabled(user mediabrowser.User, disabled bool) (e } func (app *appContext) DeleteUser(user mediabrowser.User) (err error, deleted bool) { + // FIXME: Add DeleteContactMethod to TPS if app.ombi != nil { var tpUser map[string]any - tpUser, err = app.getOmbiUser(user.ID) + tpUser, err = app.getOmbiUser(user.ID, nil) if err == nil { if id, ok := tpUser["id"]; ok { err = app.ombi.DeleteUser(id.(string)) diff --git a/views.go b/views.go index c65b7b7..3c0a94c 100644 --- a/views.go +++ b/views.go @@ -421,7 +421,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) { app.err.Printf(lm.FailedGetUser, username, lm.Jellyfin, err) return } - ombiUser, err := app.getOmbiUser(jfUser.ID) + ombiUser, err := app.getOmbiUser(jfUser.ID, nil) if err != nil { app.err.Printf(lm.FailedGetUser, username, lm.Ombi, err) return