Files
gronod 0ce9e2626b Surface parent provider ids on tv_child projections
ChildRequests carries no top-level provider ids upstream — they live
on the embedded parentRequest navigation property — so children/list
items emitted empty identifiers. Read them from the embedded record;
the child target id itself stays the true child PK (provider-shaped
for first-request children by upstream design, and callable for
child-scoped operations). Mock child rows and the new
/Request/tv/{id}/child fixture now match the real wire shape.
2026-09-19 16:16:54 +01:00

889 lines
24 KiB
Go

package tools
import (
"bytes"
"encoding/json"
"strconv"
"strings"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate"
)
// Projection layer: bounded allowlisted projections of upstream
// entities into the docs/schema/04-results.md domain families.
// Upstream entities decode into map[string]any; helpers below never
// pass through nested objects wholesale (allowlist, not passthrough).
// --- generic accessors ---
func jstr(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok {
return s
}
}
}
return ""
}
func jint(m map[string]any, keys ...string) *int {
for _, k := range keys {
if v, ok := m[k]; ok {
if i, ok := toInt(v); ok {
return &i
}
}
}
return nil
}
func jbool(m map[string]any, keys ...string) *bool {
for _, k := range keys {
if v, ok := m[k]; ok {
if b, ok := v.(bool); ok {
return &b
}
}
}
return nil
}
func jarr(m map[string]any, key string) []any {
if v, ok := m[key]; ok {
if a, ok := v.([]any); ok {
return a
}
}
return nil
}
func jobj(m map[string]any, key string) map[string]any {
if v, ok := m[key]; ok {
if o, ok := v.(map[string]any); ok {
return o
}
}
return nil
}
func toInt(v any) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
case json.Number:
i, err := n.Int64()
if err == nil {
return int(i), true
}
case string:
i, err := strconv.Atoi(n)
if err == nil {
return i, true
}
}
return 0, false
}
func toStr(v any) (string, bool) {
switch s := v.(type) {
case string:
return s, true
case float64:
return strconv.FormatFloat(s, 'f', -1, 64), true
case int:
return strconv.Itoa(s), true
case int64:
return strconv.FormatInt(s, 10), true
case json.Number:
return s.String(), true
case bool:
return strconv.FormatBool(s), true
}
return "", false
}
func yearOf(s string) *int {
if len(s) < 4 {
return nil
}
if y, err := strconv.Atoi(s[:4]); err == nil && y > 0 {
return &y
}
return nil
}
// --- enum twins (Phase 04 label maps; unmapped → code + warning) ---
// enumPair emits the raw int into *code and the mapped label; an
// unmapped upstream int keeps the code only and warns.
func (o *op) enumPair(code *int, label string, ok bool, field string) (*int, string) {
if code == nil {
return nil, ""
}
if !ok {
o.warnf("unmapped upstream enum value %d for %s", *code, field)
return code, ""
}
return code, label
}
func (o *op) requestTypeTwin(v any) (*int, string) {
c, ok := toInt(v)
if !ok {
return nil, ""
}
l, mapped := translate.RequestTypeFromWire(c)
return o.enumPair(&c, l, mapped, "request_type")
}
func (o *op) issueStatusTwin(v any) (*int, string) {
c, ok := toInt(v)
if !ok {
return nil, ""
}
l, mapped := translate.IssueStatusFromWire(c)
return o.enumPair(&c, l, mapped, "status")
}
func (o *op) agentTwin(v any) (*int, string) {
c, ok := toInt(v)
if !ok {
return nil, ""
}
l, mapped := translate.AgentFromWire(c)
return o.enumPair(&c, l, mapped, "agent")
}
// --- identifiers ---
func addID(ids []Identifier, ns string, v any) []Identifier {
s, ok := toStr(v)
if !ok || s == "" || s == "0" {
return ids
}
for _, id := range ids {
if id.Namespace == ns && id.Value == s {
return ids
}
}
return append(ids, Identifier{Namespace: ns, Value: s})
}
// firstID returns the first present, non-zero identifier value among
// keys. Used so v2 browse/details payloads that only populate `id`
// still emit a labelled identifier, without inventing a namespace.
func firstID(m map[string]any, keys ...string) any {
for _, k := range keys {
s, ok := toStr(m[k])
if ok && s != "" && s != "0" {
return m[k]
}
}
return nil
}
// --- media projections ---
// projectSearchMedia projects the shared tail of SearchMovieViewModel,
// SearchTvShowViewModel, SearchAlbumViewModel, ReleaseGroup and
// RecentlyAddedMovieModel — they share the same trailing fields.
// tvIDNS is the namespace the upstream theMovieDbId field actually
// carries for TV at this call site: "tvdb" for the TVMaze-backed v1
// routes (the field holds the TVDB id despite its name) and "tmdb"
// for the TMDB-keyed v2 engine. Ignored for non-TV media.
func projectSearchMedia(m map[string]any, media, tvIDNS string) Media {
out := Media{Media: media, Identifiers: []Identifier{}}
out.Title = jstr(m, "title", "artistName", "name")
out.Overview = jstr(m, "overview")
if y := yearOf(jstr(m, "releaseDate", "firstAired", "releaseYear")); y != nil {
out.Year = y
}
out.Available = jbool(m, "available", "fullyAvailable")
out.Requested = jbool(m, "requested")
out.Genres = strList(jarr(m, "genre"))
switch media {
case "movie":
// MovieFullInfoViewModel and v2 browse/collection members often
// carry the TMDB id only in `id`; theMovieDbId is the preferred
// field when present.
out.Identifiers = addID(out.Identifiers, "tmdb", firstID(m, "theMovieDbId", "id"))
out.Identifiers = addID(out.Identifiers, "imdb", m["imdbId"])
if rid, ok := toInt(m["requestId"]); ok && rid > 0 {
out.RequestTargets = []OutTarget{{Kind: "movie", ID: rid}}
}
case "tv":
// Label `id` with the same origin namespace as theMovieDbId so
// v2 browse (id=TMDB, theMovieDbId absent) emits tmdb, while v1
// TVMaze (id=TVDB) does not grow a bogus tmdb identifier.
out.Identifiers = addID(out.Identifiers, tvIDNS, firstID(m, "theMovieDbId", "id"))
out.Identifiers = addID(out.Identifiers, "tvdb", m["theTvDbId"])
out.Identifiers = addID(out.Identifiers, "imdb", m["imdbId"])
// seriesId is a TVMaze id only on the v1 TVMaze-backed routes.
// On the v2 moviedb route it echoes the TMDB id.
if tvIDNS == "tvdb" {
out.Identifiers = addID(out.Identifiers, "tvmaze", m["seriesId"])
}
if rid, ok := toInt(m["requestId"]); ok && rid > 0 {
out.RequestTargets = []OutTarget{{Kind: "tv_parent", ID: rid}}
}
case "album":
out.Identifiers = addID(out.Identifiers, "musicbrainz", m["foreignAlbumId"])
if rid, ok := toInt(m["requestId"]); ok && rid > 0 {
out.RequestTargets = []OutTarget{{Kind: "album", ID: rid}}
}
case "artist":
for _, k := range []string{"forignArtistId", "foreignArtistId", "id"} {
if v, ok := m[k]; ok {
out.Identifiers = addID(out.Identifiers, "musicbrainz", v)
break
}
}
}
if u := jstr(m, "posterPath", "poster", "cover"); u != "" {
out.ArtworkURIs = append(out.ArtworkURIs, u)
}
return out
}
func strList(a []any) []string {
var out []string
for _, v := range a {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
// projectFullMovie enriches a movie projection with credits, genres,
// ratings and artwork from MovieFullInfoViewModel. belongsToCollection.id
// is the collection's TMDB id, not the movie's, and is not emitted as
// a movie identifier.
func projectFullMovie(m map[string]any) Media {
out := projectSearchMedia(m, "movie", "tmdb")
out.Genres = namesOf(jarr(m, "genres"))
out.Credits = creditsOf(m)
if v := jobj(m, "externalIds"); v != nil {
out.Identifiers = addID(out.Identifiers, "imdb", v["imdbId"])
}
if v := m["voteAverage"]; v != nil {
if f, ok := v.(float64); ok {
out.Ratings = append(out.Ratings, Reference{Name: "vote_average", Value: f})
}
}
if v := m["voteCount"]; v != nil {
if i, ok := toInt(v); ok {
out.Ratings = append(out.Ratings, Reference{Name: "vote_count", Value: i})
}
}
for _, k := range []string{"backdropPath", "posterPath"} {
if u := jstr(m, k); u != "" {
out.ArtworkURIs = appendIfMissing(out.ArtworkURIs, u)
}
}
return out
}
// projectFullTV enriches a TV projection with credits, genres and
// season/episode structure from SearchFullInfoTvShowViewModel or the
// lighter v1 SearchTvShowViewModel. tvIDNS labels theMovieDbId per
// the origin route (see projectSearchMedia). Genres/credits are only
// overwritten when the richer object arrays are actually present —
// the v1 model carries genre strings instead and would otherwise be
// blanked out.
func (o *op) projectFullTV(m map[string]any, tvIDNS string) Media {
out := projectSearchMedia(m, "tv", tvIDNS)
if g := namesOf(jarr(m, "genres")); len(g) > 0 {
out.Genres = g
}
if c := creditsOf(m); len(c) > 0 {
out.Credits = c
}
if v := jobj(m, "externalIds"); v != nil {
out.Identifiers = addID(out.Identifiers, "imdb", v["imdbId"])
out.Identifiers = addID(out.Identifiers, "tvdb", v["tvdbId"])
}
if u := jstr(m, "backdropPath", "banner"); u != "" {
out.ArtworkURIs = appendIfMissing(out.ArtworkURIs, u)
}
out.Seasons = o.seasonsOf(jarr(m, "seasonRequests"))
return out
}
func creditsOf(m map[string]any) []Credit {
var out []Credit
for _, c := range jarr(m, "cast") {
if cm, ok := c.(map[string]any); ok {
out = append(out, Credit{
Name: jstr(cm, "person", "name"),
PersonID: jint(cm, "id"),
Role: jstr(cm, "character"),
})
}
}
for _, c := range jarr(m, "crew") {
if cm, ok := c.(map[string]any); ok {
out = append(out, Credit{
Name: jstr(cm, "name"),
PersonID: jint(cm, "id"),
Department: jstr(cm, "department", "job"),
})
}
}
return out
}
func namesOf(a []any) []string {
var out []string
for _, v := range a {
if vm, ok := v.(map[string]any); ok {
if s := jstr(vm, "name"); s != "" {
out = append(out, s)
}
}
}
return out
}
// seasonsOf projects upstream SeasonRequests (seasonNumber +
// episodes[{episodeNumber,title,available,requested}]) into the
// bounded season family, capping nested episodes at 100.
func (o *op) seasonsOf(a []any) []SeasonOut {
if len(a) == 0 {
return nil
}
var out []SeasonOut
for _, s := range a {
sm, ok := s.(map[string]any)
if !ok {
continue
}
sn, ok := toInt(sm["seasonNumber"])
if !ok {
continue
}
eps := jarr(sm, "episodes")
trunc := false
if len(eps) > 100 {
eps = eps[:100]
trunc = true
o.warnf("season %d episode list truncated at 100", sn)
}
so := SeasonOut{SeasonNumber: sn, Episodes: []EpisodeOut{}, Truncated: trunc}
for _, e := range eps {
em, ok := e.(map[string]any)
if !ok {
continue
}
en, ok := toInt(em["episodeNumber"])
if !ok {
continue
}
so.Episodes = append(so.Episodes, EpisodeOut{
EpisodeNumber: en,
Title: jstr(em, "title"),
Requested: jbool(em, "requested"),
Available: jbool(em, "available"),
})
}
out = append(out, so)
}
return out
}
func appendIfMissing(s []string, v string) []string {
for _, x := range s {
if x == v {
return s
}
}
return append(s, v)
}
// --- request projections ---
// projectRequest projects MovieRequests / TvRequests / ChildRequests /
// AlbumRequest / RecentlyRequestedModel into the request family.
func (o *op) projectRequest(m map[string]any, kind string) Request {
r := Request{Target: OutTarget{Kind: kind}}
// requestId is the Ombi request id on RecentlyRequestedModel and
// on some request entities; `id` is the request id on list/get
// entities but a provider id on recent TV payloads. Prefer
// requestId so a provider id never occupies the target slot.
if id, ok := toInt(m["requestId"]); ok && id > 0 {
r.Target.ID = id
} else if id, ok := toInt(m["id"]); ok && id > 0 {
r.Target.ID = id
}
r.Title = jstr(m, "title", "artistName")
r.ParentRequestID = jint(m, "parentRequestId")
r.Approved = jbool(m, "approved")
r.Available = jbool(m, "available")
r.Denied = jbool(m, "denied")
r.StatusText = jstr(m, "requestStatus")
r.RequestedDate = jstr(m, "requestedDate", "requestDate")
r.RequestedUserID = jstr(m, "requestedUserId")
r.DeniedReason = jstr(m, "deniedReason")
r.Subscribed = jbool(m, "subscribed")
r.CanApprove = jbool(m, "canApprove")
r.Identifiers = []Identifier{}
switch kind {
case "movie":
r.Identifiers = addID(r.Identifiers, "tmdb", m["theMovieDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", m["mediaId"])
r.Identifiers = addID(r.Identifiers, "imdb", m["imdbId"])
r.Is4K = jbool(m, "is4kRequest")
r.Approved4K = jbool(m, "approved4K")
r.Available4K = jbool(m, "available4K")
r.Denied4K = jbool(m, "denied4K")
case "tv_parent", "tv_child":
r.Identifiers = addID(r.Identifiers, "tvdb", m["tvDbId"])
r.Identifiers = addID(r.Identifiers, "tvdb", m["theTvDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", m["externalProviderId"])
r.Identifiers = addID(r.Identifiers, "tmdb", m["mediaId"])
r.Identifiers = addID(r.Identifiers, "imdb", m["imdbId"])
// ChildRequests carries no top-level provider ids — they live
// on the embedded parent record.
if pr := jobj(m, "parentRequest"); pr != nil {
r.Identifiers = addID(r.Identifiers, "tvdb", pr["tvDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", pr["externalProviderId"])
r.Identifiers = addID(r.Identifiers, "imdb", pr["imdbId"])
}
case "album":
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["foreignAlbumId"])
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["mediaId"])
}
if srs := jarr(m, "seasonRequests"); len(srs) > 0 {
r.Seasons = o.seasonsOf(srs)
}
return r
}
// projectRetryEntry projects FailedRequestViewModel — queue_id is
// strictly distinct from the underlying request_id.
func (o *op) projectRetryEntry(m map[string]any) RetryEntry {
r := RetryEntry{Title: jstr(m, "title"), Reason: jstr(m, "error")}
if id, ok := toInt(m["failedId"]); ok {
r.QueueID = id
}
r.RequestID = jint(m, "requestId")
r.RequestTypeCode, r.RequestType = o.requestTypeTwin(m["type"])
return r
}
// --- issue projections ---
func (o *op) projectIssue(m map[string]any) Issue {
i := Issue{}
if id, ok := toInt(m["id"]); ok {
i.ID = id
}
i.Title = jstr(m, "title")
i.Subject = jstr(m, "subject")
i.Description = jstr(m, "description")
i.CategoryID = jint(m, "issueCategoryId")
i.StatusCode, i.Status = o.issueStatusTwin(m["status"])
i.RequestTypeCode, i.RequestType = o.requestTypeTwin(m["requestType"])
i.RequestID = jint(m, "requestId")
i.ProviderID = jstr(m, "providerId")
i.CreatedDate = jstr(m, "createdDate")
// Upstream field is spelled "resovledDate"; map to resolved_date
// without adopting the typo.
i.ResolvedDate = jstr(m, "resovledDate", "resolvedDate")
i.ReportedByUserID = jstr(m, "userReportedId")
return i
}
// projectGroup projects IssuesSummaryModel into the provider-group
// family, truncating nested issues at 100 with a warning.
func (o *op) projectGroup(m map[string]any) Group {
g := Group{
ProviderID: jstr(m, "providerId"),
Title: jstr(m, "title"),
Count: jint(m, "count"),
}
arr := jarr(m, "issues")
if len(arr) > 100 {
arr = arr[:100]
o.warnf("nested issue list truncated at 100 for provider group %q", g.ProviderID)
o.truncated = true
}
if len(arr) > 0 {
g.Issues = []Issue{}
for _, v := range arr {
if im, ok := v.(map[string]any); ok {
g.Issues = append(g.Issues, o.projectIssue(im))
}
}
}
return g
}
func projectComment(m map[string]any) Comment {
return Comment{
ID: jint(m, "id"),
IssueID: jint(m, "issuesId", "issueId"),
Comment: jstr(m, "comment"),
AuthorID: jstr(m, "userId", "username"),
CreatedDate: jstr(m, "date", "createdDate"),
}
}
// --- vote projections ---
// projectVoteSummary projects VoteViewModel (global list entries).
func (o *op) projectVoteSummary(m map[string]any) Vote {
v := Vote{
RequestID: jint(m, "requestId"),
Up: jint(m, "upvotes"),
Down: jint(m, "downvotes"),
}
_, v.Media = o.requestTypeTwin(m["requestType"])
return v
}
// projectVoteRecord projects a Votes entity (per-request votes).
// VoteType has no published label map — preserve the raw code.
func (o *op) projectVoteRecord(m map[string]any) Vote {
v := Vote{
RequestID: jint(m, "requestId"),
UserID: jstr(m, "userId"),
}
_, v.Media = o.requestTypeTwin(m["requestType"])
if c, ok := toInt(m["voteType"]); ok {
v.VoteCode = &c
if c != 0 && c != 1 {
o.warnf("unmapped upstream enum value %d for vote_type", c)
}
}
return v
}
// --- user projections ---
func projectUser(m map[string]any) User {
u := User{
ID: jstr(m, "id", "userId"),
UserName: jstr(m, "userName", "username", "displayName"),
Alias: jstr(m, "alias"),
Language: jstr(m, "language"),
StreamingCountry: jstr(m, "streamingCountry"),
}
return u
}
// --- calendar projection ---
func (o *op) projectCalendarEntry(m map[string]any) CalendarEntry {
c := CalendarEntry{
Title: jstr(m, "title"),
Date: jstr(m, "start"),
Identifiers: []Identifier{},
}
if code, ok := toInt(m["type"]); ok {
switch code {
case 1:
c.Media = "movie"
case 0:
c.Media = "tv"
default:
c.Media = "unknown"
}
}
for _, ep := range jarr(m, "extraParams") {
em, ok := ep.(map[string]any)
if !ok {
continue
}
if pid, ok := toInt(em["providerId"]); ok && pid > 0 {
c.Identifiers = addID(c.Identifiers, "provider_unknown", pid)
}
}
return c
}
// --- generic reference projection ---
// refOf projects one upstream object into a reference record using
// the first present key from each candidate list.
func (o *op) refOf(m map[string]any, idKeys, nameKeys, valueKeys []string, category string) Reference {
r := Reference{Category: category}
var idRaw any
for _, k := range idKeys {
if s, ok := toStr(m[k]); ok && s != "" {
r.ID = s
idRaw = m[k]
break
}
}
var firstCorrupted string
for _, k := range nameKeys {
if s, ok := m[k].(string); ok && s != "" {
if !corrupted(s) {
r.Name = s
firstCorrupted = ""
break
}
if firstCorrupted == "" {
firstCorrupted = s
}
}
}
if r.Name == "" && firstCorrupted != "" {
r.Name = firstCorrupted
if o != nil {
o.warnf("upstream %s label appears corrupted (id %q)", category, r.ID)
}
}
if len(valueKeys) > 0 {
r.Value = scalarAt(m, valueKeys)
} else if idRaw != nil {
r.Value = idRaw
} else if r.Name != "" {
r.Value = r.Name
}
return r
}
func corrupted(s string) bool {
t := strings.TrimSpace(s)
if t == "" {
return false
}
if strings.ContainsRune(s, '\uFFFD') {
return true
}
for _, r := range t {
if r != '?' {
return false
}
}
return true
}
func scalarAt(m map[string]any, keys []string) any {
for _, k := range keys {
if v, ok := m[k]; ok {
switch v.(type) {
case string, float64, bool, int, int64:
return v
}
}
}
return nil
}
// --- paging ---
const (
defaultLimit = 25
maxLimit = 100
maxRecords = 100
)
// bounds resolves the server-side page defaults: offset 0, limit 25,
// maximum limit 100.
func bounds(p *Page) (offset, limit int) {
if p == nil {
return 0, defaultLimit
}
offset, limit = p.Offset, p.Limit
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = defaultLimit
}
if limit > maxLimit {
limit = maxLimit
}
return offset, limit
}
// capItems applies the 100-record response budget.
func capItems[T any](o *op, items []T) []T {
if len(items) > maxRecords {
o.warnf("result capped at %d records", maxRecords)
o.truncated = true
return items[:maxRecords]
}
return items
}
// localWindow slices a complete upstream array locally and reports
// truthful totals (total is known only because the full authorized
// collection was received).
func localWindow[T any](o *op, items []T, p *Page, unit string) ([]T, *Paging) {
offset, limit := bounds(p)
total := len(items)
var win []T
if offset >= total {
win = []T{}
} else {
end := offset + limit
if end > total {
end = total
}
win = items[offset:end]
}
win = capItems(o, win)
hasMore := offset+len(win) < total
pg := &Paging{
Offset: offset,
Limit: limit,
Returned: len(win),
Total: &total,
HasMore: &hasMore,
Mode: "local",
Unit: unit,
}
if hasMore {
next := offset + len(win)
pg.NextOffset = &next
}
return win, pg
}
// upstreamPage builds paging metadata for a server-paged route.
// total is nil when the upstream response carries no reliable total;
// next_offset is offered only when a further page plausibly exists.
func upstreamPage(o *op, p *Page, returned int, total *int, unit string) *Paging {
offset, limit := bounds(p)
pg := &Paging{
Offset: offset,
Limit: limit,
Returned: returned,
Total: total,
Mode: "upstream",
Unit: unit,
}
if total != nil {
more := offset+returned < *total
pg.HasMore = &more
if more {
next := offset + returned
pg.NextOffset = &next
}
return pg
}
if returned < limit {
f := false
pg.HasMore = &f
return pg
}
// A full page was returned: a continuation plausibly exists but
// upstream pagination has no snapshot guarantee.
next := offset + returned
pg.NextOffset = &next
o.warnf("upstream has no paging snapshot; concurrent changes may shift offsets")
return pg
}
// singlePage builds mode=none metadata for a fixed-size result.
func singlePage(n int, unit string) *Paging {
f := false
return &Paging{
Offset: 0,
Limit: n,
Returned: n,
Total: &n,
HasMore: &f,
Mode: "none",
Unit: unit,
}
}
// engineMutation interprets an EngineResult upstream body per the
// output contract: isError/result=false is a tool failure; true is a
// completed mutation; missing/conflicting fields are unknown.
func (o *op) engineMutation(er *ombi.EngineResult, target *OutTarget) (*Mutation, *ToolResult) {
if er == nil {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH", "empty upstream result body", false)
}
m := &Mutation{Kind: "mutation", Target: target, RequestID: er.RequestID}
m.UpstreamResult = er.Result
m.UpstreamIsError = er.IsError
code, codeName := er.ErrorCodeValue()
m.UpstreamErrorCode = code
if er.IsError != nil && *er.IsError {
msg := sanitizeText(er.ErrorMessage, maxSanitizedMsg)
if msg == "" {
msg = "upstream reported an error"
}
if codeName != "" {
msg += " (errorCode: " + sanitizeText(codeName, 100) + ")"
}
return nil, o.fail("UPSTREAM_REJECTED", msg, false)
}
if er.Result != nil && !*er.Result {
msg := sanitizeText(er.ErrorMessage, maxSanitizedMsg)
if msg == "" {
msg = "upstream rejected the request"
}
if codeName != "" {
msg += " (errorCode: " + sanitizeText(codeName, 100) + ")"
}
return nil, o.fail("UPSTREAM_REJECTED", msg, false)
}
if er.Result == nil {
return nil, o.fail("UNKNOWN_OUTCOME",
"upstream result carried no success indicator", false)
}
m.Outcome = "completed"
m.Message = sanitizeText(er.Message, maxSanitizedMsg)
return m, nil
}
// boolMutation interprets an upstream boolean write result.
func (o *op) boolMutation(raw []byte, target *OutTarget) (*Mutation, *ToolResult) {
b, fail := o.decodeBool(raw)
if fail != nil {
return nil, fail
}
if !b {
return nil, o.fail("UPSTREAM_REJECTED", "upstream returned a failure result", false)
}
return &Mutation{Kind: "mutation", Outcome: "completed", Target: target,
UpstreamResult: &b}, nil
}
// emptyMutation reports an HTTP success with no response body —
// accepted, without inventing IDs or affected counts.
func emptyMutation(outcome string, target *OutTarget) *Mutation {
if outcome == "" {
outcome = "accepted"
}
return &Mutation{Kind: "mutation", Outcome: outcome, Target: target}
}
// mutationPage wraps a mutation as the data family.
func mutationResult(m *Mutation) any { return m }
// writeResult interprets a write response whose body shape is
// unspecified: an EngineResult object, a bare boolean, or an empty
// body. A false result or isError is a tool failure; an absent body
// completes without inventing IDs or counts.
func (o *op) writeResult(raw []byte, target *OutTarget) (*Mutation, *ToolResult) {
trim := bytes.TrimSpace(raw)
switch {
case len(trim) == 0:
return emptyMutation("completed", target), nil
case trim[0] == '{':
var er ombi.EngineResult
if fail := o.decodeJSON(trim, &er); fail != nil {
return nil, fail
}
return o.engineMutation(&er, target)
default:
return o.boolMutation(trim, target)
}
}