Files

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
}