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"` +}