1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-26 19:10:10 +00:00

Compare commits

...

4 Commits

Author SHA1 Message Date
d60dea61db
accounts: make all components of profile application optional
Basically, added the ability to -not- apply the profile's policy.
2024-07-31 15:54:05 +01:00
a136800ff2
jellyseerr: set appropriate daemon period
was 30s for testing, is now every 10 minutes.
2024-07-31 15:32:49 +01:00
db1c62cc46
jellyseerr: cleanup requests method, read proper errors
single req() function is wrapped by methods for each http method, and
error messages are parsed and returned if given by the server.

also added note about Jellyseerr's enforcement of unique email addresses
in settings.
2024-07-31 15:31:11 +01:00
1fa340f096
jellyseerr: add option to auto-import users
"import_existing" option in settings enables an every 5-minute daemon
which loops through users and imports them to Jellyseerr and copies
contact info, if necessary. Also sets new API client flag
AutoImportUsers, which decides whether to automatically import non-existent users in
it's various methods.

also cleaned up the various daemons in the software, most now using the
GenericDaemon struct and just providing a new constructor.

broken page loop in jellyseerr client also fixed.
2024-07-31 15:02:25 +01:00
14 changed files with 365 additions and 229 deletions

View File

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@ -529,7 +528,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context)
} }
if telegramVerified { if telegramVerified {
u, _ := app.storage.GetTelegramKey(user.ID) u, _ := app.storage.GetTelegramKey(user.ID)
contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(u.ChatID, 10) contactMethods[jellyseerr.FieldTelegram] = u.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
} }
if emailEnabled || discordVerified || telegramVerified { if emailEnabled || discordVerified || telegramVerified {
@ -1373,7 +1372,9 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
configuration = profile.Configuration configuration = profile.Configuration
displayprefs = profile.Displayprefs displayprefs = profile.Displayprefs
} }
policy = profile.Policy if req.Policy {
policy = profile.Policy
}
if req.Ombi && 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 { if profile.Ombi != nil && len(profile.Ombi) != 0 {
ombi = profile.Ombi ombi = profile.Ombi
@ -1395,7 +1396,9 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
return return
} }
applyingFrom = "\"" + user.Name + "\"" applyingFrom = "\"" + user.Name + "\""
policy = user.Policy if req.Policy {
policy = user.Policy
}
if req.Homescreen { if req.Homescreen {
displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID) displayprefs, status, err = app.jf.GetDisplayPreferences(req.ID)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
@ -1422,9 +1425,13 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
app.debug.Println("Adding delay between requests for large batch") app.debug.Println("Adding delay between requests for large batch")
} }
for _, id := range req.ApplyTo { for _, id := range req.ApplyTo {
status, err := app.jf.SetPolicy(id, policy) var status int
if !(status == 200 || status == 204) || err != nil { var err error
errors["policy"][id] = fmt.Sprintf("%d: %s", status, err) 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 { if shouldDelay {
time.Sleep(250 * time.Millisecond) time.Sleep(250 * time.Millisecond)

View File

@ -161,20 +161,13 @@ func (app *appContext) loadPendingBackup() {
LOADBAK = "" 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 interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
daemon := housekeepingDaemon{ d := NewGenericDaemon(interval, app,
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
func(app *appContext) { func(app *appContext) {
app.debug.Println("Backups: Creating backup") app.debug.Println("Backups: Creating backup")
app.makeBackup() app.makeBackup()
}, },
} )
return &daemon return d
} }

View File

@ -1620,6 +1620,23 @@
"value": "", "value": "",
"depends_true": "enabled", "depends_true": "enabled",
"description": "API Key. Get this from the first tab in Jellyseerr's settings." "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."
} }
} }
}, },

View File

@ -116,32 +116,16 @@ func (app *appContext) clearActivities() {
} }
} }
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
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(app *appContext) { func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites") app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites() app.checkInvites()
}, },
func(app *appContext) { app.clearActivities() }, func(app *appContext) { app.clearActivities() },
} )
d.Name("Housekeeping daemon")
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false) clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").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) clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix { 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 { if clearEmail {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() }) d.appendJobs(func(app *appContext) { app.clearEmails() })
} }
if clearDiscord { if clearDiscord {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() }) d.appendJobs(func(app *appContext) { app.clearDiscord() })
} }
if clearTelegram { if clearTelegram {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() }) d.appendJobs(func(app *appContext) { app.clearTelegram() })
} }
if clearMatrix { if clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() }) d.appendJobs(func(app *appContext) { app.clearMatrix() })
} }
if clearPWR { if clearPWR {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearPWRCaptchas() }) d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
} }
return &daemon return d
}
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)
} }

65
genericdaemon.go Normal file
View 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)
}

View File

@ -101,6 +101,10 @@
<div class="select ~neutral @low unfocused"> <div class="select ~neutral @low unfocused">
<select id="modify-user-users"></select> <select id="modify-user-users"></select>
</div> </div>
<label class="switch">
<input type="checkbox" id="modify-user-configuration" checked>
<span>{{ .strings.applyConfigurationAndPolicy }}</span>
</label>
<label class="switch"> <label class="switch">
<input type="checkbox" id="modify-user-homescreen" checked> <input type="checkbox" id="modify-user-homescreen" checked>
<span>{{ .strings.applyHomescreenLayout }}</span> <span>{{ .strings.applyHomescreenLayout }}</span>

View File

@ -30,6 +30,7 @@ type Jellyseerr struct {
cacheLength time.Duration cacheLength time.Duration
timeoutHandler common.TimeoutHandler timeoutHandler common.TimeoutHandler
LogRequestBodies bool LogRequestBodies bool
AutoImportUsers bool
} }
// NewJellyseerr returns an Ombi object. // NewJellyseerr returns an Ombi object.
@ -54,96 +55,90 @@ func NewJellyseerr(server, key string, timeoutHandler common.TimeoutHandler) *Je
} }
} }
// does a GET and returns the response as a string. func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
func (js *Jellyseerr) getJSON(url string, params map[string]string, queryParams url.Values) (string, int, error) { var params []byte
if js.key == "" { if data != nil {
return "", 401, fmt.Errorf("No API key provided") 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 var req *http.Request
if params != nil { if data != nil {
jsonParams, _ := json.Marshal(params) req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending Data \"%s\"\n", string(jsonParams))
}
req, _ = http.NewRequest("GET", url+"?"+queryParams.Encode(), bytes.NewBuffer(jsonParams))
} else { } else {
req, _ = http.NewRequest("GET", url+"?"+queryParams.Encode(), nil) req, _ = http.NewRequest(mode, uri, 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 any, response bool, headers map[string]string) (string, int, error) {
responseText := ""
params, _ := json.Marshal(data)
if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending Data \"%s\"\n", string(params))
}
req, _ := http.NewRequest(mode, url, bytes.NewBuffer(params))
req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Type", "application/json")
for name, value := range js.header { for name, value := range js.header {
req.Header.Add(name, value) req.Header.Add(name, value)
} }
for name, value := range headers { if headers != nil {
req.Header.Add(name, value) for name, value := range headers {
req.Header.Add(name, value)
}
} }
resp, err := js.httpClient.Do(req) resp, err := js.httpClient.Do(req)
reqFailed := err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201)
defer js.timeoutHandler() defer js.timeoutHandler()
if err != nil || !(resp.StatusCode == 200 || resp.StatusCode == 201) { var responseText string
if resp.StatusCode == 401 { defer resp.Body.Close()
return "", 401, fmt.Errorf("Invalid API Key") 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
} }
if response { return responseText, resp.StatusCode, err
defer resp.Body.Close() }
var out io.Reader
switch resp.Header.Get("Content-Encoding") { func (js *Jellyseerr) decodeResp(resp *http.Response) (string, error) {
case "gzip": var out io.Reader
out, _ = gzip.NewReader(resp.Body) switch resp.Header.Get("Content-Encoding") {
default: case "gzip":
out = resp.Body out, _ = gzip.NewReader(resp.Body)
} default:
buf := new(strings.Builder) out = resp.Body
_, err = io.Copy(buf, out)
if err != nil {
return "", 500, err
}
responseText = buf.String()
} }
return responseText, resp.StatusCode, nil buf := new(strings.Builder)
_, err := io.Copy(buf, out)
if err != nil {
return "", err
}
return buf.String(), nil
} }
func (js *Jellyseerr) post(url string, data any, response bool) (string, int, error) { func (js *Jellyseerr) get(uri string, data any, params url.Values) (string, int, error) {
return js.send("POST", url, data, response, nil) return js.req(http.MethodGet, uri, data, params, nil, true)
} }
func (js *Jellyseerr) put(url string, data any, response bool) (string, int, error) { func (js *Jellyseerr) post(uri string, data any, response bool) (string, int, error) {
return js.send("PUT", url, data, response, nil) 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) { func (js *Jellyseerr) ImportFromJellyfin(jfIDs ...string) ([]User, error) {
@ -175,7 +170,7 @@ func (js *Jellyseerr) getUsers() error {
pageCount := 1 pageCount := 1
pageIndex := 0 pageIndex := 0
for { for {
res, err := js.getUserPage(0) res, err := js.getUserPage(pageIndex)
if err != nil { if err != nil {
return err return err
} }
@ -197,9 +192,12 @@ func (js *Jellyseerr) getUsers() error {
func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) { func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
params := url.Values{} params := url.Values{}
params.Add("take", "30") params.Add("take", "30")
params.Add("skip", strconv.Itoa(page)) params.Add("skip", strconv.Itoa(page*30))
params.Add("sort", "created") params.Add("sort", "created")
resp, status, err := js.getJSON(js.server+"/user", nil, params) 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 var data GetUsersDTO
if status != 200 { if status != 200 {
return data, fmt.Errorf("failed (error %d)", status) return data, fmt.Errorf("failed (error %d)", status)
@ -211,29 +209,59 @@ func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
return data, err 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) { func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
js.getUsers() u, _, err := js.GetOrImportUser(jfID)
if u, ok := js.userCache[jfID]; ok { return u, err
return u, nil }
// 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
} }
users, err := js.ImportFromJellyfin(jfID) var users []User
var u User users, err = js.ImportFromJellyfin(jfID)
if err != nil { if err != nil {
return u, err return
} }
if len(users) != 0 { if len(users) != 0 {
return users[0], err u = users[0]
err = nil
return
} }
if u, ok := js.userCache[jfID]; ok { err = fmt.Errorf("user not found or imported")
return u, nil 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
} }
return u, fmt.Errorf("user not found") 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) { func (js *Jellyseerr) Me() (User, error) {
resp, status, err := js.getJSON(js.server+"/auth/me", nil, url.Values{}) resp, status, err := js.get(js.server+"/auth/me", nil, url.Values{})
var data User var data User
data.ID = -1 data.ID = -1
if status != 200 { if status != 200 {
@ -248,12 +276,12 @@ func (js *Jellyseerr) Me() (User, error) {
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) { func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
data := permissionsDTO{Permissions: -1} data := permissionsDTO{Permissions: -1}
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return data.Permissions, err return data.Permissions, err
} }
resp, status, err := js.getJSON(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{}) resp, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/permissions", u.ID), nil, url.Values{})
if err != nil { if err != nil {
return data.Permissions, err return data.Permissions, err
} }
@ -265,7 +293,7 @@ func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
} }
func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error { func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -283,7 +311,7 @@ func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
} }
func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error { func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -304,7 +332,7 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
if _, ok := conf[FieldEmail]; ok { if _, ok := conf[FieldEmail]; ok {
return fmt.Errorf("email is read only, set with ModifyMainUserSettings instead") return fmt.Errorf("email is read only, set with ModifyMainUserSettings instead")
} }
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -322,12 +350,12 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
} }
func (js *Jellyseerr) DeleteUser(jfID string) error { func (js *Jellyseerr) DeleteUser(jfID string) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
_, status, err := js.send("DELETE", fmt.Sprintf(js.server+"/user/%d", u.ID), nil, false, nil) status, err := js.delete(fmt.Sprintf(js.server+"/user/%d", u.ID), nil)
if status != 200 && status != 201 { if status != 200 && status != 201 {
return fmt.Errorf("failed (error %d)", status) return fmt.Errorf("failed (error %d)", status)
} }
@ -339,7 +367,7 @@ func (js *Jellyseerr) DeleteUser(jfID string) error {
} }
func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) { func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return Notifications{}, err return Notifications{}, err
} }
@ -348,7 +376,7 @@ func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, er
func (js *Jellyseerr) GetNotificationPreferencesByID(jellyseerrID int64) (Notifications, error) { func (js *Jellyseerr) GetNotificationPreferencesByID(jellyseerrID int64) (Notifications, error) {
var data Notifications var data Notifications
resp, status, err := js.getJSON(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{}) resp, status, err := js.get(fmt.Sprintf(js.server+"/user/%d/settings/notifications", jellyseerrID), nil, url.Values{})
if err != nil { if err != nil {
return data, err return data, err
} }
@ -364,7 +392,7 @@ func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl Notific
/* if tmpl.NotifTypes.Empty() { /* if tmpl.NotifTypes.Empty() {
tmpl.NotifTypes = nil tmpl.NotifTypes = nil
}*/ }*/
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -380,7 +408,7 @@ func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl Notific
} }
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error { func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -401,7 +429,7 @@ func (js *Jellyseerr) GetUsers() (map[string]User, error) {
} }
func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) { func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) {
resp, status, err := js.getJSON(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{}) resp, status, err := js.get(js.server+fmt.Sprintf("/user/%d", jellyseerrID), nil, url.Values{})
var data User var data User
if status != 200 { if status != 200 {
return data, fmt.Errorf("failed (error %d)", status) return data, fmt.Errorf("failed (error %d)", status)
@ -414,12 +442,12 @@ func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) {
} }
func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error { func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false) _, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
if err != nil { if err != nil {
return err return err
} }

View File

@ -130,3 +130,7 @@ type MainUserSettings struct {
WatchlistSyncMovies any `json:"watchlistSyncMovies,omitempty"` WatchlistSyncMovies any `json:"watchlistSyncMovies,omitempty"`
WatchlistSyncTv any `json:"watchlistSyncTv,omitempty"` WatchlistSyncTv any `json:"watchlistSyncTv,omitempty"`
} }
type ErrorDTO struct {
Message string `json:"message,omitempty"`
}

81
jellyseerrdaemon.go Normal file
View 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
}

View File

@ -81,6 +81,7 @@
"useInviteExpiry": "Set expiry from profile/invite", "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.", "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", "applyHomescreenLayout": "Apply homescreen layout",
"applyConfigurationAndPolicy": "Apply Jellyfin configuration/policy",
"applyOmbi": "Apply Ombi profile (if available)", "applyOmbi": "Apply Ombi profile (if available)",
"applyJellyseerr": "Apply Jellyseerr profile (if available)", "applyJellyseerr": "Apply Jellyseerr profile (if available)",
"sendDeleteNotificationEmail": "Send notification message", "sendDeleteNotificationEmail": "Send notification message",

15
main.go
View File

@ -369,6 +369,7 @@ func start(asDaemon, firstCall bool) {
app.config.Section("jellyseerr").Key("api_key").String(), app.config.Section("jellyseerr").Key("api_key").String(),
common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true), common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true),
) )
app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false)
// app.js.LogRequestBodies = true // app.js.LogRequestBodies = true
} }
@ -480,13 +481,21 @@ func start(asDaemon, firstCall bool) {
os.Exit(0) os.Exit(0)
} }
invDaemon := newInviteDaemon(time.Duration(60*time.Second), app) invDaemon := newHousekeepingDaemon(time.Duration(60*time.Second), app)
go invDaemon.run() go invDaemon.run()
defer invDaemon.Shutdown() defer invDaemon.Shutdown()
userDaemon := newUserDaemon(time.Duration(60*time.Second), app) userDaemon := newUserDaemon(time.Duration(60*time.Second), app)
go userDaemon.run() 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 { if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
go app.StartPWR() go app.StartPWR()
@ -496,7 +505,7 @@ func start(asDaemon, firstCall bool) {
go app.checkForUpdates() go app.checkForUpdates()
} }
var backupDaemon *housekeepingDaemon var backupDaemon *GenericDaemon
if app.config.Section("backups").Key("enabled").MustBool(false) { if app.config.Section("backups").Key("enabled").MustBool(false) {
backupDaemon = newBackupDaemon(app) backupDaemon = newBackupDaemon(app)
go backupDaemon.run() go backupDaemon.run()

View File

@ -174,13 +174,16 @@ type ombiUsersDTO struct {
type modifyEmailsDTO map[string]string type modifyEmailsDTO map[string]string
type userSettingsDTO struct { type userSettingsDTO struct {
From string `json:"from"` // Whether to apply from "user" or "profile" From string `json:"from"` // Whether to apply from "user" or "profile"
Profile string `json:"profile"` // Name of profile (if from = "profile") Profile string `json:"profile"` // Name of profile (if from = "profile")
ApplyTo []string `json:"apply_to"` // Users to apply settings to ApplyTo []string `json:"apply_to"` // Users to apply settings to
ID string `json:"id"` // ID of user (if from = "user") ID string `json:"id"` // ID of user (if from = "user")
Homescreen bool `json:"homescreen"` // Whether to apply homescreen layout or not // Note confusing name: "Configuration" on the admin UI just means it in the sense
Ombi bool `json:"ombi"` // Whether to apply ombi profile or not // of the account's settings.
Jellyseerr bool `json:"jellyseerr"` // Whether to apply jellyseerr profile or not 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 { type announcementDTO struct {

View File

@ -795,7 +795,8 @@ export class accountsList {
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement; private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
private _search: Search; private _search: Search;
private _applyHomesreen = document.getElementById("modify-user-homescreen") as HTMLInputElement; 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 _applyOmbi = document.getElementById("modify-user-ombi") as HTMLInputElement;
private _applyJellyseerr = document.getElementById("modify-user-jellyseerr") as HTMLInputElement; private _applyJellyseerr = document.getElementById("modify-user-jellyseerr") as HTMLInputElement;
@ -1490,7 +1491,8 @@ export class accountsList {
toggleLoader(button); toggleLoader(button);
let send = { let send = {
"apply_to": list, "apply_to": list,
"homescreen": this._applyHomesreen.checked, "homescreen": this._applyHomescreen.checked,
"configuration": this._applyConfiguration.checked,
"ombi": this._applyOmbi.checked, "ombi": this._applyOmbi.checked,
"jellyseerr": this._applyJellyseerr.checked "jellyseerr": this._applyJellyseerr.checked
}; };

View File

@ -7,47 +7,14 @@ import (
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
) )
type userDaemon struct { func newUserDaemon(interval time.Duration, app *appContext) *GenericDaemon {
Stopped bool d := NewGenericDaemon(interval, app,
ShutdownChannel chan string func(app *appContext) {
Interval time.Duration app.checkUsers()
period time.Duration },
app *appContext )
} d.Name("User daemon")
return d
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 (app *appContext) checkUsers() { func (app *appContext) checkUsers() {