package main import ( "encoding/json" "io/fs" "log" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/hrfee/mediabrowser" ) type Storage struct { timePattern string invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path string users map[string]time.Time invites Invites profiles map[string]Profile defaultProfile string emails, displayprefs, ombi_template map[string]interface{} customEmails customEmails policy mediabrowser.Policy configuration mediabrowser.Configuration lang Lang invitesLock, usersLock sync.Mutex } type customEmails struct { UserCreated customEmail `json:"userCreated"` InviteExpiry customEmail `json:"inviteExpiry"` PasswordReset customEmail `json:"passwordReset"` UserDeleted customEmail `json:"userDeleted"` UserDisabled customEmail `json:"userDisabled"` UserEnabled customEmail `json:"userEnabled"` InviteEmail customEmail `json:"inviteEmail"` WelcomeEmail customEmail `json:"welcomeEmail"` EmailConfirmation customEmail `json:"emailConfirmation"` UserExpired customEmail `json:"userExpired"` } type customEmail struct { Enabled bool `json:"enabled,omitempty"` Content string `json:"content"` Variables []string `json:"variables,omitempty"` Conditionals []string `json:"conditionals,omitempty"` } // timePattern: %Y-%m-%dT%H:%M:%S.%f type Profile struct { Admin bool `json:"admin,omitempty"` LibraryAccess string `json:"libraries,omitempty"` FromUser string `json:"fromUser,omitempty"` Policy mediabrowser.Policy `json:"policy,omitempty"` Configuration mediabrowser.Configuration `json:"configuration,omitempty"` Displayprefs map[string]interface{} `json:"displayprefs,omitempty"` Default bool `json:"default,omitempty"` } type Invite struct { Created time.Time `json:"created"` NoLimit bool `json:"no-limit"` RemainingUses int `json:"remaining-uses"` ValidTill time.Time `json:"valid_till"` UserExpiry bool `json:"user-duration"` UserMonths int `json:"user-months,omitempty"` UserDays int `json:"user-days,omitempty"` UserHours int `json:"user-hours,omitempty"` UserMinutes int `json:"user-minutes,omitempty"` Email string `json:"email"` // Used to be stored as formatted time, now as Unix. UsedBy [][]string `json:"used-by"` Notify map[string]map[string]bool `json:"notify"` Profile string `json:"profile"` Label string `json:"label,omitempty"` Keys []string `json:"keys,omitempty"` } type Lang struct { AdminPath string chosenAdminLang string Admin adminLangs AdminJSON map[string]string FormPath string chosenFormLang string Form formLangs PasswordResetPath string chosenPWRLang string PasswordReset pwrLangs EmailPath string chosenEmailLang string Email emailLangs CommonPath string Common commonLangs SetupPath string Setup setupLangs } func (st *Storage) loadLang(filesystems ...fs.FS) (err error) { err = st.loadLangCommon(filesystems...) if err != nil { return } err = st.loadLangAdmin(filesystems...) if err != nil { return } err = st.loadLangForm(filesystems...) if err != nil { return } err = st.loadLangPWR(filesystems...) if err != nil { return } err = st.loadLangEmail(filesystems...) return } // The following patch* functions fill in a language with missing values // from a list of other sources in a preferred order. // languages to patch from should be in decreasing priority, // E.g: If to = fr-be, from = [fr-fr, en-us]. func (common *commonLangs) patchCommon(to *langSection, from ...string) { if *to == nil { *to = langSection{} } for n, ev := range (*common)[from[len(from)-1]].Strings { if v, ok := (*to)[n]; !ok || v == "" { i := 0 for i < len(from)-1 { ev, ok = (*common)[from[i]].Strings[n] if ok && ev != "" { break } i++ } (*to)[n] = ev } } } func patchLang(to *langSection, from ...*langSection) { if *to == nil { *to = langSection{} } for n, ev := range *from[len(from)-1] { if v, ok := (*to)[n]; !ok || v == "" { i := 0 for i < len(from)-1 { ev, ok = (*from[i])[n] if ok && ev != "" { break } i++ } (*to)[n] = ev } } } func patchQuantityStrings(to *map[string]quantityString, from ...*map[string]quantityString) { if *to == nil { *to = map[string]quantityString{} } for n, ev := range *from[len(from)-1] { qs, ok := (*to)[n] if !ok || qs.Singular == "" || qs.Plural == "" { i := 0 subOk := false for i < len(from)-1 { ev, subOk = (*from[i])[n] if subOk && ev.Singular != "" && ev.Plural != "" { break } i++ } if !ok { (*to)[n] = ev continue } else if qs.Singular == "" { qs.Singular = ev.Singular } else if qs.Plural == "" { qs.Plural = ev.Plural } (*to)[n] = qs } } } type loadLangFunc func(fsIndex int, name string) error func (st *Storage) loadLangCommon(filesystems ...fs.FS) error { st.lang.Common = map[string]commonLang{} var english commonLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := commonLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.CommonPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.Common[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.Common[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.Strings, &fallback.Strings, &english.Strings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) } } st.lang.Common[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.Common["en-us"] commonLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.CommonPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { commonLoaded = true loadedLangs[i][f.Name()] = true } } } } if !commonLoaded { return err } return nil } func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error { st.lang.Admin = map[string]adminLang{} var english adminLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := adminLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.AdminPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommon(&lang.Strings, index) if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.Admin[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.Admin[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.Strings, &fallback.Strings, &english.Strings) patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications) patchQuantityStrings(&lang.QuantityStrings, &fallback.QuantityStrings, &english.QuantityStrings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) patchLang(&lang.Notifications, &english.Notifications) patchQuantityStrings(&lang.QuantityStrings, &english.QuantityStrings) } } stringAdmin, err := json.Marshal(lang) if err != nil { return err } lang.JSON = string(stringAdmin) st.lang.Admin[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.Admin["en-us"] adminLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.AdminPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { adminLoaded = true loadedLangs[i][f.Name()] = true } } } } if !adminLoaded { return err } return nil } func (st *Storage) loadLangForm(filesystems ...fs.FS) error { st.lang.Form = map[string]formLang{} var english formLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := formLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.FormPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommon(&lang.Strings, index) if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.Form[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.Form[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.Strings, &fallback.Strings, &english.Strings) patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications) patchQuantityStrings(&lang.ValidationStrings, &fallback.ValidationStrings, &english.ValidationStrings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) patchLang(&lang.Notifications, &english.Notifications) patchQuantityStrings(&lang.ValidationStrings, &english.ValidationStrings) } } notifications, err := json.Marshal(lang.Notifications) if err != nil { return err } validationStrings, err := json.Marshal(lang.ValidationStrings) if err != nil { return err } lang.notificationsJSON = string(notifications) lang.validationStringsJSON = string(validationStrings) st.lang.Form[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.Form["en-us"] formLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.FormPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { formLoaded = true loadedLangs[i][f.Name()] = true } } } } if !formLoaded { return err } return nil } func (st *Storage) loadLangPWR(filesystems ...fs.FS) error { st.lang.PasswordReset = map[string]pwrLang{} var english pwrLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := pwrLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.PasswordResetPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommon(&lang.Strings, index) if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.PasswordReset[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.PasswordReset[lang.Meta.Fallback] } if err == nil { patchLang(&lang.Strings, &fallback.Strings, &english.Strings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.Strings, &english.Strings) } } st.lang.PasswordReset[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.PasswordReset["en-us"] formLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.PasswordResetPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { formLoaded = true loadedLangs[i][f.Name()] = true } } } } if !formLoaded { return err } return nil } func (st *Storage) loadLangEmail(filesystems ...fs.FS) error { st.lang.Email = map[string]emailLang{} var english emailLang loadedLangs := make([]map[string]bool, len(filesystems)) var load loadLangFunc load = func(fsIndex int, fname string) error { filesystem := filesystems[fsIndex] index := strings.TrimSuffix(fname, filepath.Ext(fname)) lang := emailLang{} f, err := fs.ReadFile(filesystem, FSJoin(st.lang.EmailPath, fname)) if err != nil { return err } if substituteStrings != "" { f = []byte(strings.ReplaceAll(string(f), "Jellyfin", substituteStrings)) } err = json.Unmarshal(f, &lang) if err != nil { return err } st.lang.Common.patchCommon(&lang.Strings, index) if fname != "en-us.json" { if lang.Meta.Fallback != "" { fallback, ok := st.lang.Email[lang.Meta.Fallback] err = nil if !ok { err = load(fsIndex, lang.Meta.Fallback+".json") fallback = st.lang.Email[lang.Meta.Fallback] } if err == nil { loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true patchLang(&lang.UserCreated, &fallback.UserCreated, &english.UserCreated) patchLang(&lang.InviteExpiry, &fallback.InviteExpiry, &english.InviteExpiry) patchLang(&lang.PasswordReset, &fallback.PasswordReset, &english.PasswordReset) patchLang(&lang.UserDeleted, &fallback.UserDeleted, &english.UserDeleted) patchLang(&lang.UserDisabled, &fallback.UserDisabled, &english.UserDisabled) patchLang(&lang.UserEnabled, &fallback.UserEnabled, &english.UserEnabled) patchLang(&lang.InviteEmail, &fallback.InviteEmail, &english.InviteEmail) patchLang(&lang.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail) patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.UserExpired, &fallback.UserExpired, &english.UserExpired) patchLang(&lang.Strings, &fallback.Strings, &english.Strings) } } if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { patchLang(&lang.UserCreated, &english.UserCreated) patchLang(&lang.InviteExpiry, &english.InviteExpiry) patchLang(&lang.PasswordReset, &english.PasswordReset) patchLang(&lang.UserDeleted, &english.UserDeleted) patchLang(&lang.UserDisabled, &english.UserDisabled) patchLang(&lang.UserEnabled, &english.UserEnabled) patchLang(&lang.InviteEmail, &english.InviteEmail) patchLang(&lang.WelcomeEmail, &english.WelcomeEmail) patchLang(&lang.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.UserExpired, &english.UserExpired) patchLang(&lang.Strings, &english.Strings) } } st.lang.Email[index] = lang return nil } engFound := false var err error for i := range filesystems { loadedLangs[i] = map[string]bool{} err = load(i, "en-us.json") if err == nil { engFound = true } loadedLangs[i]["en-us.json"] = true } if !engFound { return err } english = st.lang.Email["en-us"] emailLoaded := false for i := range filesystems { files, err := fs.ReadDir(filesystems[i], st.lang.EmailPath) if err != nil { continue } for _, f := range files { if !loadedLangs[i][f.Name()] { err = load(i, f.Name()) if err == nil { emailLoaded = true loadedLangs[i][f.Name()] = true } } } } if !emailLoaded { return err } return nil } type Invites map[string]Invite func (st *Storage) loadInvites() error { st.invitesLock.Lock() defer st.invitesLock.Unlock() return loadJSON(st.invite_path, &st.invites) } func (st *Storage) storeInvites() error { st.invitesLock.Lock() defer st.invitesLock.Unlock() return storeJSON(st.invite_path, st.invites) } func (st *Storage) loadUsers() error { st.usersLock.Lock() defer st.usersLock.Unlock() if st.users == nil { st.users = map[string]time.Time{} } temp := map[string]time.Time{} err := loadJSON(st.users_path, &temp) if err != nil { return err } for id, t1 := range temp { if _, ok := st.users[id]; !ok { st.users[id] = t1 } } return nil } func (st *Storage) storeUsers() error { return storeJSON(st.users_path, st.users) } func (st *Storage) loadEmails() error { return loadJSON(st.emails_path, &st.emails) } func (st *Storage) storeEmails() error { return storeJSON(st.emails_path, st.emails) } func (st *Storage) loadCustomEmails() error { return loadJSON(st.customEmails_path, &st.customEmails) } func (st *Storage) storeCustomEmails() error { return storeJSON(st.customEmails_path, st.customEmails) } func (st *Storage) loadPolicy() error { return loadJSON(st.policy_path, &st.policy) } func (st *Storage) storePolicy() error { return storeJSON(st.policy_path, st.policy) } func (st *Storage) loadConfiguration() error { return loadJSON(st.configuration_path, &st.configuration) } func (st *Storage) storeConfiguration() error { return storeJSON(st.configuration_path, st.configuration) } func (st *Storage) loadDisplayprefs() error { return loadJSON(st.displayprefs_path, &st.displayprefs) } func (st *Storage) storeDisplayprefs() error { return storeJSON(st.displayprefs_path, st.displayprefs) } func (st *Storage) loadOmbiTemplate() error { return loadJSON(st.ombi_path, &st.ombi_template) } func (st *Storage) storeOmbiTemplate() error { return storeJSON(st.ombi_path, st.ombi_template) } func (st *Storage) loadProfiles() error { err := loadJSON(st.profiles_path, &st.profiles) for name, profile := range st.profiles { if profile.Default { st.defaultProfile = name } change := false if profile.Policy.IsAdministrator != profile.Admin { change = true } profile.Admin = profile.Policy.IsAdministrator if profile.Policy.EnabledFolders != nil { length := len(profile.Policy.EnabledFolders) if length == 0 { profile.LibraryAccess = "All" } else { profile.LibraryAccess = strconv.Itoa(length) } change = true } if profile.FromUser == "" { profile.FromUser = "Unknown" change = true } if change { st.profiles[name] = profile } } if st.defaultProfile == "" { for n := range st.profiles { st.defaultProfile = n } } return err } func (st *Storage) storeProfiles() error { return storeJSON(st.profiles_path, st.profiles) } func (st *Storage) migrateToProfile() error { st.loadPolicy() st.loadConfiguration() st.loadDisplayprefs() st.loadProfiles() st.profiles["Default"] = Profile{ Policy: st.policy, Configuration: st.configuration, Displayprefs: st.displayprefs, } return st.storeProfiles() } func loadJSON(path string, obj interface{}) error { var file []byte var err error file, err = os.ReadFile(path) if err != nil { file = []byte("{}") } err = json.Unmarshal(file, &obj) if err != nil { log.Printf("ERROR: Failed to read \"%s\": %s", path, err) } return err } func storeJSON(path string, obj interface{}) error { data, err := json.Marshal(obj) if err != nil { return err } err = os.WriteFile(path, data, 0644) if err != nil { log.Printf("ERROR: Failed to write to \"%s\": %s", path, err) } return err } // One build of JF 10.7.0 hyphenated user IDs while another one later didn't. These functions will hyphenate/de-hyphenate email storage. func hyphenate(userID string) string { if userID[8] == '-' { return userID } return userID[:8] + "-" + userID[8:12] + "-" + userID[12:16] + "-" + userID[16:20] + "-" + userID[20:] } func (app *appContext) deHyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) { jfUsers, status, err := app.jf.GetUsers(false) if status != 200 || err != nil { return nil, status, err } newEmails := map[string]interface{}{} for _, user := range jfUsers { unHyphenated := user.ID hyphenated := hyphenate(unHyphenated) val, ok := old[hyphenated] if ok { newEmails[unHyphenated] = val } } return newEmails, status, err } func (app *appContext) hyphenateStorage(old map[string]interface{}) (map[string]interface{}, int, error) { jfUsers, status, err := app.jf.GetUsers(false) if status != 200 || err != nil { return nil, status, err } newEmails := map[string]interface{}{} for _, user := range jfUsers { unstripped := user.ID stripped := strings.ReplaceAll(unstripped, "-", "") val, ok := old[stripped] if ok { newEmails[unstripped] = val } } return newEmails, status, err } func (app *appContext) hyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { return app.hyphenateStorage(old) } func (app *appContext) deHyphenateEmailStorage(old map[string]interface{}) (map[string]interface{}, int, error) { return app.deHyphenateStorage(old) } func (app *appContext) hyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) { asInterface := map[string]interface{}{} for k, v := range old { asInterface[k] = v } fixed, status, err := app.hyphenateStorage(asInterface) if err != nil { return nil, status, err } out := map[string]time.Time{} for k, v := range fixed { out[k] = v.(time.Time) } return out, status, err } func (app *appContext) deHyphenateUserStorage(old map[string]time.Time) (map[string]time.Time, int, error) { asInterface := map[string]interface{}{} for k, v := range old { asInterface[k] = v } fixed, status, err := app.deHyphenateStorage(asInterface) if err != nil { return nil, status, err } out := map[string]time.Time{} for k, v := range fixed { out[k] = v.(time.Time) } return out, status, err }