Build and publish / Test and build (linux) (pull_request) Canceled after 0s
Build and publish / Test and build (windows) (pull_request) Canceled after 0s
Build and publish / Build and publish Docker image (pull_request) Canceled after 0s
Build and publish / Test and build (darwin) (pull_request) Canceled after 40s
Build and publish / Test and build (darwin) (push) Successful in 2m9s
Build and publish / Test and build (linux) (push) Successful in 2m34s
Build and publish / Test and build (windows) (push) Successful in 3m11s
Build and publish / Build and publish Docker image (push) Successful in 1m55s
v2 request lists sent the RAML example sort field requestDate; Ombi looks up RequestedDate and NullReferenceException'd every non-empty page. Browse now streams TV popular/most-watched payloads and skips the hydrated seasonRequests graph that blew the 8 MiB read budget. provider_summary treats an empty upstream body as an empty group_page.
431 lines
12 KiB
Go
431 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 RequestedDate
|
|
// — Ombi's TypeDescriptor lookup is the C# property name, and the
|
|
// RAML example `requestDate` does not match (NullReferenceException
|
|
// on every non-empty page; #10).
|
|
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/RequestedDate/%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", "")}})
|
|
}
|