522 lines
16 KiB
Go
522 lines
16 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// read_integration — saved ARR options and authorized media-server
|
|
// metadata. No credentials or connection overrides are accepted.
|
|
// Families: reference_page, user_page.
|
|
func handleIntegration(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
|
o := newOp(ctx, env, "read_integration", false)
|
|
var a IntegrationArgs
|
|
if fail := o.args(raw, &a); fail != nil {
|
|
return fail
|
|
}
|
|
switch a.Action {
|
|
case "options":
|
|
return o.integrationOptions(&a)
|
|
case "plex":
|
|
return o.integrationPlex(&a)
|
|
case "plex_libraries":
|
|
return o.integrationPlexLibraries(&a)
|
|
case "media_server":
|
|
return o.integrationMediaServer(&a)
|
|
default:
|
|
return o.invalid("action", "unsupported action %q", a.Action)
|
|
}
|
|
}
|
|
|
|
// integrationOptions covers the saved-settings option reads. GET
|
|
// routes are preferred; POST routes with privately constructed saved
|
|
// settings exist only where the ledger marks them D.
|
|
func (o *op) integrationOptions(a *IntegrationArgs) *ToolResult {
|
|
var path, method, cat string
|
|
var privateSettingsPath string
|
|
switch a.Service {
|
|
case "radarr":
|
|
switch a.Resource {
|
|
case "profiles":
|
|
cat = "profile"
|
|
path = "/api/v1/Radarr/Profiles"
|
|
if a.Variant == "4k" {
|
|
path += "/4k"
|
|
} else if a.Variant != "" && a.Variant != "standard" {
|
|
return o.invalid("variant", "variant must be standard or 4k")
|
|
}
|
|
case "root_folders":
|
|
cat = "root_folder"
|
|
path = "/api/v1/Radarr/RootFolders"
|
|
if a.Variant == "4k" {
|
|
path += "/4k"
|
|
} else if a.Variant != "" && a.Variant != "standard" {
|
|
return o.invalid("variant", "variant must be standard or 4k")
|
|
}
|
|
case "tags":
|
|
cat, path = "tag", "/api/v1/Radarr/tags"
|
|
case "enabled":
|
|
cat, path = "enabled", "/api/v1/Radarr/enabled"
|
|
default:
|
|
return o.invalid("resource", "unsupported radarr resource %q", a.Resource)
|
|
}
|
|
case "sonarr":
|
|
switch a.Resource {
|
|
case "profiles":
|
|
cat, path = "profile", "/api/v1/Sonarr/Profiles"
|
|
case "root_folders":
|
|
cat, path = "root_folder", "/api/v1/Sonarr/RootFolders"
|
|
case "tags":
|
|
cat, path = "tag", "/api/v1/Sonarr/tags"
|
|
case "language_profiles":
|
|
cat, path = "language_profile", "/api/v1/Sonarr/v3/LanguageProfiles"
|
|
case "enabled":
|
|
cat, path = "enabled", "/api/v1/Sonarr/enabled"
|
|
case "version":
|
|
cat, path = "version", "/api/v1/Sonarr/version"
|
|
default:
|
|
return o.invalid("resource", "unsupported sonarr resource %q", a.Resource)
|
|
}
|
|
case "lidarr":
|
|
switch a.Resource {
|
|
case "profiles":
|
|
cat, path = "profile", "/api/v1/Lidarr/Profiles"
|
|
case "root_folders":
|
|
cat, path = "root_folder", "/api/v1/Lidarr/RootFolders"
|
|
case "metadata":
|
|
// POST-only route; saved settings are loaded privately.
|
|
cat, path = "metadata_profile", "/api/v1/Lidarr/Metadata"
|
|
method, privateSettingsPath = "POST", "/api/v1/Settings/lidarr"
|
|
case "enabled":
|
|
cat, path = "enabled", "/api/v1/Lidarr/enabled"
|
|
default:
|
|
return o.invalid("resource", "unsupported lidarr resource %q", a.Resource)
|
|
}
|
|
case "couchpotato":
|
|
if a.Resource != "profiles" {
|
|
return o.invalid("resource", "unsupported couchpotato resource %q", a.Resource)
|
|
}
|
|
// Singular profile route; POST-only with saved settings.
|
|
cat, path = "profile", "/api/v1/CouchPotato/profile"
|
|
method, privateSettingsPath = "POST", "/api/v1/Settings/CouchPotato"
|
|
default:
|
|
return o.invalid("service", "unsupported service %q", a.Service)
|
|
}
|
|
|
|
var body any
|
|
if method == "POST" {
|
|
raw, fail := o.call("GET", privateSettingsPath, nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
settings, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
body = settings
|
|
} else {
|
|
method = "GET"
|
|
}
|
|
raw, fail := o.call(method, path, nil, body)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.refsOrScalar(raw, cat)
|
|
}
|
|
|
|
type refKeySet struct {
|
|
id []string
|
|
name []string
|
|
value []string
|
|
}
|
|
|
|
var defaultOptionRefKeys = refKeySet{
|
|
id: []string{"id", "value", "key"},
|
|
name: []string{"name", "label", "value", "path"},
|
|
value: nil,
|
|
}
|
|
|
|
var optionRefKeys = map[string]refKeySet{
|
|
"root_folder": {
|
|
id: []string{"id"},
|
|
name: []string{"path", "name", "label", "value"},
|
|
value: nil,
|
|
},
|
|
"plex_library": {
|
|
id: []string{"key", "id"},
|
|
name: []string{"title", "name", "label"},
|
|
value: nil,
|
|
},
|
|
"plex_server": {
|
|
id: []string{"machineId", "serverId", "id", "machineIdentifier", "key"},
|
|
name: []string{"serverName", "name", "title"},
|
|
value: []string{"serverId", "machineId"},
|
|
},
|
|
"emby_info": {
|
|
id: []string{"id"},
|
|
name: []string{"serverName", "name"},
|
|
value: nil,
|
|
},
|
|
"jellyfin_info": {
|
|
id: []string{"id"},
|
|
name: []string{"serverName", "name"},
|
|
value: nil,
|
|
},
|
|
"emby_libraries": {
|
|
id: []string{"id", "key"},
|
|
name: []string{"name", "title"},
|
|
value: nil,
|
|
},
|
|
"jellyfin_libraries": {
|
|
id: []string{"id", "key"},
|
|
name: []string{"name", "title"},
|
|
value: nil,
|
|
},
|
|
}
|
|
|
|
func refKeysFor(cat string, defaults refKeySet) refKeySet {
|
|
if ks, ok := optionRefKeys[cat]; ok {
|
|
return ks
|
|
}
|
|
return defaults
|
|
}
|
|
|
|
func isRejectedObject(m map[string]any) (bool, string) {
|
|
for _, k := range []string{"success", "successful"} {
|
|
if v, ok := m[k]; ok {
|
|
if b, ok := v.(bool); ok && !b {
|
|
msg := jstr(m, "message", "Message", "errorMessage", "ErrorMessage")
|
|
if msg == "" {
|
|
msg = "upstream operation was unsuccessful"
|
|
}
|
|
return true, sanitizeText(msg, maxSanitizedMsg)
|
|
}
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// refsOrScalar projects an option response: arrays become reference
|
|
// items; objects become nested arrays or single records; scalars become
|
|
// single records.
|
|
func (o *op) refsOrScalar(raw []byte, cat string) *ToolResult {
|
|
topKeys := refKeysFor(cat, defaultOptionRefKeys)
|
|
if items, fail := o.refArray(raw, topKeys.id, topKeys.name, cat); fail == nil {
|
|
return o.ok(&ReferencePage{Kind: "reference_page",
|
|
Items: items, Page: singlePage(len(items), "references")})
|
|
}
|
|
if m, fail := o.decodeObject(raw); fail == nil {
|
|
if rejected, msg := isRejectedObject(m); rejected {
|
|
return o.fail("UPSTREAM_REJECTED", msg, false)
|
|
}
|
|
// Nested containers (e.g. CouchPotatoProfiles.list, Plex Libraries data, Media Server items)
|
|
// project their first array member list.
|
|
nestedDefaults := refKeySet{
|
|
id: []string{"_id", "id", "key", "value"},
|
|
name: []string{"label", "name", "title", "serverName", "path"},
|
|
}
|
|
nestedKeys := refKeysFor(cat, nestedDefaults)
|
|
for _, v := range m {
|
|
if arr, ok := v.([]any); ok {
|
|
items := []Reference{}
|
|
for _, e := range arr {
|
|
if em, ok := e.(map[string]any); ok {
|
|
items = append(items, o.refOf(em,
|
|
nestedKeys.id, nestedKeys.name, nestedKeys.value, cat))
|
|
}
|
|
}
|
|
items = capItems(o, items)
|
|
return o.ok(&ReferencePage{Kind: "reference_page",
|
|
Items: items, Page: singlePage(len(items), "references")})
|
|
}
|
|
}
|
|
fallbackDefaults := refKeySet{
|
|
id: []string{"id"},
|
|
name: []string{"name", "serverName", "title", "label", "path"},
|
|
}
|
|
fallbackKeys := refKeysFor(cat, fallbackDefaults)
|
|
r := o.refOf(m, fallbackKeys.id, fallbackKeys.name, fallbackKeys.value, cat)
|
|
return o.ok(&ReferencePage{Kind: "reference_page",
|
|
Items: []Reference{r}, Page: singlePage(1, "references")})
|
|
}
|
|
if v, fail := o.decodeScalar(raw); fail == nil && v != nil {
|
|
switch t := v.(type) {
|
|
case string, float64, bool:
|
|
r := Reference{Name: cat, Category: cat, Value: t}
|
|
return o.ok(&ReferencePage{Kind: "reference_page",
|
|
Items: []Reference{r}, Page: singlePage(1, "references")})
|
|
}
|
|
}
|
|
return o.fail("UPSTREAM_SCHEMA_MISMATCH", "upstream response was not a JSON array, object, or scalar", false)
|
|
}
|
|
|
|
func (o *op) integrationPlex(a *IntegrationArgs) *ToolResult {
|
|
var path, cat string
|
|
switch a.Resource {
|
|
case "servers":
|
|
path, cat = "/api/v1/Plex/servers", "plex_server"
|
|
case "friends":
|
|
path, cat = "/api/v1/Plex/friends", "plex_friend"
|
|
case "watchlist_users":
|
|
path, cat = "/api/v1/Plex/WatchlistUsers", "watchlist_user"
|
|
default:
|
|
return o.invalid("resource", "unsupported plex resource %q", a.Resource)
|
|
}
|
|
raw, fail := o.call("GET", path, nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.refsOrUsers(raw, cat)
|
|
}
|
|
|
|
// refsOrUsers projects a response that may be an array of user-like
|
|
// objects or reference-like objects, or an object wrapper containing them.
|
|
func (o *op) refsOrUsers(raw []byte, cat string) *ToolResult {
|
|
arr, fail := o.decodeArray(raw)
|
|
if fail != nil {
|
|
m, objFail := o.decodeObject(raw)
|
|
if objFail != nil {
|
|
return fail
|
|
}
|
|
if rejected, msg := isRejectedObject(m); rejected {
|
|
return o.fail("UPSTREAM_REJECTED", msg, false)
|
|
}
|
|
var innerArr []map[string]any
|
|
for _, v := range m {
|
|
if a, ok := v.([]any); ok {
|
|
innerArr = make([]map[string]any, 0, len(a))
|
|
for _, elem := range a {
|
|
if em, ok := elem.(map[string]any); ok {
|
|
innerArr = append(innerArr, em)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if innerArr == nil {
|
|
return fail
|
|
}
|
|
arr = innerArr
|
|
}
|
|
refKeys := refKeysFor(cat, refKeySet{
|
|
id: []string{"id", "machineIdentifier", "key"},
|
|
name: []string{"name", "title"},
|
|
value: nil,
|
|
})
|
|
// User-shaped records (id+userName/username/email) → user_page.
|
|
users := []User{}
|
|
refs := []Reference{}
|
|
for _, m := range arr {
|
|
if jstr(m, "userName", "username", "email") != "" {
|
|
users = append(users, projectUser(m))
|
|
} else {
|
|
refs = append(refs, o.refOf(m,
|
|
refKeys.id, refKeys.name, refKeys.value, cat))
|
|
}
|
|
}
|
|
if len(users) > 0 && len(refs) == 0 {
|
|
users = capItems(o, users)
|
|
return o.ok(&UserPage{Kind: "user_page",
|
|
Items: users, Page: singlePage(len(users), "users")})
|
|
}
|
|
refs = capItems(o, refs)
|
|
return o.ok(&ReferencePage{Kind: "reference_page",
|
|
Items: refs, Page: singlePage(len(refs), "references")})
|
|
}
|
|
|
|
func (o *op) integrationPlexLibraries(a *IntegrationArgs) *ToolResult {
|
|
if !nonempty(a.MachineID) {
|
|
return o.invalid("machine_id", "machine_id is required")
|
|
}
|
|
raw, fail := o.call("GET",
|
|
"/api/v1/Plex/Libraries/"+seg(a.MachineID), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.refsOrScalar(raw, "plex_library")
|
|
}
|
|
|
|
// integrationMediaServer resolves a saved server privately by
|
|
// server_id — never accepts an arbitrary server object.
|
|
func (o *op) integrationMediaServer(a *IntegrationArgs) *ToolResult {
|
|
var svc, svcSeg, settingsPath string
|
|
switch a.Service {
|
|
case "emby":
|
|
svc, svcSeg, settingsPath = "emby", "Emby", "/api/v1/Settings/emby"
|
|
case "jellyfin":
|
|
svc, svcSeg, settingsPath = "jellyfin", "Jellyfin", "/api/v1/Settings/jellyfin"
|
|
default:
|
|
return o.invalid("service", "service must be emby or jellyfin")
|
|
}
|
|
switch a.Resource {
|
|
case "users":
|
|
raw, fail := o.call("GET",
|
|
fmt.Sprintf("/api/v1/%s/users", svcSeg), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.refsOrUsers(raw, svc+"_user")
|
|
case "info", "libraries":
|
|
if !nonempty(a.ServerID) {
|
|
return o.invalid("server_id", "server_id is required")
|
|
}
|
|
server, fail := o.savedServer(settingsPath, a.ServerID)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
path := fmt.Sprintf("/api/v1/%s/info", svcSeg)
|
|
if a.Resource == "libraries" {
|
|
path = fmt.Sprintf("/api/v1/%s/Library", svcSeg)
|
|
}
|
|
raw, fail := o.call("POST", path, nil, server)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.refsOrScalar(raw, svc+"_"+a.Resource)
|
|
default:
|
|
return o.invalid("resource", "resource must be users|info|libraries")
|
|
}
|
|
}
|
|
|
|
// savedServer loads a media-server settings section privately and
|
|
// returns the server entry matching id — identity match only, no
|
|
// passthrough object construction.
|
|
func (o *op) savedServer(settingsPath, serverID string) (map[string]any, *ToolResult) {
|
|
raw, fail := o.call("GET", settingsPath, nil, nil)
|
|
if fail != nil {
|
|
return nil, fail
|
|
}
|
|
settings, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return nil, fail
|
|
}
|
|
for _, v := range jarr(settings, "servers") {
|
|
if sm, ok := v.(map[string]any); ok {
|
|
if s, _ := toStr(sm["id"]); s == serverID {
|
|
return sm, nil
|
|
}
|
|
if s, _ := toStr(sm["serverId"]); s == serverID {
|
|
return sm, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, o.invalid("server_id", "no saved server matches server_id")
|
|
}
|
|
|
|
// write_integration_test — POST Tester/{service} with a saved
|
|
// profile resolved privately by profile_id. Some tests really send
|
|
// messages; completion means the tester returned.
|
|
func handleIntegrationTest(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
|
o := newOp(ctx, env, "write_integration_test", true)
|
|
var a IntegrationTestArgs
|
|
if fail := o.args(raw, &a); fail != nil {
|
|
return fail
|
|
}
|
|
settingsPath, ok := testerSettingsPath(a.Service)
|
|
if !ok {
|
|
return o.fail("UNSUPPORTED_CAPABILITY",
|
|
fmt.Sprintf("service %q has no saved-settings profile route", a.Service), false)
|
|
}
|
|
if !nonempty(a.ProfileID) {
|
|
return o.invalid("profile_id", "profile_id is required")
|
|
}
|
|
raw, fail := o.call("GET", settingsPath, nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
settings, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
body, fail := o.resolveProfile(settings, a.ProfileID)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
raw2, fail := o.call("POST", "/api/v1/Tester/"+a.Service, nil, body)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
// Tester responses are a boolean or TesterResultModel.
|
|
if b, fail2 := o.decodeBool(raw2); fail2 == nil {
|
|
if !b {
|
|
return o.fail("UPSTREAM_REJECTED", "integration test failed", false)
|
|
}
|
|
return o.ok(&Mutation{Kind: "mutation", Outcome: "completed",
|
|
UpstreamResult: &b})
|
|
}
|
|
m, fail := o.decodeObject(raw2)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
out := &Mutation{Kind: "mutation", Outcome: "completed"}
|
|
if v := jbool(m, "isValid"); v != nil {
|
|
out.UpstreamResult = v
|
|
if !*v {
|
|
return o.fail("UPSTREAM_REJECTED",
|
|
sanitizeText(jstr(m, "additionalInformation", "message"),
|
|
maxSanitizedMsg), false)
|
|
}
|
|
}
|
|
if s := jstr(m, "version", "expectedSubDir", "additionalInformation"); s != "" {
|
|
out.Message = sanitizeText(s, maxSanitizedMsg)
|
|
}
|
|
return o.ok(out)
|
|
}
|
|
|
|
// testerSettingsPath maps a tester service to its saved-settings GET.
|
|
// whatsapp has no complete saved-settings route per the registry.
|
|
func testerSettingsPath(service string) (string, bool) {
|
|
switch service {
|
|
case "radarr":
|
|
return "/api/v1/Settings/radarr", true
|
|
case "sonarr":
|
|
return "/api/v1/Settings/sonarr", true
|
|
case "lidarr":
|
|
return "/api/v1/Settings/lidarr", true
|
|
case "couchpotato":
|
|
return "/api/v1/Settings/CouchPotato", true
|
|
case "sickrage":
|
|
return "/api/v1/Settings/SickRage", true
|
|
case "plex":
|
|
return "/api/v1/Settings/plex", true
|
|
case "emby":
|
|
return "/api/v1/Settings/emby", true
|
|
case "jellyfin":
|
|
return "/api/v1/Settings/jellyfin", true
|
|
case "email", "discord", "telegram", "pushbullet", "pushover",
|
|
"slack", "mattermost", "twilio", "mobile", "gotify", "ntfy",
|
|
"webhook", "newsletter":
|
|
return "/api/v1/Settings/notifications/" + service, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// resolveProfile selects the entity matching profile_id from a saved
|
|
// settings object. Array-backed sections match element identities;
|
|
// single-object sections match their id field when present.
|
|
func (o *op) resolveProfile(settings map[string]any, profileID string) (map[string]any, *ToolResult) {
|
|
for _, key := range []string{"servers", "profiles"} {
|
|
for _, v := range jarr(settings, key) {
|
|
if sm, ok := v.(map[string]any); ok {
|
|
for _, ik := range []string{"id", "serverId", "machineIdentifier", "name"} {
|
|
if s, _ := toStr(sm[ik]); s == profileID {
|
|
return sm, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if id, ok := toStr(settings["id"]); ok && id != "" {
|
|
if id != profileID {
|
|
return nil, o.invalid("profile_id",
|
|
"profile_id does not match the saved profile")
|
|
}
|
|
return settings, nil
|
|
}
|
|
// Single-entity settings without an identity field: the saved
|
|
// object itself is the administrator-provisioned profile.
|
|
return settings, nil
|
|
}
|