mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-26 19:10:10 +00:00
Compare commits
15 Commits
dabef831d7
...
2d066ea7cd
Author | SHA1 | Date | |
---|---|---|---|
2d066ea7cd | |||
efa113ab5f | |||
d60dea61db | |||
a136800ff2 | |||
db1c62cc46 | |||
1fa340f096 | |||
2a6937228c | |||
785395dd20 | |||
385953b0cb | |||
35f8337a36 | |||
769a7c45da | |||
a97bccc88f | |||
7b9cdf385a | |||
73e985c45c | |||
9c34192b4f |
@ -1,5 +1,5 @@
|
||||
![jfa-go](images/banner.svg)
|
||||
[![Build Status](https://drone.hrfee.dev/api/badges/hrfee/jfa-go/status.svg?ref=refs/heads/main)](https://drone.hrfee.dev/hrfee/jfa-go)
|
||||
[![Build Status](https://ci.hrfee.dev/api/badges/3/status.svg)](https://ci.hrfee.dev/repos/3)
|
||||
[![Docker Hub](https://img.shields.io/docker/pulls/hrfee/jfa-go?label=docker)](https://hub.docker.com/r/hrfee/jfa-go)
|
||||
[![Translation status](https://weblate.jfa-go.com/widgets/jfa-go/-/svg-badge.svg)](https://weblate.jfa-go.com/engage/jfa-go/)
|
||||
[![Docs/Wiki](https://img.shields.io/static/v1?label=documentation&message=jfa-go.com&color=informational)](https://wiki.jfa-go.com)
|
||||
@ -13,13 +13,14 @@
|
||||
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
|
||||
|
||||
#### Does/Will it still work?
|
||||
jfa-go currently works on Jellyfin 10.8.13, the latest version as of 26/12/23. I should be able to maintain compatability in the future, unless any big changes occur.
|
||||
jfa-go currently works on Jellyfin 10.9.8, the latest version as of 31/07/2024. I should be able to maintain compatability in the future, unless any big changes occur.
|
||||
|
||||
#### Alternatives
|
||||
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.
|
||||
|
||||
* [Wizarr](https://github.com/Wizarrrr/wizarr) focuses on invites, and also includes some Discord & Ombi integration.
|
||||
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr, which can manage users and mainly acts as an Ombi alternative.
|
||||
* [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr which can manage users and mainly acts as an Ombi alternative.
|
||||
* [jfa-go now integrates with Jellyseerr, much like Ombi, but better.](https://github.com/hrfee/jfa-go/pull/351)
|
||||
* [Organizr](https://github.com/causefx/Organizr) doesn't focus on Jellyfin, but allows putting self-hosted services into "tabs" on a central page, and allows creating users, which lets one control who can access what.
|
||||
---
|
||||
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) (and [Emby](https://emby.media/) as 2nd class) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
|
||||
@ -32,7 +33,7 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly
|
||||
* Password validation: Ensure users choose a strong password.
|
||||
* CAPTCHAs and contact method verificatoin can be enabled to avoid bots.
|
||||
* ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too.
|
||||
* 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions. See [wiki](https://wiki.jfa-go.com/docs/ombi/) for a warning on this one.
|
||||
* 🔗 Ombi/Jellyseerr Integration: Automatically creates and synchronizes details for new accounts. Supports setting permissions with the Profiles feature. **Ombi integration use is risky, see [wiki](https://wiki.jfa-go.com/docs/ombi/)**.
|
||||
* Account management: Bulk or individually; apply settings, delete, disable/enable, send messages and much more.
|
||||
* 📣 Announcements: Bulk message your users with announcements about your server.
|
||||
* Telegram/Discord/Matrix Integration: Verify users via a chat bot, and send Password Resets, Announcements, etc. through it.
|
||||
|
100
api-jellyseerr.go
Normal file
100
api-jellyseerr.go
Normal file
@ -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)
|
||||
}
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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"),
|
||||
|
176
api-users.go
176
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
|
||||
}
|
||||
if req.Policy {
|
||||
policy = profile.Policy
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
}
|
||||
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 + "\""
|
||||
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,10 +1425,14 @@ 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)
|
||||
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)
|
||||
}
|
||||
|
15
backups.go
15
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
|
||||
}
|
||||
|
@ -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": {
|
||||
|
69
daemon.go
69
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
|
||||
}
|
||||
|
65
genericdaemon.go
Normal file
65
genericdaemon.go
Normal file
@ -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)
|
||||
}
|
5
go.mod
5
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
|
||||
|
@ -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 @@
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href="">
|
||||
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
|
||||
<div class="flex flex-row mb-4">
|
||||
<label class="grow mr-2">
|
||||
<div class="flex flex-col gap-4 my-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="grow">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
|
||||
</label>
|
||||
<label class="grow ml-2">
|
||||
<label class="grow">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="select ~neutral @low mb-4">
|
||||
<div class="select ~neutral @low">
|
||||
<select id="modify-user-profiles"></select>
|
||||
</div>
|
||||
<div class="select ~neutral @low mb-4 unfocused">
|
||||
<div class="select ~neutral @low unfocused">
|
||||
<select id="modify-user-users"></select>
|
||||
</div>
|
||||
<label class="switch mb-4">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-configuration" checked>
|
||||
<span>{{ .strings.applyConfigurationAndPolicy }}</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-homescreen" checked>
|
||||
<span>{{ .strings.applyHomescreenLayout }}</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-ombi" checked>
|
||||
<span>{{ .strings.applyOmbi }}</span>
|
||||
</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="modify-user-jellyseerr" checked>
|
||||
<span>{{ .strings.applyJellyseerr }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ if .referralsEnabled }}
|
||||
@ -396,6 +411,19 @@
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-jellyseerr-profile" class="modal">
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-jellyseerr-defaults" href="">
|
||||
<span class="heading">{{ .strings.jellyseerrProfile }} <span class="modal-close">×</span></span>
|
||||
<p class="content my-4">{{ .strings.jellyseerrUserDefaultsDescription }}</p>
|
||||
<div class="select ~neutral @low mb-4">
|
||||
<select></select>
|
||||
</div>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.submit }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<div id="modal-user-profiles" class="modal">
|
||||
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card">
|
||||
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">×</span></span>
|
||||
@ -409,6 +437,9 @@
|
||||
{{ if .ombiEnabled }}
|
||||
<th>Ombi</th>
|
||||
{{ end }}
|
||||
{{ if .jellyseerrEnabled }}
|
||||
<th>Jellyseerr</th>
|
||||
{{ end }}
|
||||
{{ if .referralsEnabled }}
|
||||
<th>{{ .strings.referrals }}</th>
|
||||
{{ end }}
|
||||
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 56 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 72 KiB |
7
jellyseerr/go.mod
Normal file
7
jellyseerr/go.mod
Normal file
@ -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
|
460
jellyseerr/jellyseerr.go
Normal file
460
jellyseerr/jellyseerr.go
Normal file
@ -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
|
||||
}
|
69
jellyseerr/jellyseerr_test.go
Normal file
69
jellyseerr/jellyseerr_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
136
jellyseerr/models.go
Normal file
136
jellyseerr/models.go
Normal file
@ -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"`
|
||||
}
|
81
jellyseerrdaemon.go
Normal file
81
jellyseerrdaemon.go
Normal file
@ -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
|
||||
}
|
@ -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.",
|
||||
|
29
main.go
29
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()
|
||||
|
@ -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.
|
||||
}
|
||||
|
||||
@ -176,7 +178,12 @@ type userSettingsDTO struct {
|
||||
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 {
|
||||
|
@ -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)
|
||||
|
@ -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"`
|
||||
|
@ -50,6 +50,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"));
|
||||
|
||||
window.modals.addProfile = new Modal(document.getElementById("modal-add-profile"));
|
||||
@ -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) {
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
@ -52,6 +55,21 @@ class profile implements Profile {
|
||||
}
|
||||
}
|
||||
|
||||
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 += `
|
||||
<td><span class="button @low profile-ombi"></span></td>
|
||||
`;
|
||||
if (window.jellyseerrEnabled) innerHTML += `
|
||||
<td><span class="button @low profile-jellyseerr"></span></td>
|
||||
`;
|
||||
if (window.referralsEnabled) innerHTML += `
|
||||
<td><span class="button @low profile-referrals"></span></td>
|
||||
`;
|
||||
@ -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 += `<option value="${user.id}">${user.name}</option>`;
|
||||
}
|
||||
this._select.innerHTML = innerHTML;
|
||||
window.modals.jellyseerrProfile.show();
|
||||
} else {
|
||||
window.notifications.customError("jellyseerrLoadError", window.lang.notif("errorLoadUsers"))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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() {
|
||||
|
5
views.go
5
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
|
||||
@ -164,6 +165,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
|
||||
"discordEnabled": discordEnabled,
|
||||
"matrixEnabled": matrixEnabled,
|
||||
"ombiEnabled": ombiEnabled,
|
||||
"jellyseerrEnabled": jellyseerrEnabled,
|
||||
"linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false),
|
||||
"notifications": notificationsEnabled,
|
||||
"version": version,
|
||||
@ -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]
|
||||
|
Loading…
Reference in New Issue
Block a user