Files
ombi-mcp/internal/tools/requestscan.go
gronod 40155b5981 Resolve recent TV rows to real tv_parent request ids
Closes #11. Ombi builds recentlyRequested TV rows from child requests,
so requestId is a child id — and upstream persists the provider id as
the child PK for new-request children, so the value is provider-shaped
(TVDB/TMDB) while the parent request id never appears in the payload.
Consumers following target into read_requests get hit upstream 500s.

TV rows now resolve through one bounded v1 parent scan: an exact
child-id match against embedded childRequests (authoritative), then a
provider-id fallback against tvDbId/externalProviderId covering
versions that put the provider id in requestId directly. The child id
is preserved as an ombi_tv_child identifier, provider ids land under
tvdb/tmdb/imdb, and unresolvable rows emit target.id 0 with a warning
rather than a fabricated or provider-shaped target.
2026-09-19 16:15:18 +01:00

267 lines
7.1 KiB
Go

package tools
import (
"bytes"
"fmt"
"strconv"
)
// eachTVRequestParent iterates GET /api/v1/Request/tv/{count}/{pos}/1/0/0
// pages (filters ignored upstream; orderType 1 keeps the path valid).
// fn(parent) returning true stops the scan. Returns truncated=true when
// the 10-page cap is hit without exhausting the list.
func (o *op) eachTVRequestParent(fn func(map[string]any) bool) (truncated bool, err *ToolResult) {
count := 100
pos := 0
pages := 0
for pages < 10 {
path := fmt.Sprintf("/api/v1/Request/tv/%d/%d/1/0/0", count, pos)
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return false, fail
}
arr, fail := o.decodeTVParentPage(raw)
if fail != nil {
return false, fail
}
for _, m := range arr {
if fn(m) {
return false, nil
}
}
if len(arr) < count {
return false, nil // exhausted
}
pos += count
pages++
}
return true, nil // hit cap
}
// decodeTVParentPage decodes one page of the v1 TV parent list. The
// documented shape is RequestsViewModel<TvRequests> — a
// {"collection":[...],"total":N} object — but a bare array is tolerated
// for versions or proxies that unwrap it.
func (o *op) decodeTVParentPage(raw []byte) ([]map[string]any, *ToolResult) {
trim := bytes.TrimSpace(raw)
if len(trim) > 0 && trim[0] == '[' {
return o.decodeArray(raw)
}
vm, fail := o.decodeObject(raw)
if fail != nil {
return nil, fail
}
if _, present := vm["collection"]; !present {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"tv parent page object lacked a collection", false)
}
arr := jarr(vm, "collection")
out := make([]map[string]any, 0, len(arr))
for _, v := range arr {
if m, ok := v.(map[string]any); ok {
out = append(out, m)
}
}
return out, nil
}
// tvRecentKey carries the identity hints one recentlyRequested TV row
// provides: requestId is the CHILD request id (Ombi builds recent TV
// rows from child requests and persists the provider id as the child
// PK for new-request children), mediaId the parent's
// externalProviderId. The parent request id itself is never sent.
type tvRecentKey struct {
requestID int
mediaID string
}
const (
tvMatchNone = iota
tvMatchChild
tvMatchProvider
)
// tvParentChildIDs collects the real child request ids a TvRequests
// parent record embeds: childRequests[].id plus
// seasonRequests[].childRequestId.
func tvParentChildIDs(p map[string]any) map[int]bool {
ids := map[int]bool{}
for _, cr := range jarr(p, "childRequests") {
crm, ok := cr.(map[string]any)
if !ok {
continue
}
if id := jint(crm, "id"); id != nil && *id > 0 {
ids[*id] = true
}
for _, sr := range jarr(crm, "seasonRequests") {
srm, ok := sr.(map[string]any)
if !ok {
continue
}
if id := jint(srm, "childRequestId"); id != nil && *id > 0 {
ids[*id] = true
}
}
}
return ids
}
// tvParentProviderIDs collects a parent record's provider ids as a
// string set so both int requestId and string mediaId keys compare.
func tvParentProviderIDs(p map[string]any) map[string]bool {
ids := map[string]bool{}
for _, k := range []string{"tvDbId", "externalProviderId"} {
if s, ok := toStr(p[k]); ok && s != "" && s != "0" {
ids[s] = true
}
}
return ids
}
// tvParentMatch reports how parent record p matches key: tvMatchChild
// when key.requestID is one of the parent's embedded child request ids
// (authoritative), tvMatchProvider when requestID/mediaID equals a
// parent provider id (fallback — covers versions that put the provider
// id in requestId directly or omit embedded children).
func tvParentMatch(p map[string]any, k tvRecentKey) int {
if k.requestID > 0 && tvParentChildIDs(p)[k.requestID] {
return tvMatchChild
}
provs := tvParentProviderIDs(p)
if k.requestID > 0 && provs[strconv.Itoa(k.requestID)] {
return tvMatchProvider
}
if k.mediaID != "" && provs[k.mediaID] {
return tvMatchProvider
}
return tvMatchNone
}
// resolveRecentTVParents maps recentlyRequested TV rows onto their
// parent request records via one bounded parent scan. Child-id matches
// win over provider-id candidates so an unrelated parent's provider id
// can never shadow a real child id found later in the scan. Returns
// row-index → parent record; scan failures degrade to a warning, never
// an error, since the recent payload itself succeeded.
func (o *op) resolveRecentTVParents(keys []tvRecentKey) map[int]map[string]any {
resolved := map[int]map[string]any{}
candidate := map[int]map[string]any{}
truncated, fail := o.eachTVRequestParent(func(p map[string]any) bool {
for i, k := range keys {
if _, done := resolved[i]; done {
continue
}
switch tvParentMatch(p, k) {
case tvMatchChild:
resolved[i] = p
delete(candidate, i)
case tvMatchProvider:
if _, ok := candidate[i]; !ok {
candidate[i] = p
}
}
}
return len(resolved) == len(keys)
})
for i, p := range candidate {
if _, ok := resolved[i]; !ok {
resolved[i] = p
}
}
switch {
case fail != nil:
o.warnf("tv parent lookup failed; recent tv targets left unresolved")
case truncated:
o.warnf("tv parent lookup truncated; some recent tv targets may be unresolved")
}
return resolved
}
// mergeTVRequestState attempts to match and apply request state from a parent record p.
// Returns true if the parent matched (stopping the scan).
func mergeTVRequestState(it *Media, p map[string]any, tvdbID int, imdbID string) bool {
match := false
if jint(p, "tvDbId") != nil && *jint(p, "tvDbId") == tvdbID {
match = true
} else if imdbID != "" && jstr(p, "imdbId") == imdbID {
match = true
}
if !match {
return false
}
bTrue := true
it.Requested = &bTrue
it.RequestTargets = []OutTarget{{Kind: "tv_parent", ID: *jint(p, "id")}}
// Overlay season/episode states
childRequests := jarr(p, "childRequests")
for _, cr := range childRequests {
crm, ok := cr.(map[string]any)
if !ok {
continue
}
seasonRequests := jarr(crm, "seasonRequests")
for _, sr := range seasonRequests {
srm, ok := sr.(map[string]any)
if !ok {
continue
}
sNum := jint(srm, "seasonNumber")
if sNum == nil {
continue
}
// Find matching season in 'it'
var season *SeasonOut
for i := range it.Seasons {
if it.Seasons[i].SeasonNumber == *sNum {
season = &it.Seasons[i]
break
}
}
if season == nil {
continue
}
episodes := jarr(srm, "episodes")
for _, ep := range episodes {
epm, ok := ep.(map[string]any)
if !ok {
continue
}
epNum := jint(epm, "episodeNumber")
if epNum == nil {
continue
}
// Find matching episode
for i := range season.Episodes {
if season.Episodes[i].EpisodeNumber == *epNum {
season.Episodes[i].Requested = &bTrue
if avail := jbool(epm, "available"); avail != nil && *avail {
season.Episodes[i].Available = &bTrue
}
break
}
}
}
}
}
return true
}
func (o *op) overlayTVRequestState(it *Media, tvdbID int, imdbID string) {
truncated, fail := o.eachTVRequestParent(func(p map[string]any) bool {
return mergeTVRequestState(it, p, tvdbID, imdbID)
})
if fail != nil || truncated {
o.warnf("request state scan incomplete: degraded upstream flags kept")
}
}