mirror of https://github.com/hrfee/jfa-go
commit
efa113ab5f
@ -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)
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
@ -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
|
@ -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
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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"`
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
Loading…
Reference in new issue