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.
267 lines
7.1 KiB
Go
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")
|
|
}
|
|
}
|