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.
889 lines
24 KiB
Go
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)
|
|
}
|
|
}
|