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 }