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.
This commit is contained in:
Harvey Tindall
2025-12-20 11:21:13 +00:00
parent c21df253a1
commit d7bad69d40
15 changed files with 89 additions and 44 deletions

View File

@@ -248,7 +248,7 @@ $(GO_TARGET): $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) mod download $(GOBINARY) mod download
$(info Building) $(info Building)
mkdir -p build 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 test: $(BUILDDEPS) $(COMPDEPS) $(SWAGGER_TARGET) $(GO_SRC) go.mod go.sum
$(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1 $(GOBINARY) test -ldflags="$(LDFLAGS)" $(TAGS) -p 1

View File

@@ -12,17 +12,22 @@ import (
"github.com/hrfee/mediabrowser" "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) jfUser, err := app.jf.UserByID(jfID, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
username := jfUser.Name username := jfUser.Name
email := "" if email == nil {
if e, ok := app.storage.GetEmailsKey(jfID); ok { addr := ""
email = e.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 return user, err
} }
@@ -147,7 +152,7 @@ func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
} }
type OmbiWrapper struct { type OmbiWrapper struct {
OmbiUserByJfID func(jfID string) (map[string]interface{}, error) OmbiUserByJfID func(jfID string, email *string) (map[string]interface{}, error)
*ombiLib.Ombi *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) { 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 { if err != nil {
return return
} }

View File

@@ -206,14 +206,14 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked, Type: ActivityContactLinked,
UserID: gc.GetString("jfId"), UserID: id,
SourceType: ActivityUser, SourceType: ActivityUser,
Source: gc.GetString("jfId"), Source: id,
Value: "email", Value: "email",
Time: time.Now(), Time: time.Now(),
}, gc, true) }, gc, true)
app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId")) app.info.Printf(lm.UserEmailAdjusted, id)
gc.Redirect(http.StatusSeeOther, PAGES.MyAccount) gc.Redirect(http.StatusSeeOther, PAGES.MyAccount)
return return
} }
@@ -270,7 +270,7 @@ func (app *appContext) ModifyMyEmail(gc *gin.Context) {
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err) app.err.Printf(lm.FailedSendConfirmationEmail, id, req.Email, err)
} else { } else {
app.err.Printf(lm.SentConfirmationEmail, id, req.Email) app.info.Printf(lm.SentConfirmationEmail, id, req.Email)
} }
return return
} }
@@ -716,7 +716,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() { func() {
ombiUser, err := app.getOmbiUser(gc.GetString("jfId")) ombiUser, err := app.getOmbiUser(gc.GetString("jfId"), nil)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err) app.err.Printf(lm.FailedGetUser, user.Name, lm.Ombi, err)
return return

View File

@@ -294,6 +294,7 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
} }
contactPrefs.Email = &(emailStore.Contact) contactPrefs.Email = &(emailStore.Contact)
if profile != nil { if profile != nil {
// FIXME: Why?
profile.ReferralTemplateKey = profile.ReferralTemplateKey profile.ReferralTemplateKey = profile.ReferralTemplateKey
} }
/// Ensures at least one contact method is enabled. /// Ensures at least one contact method is enabled.
@@ -1369,7 +1370,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
} }
if ombi != nil { if ombi != nil {
errorString := "" errorString := ""
user, err := app.getOmbiUser(id) user, err := app.getOmbiUser(id, nil)
if err != nil { if err != nil {
errorString += fmt.Sprintf("Ombi GetUser: %v ", err) errorString += fmt.Sprintf("Ombi GetUser: %v ", err)
} else { } else {
@@ -1439,5 +1440,13 @@ func (app *appContext) GetJFActivitesForUser(gc *gin.Context) {
respondBool(400, false, gc) respondBool(400, false, gc)
return 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)
} }

2
api.go
View File

@@ -201,7 +201,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
respondBool(200, true, gc) respondBool(200, true, gc)
return return
} */ } */
ombiUser, err := app.getOmbiUser(user.ID) ombiUser, err := app.getOmbiUser(user.ID, nil)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err) app.err.Printf(lm.FailedGetUser, user.ID, lm.Ombi, err)
respondBool(200, true, gc) respondBool(200, true, gc)

View File

@@ -291,6 +291,7 @@ func NewConfig(configPathOrContents any, dataPath string, logs LoggerSet) (*Conf
config.MustSetValue("jellyfin", "cache_timeout", "30") config.MustSetValue("jellyfin", "cache_timeout", "30")
config.MustSetValue("jellyfin", "web_cache_async_timeout", "1") config.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
config.MustSetValue("jellyfin", "web_cache_sync_timeout", "10") 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) LOGIP = config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false) LOGIPU = config.Section("advanced").Key("log_ips_users").MustBool(false)

View File

@@ -136,6 +136,13 @@ sections:
type: number type: number
value: 10 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." 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 - setting: type
name: Server type name: Server type
requires_restart: true requires_restart: true

View File

@@ -11,11 +11,13 @@ import (
) )
const ( const (
// ActivityLimit is the maximum number of ActivityLogEntries to fetch at once. // ActivityLimit is the maximum number of ActivityLogEntries to keep in memory.
ActivityLimit = 5000 // 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 // If ByUserLimitLength is true, ByUserLengthOrBaseLength is the maximum number of records attached
// to a user. // 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 ByUserLengthOrBaseLength = 128
ByUserLimitLength = false 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: // 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. // return if cache is fresh, sync if not, or wait if another sync is happening already.
func (c *JFActivityCache) MaybeSync() error { func (c *JFActivityCache) MaybeSync() error {
syncTime := time.Now()
shouldWaitForSync := time.Now().After(c.LastSync.Add(c.WaitForSyncTimeout)) shouldWaitForSync := time.Now().After(c.LastSync.Add(c.WaitForSyncTimeout))
if !shouldWaitForSync { if !shouldWaitForSync {
return nil return nil
} }
defer func() { fmt.Printf("sync took %v", time.Since(syncTime)) }()
syncStatus := make(chan error) syncStatus := make(chan error)
@@ -153,7 +157,7 @@ func (c *JFActivityCache) MaybeSync() error {
} }
} }
if recvLength > 0 { 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. // Wipe them, and then append each new refresh element as we process them.
// Then loop through all the old entries and append them too. // Then loop through all the old entries and append them too.
for uid := range c.byUserID { for uid := range c.byUserID {

View File

@@ -333,6 +333,9 @@ const (
// usercache.go // usercache.go
CacheRefreshCompleted = "Usercache refreshed, %d in %.2fs (%f.2u/sec)" CacheRefreshCompleted = "Usercache refreshed, %d in %.2fs (%f.2u/sec)"
// Other
GotNEntries = "got %d entries"
) )
const ( const (

12
main.go
View File

@@ -157,14 +157,6 @@ func generateSecret(length int) (string, error) {
} }
func test(app *appContext) { 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") fmt.Printf("\n\n----\n\n")
settings := map[string]any{ settings := map[string]any{
"server": app.jf.Server, "server": app.jf.Server,
@@ -453,6 +445,10 @@ func start(asDaemon, firstCall bool) {
if err != nil { if err != nil {
app.err.Fatalf(lm.FailedAuthJellyfin, server, -1, err) 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 { /*if debugMode {
app.jf.Verbose = true app.jf.Verbose = true
}*/ }*/

View File

@@ -189,7 +189,7 @@ func linkExistingOmbiDiscordTelegram(app *appContext) error {
idList[user.JellyfinID] = vals idList[user.JellyfinID] = vals
} }
for jfID, ids := range idList { for jfID, ids := range idList {
ombiUser, err := app.getOmbiUser(jfID) ombiUser, err := app.getOmbiUser(jfID, nil)
if err != nil { if err != nil {
app.debug.Printf("Failed to get Ombi user with Discord/Telegram \"%s\"/\"%s\": %v", ids[0], ids[1], err) app.debug.Printf("Failed to get Ombi user with Discord/Telegram \"%s\"/\"%s\": %v", ids[0], ids[1], err)
continue continue

View File

@@ -517,5 +517,10 @@ type LabelsDTO struct {
} }
type ActivityLogEntriesDTO struct { type ActivityLogEntriesDTO struct {
Entries []mediabrowser.ActivityLogEntry `json:"entries"` Entries []ActivityLogEntryDTO `json:"entries"`
}
type ActivityLogEntryDTO struct {
mediabrowser.ActivityLogEntry
Date int64 `json:"Date"`
} }

View File

@@ -31,7 +31,7 @@ enum SelectAllState {
All = 1, All = 1,
} }
interface User { interface UserDTO {
id: string; id: string;
name: string; name: string;
email: string | undefined; 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 _id = "";
private _row: HTMLTableRowElement; private _row: HTMLTableRowElement;
private _check: HTMLInputElement; private _check: HTMLInputElement;
@@ -691,7 +691,7 @@ class user implements User, SearchableItem {
private _checkEvent = () => new CustomEvent("accountCheckEvent", { detail: this.id }); private _checkEvent = () => new CustomEvent("accountCheckEvent", { detail: this.id });
private _uncheckEvent = () => new CustomEvent("accountUncheckEvent", { 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 = document.createElement("tr") as HTMLTableRowElement;
this._row.classList.add("border-b", "border-dashed", "dark:border-dotted", "dark:border-stone-700"); this._row.classList.add("border-b", "border-dashed", "dark:border-dotted", "dark:border-stone-700");
let innerHTML = ` let innerHTML = `
@@ -897,7 +897,7 @@ class user implements User, SearchableItem {
this._row.setAttribute(SearchableItemDataAttribute, v); this._row.setAttribute(SearchableItemDataAttribute, v);
} }
update = (user: User) => { update = (user: UserDTO) => {
this.id = user.id; this.id = user.id;
this.name = user.name; this.name = user.name;
this.email = user.email || ""; this.email = user.email || "";
@@ -934,7 +934,7 @@ class user implements User, SearchableItem {
} }
interface UsersDTO extends paginatedDTO { interface UsersDTO extends paginatedDTO {
users: User[]; users: UserDTO[];
} }
declare interface ExtendExpiryDTO { declare interface ExtendExpiryDTO {
@@ -1000,8 +1000,8 @@ export class accountsList extends PaginatedList {
private _selectAllState: SelectAllState = SelectAllState.None; private _selectAllState: SelectAllState = SelectAllState.None;
// private _users: { [id: string]: user }; // private _users: { [id: string]: user };
// private _ordering: string[] = []; // private _ordering: string[] = [];
get users(): { [id: string]: user } { get users(): { [id: string]: User } {
return this._search.items as { [id: string]: user }; return this._search.items as { [id: string]: User };
} }
// set users(v: { [id: string]: user }) { this._search.items = v as SearchableItems; } // 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( this._columns[headerGetters[i]] = new Column(
header, header,
headerGetters[i], 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) => { add = (u: UserDTO) => {
let domAccount = new user(u); let domAccount = new User(u);
this.users[u.id] = domAccount; this.users[u.id] = domAccount;
// console.log("after appending lengths:", Object.keys(this.users).length, Object.keys(this._search.items).length); // 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 = () => { sendPWR = () => {
addLoader(this._sendPWR); addLoader(this._sendPWR);
let list = this._collectUsers(); let list = this._collectUsers();
let manualUser: user; let manualUser: User;
for (let id of list) { for (let id of list) {
let user = this.users[id]; let user = this.users[id];
if (!user.lastNotifyMethod() && !user.email) { 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. // 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); let userIDs = Object.keys(users);
userIDs.sort((a: string, b: string): number => { userIDs.sort((a: string, b: string): number => {
let av: GetterReturnType = this._getter.call(users[a]); let av: GetterReturnType = this._getter.call(users[a]);
@@ -2560,3 +2560,17 @@ class Column {
return userIDs; 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;
}

View File

@@ -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) { func (app *appContext) DeleteUser(user mediabrowser.User) (err error, deleted bool) {
// FIXME: Add DeleteContactMethod to TPS
if app.ombi != nil { if app.ombi != nil {
var tpUser map[string]any var tpUser map[string]any
tpUser, err = app.getOmbiUser(user.ID) tpUser, err = app.getOmbiUser(user.ID, nil)
if err == nil { if err == nil {
if id, ok := tpUser["id"]; ok { if id, ok := tpUser["id"]; ok {
err = app.ombi.DeleteUser(id.(string)) err = app.ombi.DeleteUser(id.(string))

View File

@@ -421,7 +421,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
app.err.Printf(lm.FailedGetUser, username, lm.Jellyfin, err) app.err.Printf(lm.FailedGetUser, username, lm.Jellyfin, err)
return return
} }
ombiUser, err := app.getOmbiUser(jfUser.ID) ombiUser, err := app.getOmbiUser(jfUser.ID, nil)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGetUser, username, lm.Ombi, err) app.err.Printf(lm.FailedGetUser, username, lm.Ombi, err)
return return