From 9c34192b4f33d3879aa0f0191d126d7f9895425f Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 29 Jul 2024 16:46:37 +0100 Subject: [PATCH 01/13] jellyseerr: start API client Currently uses an API key (Seems simpler for the user than importing the jfa-go user and granting perms). Strategy as follows: * MustGetUser(jfID) function checks the cache for user, if not, calls Jellyseerr's importer passing jfID. From either, the user object is returned, which (in later commits) can be used to update the user's email (and potentially other info). My API key is in there rn but its for a local testing instance, who cares. --- jellyseerr/go.mod | 7 + jellyseerr/jellyseerr.go | 238 ++++++++++++++++++++++++++++++++++ jellyseerr/jellyseerr_test.go | 49 +++++++ jellyseerr/models.go | 41 ++++++ 4 files changed, 335 insertions(+) create mode 100644 jellyseerr/go.mod create mode 100644 jellyseerr/jellyseerr.go create mode 100644 jellyseerr/jellyseerr_test.go create mode 100644 jellyseerr/models.go diff --git a/jellyseerr/go.mod b/jellyseerr/go.mod new file mode 100644 index 0000000..4bee00c --- /dev/null +++ b/jellyseerr/go.mod @@ -0,0 +1,7 @@ +module github.com/hrfee/jfa-go/jellyseerr + +replace github.com/hrfee/jfa-go/common => ../common + +go 1.18 + +require github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769 // indirect diff --git a/jellyseerr/jellyseerr.go b/jellyseerr/jellyseerr.go new file mode 100644 index 0000000..ed41863 --- /dev/null +++ b/jellyseerr/jellyseerr.go @@ -0,0 +1,238 @@ +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" +) + +// 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 +} + +// 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{}, + } +} + +// does a GET and returns the response as a string. +func (js *Jellyseerr) getJSON(url string, params map[string]string, queryParams url.Values) (string, int, error) { + if js.key == "" { + return "", 401, fmt.Errorf("No API key provided") + } + var req *http.Request + if params != nil { + jsonParams, _ := json.Marshal(params) + req, _ = http.NewRequest("GET", url+"?"+queryParams.Encode(), bytes.NewBuffer(jsonParams)) + } else { + req, _ = http.NewRequest("GET", url+"?"+queryParams.Encode(), nil) + } + for name, value := range js.header { + req.Header.Add(name, value) + } + resp, err := js.httpClient.Do(req) + defer js.timeoutHandler() + if err != nil || resp.StatusCode != 200 { + if resp.StatusCode == 401 { + return "", 401, fmt.Errorf("Invalid API Key") + } + return "", resp.StatusCode, err + } + defer resp.Body.Close() + var data io.Reader + switch resp.Header.Get("Content-Encoding") { + case "gzip": + data, _ = gzip.NewReader(resp.Body) + default: + data = resp.Body + } + buf := new(strings.Builder) + _, err = io.Copy(buf, data) + if err != nil { + return "", 500, err + } + return buf.String(), resp.StatusCode, nil +} + +// does a POST and optionally returns response as string. Returns a string instead of an io.reader bcs i couldn't get it working otherwise. +func (js *Jellyseerr) send(mode string, url string, data interface{}, response bool, headers map[string]string) (string, int, error) { + responseText := "" + params, _ := json.Marshal(data) + req, _ := http.NewRequest(mode, url, bytes.NewBuffer(params)) + req.Header.Add("Content-Type", "application/json") + for name, value := range js.header { + req.Header.Add(name, value) + } + for name, value := range headers { + req.Header.Add(name, value) + } + resp, err := js.httpClient.Do(req) + defer js.timeoutHandler() + if err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201) { + if resp.StatusCode == 401 { + return "", 401, fmt.Errorf("Invalid API Key") + } + return responseText, resp.StatusCode, err + } + if response { + defer resp.Body.Close() + 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 "", 500, err + } + responseText = buf.String() + } + return responseText, resp.StatusCode, nil +} + +func (js *Jellyseerr) post(url string, data map[string]interface{}, response bool) (string, int, error) { + return js.send("POST", url, data, response, nil) +} + +func (js *Jellyseerr) put(url string, data map[string]interface{}, response bool) (string, int, error) { + return js.send("PUT", url, data, response, nil) +} + +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(0) + 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)) + params.Add("sort", "created") + resp, status, err := js.getJSON(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 +} + +// MustGetUser provides the same function as ImportFromJellyfin, but will always return the user, +// even if they already existed. +func (js *Jellyseerr) MustGetUser(jfID string) (User, error) { + js.getUsers() + if u, ok := js.userCache[jfID]; ok { + return u, nil + } + users, err := js.ImportFromJellyfin(jfID) + var u User + if err != nil { + return u, err + } + if len(users) != 0 { + return users[0], err + } + if u, ok := js.userCache[jfID]; ok { + return u, nil + } + return u, fmt.Errorf("user not found") +} + +func (js *Jellyseerr) Me() (User, error) { + resp, status, err := js.getJSON(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 +} diff --git a/jellyseerr/jellyseerr_test.go b/jellyseerr/jellyseerr_test.go new file mode 100644 index 0000000..6159ad7 --- /dev/null +++ b/jellyseerr/jellyseerr_test.go @@ -0,0 +1,49 @@ +package jellyseerr + +import ( + "testing" + + "github.com/hrfee/jfa-go/common" +) + +const ( + API_KEY = "MTcyMjI2MDM2MTYyMzMxNDZkZmYyLTE4MzMtNDUyNy1hODJlLTI0MTZkZGUyMDg2Ng==" + URI = "http://localhost:5055" +) + +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") + } +} diff --git a/jellyseerr/models.go b/jellyseerr/models.go new file mode 100644 index 0000000..438c1dc --- /dev/null +++ b/jellyseerr/models.go @@ -0,0 +1,41 @@ +package jellyseerr + +import "time" + +type User struct { + Permissions int `json:"permissions"` + Warnings []any `json:"warnings"` + ID int `json:"id"` + Email string `json:"email"` + PlexUsername string `json:"plexUsername"` + JellyfinUsername string `json:"jellyfinUsername"` + Username string `json:"username"` + RecoveryLinkExpirationDate any `json:"recoveryLinkExpirationDate"` + UserType int `json:"userType"` + PlexID string `json:"plexId"` + JellyfinUserID string `json:"jellyfinUserId"` + JellyfinDeviceID string `json:"jellyfinDeviceId"` + JellyfinAuthToken string `json:"jellyfinAuthToken"` + PlexToken string `json:"plexToken"` + Avatar string `json:"avatar"` + MovieQuotaLimit any `json:"movieQuotaLimit"` + MovieQuotaDays any `json:"movieQuotaDays"` + TvQuotaLimit any `json:"tvQuotaLimit"` + TvQuotaDays any `json:"tvQuotaDays"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RequestCount int `json:"requestCount"` + DisplayName string `json:"displayName"` +} + +type PageInfo struct { + Pages int `json:"pages"` + PageSize int `json:"pageSize"` + Results int `json:"results"` + Page int `json:"page"` +} + +type GetUsersDTO struct { + Page PageInfo `json:"pageInfo"` + Results []User `json:"results"` +} From 73e985c45c21f676821a14125e70c2b575be511b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 29 Jul 2024 17:26:14 +0100 Subject: [PATCH 02/13] jellyseerr: add user modification methods addded permissions get/set before realizing it already comes as part of the User object. Split User attributes that will be templated into UserTemplate struct, which User inherits. ApplyTemplateToUser takes a UserTemplate, while ModifyUser takes a plain map with some typed fields (display name and email, for now). --- jellyseerr/jellyseerr.go | 95 +++++++++++++++++++++++++++++++++-- jellyseerr/jellyseerr_test.go | 24 ++++++++- jellyseerr/models.go | 29 ++++++++--- 3 files changed, 137 insertions(+), 11 deletions(-) diff --git a/jellyseerr/jellyseerr.go b/jellyseerr/jellyseerr.go index ed41863..82659fc 100644 --- a/jellyseerr/jellyseerr.go +++ b/jellyseerr/jellyseerr.go @@ -91,7 +91,7 @@ func (js *Jellyseerr) getJSON(url string, params map[string]string, queryParams } // does a POST and optionally returns response as string. Returns a string instead of an io.reader bcs i couldn't get it working otherwise. -func (js *Jellyseerr) send(mode string, url string, data interface{}, response bool, headers map[string]string) (string, int, error) { +func (js *Jellyseerr) send(mode string, url string, data any, response bool, headers map[string]string) (string, int, error) { responseText := "" params, _ := json.Marshal(data) req, _ := http.NewRequest(mode, url, bytes.NewBuffer(params)) @@ -129,11 +129,11 @@ func (js *Jellyseerr) send(mode string, url string, data interface{}, response b return responseText, resp.StatusCode, nil } -func (js *Jellyseerr) post(url string, data map[string]interface{}, response bool) (string, int, error) { +func (js *Jellyseerr) post(url string, data any, response bool) (string, int, error) { return js.send("POST", url, data, response, nil) } -func (js *Jellyseerr) put(url string, data map[string]interface{}, response bool) (string, int, error) { +func (js *Jellyseerr) put(url string, data any, response bool) (string, int, error) { return js.send("PUT", url, data, response, nil) } @@ -236,3 +236,92 @@ func (js *Jellyseerr) Me() (User, error) { err = json.Unmarshal([]byte(resp), &data) return data, err } + +func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) { + data := permissionsDTO{Permissions: -1} + u, err := js.MustGetUser(jfID) + if err != nil { + return data.Permissions, err + } + + resp, status, err := js.getJSON(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.MustGetUser(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.MustGetUser(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]string) error { + u, err := js.MustGetUser(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.MustGetUser(jfID) + if err != nil { + return err + } + + _, status, err := js.send("DELETE", fmt.Sprintf(js.server+"/user/%d", u.ID), nil, false, nil) + if status != 200 && status != 201 { + return fmt.Errorf("failed (error %d)", status) + } + if err != nil { + return err + } + delete(js.userCache, jfID) + return err +} diff --git a/jellyseerr/jellyseerr_test.go b/jellyseerr/jellyseerr_test.go index 6159ad7..1180758 100644 --- a/jellyseerr/jellyseerr_test.go +++ b/jellyseerr/jellyseerr_test.go @@ -9,6 +9,7 @@ import ( const ( API_KEY = "MTcyMjI2MDM2MTYyMzMxNDZkZmYyLTE4MzMtNDUyNy1hODJlLTI0MTZkZGUyMDg2Ng==" URI = "http://localhost:5055" + PERM = 2097184 ) func client() *Jellyseerr { @@ -26,7 +27,7 @@ func TestMe(t *testing.T) { } } -func TestImportFromJellyfin(t *testing.T) { +/* func TestImportFromJellyfin(t *testing.T) { js := client() list, err := js.ImportFromJellyfin("6b75e189efb744f583aa2e8e9cee41d3") if err != nil { @@ -35,7 +36,7 @@ func TestImportFromJellyfin(t *testing.T) { if len(list) == 0 { t.Fatalf("returned no users") } -} +} */ func TestMustGetUser(t *testing.T) { js := client() @@ -47,3 +48,22 @@ func TestMustGetUser(t *testing.T) { t.Fatalf("returned no users") } } + +func TestSetPermissions(t *testing.T) { + js := client() + err := js.SetPermissions("6b75e189efb744f583aa2e8e9cee41d3", PERM) + if err != nil { + t.Fatalf("returned error %+v", err) + } +} + +func TestGetPermissions(t *testing.T) { + js := client() + perm, err := js.GetPermissions("6b75e189efb744f583aa2e8e9cee41d3") + if err != nil { + t.Fatalf("returned error %+v", err) + } + if perm != PERM { + t.Fatalf("got unexpected perm code %d", perm) + } +} diff --git a/jellyseerr/models.go b/jellyseerr/models.go index 438c1dc..4cc3c02 100644 --- a/jellyseerr/models.go +++ b/jellyseerr/models.go @@ -2,8 +2,15 @@ package jellyseerr import "time" +type UserField string + +const ( + FieldDisplayName UserField = "displayName" + FieldEmail UserField = "email" +) + type User struct { - Permissions int `json:"permissions"` + UserTemplate // Note: You can set this with User.UserTemplate = value. Warnings []any `json:"warnings"` ID int `json:"id"` Email string `json:"email"` @@ -11,23 +18,27 @@ type User struct { JellyfinUsername string `json:"jellyfinUsername"` Username string `json:"username"` RecoveryLinkExpirationDate any `json:"recoveryLinkExpirationDate"` - UserType int `json:"userType"` PlexID string `json:"plexId"` JellyfinUserID string `json:"jellyfinUserId"` JellyfinDeviceID string `json:"jellyfinDeviceId"` JellyfinAuthToken string `json:"jellyfinAuthToken"` PlexToken string `json:"plexToken"` Avatar string `json:"avatar"` - MovieQuotaLimit any `json:"movieQuotaLimit"` - MovieQuotaDays any `json:"movieQuotaDays"` - TvQuotaLimit any `json:"tvQuotaLimit"` - TvQuotaDays any `json:"tvQuotaDays"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` RequestCount int `json:"requestCount"` DisplayName string `json:"displayName"` } +type UserTemplate struct { + Permissions Permissions `json:"permissions"` + UserType int `json:"userType"` + MovieQuotaLimit any `json:"movieQuotaLimit"` + MovieQuotaDays any `json:"movieQuotaDays"` + TvQuotaLimit any `json:"tvQuotaLimit"` + TvQuotaDays any `json:"tvQuotaDays"` +} + type PageInfo struct { Pages int `json:"pages"` PageSize int `json:"pageSize"` @@ -39,3 +50,9 @@ type GetUsersDTO struct { Page PageInfo `json:"pageInfo"` Results []User `json:"results"` } + +type permissionsDTO struct { + Permissions Permissions `json:"permissions"` +} + +type Permissions int From 7b9cdf385a25639528bae025aa1f6f2e5c133ed7 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 29 Jul 2024 17:56:28 +0100 Subject: [PATCH 03/13] jellyseerr: add notifications related methods similar in style to User, with Notifications/NotificationsTemplate, and named fields for modifying discord and telegram IDs, and two modify methods. --- jellyseerr/jellyseerr.go | 50 ++++++++++++++++++++++++++++++++++++++++ jellyseerr/models.go | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/jellyseerr/jellyseerr.go b/jellyseerr/jellyseerr.go index 82659fc..9dcf5b8 100644 --- a/jellyseerr/jellyseerr.go +++ b/jellyseerr/jellyseerr.go @@ -325,3 +325,53 @@ func (js *Jellyseerr) DeleteUser(jfID string) error { delete(js.userCache, jfID) return err } + +func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) { + var data Notifications + u, err := js.MustGetUser(jfID) + if err != nil { + return data, err + } + + resp, status, err := js.getJSON(fmt.Sprintf(js.server+"/user/%d/settings/notifications", u.ID), 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 { + u, err := js.MustGetUser(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]string) error { + u, err := js.MustGetUser(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 +} diff --git a/jellyseerr/models.go b/jellyseerr/models.go index 4cc3c02..4ec8d4b 100644 --- a/jellyseerr/models.go +++ b/jellyseerr/models.go @@ -56,3 +56,45 @@ type permissionsDTO struct { } type Permissions int + +type NotificationTypes struct { + Discord int `json:"discord"` + Email int `json:"email"` + Pushbullet int `json:"pushbullet"` + Pushover int `json:"pushover"` + Slack int `json:"slack"` + Telegram int `json:"telegram"` + Webhook int `json:"webhook"` + Webpush int `json:"webpush"` +} + +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"` + DiscordID string `json:"discordId"` + PushbulletAccessToken any `json:"pushbulletAccessToken"` + PushoverApplicationToken any `json:"pushoverApplicationToken"` + PushoverUserKey any `json:"pushoverUserKey"` + TelegramChatID string `json:"telegramChatId"` +} + +type NotificationsTemplate struct { + EmailEnabled bool `json:"emailEnabled"` + DiscordEnabled bool `json:"discordEnabled"` + DiscordEnabledTypes int `json:"discordEnabledTypes"` + PushoverSound any `json:"pushoverSound"` + TelegramEnabled bool `json:"telegramEnabled"` + TelegramSendSilently any `json:"telegramSendSilently"` + WebPushEnabled bool `json:"webPushEnabled"` + NotifTypes NotificationTypes `json:"notificationTypes"` +} From a97bccc88f4b6ac96c6e645c045131ae790333a8 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 30 Jul 2024 16:36:59 +0100 Subject: [PATCH 04/13] jellyseerr: use in profiles, apply on user creation and modification added in the same way as ombi profiles. Most code is copy-pasted and adjusted from ombi (especially on web), so maybe this can be merged in the future. Also, profile names are url-escaped like announcement template names were not too long ago. API client has "LogRequestBodies" option which just dumps the request body when enabled (useful for recreating reqs in the jellyseerr swagger UI). User.Name() helper returns a name from all three possible values in the struct. --- api-jellyseerr.go | 100 +++++++++++++++++++++++++++++++++ api-ombi.go | 7 ++- api-profiles.go | 1 + api-users.go | 102 ++++++++++++++++++++++++++++++++++ config/config-base.json | 35 ++++++++++++ go.mod | 5 +- html/admin.html | 17 ++++++ jellyseerr/jellyseerr.go | 61 ++++++++++++++------ jellyseerr/models.go | 117 ++++++++++++++++++++++----------------- lang/admin/en-us.json | 4 ++ main.go | 13 +++++ models.go | 2 + router.go | 5 ++ storage.go | 8 +++ ts/admin.ts | 3 + ts/form.ts | 8 ++- ts/modules/profiles.ts | 109 ++++++++++++++++++++++++++++++++++-- ts/typings/d.ts | 2 + views.go | 61 ++++++++++---------- 19 files changed, 555 insertions(+), 105 deletions(-) create mode 100644 api-jellyseerr.go diff --git a/api-jellyseerr.go b/api-jellyseerr.go new file mode 100644 index 0000000..5ced607 --- /dev/null +++ b/api-jellyseerr.go @@ -0,0 +1,100 @@ +package main + +import ( + "net/url" + "strconv" + + "github.com/gin-gonic/gin" +) + +// @Summary Get a list of Jellyseerr users. +// @Produce json +// @Success 200 {object} ombiUsersDTO +// @Failure 500 {object} stringResponse +// @Router /jellyseerr/users [get] +// @Security Bearer +// @tags Jellyseerr +func (app *appContext) JellyseerrUsers(gc *gin.Context) { + app.debug.Println("Jellyseerr users requested") + users, err := app.js.GetUsers() + if err != nil { + app.err.Printf("Failed to get users from Jellyseerr: %v", err) + respond(500, "Couldn't get users", gc) + return + } + app.debug.Printf("Jellyseerr users retrieved: %d", len(users)) + userlist := make([]ombiUser, len(users)) + i := 0 + for _, u := range users { + userlist[i] = ombiUser{ + Name: u.Name(), + ID: strconv.FormatInt(u.ID, 10), + } + i++ + } + gc.JSON(200, ombiUsersDTO{Users: userlist}) +} + +// @Summary Store Jellyseerr user template in an existing profile. +// @Produce json +// @Param id path string true "Jellyseerr ID of user to source from" +// @Param profile path string true "Name of profile to store in" +// @Success 200 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /profiles/jellyseerr/{profile}/{id} [post] +// @Security Bearer +// @tags Jellyseerr +func (app *appContext) SetJellyseerrProfile(gc *gin.Context) { + jellyseerrID, err := strconv.ParseInt(gc.Param("id"), 10, 64) + if err != nil { + respondBool(400, false, gc) + return + } + escapedProfileName := gc.Param("profile") + profileName, _ := url.QueryUnescape(escapedProfileName) + profile, ok := app.storage.GetProfileKey(profileName) + if !ok { + respondBool(400, false, gc) + return + } + u, err := app.js.UserByID(jellyseerrID) + if err != nil { + app.err.Printf("Couldn't get user from Jellyseerr: %v", err) + respond(500, "Couldn't get user", gc) + return + } + profile.Jellyseerr.User = u.UserTemplate + n, err := app.js.GetNotificationPreferencesByID(jellyseerrID) + if err != nil { + app.err.Printf("Couldn't get user's notification prefs from Jellyseerr: %v", err) + respond(500, "Couldn't get user notification prefs", gc) + return + } + profile.Jellyseerr.Notifications = n.NotificationsTemplate + profile.Jellyseerr.Enabled = true + app.storage.SetProfileKey(profileName, profile) + respondBool(204, true, gc) +} + +// @Summary Remove jellyseerr user template from a profile. +// @Produce json +// @Param profile path string true "Name of profile to store in" +// @Success 200 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Router /profiles/jellyseerr/{profile} [delete] +// @Security Bearer +// @tags Jellyseerr +func (app *appContext) DeleteJellyseerrProfile(gc *gin.Context) { + escapedProfileName := gc.Param("profile") + profileName, _ := url.QueryUnescape(escapedProfileName) + profile, ok := app.storage.GetProfileKey(profileName) + if !ok { + respondBool(400, false, gc) + return + } + profile.Jellyseerr.Enabled = false + app.storage.SetProfileKey(profileName, profile) + respondBool(204, true, gc) +} diff --git a/api-ombi.go b/api-ombi.go index ca148aa..ab9b534 100644 --- a/api-ombi.go +++ b/api-ombi.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net/url" "github.com/gin-gonic/gin" "github.com/hrfee/mediabrowser" @@ -95,7 +96,8 @@ func (app *appContext) OmbiUsers(gc *gin.Context) { func (app *appContext) SetOmbiProfile(gc *gin.Context) { var req ombiUser gc.BindJSON(&req) - profileName := gc.Param("profile") + escapedProfileName := gc.Param("profile") + profileName, _ := url.QueryUnescape(escapedProfileName) profile, ok := app.storage.GetProfileKey(profileName) if !ok { respondBool(400, false, gc) @@ -122,7 +124,8 @@ func (app *appContext) SetOmbiProfile(gc *gin.Context) { // @Security Bearer // @tags Ombi func (app *appContext) DeleteOmbiProfile(gc *gin.Context) { - profileName := gc.Param("profile") + escapedProfileName := gc.Param("profile") + profileName, _ := url.QueryUnescape(escapedProfileName) profile, ok := app.storage.GetProfileKey(profileName) if !ok { respondBool(400, false, gc) diff --git a/api-profiles.go b/api-profiles.go index ccbac7d..482abbb 100644 --- a/api-profiles.go +++ b/api-profiles.go @@ -27,6 +27,7 @@ func (app *appContext) GetProfiles(gc *gin.Context) { LibraryAccess: p.LibraryAccess, FromUser: p.FromUser, Ombi: p.Ombi != nil, + Jellyseerr: p.Jellyseerr.Enabled, ReferralsEnabled: false, } if referralsEnabled { diff --git a/api-users.go b/api-users.go index 64d1997..ddafe08 100644 --- a/api-users.go +++ b/api-users.go @@ -4,11 +4,13 @@ import ( "fmt" "net/url" "os" + "strconv" "strings" "time" "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 +96,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 +363,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 +497,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.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) + } 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] = strconv.FormatInt(u.ChatID, 10) + 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) @@ -1265,6 +1339,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) @@ -1288,6 +1364,11 @@ func (app *appContext) ApplySettings(gc *gin.Context) { ombi = profile.Ombi } } + if app.config.Section("jellyseerr").Key("enabled").MustBool(false) { + if profile.Jellyseerr.Enabled { + jellyseerr = profile.Jellyseerr + } + } } else if req.From == "user" { applyingFrom = "user" @@ -1315,6 +1396,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 @@ -1367,6 +1449,26 @@ func (app *appContext) ApplySettings(gc *gin.Context) { errors["ombi"][id] = errorString } } + if jellyseerr.Enabled { + errorString := "" + // newUser := ombi + // newUser["id"] = user["id"] + // newUser["userName"] = user["userName"] + // newUser["alias"] = user["alias"] + // newUser["emailAddress"] = user["emailAddress"] + err := app.js.ApplyTemplateToUser(id, jellyseerr.User) + if err != nil { + errorString += fmt.Sprintf("ApplyUser: %v ", err) + } + err = app.js.ApplyNotificationsTemplateToUser(id, jellyseerr.Notifications) + if err != nil { + errorString += fmt.Sprintf("ApplyNotifications: %v ", err) + } + if errorString != "" { + errors["jellyseerr"][id] = errorString + } + } + if shouldDelay { time.Sleep(250 * time.Millisecond) } diff --git a/config/config-base.json b/config/config-base.json index 1b3bdd2..e18224a 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -1580,6 +1580,41 @@ } } }, + "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." + }, + "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." + } + } + }, "backups": { "order": [], "meta": { diff --git a/go.mod b/go.mod index e9969af..de1d06d 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,8 @@ replace github.com/hrfee/jfa-go/api => ./api replace github.com/hrfee/jfa-go/easyproxy => ./easyproxy +replace github.com/hrfee/jfa-go/jellyseerr => ./jellyseerr + require ( github.com/bwmarrin/discordgo v0.27.1 github.com/dgraph-io/badger/v3 v3.2103.5 @@ -29,7 +31,7 @@ require ( github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a - github.com/hrfee/jfa-go/common v0.0.0-20230626224816-f72960635dc3 + github.com/hrfee/jfa-go/common v0.0.0-20240728190513-dabef831d769 github.com/hrfee/jfa-go/docs v0.0.0-20230626224816-f72960635dc3 github.com/hrfee/jfa-go/easyproxy v0.0.0-00010101000000-000000000000 github.com/hrfee/jfa-go/linecache v0.0.0-20230626224816-f72960635dc3 @@ -88,6 +90,7 @@ require ( github.com/google/uuid v1.3.0 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect + github.com/hrfee/jfa-go/jellyseerr v0.0.0-00010101000000-000000000000 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.16.6 // indirect diff --git a/html/admin.html b/html/admin.html index 1dc3ad9..f7de13e 100644 --- a/html/admin.html +++ b/html/admin.html @@ -10,6 +10,7 @@ window.discordEnabled = {{ .discordEnabled }}; window.matrixEnabled = {{ .matrixEnabled }}; window.ombiEnabled = {{ .ombiEnabled }}; + window.jellyseerrEnabled = {{ .jellyseerrEnabled }}; window.usernameEnabled = {{ .username }}; window.langFile = JSON.parse({{ .language }}); window.linkResetEnabled = {{ .linkResetEnabled }}; @@ -396,6 +397,19 @@ +