Files
ombi-mcp/internal/tools/requests.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

428 lines
12 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// read_requests — v2 list/status routes, single gets, TV children,
// search, recent requests and the privileged retry queue.
// Families: request_page, retry_page.
func handleRequests(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_requests", false)
var a RequestsListArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "list":
return o.requestsList(&a)
case "get":
return o.requestsGet(&a)
case "children":
return o.requestsChildren(&a)
case "search":
return o.requestsSearch(&a)
case "recent":
return o.requestsRecent(&a)
case "retry_queue":
return o.requestsRetryQueue(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
// list maps media+status onto the v2 paged routes. `all` uses the
// base route, never an /all/ segment. Sort is always requestDate.
func (o *op) requestsList(a *RequestsListArgs) *ToolResult {
var mediaSeg, kind string
switch a.Media {
case "movie":
mediaSeg, kind = "movie", "movie"
case "tv":
mediaSeg, kind = "tv", "tv_child" // v2 TV pages are child requests
case "album":
mediaSeg, kind = "album", "album"
default:
return o.invalid("media", "media must be movie|tv|album")
}
status := a.Status
if status == "" {
status = "all"
}
switch status {
case "all", "pending", "processing", "available", "denied":
case "unavailable":
if a.Media == "album" {
return o.invalid("status", "album has no unavailable-status route")
}
default:
return o.invalid("status", "unsupported status %q", status)
}
order := a.SortDirection
if order == "" {
order = "desc"
}
if order != "asc" && order != "desc" {
return o.invalid("sort_direction", "sort_direction must be asc or desc")
}
pos, amt := bounds(a.Page)
path := fmt.Sprintf("/api/v2/Requests/%s", mediaSeg)
if status != "all" {
path += "/" + status
}
path += fmt.Sprintf("/%d/%d/requestDate/%s", amt, pos, order)
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
vm, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
items := []Request{}
for _, v := range jarr(vm, "collection") {
if m, ok := v.(map[string]any); ok {
items = append(items, o.projectRequest(m, kind))
}
}
items = capItems(o, items)
pg := upstreamPage(o, a.Page, len(items), jint(vm, "total"), "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: items, Page: pg})
}
func (o *op) requestsGet(a *RequestsListArgs) *ToolResult {
if a.Target == nil {
return o.invalid("target", "target is required")
}
var path, kind string
switch a.Target.Kind {
case "movie":
path = "/api/v1/Request/movie/info/"
kind = "movie"
case "tv_parent":
path = "/api/v1/Request/tv/"
kind = "tv_parent"
default:
return o.invalid("target.kind", "target kind must be movie or tv_parent")
}
if a.Target.ID < 1 {
return o.invalid("target.id", "target id must be a positive integer")
}
raw, fail := o.call("GET", path+segInt(a.Target.ID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
r := o.projectRequest(m, kind)
return o.ok(&RequestPage{Kind: "request_page",
Items: []Request{r}, Page: singlePage(1, "requests")})
}
func (o *op) requestsChildren(a *RequestsListArgs) *ToolResult {
if a.ParentRequestID == nil || *a.ParentRequestID < 1 {
return o.invalid("parent_request_id", "parent_request_id must be a positive integer")
}
raw, fail := o.call("GET",
fmt.Sprintf("/api/v1/Request/tv/%d/child", *a.ParentRequestID), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Request{}
for _, m := range arr {
r := o.projectRequest(m, "tv_child")
r.ParentRequestID = a.ParentRequestID
items = append(items, r)
}
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
func (o *op) requestsSearch(a *RequestsListArgs) *ToolResult {
if !nonempty(a.Query) {
return o.invalid("query", "query must contain non-whitespace text")
}
var path, kind string
switch a.Media {
case "movie":
path, kind = "/api/v1/Request/movie/search/", "movie"
case "tv":
// v1 search returns TV parent records.
path, kind = "/api/v1/Request/tv/search/", "tv_parent"
case "album":
path, kind = "/api/v1/request/music/search/", "album"
default:
return o.invalid("media", "media must be movie|tv|album")
}
raw, fail := o.call("GET", path+seg(a.Query), nil, nil)
// #10 tv search fallback
if fail != nil && a.Media == "tv" {
items := []Request{}
q := strings.ToLower(a.Query)
_, failScan := o.eachTVRequestParent(func(p map[string]any) bool {
if strings.Contains(strings.ToLower(jstr(p, "title")), q) {
items = append(items, o.projectRequest(p, kind))
}
return false
})
if failScan != nil {
return fail // surface the original error
}
o.warnf("primary tv search route failed; fell back to parent scan")
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Request{}
for _, m := range arr {
items = append(items, o.projectRequest(m, kind))
}
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
func (o *op) requestsRecent(a *RequestsListArgs) *ToolResult {
raw, fail := o.call("GET", "/api/v2/Requests/recentlyRequested", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Request{}
type pendingTV struct {
idx int
key tvRecentKey
}
pending := []pendingTV{}
for _, m := range arr {
code, label := o.requestTypeTwin(m["type"])
kind := "movie"
switch label {
case "tv":
kind = "tv_parent" // show-level recent-request record
case "album":
kind = "album"
case "":
if code != nil {
o.warnf("unmapped request type %d on recent item", *code)
}
kind = "movie"
}
r := o.projectRequest(m, kind)
if kind == "tv_parent" {
// On TV rows RecentlyRequestedModel.requestId is the CHILD
// request id (upstream persists the provider id as the
// child PK for new-request children), never the parent id
// `get` needs. Zero whatever requestId/`id` projected and
// resolve the parent through the v1 parent list; the child
// id stays available as an ombi_tv_child identifier.
r.Target.ID = 0
key := tvRecentKey{mediaID: jstr(m, "mediaId")}
if id, ok := toInt(m["requestId"]); ok && id > 0 {
key.requestID = id
r.Identifiers = addID(r.Identifiers, "ombi_tv_child", id)
}
pending = append(pending, pendingTV{len(items), key})
} else {
// RecentlyRequestedModel.requestId is the request id for
// movie/album rows; a stray provider `id` must not occupy
// the target even when requestId is absent.
if id, ok := toInt(m["requestId"]); ok && id > 0 {
r.Target.ID = id
} else {
r.Target.ID = 0
}
}
r.RequestedUserID = jstr(m, "userId")
items = append(items, r)
}
if len(pending) > 0 {
keys := make([]tvRecentKey, len(pending))
for i, p := range pending {
keys[i] = p.key
}
parents := o.resolveRecentTVParents(keys)
for i, p := range pending {
r := &items[p.idx]
parent, ok := parents[i]
pid := jint(parent, "id")
if !ok || pid == nil || *pid < 1 {
o.warnf("recent tv item %q has no resolvable parent request; target.id left unknown",
r.Title)
continue
}
r.Target.ID = *pid
r.Identifiers = addID(r.Identifiers, "tvdb", parent["tvDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", parent["externalProviderId"])
r.Identifiers = addID(r.Identifiers, "imdb", parent["imdbId"])
}
}
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
func (o *op) requestsRetryQueue(a *RequestsListArgs) *ToolResult {
raw, fail := o.call("GET", "/api/v1/RequestRetry", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []RetryEntry{}
for _, m := range arr {
items = append(items, o.projectRetryEntry(m))
}
win, pg := localWindow(o, items, a.Page, "queue_entries")
return o.ok(&RetryPage{Kind: "retry_page", Items: win, Page: pg})
}
// read_request_stats — counts, totals, quotas and has_requests.
// Family: metrics with explicit scopes.
func handleRequestStats(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_request_stats", false)
var a RequestStatsArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "counts":
return o.statsCounts()
case "total":
return o.statsTotal(&a)
case "quota":
return o.statsQuota(&a)
case "has_requests":
return o.statsHasRequests(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func metric(name string, value any, scope, unit string) Metric {
return Metric{Name: name, Value: value, Scope: scope, Unit: unit}
}
func (o *op) statsCounts() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Request/count", nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
vals := []Metric{}
for _, k := range []string{"pending", "approved", "available", "denied"} {
if v, ok := m[k]; ok {
if i, ok := toInt(v); ok {
vals = append(vals, metric(k, i, "instance", "requests"))
}
}
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
func (o *op) statsTotal(a *RequestStatsArgs) *ToolResult {
var path string
switch a.Media {
case "movie":
path = "/api/v1/Request/movie/total"
case "tv":
path = "/api/v1/Request/tv/total"
case "album":
path = "/api/v1/request/music/total"
default:
return o.invalid("media", "media must be movie|tv|album")
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
v, fail := o.decodeScalar(raw)
if fail != nil {
return fail
}
n, ok := toInt(v)
if !ok {
return o.fail("UPSTREAM_SCHEMA_MISMATCH", "upstream total was not an integer", false)
}
return o.ok(&Metrics{Kind: "metrics",
Values: []Metric{metric("total", n, "instance", "requests")}})
}
func (o *op) statsQuota(a *RequestStatsArgs) *ToolResult {
var path string
switch a.Media {
case "movie":
path = "/api/v1/Request/movie/remaining"
case "tv":
path = "/api/v1/Request/tv/remaining"
case "album":
path = "/api/v1/request/music/remaining"
default:
return o.invalid("media", "media must be movie|tv|album")
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
vals := []Metric{}
if v, ok := m["hasLimit"]; ok {
if b, ok := v.(bool); ok {
vals = append(vals, metric("has_limit", b, "principal", ""))
}
}
if v := jint(m, "limit"); v != nil {
vals = append(vals, metric("limit", *v, "principal", "requests"))
}
if v := jint(m, "remaining"); v != nil {
vals = append(vals, metric("remaining", *v, "principal", "requests"))
}
if s := jstr(m, "nextRequest"); s != "" {
vals = append(vals, metric("next_request", s, "principal", ""))
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
func (o *op) statsHasRequests(a *RequestStatsArgs) *ToolResult {
if !nonempty(a.UserID) {
return o.invalid("user_id", "user_id is required")
}
raw, fail := o.call("GET", "/api/v1/Request/userhasrequest",
map[string]string{"userId": a.UserID}, nil)
if fail != nil {
return fail
}
b, fail := o.decodeBool(raw)
if fail != nil {
return fail
}
return o.ok(&Metrics{Kind: "metrics",
Values: []Metric{metric("has_requests", b, "selected_user", "")}})
}