diff --git a/api-jellyseerr.go b/api-jellyseerr.go
new file mode 100644
index 0000000..5ced607
--- /dev/null
+++ b/api-jellyseerr.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+ "net/url"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+)
+
+// @Summary Get a list of Jellyseerr users.
+// @Produce json
+// @Success 200 {object} ombiUsersDTO
+// @Failure 500 {object} stringResponse
+// @Router /jellyseerr/users [get]
+// @Security Bearer
+// @tags Jellyseerr
+func (app *appContext) JellyseerrUsers(gc *gin.Context) {
+ app.debug.Println("Jellyseerr users requested")
+ users, err := app.js.GetUsers()
+ if err != nil {
+ app.err.Printf("Failed to get users from Jellyseerr: %v", err)
+ respond(500, "Couldn't get users", gc)
+ return
+ }
+ app.debug.Printf("Jellyseerr users retrieved: %d", len(users))
+ userlist := make([]ombiUser, len(users))
+ i := 0
+ for _, u := range users {
+ userlist[i] = ombiUser{
+ Name: u.Name(),
+ ID: strconv.FormatInt(u.ID, 10),
+ }
+ i++
+ }
+ gc.JSON(200, ombiUsersDTO{Users: userlist})
+}
+
+// @Summary Store Jellyseerr user template in an existing profile.
+// @Produce json
+// @Param id path string true "Jellyseerr ID of user to source from"
+// @Param profile path string true "Name of profile to store in"
+// @Success 200 {object} boolResponse
+// @Failure 400 {object} boolResponse
+// @Failure 500 {object} stringResponse
+// @Router /profiles/jellyseerr/{profile}/{id} [post]
+// @Security Bearer
+// @tags Jellyseerr
+func (app *appContext) SetJellyseerrProfile(gc *gin.Context) {
+ jellyseerrID, err := strconv.ParseInt(gc.Param("id"), 10, 64)
+ if err != nil {
+ respondBool(400, false, gc)
+ return
+ }
+ escapedProfileName := gc.Param("profile")
+ profileName, _ := url.QueryUnescape(escapedProfileName)
+ profile, ok := app.storage.GetProfileKey(profileName)
+ if !ok {
+ respondBool(400, false, gc)
+ return
+ }
+ u, err := app.js.UserByID(jellyseerrID)
+ if err != nil {
+ app.err.Printf("Couldn't get user from Jellyseerr: %v", err)
+ respond(500, "Couldn't get user", gc)
+ return
+ }
+ profile.Jellyseerr.User = u.UserTemplate
+ n, err := app.js.GetNotificationPreferencesByID(jellyseerrID)
+ if err != nil {
+ app.err.Printf("Couldn't get user's notification prefs from Jellyseerr: %v", err)
+ respond(500, "Couldn't get user notification prefs", gc)
+ return
+ }
+ profile.Jellyseerr.Notifications = n.NotificationsTemplate
+ profile.Jellyseerr.Enabled = true
+ app.storage.SetProfileKey(profileName, profile)
+ respondBool(204, true, gc)
+}
+
+// @Summary Remove jellyseerr user template from a profile.
+// @Produce json
+// @Param profile path string true "Name of profile to store in"
+// @Success 200 {object} boolResponse
+// @Failure 400 {object} boolResponse
+// @Failure 500 {object} stringResponse
+// @Router /profiles/jellyseerr/{profile} [delete]
+// @Security Bearer
+// @tags Jellyseerr
+func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) {
+ escapedProfileName := gc.Param("profile")
+ profileName, _ := url.QueryUnescape(escapedProfileName)
+ profile, ok := app.storage.GetProfileKey(profileName)
+ if !ok {
+ respondBool(400, false, gc)
+ return
+ }
+ profile.Jellyseerr.Enabled = false
+ app.storage.SetProfileKey(profileName, profile)
+ respondBool(204, true, gc)
+}
diff --git a/api-messages.go b/api-messages.go
index a78ab85..db26db0 100644
--- a/api-messages.go
+++ b/api-messages.go
@@ -5,6 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
+ "github.com/hrfee/jfa-go/jellyseerr"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@@ -322,6 +323,14 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
tgUser.Lang = lang
}
app.storage.SetTelegramKey(req.ID, tgUser)
+
+ if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldTelegram: tgUser.ChatID,
+ jellyseerr.FieldTelegramEnabled: tgUser.Contact,
+ }); err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
}
@@ -346,6 +355,7 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
}
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
+ jsPrefs := map[jellyseerr.NotificationsField]any{}
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram
@@ -356,6 +366,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
msg = " not"
}
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
+ jsPrefs[jellyseerr.FieldTelegramEnabled] = req.Telegram
}
}
if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
@@ -368,6 +379,7 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
msg = " not"
}
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
+ jsPrefs[jellyseerr.FieldDiscordEnabled] = req.Discord
}
}
if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
@@ -392,6 +404,13 @@ func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Conte
msg = " not"
}
app.debug.Printf("\"%s\" will%s be notified via Email.", email.Addr, msg)
+ jsPrefs[jellyseerr.FieldEmailEnabled] = req.Email
+ }
+ }
+ if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
+ err := app.js.ModifyNotifications(req.ID, jsPrefs)
+ if err != nil {
+ app.err.Printf("Failed to sync contact prefs with Jellyseerr: %v", err)
}
}
respondBool(200, true, gc)
@@ -678,6 +697,13 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
app.storage.SetDiscordKey(req.JellyfinID, user)
+ if err := app.js.ModifyNotifications(req.JellyfinID, map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldDiscord: req.DiscordID,
+ jellyseerr.FieldDiscordEnabled: true,
+ }); err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: req.JellyfinID,
@@ -708,6 +734,14 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
} */
app.storage.DeleteDiscordKey(req.ID)
+ // May not actually remove Discord ID, but should disable interaction.
+ if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
+ jellyseerr.FieldDiscordEnabled: false,
+ }); err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
@@ -737,6 +771,13 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
} */
app.storage.DeleteTelegramKey(req.ID)
+ if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
+ jellyseerr.FieldTelegramEnabled: false,
+ }); err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: req.ID,
diff --git a/api-ombi.go b/api-ombi.go
index ca148aa..ab9b534 100644
--- a/api-ombi.go
+++ b/api-ombi.go
@@ -2,6 +2,7 @@ package main
import (
"fmt"
+ "net/url"
"github.com/gin-gonic/gin"
"github.com/hrfee/mediabrowser"
@@ -95,7 +96,8 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
func (app *appContext) SetOmbiProfile(gc *gin.Context) {
var req ombiUser
gc.BindJSON(&req)
- profileName := gc.Param("profile")
+ escapedProfileName := gc.Param("profile")
+ profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
@@ -122,7 +124,8 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) {
// @Security Bearer
// @tags Ombi
func (app *appContext) DeleteOmbiProfile(gc *gin.Context) {
- profileName := gc.Param("profile")
+ escapedProfileName := gc.Param("profile")
+ profileName, _ := url.QueryUnescape(escapedProfileName)
profile, ok := app.storage.GetProfileKey(profileName)
if !ok {
respondBool(400, false, gc)
diff --git a/api-profiles.go b/api-profiles.go
index ccbac7d..482abbb 100644
--- a/api-profiles.go
+++ b/api-profiles.go
@@ -27,6 +27,7 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
LibraryAccess: p.LibraryAccess,
FromUser: p.FromUser,
Ombi: p.Ombi != nil,
+ Jellyseerr: p.Jellyseerr.Enabled,
ReferralsEnabled: false,
}
if referralsEnabled {
diff --git a/api-userpage.go b/api-userpage.go
index ee7eb33..93d9490 100644
--- a/api-userpage.go
+++ b/api-userpage.go
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
+ "github.com/hrfee/jfa-go/jellyseerr"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
@@ -200,14 +201,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
gc.Redirect(http.StatusSeeOther, "/my/account")
return
} else if target == UserEmailChange {
- emailStore, ok := app.storage.GetEmailsKey(id)
- if !ok {
- emailStore = EmailAddress{
- Contact: true,
- }
- }
- emailStore.Addr = claims["email"].(string)
- app.storage.SetEmailsKey(id, emailStore)
+ app.modifyEmail(id, claims["email"].(string))
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
@@ -218,17 +212,6 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
Time: time.Now(),
}, gc, true)
- if app.config.Section("ombi").Key("enabled").MustBool(false) {
- ombiUser, code, err := app.getOmbiUser(id)
- if code == 200 && err == nil {
- ombiUser["emailAddress"] = claims["email"].(string)
- code, err = app.ombi.ModifyUser(ombiUser)
- if code != 200 || err != nil {
- app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
- }
- }
- }
-
app.info.Println("Email list modified")
gc.Redirect(http.StatusSeeOther, "/my/account")
return
@@ -371,6 +354,13 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
}
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
+ if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldDiscord: dcUser.ID,
+ jellyseerr.FieldDiscordEnabled: dcUser.Contact,
+ }); err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
@@ -419,6 +409,13 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
}
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
+ if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldTelegram: tgUser.ChatID,
+ jellyseerr.FieldTelegramEnabled: tgUser.Contact,
+ }); err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
UserID: gc.GetString("jfId"),
@@ -522,6 +519,13 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
+ if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldDiscord: jellyseerr.BogusIdentifier,
+ jellyseerr.FieldDiscordEnabled: false,
+ }); err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
@@ -543,6 +547,13 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
+ if err := app.js.ModifyNotifications(gc.GetString("jfId"), map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldTelegram: jellyseerr.BogusIdentifier,
+ jellyseerr.FieldTelegramEnabled: false,
+ }); err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactUnlinked,
UserID: gc.GetString("jfId"),
diff --git a/api-users.go b/api-users.go
index 64d1997..ec41a94 100644
--- a/api-users.go
+++ b/api-users.go
@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
+ "github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
@@ -94,6 +95,29 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
app.info.Println("Created Ombi user")
}
}
+ if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
+ // Gets existing user (not possible) or imports the given user.
+ _, err := app.js.MustGetUser(id)
+ if err != nil {
+ app.err.Printf("Failed to create Jellyseerr user: %v", err)
+ } else {
+ app.info.Println("Created Jellyseerr user")
+ }
+ err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User)
+ if err != nil {
+ app.err.Printf("Failed to apply Jellyseerr user template: %v\n", err)
+ }
+ err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications)
+ if err != nil {
+ app.err.Printf("Failed to apply Jellyseerr notifications template: %v\n", err)
+ }
+ if emailEnabled {
+ err = app.js.ModifyUser(id, map[jellyseerr.UserField]any{jellyseerr.FieldEmail: req.Email})
+ if err != nil {
+ app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
+ }
+ }
+ }
if emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" {
app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email)
msg, err := app.email.constructWelcome(req.Username, time.Time{}, app, false)
@@ -338,6 +362,10 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context)
Addr: req.Email,
Contact: (req.Email != ""),
}
+ // Only allow disabling of email contact if some other method is available.
+ if req.DiscordContact || req.TelegramContact || req.MatrixContact {
+ emailStore.Contact = req.EmailContact
+ }
if invite.UserLabel != "" {
emailStore.Label = invite.UserLabel
@@ -468,6 +496,51 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context)
app.debug.Printf("Skipping Ombi: Profile \"%s\" was empty", invite.Profile)
}
}
+ if invite.Profile != "" && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
+ if profile.Jellyseerr.Enabled {
+ // Gets existing user (not possible) or imports the given user.
+ _, err := app.js.MustGetUser(id)
+ if err != nil {
+ app.err.Printf("Failed to create Jellyseerr user: %v", err)
+ } else {
+ app.info.Println("Created Jellyseerr user")
+ }
+ err = app.js.ApplyTemplateToUser(id, profile.Jellyseerr.User)
+ if err != nil {
+ app.err.Printf("Failed to apply Jellyseerr user template: %v\n", err)
+ }
+ err = app.js.ApplyNotificationsTemplateToUser(id, profile.Jellyseerr.Notifications)
+ if err != nil {
+ app.err.Printf("Failed to apply Jellyseerr notifications template: %v\n", err)
+ }
+ contactMethods := map[jellyseerr.NotificationsField]any{}
+ if emailEnabled {
+ err = app.js.ModifyMainUserSettings(id, jellyseerr.MainUserSettings{Email: req.Email})
+ if err != nil {
+ app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
+ } else {
+ contactMethods[jellyseerr.FieldEmailEnabled] = req.EmailContact
+ }
+ }
+ if discordVerified {
+ contactMethods[jellyseerr.FieldDiscord] = discordUser.ID
+ contactMethods[jellyseerr.FieldDiscordEnabled] = req.DiscordContact
+ }
+ if telegramVerified {
+ u, _ := app.storage.GetTelegramKey(user.ID)
+ contactMethods[jellyseerr.FieldTelegram] = u.ChatID
+ contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
+ }
+ if emailEnabled || discordVerified || telegramVerified {
+ err := app.js.ModifyNotifications(id, contactMethods)
+ if err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+ }
+ } else {
+ app.debug.Printf("Skipping Jellyseerr: Profile \"%s\" was empty", invite.Profile)
+ }
+ }
if matrixVerified {
matrixUser.Contact = req.MatrixContact
delete(app.matrix.tokens, req.MatrixPIN)
@@ -1184,6 +1257,44 @@ func (app *appContext) ModifyLabels(gc *gin.Context) {
respondBool(204, true, gc)
}
+func (app *appContext) modifyEmail(jfID string, addr string) {
+ contactPrefChanged := false
+ emailStore, ok := app.storage.GetEmailsKey(jfID)
+ // Auto enable contact by email for newly added addresses
+ if !ok || emailStore.Addr == "" {
+ emailStore = EmailAddress{
+ Contact: true,
+ }
+ contactPrefChanged = true
+ }
+ emailStore.Addr = addr
+ app.storage.SetEmailsKey(jfID, emailStore)
+ if app.config.Section("ombi").Key("enabled").MustBool(false) {
+ ombiUser, code, err := app.getOmbiUser(jfID)
+ if code == 200 && err == nil {
+ ombiUser["emailAddress"] = addr
+ code, err = app.ombi.ModifyUser(ombiUser)
+ if code != 200 || err != nil {
+ app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
+ }
+ }
+ }
+ if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
+ err := app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: addr})
+ if err != nil {
+ app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
+ } else if contactPrefChanged {
+ contactMethods := map[jellyseerr.NotificationsField]any{
+ jellyseerr.FieldEmailEnabled: true,
+ }
+ err := app.js.ModifyNotifications(jfID, contactMethods)
+ if err != nil {
+ app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
+ }
+ }
+ }
+}
+
// @Summary Modify user's email addresses.
// @Produce json
// @Param modifyEmailsDTO body modifyEmailsDTO true "Map of userIDs to email addresses"
@@ -1202,22 +1313,10 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
respond(500, "Couldn't get users", gc)
return
}
- ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
for _, jfUser := range users {
id := jfUser.ID
if address, ok := req[id]; ok {
- var emailStore = EmailAddress{}
- oldEmail, ok := app.storage.GetEmailsKey(id)
- if ok {
- emailStore = oldEmail
- }
- // Auto enable contact by email for newly added addresses
- if !ok || oldEmail.Addr == "" {
- emailStore.Contact = true
- }
-
- emailStore.Addr = address
- app.storage.SetEmailsKey(id, emailStore)
+ app.modifyEmail(id, address)
activityType := ActivityContactLinked
if address == "" {
@@ -1231,17 +1330,6 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
Value: "email",
Time: time.Now(),
}, gc, false)
-
- if ombiEnabled {
- ombiUser, code, err := app.getOmbiUser(id)
- if code == 200 && err == nil {
- ombiUser["emailAddress"] = address
- code, err = app.ombi.ModifyUser(ombiUser)
- if code != 200 || err != nil {
- app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
- }
- }
- }
}
}
app.info.Println("Email list modified")
@@ -1265,6 +1353,8 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
var configuration mediabrowser.Configuration
var displayprefs map[string]interface{}
var ombi map[string]interface{}
+ var jellyseerr JellyseerrTemplate
+ jellyseerr.Enabled = false
if req.From == "profile" {
// Check profile exists & isn't empty
profile, ok := app.storage.GetProfileKey(req.Profile)
@@ -1282,12 +1372,19 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
configuration = profile.Configuration
displayprefs = profile.Displayprefs
}
- policy = profile.Policy
- if app.config.Section("ombi").Key("enabled").MustBool(false) {
+ if req.Policy {
+ policy = profile.Policy
+ }
+ if req.Ombi && app.config.Section("ombi").Key("enabled").MustBool(false) {
if profile.Ombi != nil && len(profile.Ombi) != 0 {
ombi = profile.Ombi
}
}
+ if req.Jellyseerr && app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
+ if profile.Jellyseerr.Enabled {
+ jellyseerr = profile.Jellyseerr
+ }
+ }
} else if req.From == "user" {
applyingFrom = "user"
@@ -1299,7 +1396,9 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
return
}
applyingFrom = "\"" + user.Name + "\""
- policy = user.Policy
+ if req.Policy {
+ policy = user.Policy
+ }
if req.Homescreen {
displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
if !(status == 200 || status == 204) || err != nil {
@@ -1315,6 +1414,7 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
"policy": map[string]string{},
"homescreen": map[string]string{},
"ombi": map[string]string{},
+ "jellyseerr": map[string]string{},
}
/* Jellyfin doesn't seem to like too many of these requests sent in succession
and can crash and mess up its database. Issue #160 says this occurs when more
@@ -1325,9 +1425,13 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
app.debug.Println("Adding delay between requests for large batch")
}
for _, id := range req.ApplyTo {
- status, err := app.jf.SetPolicy(id, policy)
- if !(status == 200 || status == 204) || err != nil {
- errors["policy"][id] = fmt.Sprintf("%d: %s", status, err)
+ var status int
+ var err error
+ if req.Policy {
+ status, err = app.jf.SetPolicy(id, policy)
+ if !(status == 200 || status == 204) || err != nil {
+ errors["policy"][id] = fmt.Sprintf("%d: %s", status, err)
+ }
}
if shouldDelay {
time.Sleep(250 * time.Millisecond)
@@ -1367,6 +1471,26 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
errors["ombi"][id] = errorString
}
}
+ if jellyseerr.Enabled {
+ errorString := ""
+ // newUser := ombi
+ // newUser["id"] = user["id"]
+ // newUser["userName"] = user["userName"]
+ // newUser["alias"] = user["alias"]
+ // newUser["emailAddress"] = user["emailAddress"]
+ err := app.js.ApplyTemplateToUser(id, jellyseerr.User)
+ if err != nil {
+ errorString += fmt.Sprintf("ApplyUser: %v ", err)
+ }
+ err = app.js.ApplyNotificationsTemplateToUser(id, jellyseerr.Notifications)
+ if err != nil {
+ errorString += fmt.Sprintf("ApplyNotifications: %v ", err)
+ }
+ if errorString != "" {
+ errors["jellyseerr"][id] = errorString
+ }
+ }
+
if shouldDelay {
time.Sleep(250 * time.Millisecond)
}
diff --git a/backups.go b/backups.go
index 95e2fad..676bbcb 100644
--- a/backups.go
+++ b/backups.go
@@ -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
}
diff --git a/config/config-base.json b/config/config-base.json
index 1b3bdd2..1d12d88 100644
--- a/config/config-base.json
+++ b/config/config-base.json
@@ -1580,6 +1580,66 @@
}
}
},
+ "jellyseerr": {
+ "order": [],
+ "meta": {
+ "name": "Jellyseerr Integration",
+ "description": "Connect to Jellyseerr to automatically trigger the import of users on account creation, and to automatically link contact methods (email, discord and telegram). A template must be added to a User Profile for accounts to be created."
+ },
+ "settings": {
+ "enabled": {
+ "name": "Enabled",
+ "required": false,
+ "requires_restart": true,
+ "type": "bool",
+ "value": false,
+ "description": "Enable the Jellyseerr integration."
+ },
+ "usertype_note": {
+ "name": "Password Changes:",
+ "type": "note",
+ "value": "",
+ "depends_true": "enabled",
+ "required": "false",
+ "description": "Ensure existing users on Jellyseerr are \"Jellyfin User\"s not \"Local User\"s, as password changes are not synced with Jellyseerr."
+ },
+ "server": {
+ "name": "URL",
+ "required": false,
+ "requires_restart": true,
+ "type": "text",
+ "value": "localhost:5000",
+ "depends_true": "enabled",
+ "description": "Jellyseerr server URL."
+ },
+ "api_key": {
+ "name": "API Key",
+ "required": false,
+ "requires_restart": true,
+ "type": "text",
+ "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."
+ },
+ "constraints_note": {
+ "name": "Unique Emails:",
+ "type": "note",
+ "value": "",
+ "depends_true": "import_existing",
+ "required": "false",
+ "description": "Jellyseerr requires email addresses to be unique. If this is not the case, you may see errors in jfa-go's logs. You can require unique addresses in Settings > Email."
+ }
+ }
+ },
"backups": {
"order": [],
"meta": {
diff --git a/daemon.go b/daemon.go
index 19a3f97..e7c8efe 100644
--- a/daemon.go
+++ b/daemon.go
@@ -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
}
diff --git a/genericdaemon.go b/genericdaemon.go
new file mode 100644
index 0000000..f16dd68
--- /dev/null
+++ b/genericdaemon.go
@@ -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)
+}
diff --git a/go.mod b/go.mod
index e9969af..de1d06d 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,8 @@ replace github.com/hrfee/jfa-go/api => ./api
replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy
+replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr
+
require (
github.com/bwmarrin/discordgo v0.27.1
github.com/dgraph-io/badger/v3 v3.2103.5
@@ -29,7 +31,7 @@ require (
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a
- github.com/hrfee/jfa-go/common v0.0.0-20230626224816-f72960635dc3
+ github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769
github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3
github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000
github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3
@@ -88,6 +90,7 @@ require (
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
+ github.com/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.6 // indirect
diff --git a/html/admin.html b/html/admin.html
index 1dc3ad9..cd72885 100644
--- a/html/admin.html
+++ b/html/admin.html
@@ -10,6 +10,7 @@
window.discordEnabled = {{ .discordEnabled }};
window.matrixEnabled = {{ .matrixEnabled }};
window.ombiEnabled = {{ .ombiEnabled }};
+ window.jellyseerrEnabled = {{ .jellyseerrEnabled }};
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
window.linkResetEnabled = {{ .linkResetEnabled }};
@@ -83,30 +84,44 @@
+
{{ .strings.userProfiles }} ×
@@ -409,6 +437,9 @@
{{ if .ombiEnabled }}
Ombi |
{{ end }}
+ {{ if .jellyseerrEnabled }}
+ Jellyseerr |
+ {{ end }}
{{ if .referralsEnabled }}
{{ .strings.referrals }} |
{{ end }}
diff --git a/jellyseerr/go.mod b/jellyseerr/go.mod
new file mode 100644
index 0000000..4bee00c
--- /dev/null
+++ b/jellyseerr/go.mod
@@ -0,0 +1,7 @@
+module github.com/hrfee/jfa-go/jellyseerr
+
+replace github.com/hrfee/jfa-go/common => ../common
+
+go 1.18
+
+require github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769 // indirect
diff --git a/jellyseerr/jellyseerr.go b/jellyseerr/jellyseerr.go
new file mode 100644
index 0000000..77368b0
--- /dev/null
+++ b/jellyseerr/jellyseerr.go
@@ -0,0 +1,460 @@
+package jellyseerr
+
+import (
+ "bytes"
+ "compress/gzip"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/hrfee/jfa-go/common"
+)
+
+const (
+ API_SUFFIX = "/api/v1"
+ BogusIdentifier = "123412341234123456"
+)
+
+// Jellyseerr represents a running Jellyseerr instance.
+type Jellyseerr struct {
+ server, key string
+ header map[string]string
+ httpClient *http.Client
+ userCache map[string]User // Map of jellyfin IDs to users
+ cacheExpiry time.Time
+ cacheLength time.Duration
+ timeoutHandler common.TimeoutHandler
+ LogRequestBodies bool
+ AutoImportUsers bool
+}
+
+// NewJellyseerr returns an Ombi object.
+func NewJellyseerr(server, key string, timeoutHandler common.TimeoutHandler) *Jellyseerr {
+ if !strings.HasSuffix(server, API_SUFFIX) {
+ server = server + API_SUFFIX
+ }
+ return &Jellyseerr{
+ server: server,
+ key: key,
+ httpClient: &http.Client{
+ Timeout: 10 * time.Second,
+ },
+ header: map[string]string{
+ "X-Api-Key": key,
+ },
+ cacheLength: time.Duration(30) * time.Minute,
+ cacheExpiry: time.Now(),
+ timeoutHandler: timeoutHandler,
+ userCache: map[string]User{},
+ LogRequestBodies: false,
+ }
+}
+
+func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
+ var params []byte
+ if data != nil {
+ params, _ = json.Marshal(data)
+ }
+ if js.LogRequestBodies {
+ fmt.Printf("Jellyseerr API Client: Sending Data \"%s\" to \"%s\"\n", string(params), uri)
+ }
+ if qp := queryParams.Encode(); qp != "" {
+ uri += "?" + qp
+ }
+ var req *http.Request
+ if data != nil {
+ req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
+ } else {
+ req, _ = http.NewRequest(mode, uri, nil)
+ }
+ req.Header.Add("Content-Type", "application/json")
+ for name, value := range js.header {
+ req.Header.Add(name, value)
+ }
+ if headers != nil {
+ for name, value := range headers {
+ req.Header.Add(name, value)
+ }
+ }
+ resp, err := js.httpClient.Do(req)
+ reqFailed := err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201)
+ defer js.timeoutHandler()
+ var responseText string
+ defer resp.Body.Close()
+ if response || reqFailed {
+ responseText, err = js.decodeResp(resp)
+ if err != nil {
+ return responseText, resp.StatusCode, err
+ }
+ }
+ if reqFailed {
+ var msg ErrorDTO
+ err = json.Unmarshal([]byte(responseText), &msg)
+ if err != nil {
+ return responseText, resp.StatusCode, err
+ }
+ if msg.Message == "" {
+ err = fmt.Errorf("failed (error %d)", resp.StatusCode)
+ } else {
+ err = fmt.Errorf("got %d: %s", resp.StatusCode, msg.Message)
+ }
+ return responseText, resp.StatusCode, err
+ }
+ return responseText, resp.StatusCode, err
+}
+
+func (js *Jellyseerr) decodeResp(resp *http.Response) (string, error) {
+ var out io.Reader
+ switch resp.Header.Get("Content-Encoding") {
+ case "gzip":
+ out, _ = gzip.NewReader(resp.Body)
+ default:
+ out = resp.Body
+ }
+ buf := new(strings.Builder)
+ _, err := io.Copy(buf, out)
+ if err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+func (js *Jellyseerr) get(uri string, data any, params url.Values) (string, int, error) {
+ return js.req(http.MethodGet, uri, data, params, nil, true)
+}
+
+func (js *Jellyseerr) post(uri string, data any, response bool) (string, int, error) {
+ return js.req(http.MethodPost, uri, data, url.Values{}, nil, response)
+}
+
+func (js *Jellyseerr) put(uri string, data any, response bool) (string, int, error) {
+ return js.req(http.MethodPut, uri, data, url.Values{}, nil, response)
+}
+
+func (js *Jellyseerr) delete(uri string, data any) (int, error) {
+ _, status, err := js.req(http.MethodDelete, uri, data, url.Values{}, nil, false)
+ return status, err
+}
+
+func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
+ params := map[string]interface{}{
+ "jellyfinUserIds": jfIDs,
+ }
+ resp, status, err := js.post(js.server+"/user/import-from-jellyfin", params, true)
+ var data []User
+ if err != nil {
+ return data, err
+ }
+ if status != 200 && status != 201 {
+ return data, fmt.Errorf("failed (error %d)", status)
+ }
+ err = json.Unmarshal([]byte(resp), &data)
+ for _, u := range data {
+ if u.JellyfinUserID != "" {
+ js.userCache[u.JellyfinUserID] = u
+ }
+ }
+ return data, err
+}
+
+func (js *Jellyseerr) getUsers() error {
+ if js.cacheExpiry.After(time.Now()) {
+ return nil
+ }
+ js.cacheExpiry = time.Now().Add(js.cacheLength)
+ pageCount := 1
+ pageIndex := 0
+ for {
+ res, err := js.getUserPage(pageIndex)
+ if err != nil {
+ return err
+ }
+ for _, u := range res.Results {
+ if u.JellyfinUserID == "" {
+ continue
+ }
+ js.userCache[u.JellyfinUserID] = u
+ }
+ pageCount = res.Page.Pages
+ pageIndex++
+ if pageIndex >= pageCount {
+ break
+ }
+ }
+ return nil
+}
+
+func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
+ params := url.Values{}
+ params.Add("take", "30")
+ 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.get(js.server+"/user", nil, params)
+ var data GetUsersDTO
+ if status != 200 {
+ return data, fmt.Errorf("failed (error %d)", status)
+ }
+ if err != nil {
+ return data, err
+ }
+ err = json.Unmarshal([]byte(resp), &data)
+ return data, err
+}
+
+func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
+ 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
+ }
+ var users []User
+ users, err = js.ImportFromJellyfin(jfID)
+ if err != nil {
+ return
+ }
+ if len(users) != 0 {
+ 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
+ }
+ 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 js.GetExistingUser(jfID)
+}
+
+func (js *Jellyseerr) Me() (User, error) {
+ resp, status, err := js.get(js.server+"/auth/me", nil, url.Values{})
+ var data User
+ data.ID = -1
+ if status != 200 {
+ return data, fmt.Errorf("failed (error %d)", status)
+ }
+ if err != nil {
+ return data, err
+ }
+ err = json.Unmarshal([]byte(resp), &data)
+ return data, err
+}
+
+func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
+ data := permissionsDTO{Permissions: -1}
+ u, err := js.getUser(jfID)
+ if err != nil {
+ return data.Permissions, err
+ }
+
+ resp, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
+ if err != nil {
+ return data.Permissions, err
+ }
+ if status != 200 {
+ return data.Permissions, fmt.Errorf("failed (error %d)", status)
+ }
+ err = json.Unmarshal([]byte(resp), &data)
+ return data.Permissions, err
+}
+
+func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
+ u, err := js.getUser(jfID)
+ if err != nil {
+ return err
+ }
+
+ _, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), permissionsDTO{Permissions: perm}, false)
+ if err != nil {
+ return err
+ }
+ if status != 200 && status != 201 {
+ return fmt.Errorf("failed (error %d)", status)
+ }
+ u.Permissions = perm
+ js.userCache[jfID] = u
+ return nil
+}
+
+func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error {
+ u, err := js.getUser(jfID)
+ if err != nil {
+ return err
+ }
+
+ _, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), tmpl, false)
+ if err != nil {
+ return err
+ }
+ if status != 200 && status != 201 {
+ return fmt.Errorf("failed (error %d)", status)
+ }
+ u.UserTemplate = tmpl
+ js.userCache[jfID] = u
+ return nil
+}
+
+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.getUser(jfID)
+ if err != nil {
+ return err
+ }
+
+ _, status, err := js.put(fmt.Sprintf(js.server+"/user/%d", u.ID), conf, false)
+ if err != nil {
+ return err
+ }
+ if status != 200 && status != 201 {
+ return fmt.Errorf("failed (error %d)", status)
+ }
+ // Lazily just invalidate the cache.
+ js.cacheExpiry = time.Now()
+ return nil
+}
+
+func (js *Jellyseerr) DeleteUser(jfID string) error {
+ u, err := js.getUser(jfID)
+ if err != nil {
+ return err
+ }
+
+ status, err := js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
+ if status != 200 && status != 201 {
+ return fmt.Errorf("failed (error %d)", status)
+ }
+ if err != nil {
+ return err
+ }
+ delete(js.userCache, jfID)
+ return err
+}
+
+func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) {
+ u, err := js.getUser(jfID)
+ if err != nil {
+ return Notifications{}, err
+ }
+ return js.GetNotificationPreferencesByID(u.ID)
+}
+
+func (js *Jellyseerr) GetNotificationPreferencesByID(jellyseerrID int64) (Notifications, error) {
+ var data Notifications
+ resp, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
+ if err != nil {
+ return data, err
+ }
+ if status != 200 {
+ return data, fmt.Errorf("failed (error %d)", status)
+ }
+ err = json.Unmarshal([]byte(resp), &data)
+ return data, err
+}
+
+func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl NotificationsTemplate) error {
+ // This behaviour is not desired, this being all-zero means no notifications, which is a settings state we'd want to store!
+ /* if tmpl.NotifTypes.Empty() {
+ tmpl.NotifTypes = nil
+ }*/
+ u, err := js.getUser(jfID)
+ if err != nil {
+ return err
+ }
+
+ _, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), tmpl, false)
+ if err != nil {
+ return err
+ }
+ if status != 200 && status != 201 {
+ return fmt.Errorf("failed (error %d)", status)
+ }
+ return nil
+}
+
+func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
+ u, err := js.getUser(jfID)
+ if err != nil {
+ return err
+ }
+
+ _, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), conf, false)
+ if err != nil {
+ return err
+ }
+ if status != 200 && status != 201 {
+ return fmt.Errorf("failed (error %d)", status)
+ }
+ return nil
+}
+
+func (js *Jellyseerr) GetUsers() (map[string]User, error) {
+ err := js.getUsers()
+ return js.userCache, err
+}
+
+func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) {
+ resp, status, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
+ var data User
+ if status != 200 {
+ return data, fmt.Errorf("failed (error %d)", status)
+ }
+ if err != nil {
+ return data, err
+ }
+ err = json.Unmarshal([]byte(resp), &data)
+ return data, err
+}
+
+func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error {
+ u, err := js.getUser(jfID)
+ if err != nil {
+ return err
+ }
+
+ _, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
+ if err != nil {
+ return err
+ }
+ if status != 200 && status != 201 {
+ return fmt.Errorf("failed (error %d)", status)
+ }
+ // Lazily just invalidate the cache.
+ js.cacheExpiry = time.Now()
+ return nil
+}
diff --git a/jellyseerr/jellyseerr_test.go b/jellyseerr/jellyseerr_test.go
new file mode 100644
index 0000000..1180758
--- /dev/null
+++ b/jellyseerr/jellyseerr_test.go
@@ -0,0 +1,69 @@
+package jellyseerr
+
+import (
+ "testing"
+
+ "github.com/hrfee/jfa-go/common"
+)
+
+const (
+ API_KEY = "MTcyMjI2MDM2MTYyMzMxNDZkZmYyLTE4MzMtNDUyNy1hODJlLTI0MTZkZGUyMDg2Ng=="
+ URI = "http://localhost:5055"
+ PERM = 2097184
+)
+
+func client() *Jellyseerr {
+ return NewJellyseerr(URI, API_KEY, common.NewTimeoutHandler("Jellyseerr", URI, false))
+}
+
+func TestMe(t *testing.T) {
+ js := client()
+ u, err := js.Me()
+ if err != nil {
+ t.Fatalf("returned error %+v", err)
+ }
+ if u.ID < 0 {
+ t.Fatalf("returned no user %+v\n", u)
+ }
+}
+
+/* func TestImportFromJellyfin(t *testing.T) {
+ js := client()
+ list, err := js.ImportFromJellyfin("6b75e189efb744f583aa2e8e9cee41d3")
+ if err != nil {
+ t.Fatalf("returned error %+v", err)
+ }
+ if len(list) == 0 {
+ t.Fatalf("returned no users")
+ }
+} */
+
+func TestMustGetUser(t *testing.T) {
+ js := client()
+ u, err := js.MustGetUser("8c9d25c070d641cd8ad9cf825f622a16")
+ if err != nil {
+ t.Fatalf("returned error %+v", err)
+ }
+ if u.ID < 0 {
+ t.Fatalf("returned no users")
+ }
+}
+
+func TestSetPermissions(t *testing.T) {
+ js := client()
+ err := js.SetPermissions("6b75e189efb744f583aa2e8e9cee41d3", PERM)
+ if err != nil {
+ t.Fatalf("returned error %+v", err)
+ }
+}
+
+func TestGetPermissions(t *testing.T) {
+ js := client()
+ perm, err := js.GetPermissions("6b75e189efb744f583aa2e8e9cee41d3")
+ if err != nil {
+ t.Fatalf("returned error %+v", err)
+ }
+ if perm != PERM {
+ t.Fatalf("got unexpected perm code %d", perm)
+ }
+}
diff --git a/jellyseerr/models.go b/jellyseerr/models.go
new file mode 100644
index 0000000..87648d8
--- /dev/null
+++ b/jellyseerr/models.go
@@ -0,0 +1,136 @@
+package jellyseerr
+
+import "time"
+
+type UserField string
+
+const (
+ FieldDisplayName UserField = "displayName"
+ FieldEmail UserField = "email"
+)
+
+type User struct {
+ UserTemplate // Note: You can set this with User.UserTemplate = value.
+ UserType int64 `json:"userType,omitempty"`
+ Warnings []any `json:"warnings,omitempty"`
+ ID int64 `json:"id,omitempty"`
+ Email string `json:"email,omitempty"`
+ PlexUsername string `json:"plexUsername,omitempty"`
+ JellyfinUsername string `json:"jellyfinUsername,omitempty"`
+ Username string `json:"username,omitempty"`
+ RecoveryLinkExpirationDate any `json:"recoveryLinkExpirationDate,omitempty"`
+ PlexID string `json:"plexId,omitempty"`
+ JellyfinUserID string `json:"jellyfinUserId,omitempty"`
+ JellyfinDeviceID string `json:"jellyfinDeviceId,omitempty"`
+ JellyfinAuthToken string `json:"jellyfinAuthToken,omitempty"`
+ PlexToken string `json:"plexToken,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
+ CreatedAt time.Time `json:"createdAt,omitempty"`
+ UpdatedAt time.Time `json:"updatedAt,omitempty"`
+ RequestCount int64 `json:"requestCount,omitempty"`
+ DisplayName string `json:"displayName,omitempty"`
+}
+
+func (u User) Name() string {
+ var n string
+ if u.Username != "" {
+ n = u.Username
+ } else if u.JellyfinUsername != "" {
+ n = u.JellyfinUsername
+ }
+ if u.DisplayName != "" {
+ n += " (" + u.DisplayName + ")"
+ }
+ return n
+}
+
+type UserTemplate struct {
+ Permissions Permissions `json:"permissions,omitempty"`
+ MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
+ MovieQuotaDays any `json:"movieQuotaDays,omitempty"`
+ TvQuotaLimit any `json:"tvQuotaLimit,omitempty"`
+ TvQuotaDays any `json:"tvQuotaDays,omitempty"`
+}
+
+type PageInfo struct {
+ Pages int `json:"pages,omitempty"`
+ PageSize int `json:"pageSize,omitempty"`
+ Results int `json:"results,omitempty"`
+ Page int `json:"page,omitempty"`
+}
+
+type GetUsersDTO struct {
+ Page PageInfo `json:"pageInfo,omitempty"`
+ Results []User `json:"results,omitempty"`
+}
+
+type permissionsDTO struct {
+ Permissions Permissions `json:"permissions,omitempty"`
+}
+
+type Permissions int
+
+type NotificationTypes struct {
+ Discord int64 `json:"discord"`
+ Email int64 `json:"email"`
+ Pushbullet int64 `json:"pushbullet"`
+ Pushover int64 `json:"pushover"`
+ Slack int64 `json:"slack"`
+ Telegram int64 `json:"telegram"`
+ Webhook int64 `json:"webhook"`
+ Webpush int64 `json:"webpush"`
+}
+
+/* func (nt *NotificationTypes) Empty() bool {
+ return nt.Discord == 0 && nt.Email == 0 && nt.Pushbullet == 0 && nt.Pushover == 0 && nt.Slack == 0 && nt.Telegram == 0 && nt.Webhook == 0 && nt.Webpush == 0
+} */
+
+type NotificationsField string
+
+const (
+ FieldDiscord NotificationsField = "discordId"
+ FieldTelegram NotificationsField = "telegramChatId"
+ FieldEmailEnabled NotificationsField = "emailEnabled"
+ FieldDiscordEnabled NotificationsField = "discordEnabled"
+ FieldTelegramEnabled NotificationsField = "telegramEnabled"
+)
+
+type Notifications struct {
+ NotificationsTemplate
+ PgpKey any `json:"pgpKey,omitempty"`
+ DiscordID string `json:"discordId,omitempty"`
+ PushbulletAccessToken any `json:"pushbulletAccessToken,omitempty"`
+ PushoverApplicationToken any `json:"pushoverApplicationToken,omitempty"`
+ PushoverUserKey any `json:"pushoverUserKey,omitempty"`
+ TelegramChatID string `json:"telegramChatId,omitempty"`
+}
+
+type NotificationsTemplate struct {
+ EmailEnabled bool `json:"emailEnabled,omitempty"`
+ DiscordEnabled bool `json:"discordEnabled,omitempty"`
+ DiscordEnabledTypes int64 `json:"discordEnabledTypes,omitempty"`
+ PushoverSound any `json:"pushoverSound,omitempty"`
+ TelegramEnabled bool `json:"telegramEnabled,omitempty"`
+ TelegramSendSilently any `json:"telegramSendSilently,omitempty"`
+ WebPushEnabled bool `json:"webPushEnabled,omitempty"`
+ NotifTypes NotificationTypes `json:"notificationTypes"`
+}
+
+type MainUserSettings struct {
+ Username string `json:"username,omitempty"`
+ Email string `json:"email,omitempty"`
+ DiscordID string `json:"discordId,omitempty"`
+ Locale string `json:"locale,omitempty"`
+ Region string `json:"region,omitempty"`
+ OriginalLanguage any `json:"originalLanguage,omitempty"`
+ MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
+ MovieQuotaDays any `json:"movieQuotaDays,omitempty"`
+ TvQuotaLimit any `json:"tvQuotaLimit,omitempty"`
+ TvQuotaDays any `json:"tvQuotaDays,omitempty"`
+ WatchlistSyncMovies any `json:"watchlistSyncMovies,omitempty"`
+ WatchlistSyncTv any `json:"watchlistSyncTv,omitempty"`
+}
+
+type ErrorDTO struct {
+ Message string `json:"message,omitempty"`
+}
diff --git a/jellyseerrdaemon.go b/jellyseerrdaemon.go
new file mode 100644
index 0000000..f42bfbc
--- /dev/null
+++ b/jellyseerrdaemon.go
@@ -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
+}
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json
index 7de1729..15835cb 100644
--- a/lang/admin/en-us.json
+++ b/lang/admin/en-us.json
@@ -81,6 +81,9 @@
"useInviteExpiry": "Set expiry from profile/invite",
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
"applyHomescreenLayout": "Apply homescreen layout",
+ "applyConfigurationAndPolicy": "Apply Jellyfin configuration/policy",
+ "applyOmbi": "Apply Ombi profile (if available)",
+ "applyJellyseerr": "Apply Jellyseerr profile (if available)",
"sendDeleteNotificationEmail": "Send notification message",
"sendDeleteNotifiationExample": "Your account has been deleted.",
"settingsRestart": "Restart",
@@ -99,6 +102,8 @@
"settingsMaybeUnderAdvanced": "Tip: You might find what you're looking for by enabling Advanced Settings.",
"ombiProfile": "Ombi user profile",
"ombiUserDefaultsDescription": "Create an Ombi user and configure it, then select it below. It's settings/permissions will be stored and applied to new Ombi users created by jfa-go when this profile is selected.",
+ "jellyseerrProfile": "Jellyseerr user profile",
+ "jellyseerrUserDefaultsDescription": "Create a Jellyseerr user and configure it, then select it below. It's settings/permissions will be stored and applied to new Jellyseerr users created by jfa-go when this profile is selected.",
"userProfiles": "User Profiles",
"userProfilesDescription": "Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.",
"userProfilesIsDefault": "Default",
@@ -208,6 +213,7 @@
"sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.",
"setOmbiProfile": "Stored ombi profile.",
+ "savedProfile": "Stored profile changes.",
"updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.",
@@ -224,6 +230,7 @@
"errorDeleteProfile": "Failed to delete profile {n}",
"errorLoadProfiles": "Failed to load profiles.",
"errorCreateProfile": "Failed to create profile {n}",
+ "errorSavedProfile": "Failed to save profile {n}",
"errorSetDefaultProfile": "Failed to set default profile.",
"errorLoadUsers": "Failed to load users.",
"errorLoadSettings": "Failed to load settings.",
diff --git a/main.go b/main.go
index 55a7834..62407cc 100644
--- a/main.go
+++ b/main.go
@@ -25,6 +25,7 @@ import (
"github.com/hrfee/jfa-go/common"
_ "github.com/hrfee/jfa-go/docs"
"github.com/hrfee/jfa-go/easyproxy"
+ "github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger"
"github.com/hrfee/jfa-go/ombi"
"github.com/hrfee/mediabrowser"
@@ -101,6 +102,7 @@ type appContext struct {
jf *mediabrowser.MediaBrowser
authJf *mediabrowser.MediaBrowser
ombi *ombi.Ombi
+ js *jellyseerr.Jellyseerr
datePattern string
timePattern string
storage Storage
@@ -359,6 +361,19 @@ func start(asDaemon, firstCall bool) {
}
+ if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
+ app.debug.Printf("Connecting to Jellyseerr")
+ jellyseerrServer := app.config.Section("jellyseerr").Key("server").String()
+ app.js = jellyseerr.NewJellyseerr(
+ jellyseerrServer,
+ 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
+
+ }
+
app.storage.db_path = filepath.Join(app.dataPath, "db")
app.loadPendingBackup()
app.ConnectDB()
@@ -466,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(10*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()
@@ -482,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()
diff --git a/models.go b/models.go
index ac1a1df..399d497 100644
--- a/models.go
+++ b/models.go
@@ -16,6 +16,7 @@ type newUserDTO struct {
Username string `json:"username" example:"jeff" binding:"required"` // User's username
Password string `json:"password" example:"guest" binding:"required"` // User's password
Email string `json:"email" example:"jeff@jellyf.in"` // User's email address
+ EmailContact bool `json:"email_contact"` // Whether or not to use email for notifications/pwrs
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser)
TelegramPIN string `json:"telegram_pin" example:"A1-B2-3C"` // Telegram verification PIN (if used)
TelegramContact bool `json:"telegram_contact"` // Whether or not to use telegram for notifications/pwrs
@@ -76,6 +77,7 @@ type profileDTO struct {
LibraryAccess string `json:"libraries" example:"all"` // Number of libraries profile has access to
FromUser string `json:"fromUser" example:"jeff"` // The user the profile is based on
Ombi bool `json:"ombi"` // Whether or not Ombi settings are stored in this profile.
+ Jellyseerr bool `json:"jellyseerr"` // Whether or not Jellyseerr settings are stored in this profile.
ReferralsEnabled bool `json:"referrals_enabled" example:"true"` // Whether or not the profile has referrals enabled, and has a template invite stored.
}
@@ -172,11 +174,16 @@ type ombiUsersDTO struct {
type modifyEmailsDTO map[string]string
type userSettingsDTO struct {
- From string `json:"from"` // Whether to apply from "user" or "profile"
- Profile string `json:"profile"` // Name of profile (if from = "profile")
- ApplyTo []string `json:"apply_to"` // Users to apply settings to
- ID string `json:"id"` // ID of user (if from = "user")
- Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
+ From string `json:"from"` // Whether to apply from "user" or "profile"
+ Profile string `json:"profile"` // Name of profile (if from = "profile")
+ ApplyTo []string `json:"apply_to"` // Users to apply settings to
+ ID string `json:"id"` // ID of user (if from = "user")
+ // Note confusing name: "Configuration" on the admin UI just means it in the sense
+ // of the account's settings.
+ Policy bool `json:"configuration"` // Whether to apply jf policy not
+ Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not
+ Ombi bool `json:"ombi"` // Whether to apply ombi profile or not
+ Jellyseerr bool `json:"jellyseerr"` // Whether to apply jellyseerr profile or not
}
type announcementDTO struct {
diff --git a/router.go b/router.go
index 978da6e..85900a2 100644
--- a/router.go
+++ b/router.go
@@ -238,6 +238,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/users/discord/:username", app.DiscordGetUsers)
api.POST(p+"/users/discord", app.DiscordConnect)
}
+ if app.config.Section("jellyseerr").Key("enabled").MustBool(false) {
+ api.GET(p+"/jellyseerr/users", app.JellyseerrUsers)
+ api.POST(p+"/profiles/jellyseerr/:profile/:id", app.SetJellyseerrProfile)
+ api.DELETE(p+"/profiles/jellyseerr/:profile", app.DeleteJellyseerrProfile)
+ }
if app.config.Section("ombi").Key("enabled").MustBool(false) {
api.GET(p+"/ombi/users", app.OmbiUsers)
api.POST(p+"/profiles/ombi/:profile", app.SetOmbiProfile)
diff --git a/storage.go b/storage.go
index ff75989..2a10352 100644
--- a/storage.go
+++ b/storage.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/gin-gonic/gin"
+ "github.com/hrfee/jfa-go/jellyseerr"
"github.com/hrfee/jfa-go/logger"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
@@ -650,9 +651,16 @@ type Profile struct {
Displayprefs map[string]interface{} `json:"displayprefs,omitempty"`
Default bool `json:"default,omitempty"`
Ombi map[string]interface{} `json:"ombi,omitempty"`
+ Jellyseerr JellyseerrTemplate `json:"jellyseerr,omitempty"`
ReferralTemplateKey string
}
+type JellyseerrTemplate struct {
+ Enabled bool `json:"enabled,omitempty"`
+ User jellyseerr.UserTemplate `json:"user,omitempty"`
+ Notifications jellyseerr.NotificationsTemplate `json:"notifications,omitempty"`
+}
+
type Invite struct {
Code string `badgerhold:"key"`
Created time.Time `json:"created"`
diff --git a/ts/admin.ts b/ts/admin.ts
index e03fea8..333b9d1 100644
--- a/ts/admin.ts
+++ b/ts/admin.ts
@@ -49,6 +49,9 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.ombiProfile = new Modal(document.getElementById('modal-ombi-profile'));
document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiProfile.close);
+
+ window.modals.jellyseerrProfile = new Modal(document.getElementById('modal-jellyseerr-profile'));
+ document.getElementById('form-jellyseerr-defaults').addEventListener('submit', window.modals.jellyseerrProfile.close);
window.modals.profiles = new Modal(document.getElementById("modal-user-profiles"));
@@ -184,6 +187,7 @@ login.onLogin = () => {
console.log("Logged in.");
window.updater = new Updater();
// FIXME: Decide whether to autoload activity or not
+ window.invites.reload()
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
const currentTab = window.tabs.current;
switch (currentTab) {
diff --git a/ts/form.ts b/ts/form.ts
index 873ed79..21a09cc 100644
--- a/ts/form.ts
+++ b/ts/form.ts
@@ -224,6 +224,7 @@ if (window.emailRequired) {
interface sendDTO {
code: string;
email: string;
+ email_contact?: boolean;
username: string;
password: string;
telegram_pin?: string;
@@ -252,8 +253,9 @@ const create = (event: SubmitEvent) => {
code: window.code,
username: usernameField.value,
email: emailField.value,
+ email_contact: true,
password: passwordField.value
- };
+ }
if (telegramVerified) {
send.telegram_pin = window.telegramPIN;
const checkbox = document.getElementById("contact-via-telegram") as HTMLInputElement;
@@ -275,6 +277,10 @@ const create = (event: SubmitEvent) => {
send.matrix_contact = true;
}
}
+ if (matrixVerified || discordVerified || telegramVerified) {
+ const checkbox = document.getElementById("contact-via-email") as HTMLInputElement;
+ send.email_contact = checkbox.checked;
+ }
if (window.captcha) {
if (window.reCAPTCHA) {
send.captcha_text = grecaptcha.getResponse();
diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts
index e90b3ba..512e047 100644
--- a/ts/modules/accounts.ts
+++ b/ts/modules/accounts.ts
@@ -795,6 +795,11 @@ export class accountsList {
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
private _search: Search;
+ private _applyHomescreen = document.getElementById("modify-user-homescreen") as HTMLInputElement;
+ private _applyConfiguration = document.getElementById("modify-user-configuration") as HTMLInputElement;
+ private _applyOmbi = document.getElementById("modify-user-ombi") as HTMLInputElement;
+ private _applyJellyseerr = document.getElementById("modify-user-jellyseerr") as HTMLInputElement;
+
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
private _users: { [id: string]: user };
private _ordering: string[] = [];
@@ -1459,6 +1464,7 @@ export class accountsList {
const modalHeader = document.getElementById("header-modify-user");
modalHeader.textContent = window.lang.quantity("modifySettingsFor", this._collectUsers().length)
let list = this._collectUsers();
+
(() => {
let innerHTML = "";
for (const profile of window.availableProfiles) {
@@ -1477,6 +1483,7 @@ export class accountsList {
const form = document.getElementById("form-modify-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
+
this._modifySettingsProfile.checked = true;
this._modifySettingsUser.checked = false;
form.onsubmit = (event: Event) => {
@@ -1484,7 +1491,10 @@ export class accountsList {
toggleLoader(button);
let send = {
"apply_to": list,
- "homescreen": (document.getElementById("modify-user-homescreen") as HTMLInputElement).checked
+ "homescreen": this._applyHomescreen.checked,
+ "configuration": this._applyConfiguration.checked,
+ "ombi": this._applyOmbi.checked,
+ "jellyseerr": this._applyJellyseerr.checked
};
if (this._modifySettingsProfile.checked && !this._modifySettingsUser.checked) {
send["from"] = "profile";
@@ -1821,6 +1831,16 @@ export class accountsList {
};
this._modifySettings.onclick = this.modifyUsers;
this._modifySettings.classList.add("unfocused");
+
+ if (window.ombiEnabled)
+ this._applyOmbi.parentElement.classList.remove("unfocused");
+ else
+ this._applyOmbi.parentElement.classList.add("unfocused");
+ if (window.jellyseerrEnabled)
+ this._applyJellyseerr.parentElement.classList.remove("unfocused");
+ else
+ this._applyJellyseerr.parentElement.classList.add("unfocused");
+
const checkSource = () => {
const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement;
const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement;
@@ -1831,6 +1851,8 @@ export class accountsList {
profileSpan.classList.remove("@low");
userSpan.classList.remove("@high");
userSpan.classList.add("@low");
+ this._applyOmbi.parentElement.classList.remove("unfocused");
+ this._applyJellyseerr.parentElement.classList.remove("unfocused");
} else {
this._userSelect.parentElement.classList.remove("unfocused");
this._profileSelect.parentElement.classList.add("unfocused");
@@ -1838,6 +1860,8 @@ export class accountsList {
userSpan.classList.remove("@low");
profileSpan.classList.remove("@high");
profileSpan.classList.add("@low");
+ this._applyOmbi.parentElement.classList.add("unfocused");
+ this._applyJellyseerr.parentElement.classList.add("unfocused");
}
};
this._modifySettingsProfile.onchange = checkSource;
diff --git a/ts/modules/profiles.ts b/ts/modules/profiles.ts
index 6c8ef49..cd8d5e2 100644
--- a/ts/modules/profiles.ts
+++ b/ts/modules/profiles.ts
@@ -5,6 +5,7 @@ interface Profile {
libraries: string;
fromUser: string;
ombi: boolean;
+ jellyseerr: boolean;
referrals_enabled: boolean;
}
@@ -14,9 +15,11 @@ class profile implements Profile {
private _adminChip: HTMLSpanElement;
private _libraries: HTMLTableDataCellElement;
private _ombiButton: HTMLSpanElement;
+ private _ombi: boolean;
+ private _jellyseerrButton: HTMLSpanElement;
+ private _jellyseerr: boolean;
private _fromUser: HTMLTableDataCellElement;
private _defaultRadio: HTMLInputElement;
- private _ombi: boolean;
private _referralsButton: HTMLSpanElement;
private _referralsEnabled: boolean;
@@ -51,6 +54,21 @@ class profile implements Profile {
this._ombiButton.classList.remove("~critical");
}
}
+
+ get jellyseerr(): boolean { return this._jellyseerr; }
+ set jellyseerr(v: boolean) {
+ if (!window.jellyseerrEnabled) return;
+ this._jellyseerr = v;
+ if (v) {
+ this._jellyseerrButton.textContent = window.lang.strings("delete");
+ this._jellyseerrButton.classList.add("~critical");
+ this._jellyseerrButton.classList.remove("~neutral");
+ } else {
+ this._jellyseerrButton.textContent = window.lang.strings("add");
+ this._jellyseerrButton.classList.add("~neutral");
+ this._jellyseerrButton.classList.remove("~critical");
+ }
+ }
get fromUser(): string { return this._fromUser.textContent; }
set fromUser(v: string) { this._fromUser.textContent = v; }
@@ -82,6 +100,9 @@ class profile implements Profile {
if (window.ombiEnabled) innerHTML += `
|
`;
+ if (window.jellyseerrEnabled) innerHTML += `
+ |
+ `;
if (window.referralsEnabled) innerHTML += `
|
`;
@@ -96,6 +117,8 @@ class profile implements Profile {
this._libraries = this._row.querySelector("td.profile-libraries") as HTMLTableDataCellElement;
if (window.ombiEnabled)
this._ombiButton = this._row.querySelector("span.profile-ombi") as HTMLSpanElement;
+ if (window.jellyseerrEnabled)
+ this._jellyseerrButton = this._row.querySelector("span.profile-jellyseerr") as HTMLSpanElement;
if (window.referralsEnabled)
this._referralsButton = this._row.querySelector("span.profile-referrals") as HTMLSpanElement;
this._fromUser = this._row.querySelector("td.profile-from") as HTMLTableDataCellElement;
@@ -112,10 +135,12 @@ class profile implements Profile {
this.fromUser = p.fromUser;
this.libraries = p.libraries;
this.ombi = p.ombi;
+ this.jellyseerr = p.jellyseerr;
this.referrals_enabled = p.referrals_enabled;
}
setOmbiFunc = (ombiFunc: (ombi: boolean) => void) => { this._ombiButton.onclick = () => ombiFunc(this._ombi); }
+ setJellyseerrFunc = (jellyseerrFunc: (jellyseerr: boolean) => void) => { this._jellyseerrButton.onclick = () => jellyseerrFunc(this._jellyseerr); }
setReferralFunc = (referralFunc: (enabled: boolean) => void) => { this._referralsButton.onclick = () => referralFunc(this._referralsEnabled); }
remove = () => { document.dispatchEvent(new CustomEvent("profiles-delete", { detail: this._name })); this._row.remove(); }
@@ -144,6 +169,7 @@ export class ProfileEditor {
private _profiles: { [name: string]: profile } = {};
private _default: string;
private _ombiProfiles: ombiProfiles;
+ private _jellyseerrProfiles: jellyseerrProfiles;
private _createForm = document.getElementById("form-add-profile") as HTMLFormElement;
private _profileName = document.getElementById("add-profile-name") as HTMLInputElement;
@@ -181,7 +207,7 @@ export class ProfileEditor {
this._profiles[name].update(name, resp.profiles[name]);
} else {
this._profiles[name] = new profile(name, resp.profiles[name]);
- if (window.ombiEnabled)
+ if (window.ombiEnabled) {
this._profiles[name].setOmbiFunc((ombi: boolean) => {
if (ombi) {
this._ombiProfiles.delete(name, (req: XMLHttpRequest) => {
@@ -198,7 +224,26 @@ export class ProfileEditor {
this._ombiProfiles.load(name);
}
});
- if (window.referralsEnabled)
+ }
+ if (window.jellyseerrEnabled) {
+ this._profiles[name].setJellyseerrFunc((jellyseerr: boolean) => {
+ if (jellyseerr) {
+ this._jellyseerrProfiles.delete(name, (req: XMLHttpRequest) => {
+ if (req.readyState == 4) {
+ if (req.status != 204) {
+ window.notifications.customError("errorDeleteJellyseerr", window.lang.notif("errorUnknown"));
+ return;
+ }
+ this._profiles[name].jellyseerr = false;
+ }
+ });
+ } else {
+ window.modals.profiles.close();
+ this._jellyseerrProfiles.load(name);
+ }
+ });
+ }
+ if (window.referralsEnabled) {
this._profiles[name].setReferralFunc((enabled: boolean) => {
if (enabled) {
this.disableReferrals(name);
@@ -206,6 +251,7 @@ export class ProfileEditor {
this.enableReferrals(name);
}
});
+ }
this._table.appendChild(this._profiles[name].asElement());
}
}
@@ -299,6 +345,8 @@ export class ProfileEditor {
if (window.ombiEnabled)
this._ombiProfiles = new ombiProfiles();
+ if (window.jellyseerrEnabled)
+ this._jellyseerrProfiles = new jellyseerrProfiles();
this._createButton.onclick = () => _get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
@@ -366,7 +414,7 @@ export class ombiProfiles {
let resp = {} as ombiUser;
resp.id = this._select.value;
resp.name = this._users[resp.id];
- _post("/profiles/ombi/" + this._currentProfile, resp, (req: XMLHttpRequest) => {
+ _post("/profiles/ombi/" + encodeURIComponent(encodeURIComponent(this._currentProfile)), resp, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 200 || req.status == 204) {
@@ -379,7 +427,7 @@ export class ombiProfiles {
});
}
- delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/ombi/" + profile, null, post);
+ delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/ombi/" + encodeURIComponent(encodeURIComponent(profile)), null, post);
load = (profile: string) => {
this._currentProfile = profile;
@@ -401,3 +449,54 @@ export class ombiProfiles {
});
}
}
+
+export class jellyseerrProfiles {
+ private _form: HTMLFormElement;
+ private _select: HTMLSelectElement;
+ private _users: { [id: string]: string } = {};
+ private _currentProfile: string;
+
+ constructor() {
+ this._form = document.getElementById("form-jellyseerr-defaults") as HTMLFormElement;
+ this._form.onsubmit = this.send;
+ this._select = this._form.querySelector("select") as HTMLSelectElement;
+ }
+ send = () => {
+ const button = this._form.querySelector("span.submit") as HTMLSpanElement;
+ toggleLoader(button);
+ let encodedProfile = encodeURIComponent(encodeURIComponent(this._currentProfile));
+ _post("/profiles/jellyseerr/" + encodedProfile + "/" + this._select.value, null, (req: XMLHttpRequest) => {
+ if (req.readyState == 4) {
+ toggleLoader(button);
+ if (req.status == 200 || req.status == 204) {
+ window.notifications.customSuccess("jellyseerrDefaults", window.lang.notif("savedProfile"));
+ } else {
+ window.notifications.customError("jellyseerrDefaults", window.lang.notif("errorSavedProfile"));
+ }
+ window.modals.jellyseerrProfile.close();
+ }
+ });
+ }
+
+ delete = (profile: string, post?: (req: XMLHttpRequest) => void) => _delete("/profiles/jellyseerr/" + encodeURIComponent(encodeURIComponent(profile)), null, post);
+
+ load = (profile: string) => {
+ this._currentProfile = profile;
+ _get("/jellyseerr/users", null, (req: XMLHttpRequest) => {
+ if (req.readyState == 4) {
+ if (req.status == 200 && "users" in req.response) {
+ const users = req.response["users"] as ombiUser[];
+ let innerHTML = "";
+ for (let user of users) {
+ this._users[user.id] = user.name;
+ innerHTML += ``;
+ }
+ this._select.innerHTML = innerHTML;
+ window.modals.jellyseerrProfile.show();
+ } else {
+ window.notifications.customError("jellyseerrLoadError", window.lang.notif("errorLoadUsers"))
+ }
+ }
+ });
+ }
+}
diff --git a/ts/typings/d.ts b/ts/typings/d.ts
index 2055038..34beb70 100644
--- a/ts/typings/d.ts
+++ b/ts/typings/d.ts
@@ -24,6 +24,7 @@ declare interface Window {
discordEnabled: boolean;
matrixEnabled: boolean;
ombiEnabled: boolean;
+ jellyseerrEnabled: boolean;
usernameEnabled: boolean;
linkResetEnabled: boolean;
token: string;
@@ -101,6 +102,7 @@ declare interface Modals {
settingsRestart: Modal;
settingsRefresh: Modal;
ombiProfile?: Modal;
+ jellyseerrProfile?: Modal;
profiles: Modal;
addProfile: Modal;
announce: Modal;
diff --git a/userdaemon.go b/userdaemon.go
index abe8212..52bcaa8 100644
--- a/userdaemon.go
+++ b/userdaemon.go
@@ -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() {
diff --git a/views.go b/views.go
index e342191..dfb561a 100644
--- a/views.go
+++ b/views.go
@@ -133,6 +133,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool()
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
+ jellyseerrEnabled := app.config.Section("jellyseerr").Key("enabled").MustBool(false)
jfAdminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
jfAllowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
var license string
@@ -155,34 +156,35 @@ func (app *appContext) AdminPage(gc *gin.Context) {
}
gcHTML(gc, http.StatusOK, "admin.html", gin.H{
- "urlBase": app.getURLBase(gc),
- "cssClass": app.cssClass,
- "cssVersion": cssVersion,
- "contactMessage": "",
- "emailEnabled": emailEnabled,
- "telegramEnabled": telegramEnabled,
- "discordEnabled": discordEnabled,
- "matrixEnabled": matrixEnabled,
- "ombiEnabled": ombiEnabled,
- "linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false),
- "notifications": notificationsEnabled,
- "version": version,
- "commit": commit,
- "buildTime": buildTime,
- "builtBy": builtBy,
- "username": !app.config.Section("email").Key("no_username").MustBool(false),
- "strings": app.storage.lang.Admin[lang].Strings,
- "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings,
- "language": app.storage.lang.Admin[lang].JSON,
- "langName": lang,
- "license": license,
- "jellyfinLogin": app.jellyfinLogin,
- "jfAdminOnly": jfAdminOnly,
- "jfAllowAll": jfAllowAll,
- "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
- "showUserPageLink": app.config.Section("user_page").Key("show_link").MustBool(true),
- "referralsEnabled": app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false),
- "loginAppearance": app.config.Section("ui").Key("login_appearance").MustString("clear"),
+ "urlBase": app.getURLBase(gc),
+ "cssClass": app.cssClass,
+ "cssVersion": cssVersion,
+ "contactMessage": "",
+ "emailEnabled": emailEnabled,
+ "telegramEnabled": telegramEnabled,
+ "discordEnabled": discordEnabled,
+ "matrixEnabled": matrixEnabled,
+ "ombiEnabled": ombiEnabled,
+ "jellyseerrEnabled": jellyseerrEnabled,
+ "linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false),
+ "notifications": notificationsEnabled,
+ "version": version,
+ "commit": commit,
+ "buildTime": buildTime,
+ "builtBy": builtBy,
+ "username": !app.config.Section("email").Key("no_username").MustBool(false),
+ "strings": app.storage.lang.Admin[lang].Strings,
+ "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings,
+ "language": app.storage.lang.Admin[lang].JSON,
+ "langName": lang,
+ "license": license,
+ "jellyfinLogin": app.jellyfinLogin,
+ "jfAdminOnly": jfAdminOnly,
+ "jfAllowAll": jfAllowAll,
+ "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false),
+ "showUserPageLink": app.config.Section("user_page").Key("show_link").MustBool(true),
+ "referralsEnabled": app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false),
+ "loginAppearance": app.config.Section("ui").Key("login_appearance").MustString("clear"),
})
}
@@ -192,6 +194,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) {
emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool()
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
+ jellyseerrEnabled := app.config.Section("jellyseerr").Key("enabled").MustBool(false)
data := gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
@@ -203,6 +206,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) {
"discordEnabled": discordEnabled,
"matrixEnabled": matrixEnabled,
"ombiEnabled": ombiEnabled,
+ "jellyseerrEnabled": jellyseerrEnabled,
"pwrEnabled": app.config.Section("password_resets").Key("enabled").MustBool(false),
"linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false),
"notifications": notificationsEnabled,
@@ -278,6 +282,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
"strings": app.storage.lang.PasswordReset[lang].Strings,
"success": false,
"ombiEnabled": app.config.Section("ombi").Key("enabled").MustBool(false),
+ "jellyseerrEnabled": app.config.Section("jellyseerr").Key("enabled").MustBool(false),
"customSuccessCard": false,
}
pwr, isInternal := app.internalPWRs[pin]