Files
ombi-mcp/internal/tools/discover.go
gronod ff18e60d5a Resolve discover requested-fallback TV rows to tv_parent targets
The recentlyRequested fallback emitted tv_child targets for TV rows
while the primary requested-browse path emits tv_parent — and a
tv_child target is a dead end for read_requests get/children. Resolve
the child request id to the parent via the same bounded scan so both
paths agree; rows that fail resolution keep the truthful tv_child
target.
2026-09-19 16:18:04 +01:00

425 lines
12 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"ombi-mcp/internal/ombi"
)
// read_discover — curated browse lists, similar movies, collections,
// actor credits, artist albums and advanced movie discovery.
// Family: media_page. Browse and advanced_movie use server-paged v2
// routes ({currentPosition}/{amountToLoad}); the rest page locally.
func handleDiscover(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_discover", false)
var a DiscoverArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "browse":
return o.discoverBrowse(&a)
case "similar":
return o.discoverSimilar(&a)
case "collection":
return o.discoverCollection(&a)
case "credits":
return o.discoverCredits(&a)
case "artist_albums":
return o.discoverArtistAlbums(&a)
case "advanced_movie":
return o.discoverAdvanced(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
var movieBrowseRoutes = map[string]string{
"popular": "popular",
"now_playing": "nowplaying",
"top_rated": "toprated",
"upcoming": "upcoming",
"seasonal": "seasonal",
"requested": "requested",
}
var tvBrowseRoutes = map[string]string{
"popular": "popular",
"anticipated": "anticipated",
"most_watched": "mostwatched",
"trending": "trending",
"requested": "requested",
}
func (o *op) discoverBrowse(a *DiscoverArgs) *ToolResult {
var routes map[string]string
var mediaSeg, media string
switch a.Media {
case "movie":
routes, mediaSeg, media = movieBrowseRoutes, "movie", "movie"
case "tv":
routes, mediaSeg, media = tvBrowseRoutes, "tv", "tv"
default:
return o.invalid("media", "media must be movie or tv")
}
segName, ok := routes[a.Category]
if !ok {
return o.invalid("category", "unsupported %s category %q", a.Media, a.Category)
}
pos, amt := bounds(a.Page)
path := fmt.Sprintf("/api/v2/Search/%s/%s/%d/%d", mediaSeg, segName, pos, amt)
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
if a.Category == "requested" && len(arr) == 0 {
return o.discoverRequestedFallback(a, media)
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
// v2 browse routes are TMDB-keyed for both movie and tv.
items = append(items, projectSearchMedia(m, media, "tmdb"))
}
items = capItems(o, items)
pg := upstreamPage(o, a.Page, len(items), nil, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}
// discoverRequestedFallback uses Ombi's bounded, reliable recent-request
// feed when its server-paged requested-browse route returns an empty page.
// The feed has no total or continuation contract, so its paging is local and
// deliberately reports those values as unknown.
func (o *op) discoverRequestedFallback(a *DiscoverArgs, media string) *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
}
wantType := 1 // movie
if media == "tv" {
wantType = 0
}
items := make([]Media, 0, len(arr))
type pendingTV struct {
idx int
key tvRecentKey
}
pending := []pendingTV{}
for _, m := range arr {
kind, ok := toInt(m["type"])
if !ok || kind != wantType {
continue
}
requested := true
it := Media{
Media: media,
Identifiers: []Identifier{},
Title: jstr(m, "title"),
Overview: jstr(m, "overview"),
Available: jbool(m, "available"),
Requested: &requested,
}
if y := yearOf(jstr(m, "releaseDate")); y != nil {
it.Year = y
}
it.Identifiers = addID(it.Identifiers, "tmdb", m["mediaId"])
if id, ok := toInt(m["requestId"]); ok && id > 0 {
kind := "movie"
if media == "tv" {
// Recent TV requestId is a child request id — resolve
// to the parent below; tv_child stays as the truthful
// fallback when no parent can be found.
kind = "tv_child"
pending = append(pending, pendingTV{
len(items), tvRecentKey{requestID: id, mediaID: jstr(m, "mediaId")}})
}
it.RequestTargets = []OutTarget{{Kind: kind, ID: id}}
}
for _, artwork := range []string{jstr(m, "posterPath"), jstr(m, "background")} {
if artwork != "" {
it.ArtworkURIs = appendIfMissing(it.ArtworkURIs, artwork)
}
}
items = append(items, it)
}
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 {
if pid := jint(parents[i], "id"); pid != nil && *pid > 0 {
items[p.idx].RequestTargets = []OutTarget{{Kind: "tv_parent", ID: *pid}}
}
}
}
win, _ := localWindow(o, items, a.Page, "media")
o.truncated = true
o.warnf("requested browse fell back to Ombi's bounded recently-requested feed")
offset, limit := bounds(a.Page)
pg := &Paging{Offset: offset, Limit: limit, Returned: len(win),
Mode: "local", Unit: "media"}
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) discoverSimilar(a *DiscoverArgs) *ToolResult {
if a.TmdbID == nil || *a.TmdbID < 1 {
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
}
body := ombi.SimilarMoviesRefineModel{TheMovieDbID: *a.TmdbID,
LanguageCode: a.Language}
raw, fail := o.call("POST", "/api/v2/Search/movie/similar", nil, body)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, "movie", "tmdb"))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) discoverCollection(a *DiscoverArgs) *ToolResult {
if a.CollectionID == nil || *a.CollectionID < 1 {
return o.invalid("collection_id", "collection_id must be a positive integer")
}
raw, fail := o.call("GET",
"/api/v2/Search/movie/collection/"+segInt(*a.CollectionID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
items := []Media{}
// The collection keeps its own identity first, then its members.
coll := Media{Media: "collection", Identifiers: []Identifier{},
Title: jstr(m, "name"), Overview: jstr(m, "overview")}
coll.Identifiers = addID(coll.Identifiers, "tmdb", m["id"])
if u := jstr(m, "posterPath", "backdropPath"); u != "" {
coll.ArtworkURIs = []string{u}
}
items = append(items, coll)
for _, mem := range jarr(m, "collection") {
if mm, ok := mem.(map[string]any); ok {
items = append(items, projectSearchMedia(mm, "movie", "tmdb"))
}
}
items = capItems(o, items)
pg := singlePage(len(items), "media")
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}
func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult {
if a.PersonID == nil || *a.PersonID < 1 {
return o.invalid("person_id", "person_id must be a positive integer")
}
personName := strings.TrimSpace(a.PersonName)
if personName == "" || len(personName) > 200 {
return o.invalid("person_name", "person_name must contain 1 to 200 characters")
}
var media string
switch a.Media {
case "movie", "tv":
media = a.Media
default:
return o.invalid("media", "media must be movie or tv")
}
raw, fail := o.call("GET",
fmt.Sprintf("/api/v2/Search/actor/%d/%s", *a.PersonID, media), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
// Ombi's ActorCredits model omits the person's name and, for TV, drops
// TMDB's name fields. Keep each work's roles together, then enrich only
// the requested TV window through the TMDB-keyed details route.
type creditWork struct {
record map[string]any
credits []Credit
}
works := []creditWork{}
byID := map[int]int{}
add := func(arr []any, isCrew bool) {
for _, v := range arr {
cm, ok := v.(map[string]any)
if !ok {
continue
}
id, _ := toInt(cm["id"])
if id < 1 {
continue
}
cr := Credit{Name: personName, PersonID: a.PersonID}
if isCrew {
cr.Role = jstr(cm, "job")
cr.Department = jstr(cm, "department")
} else {
cr.Role = jstr(cm, "character")
}
idx, exists := byID[id]
if !exists {
idx = len(works)
byID[id] = idx
works = append(works, creditWork{record: cm})
}
works[idx].credits = append(works[idx].credits, cr)
}
}
add(jarr(m, "cast"), false)
add(jarr(m, "crew"), true)
win, pg := localWindow(o, works, a.Page, "media")
items := make([]Media, len(win))
projectTV := func(work creditWork) (Media, *ToolResult) {
id, _ := toInt(work.record["id"])
detailRaw, detailFail := o.call("GET",
"/api/v2/Search/tv/moviedb/"+segInt(id), nil, nil)
if detailFail != nil {
return Media{}, detailFail
}
detail, detailFail := o.decodeObject(detailRaw)
if detailFail != nil {
return Media{}, detailFail
}
it := projectSearchMedia(detail, "tv", "tmdb")
it.Identifiers = addID(it.Identifiers, "tmdb", work.record["id"])
it.Credits = work.credits
return it, nil
}
if media == "tv" {
jobs := make(chan int)
failures := make(chan *ToolResult, len(win))
workers := len(win)
if workers > 8 {
workers = 8
}
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for i := range jobs {
it, detailFail := projectTV(win[i])
if detailFail != nil {
failures <- detailFail
continue
}
items[i] = it
}
}()
}
for i := range win {
jobs <- i
}
close(jobs)
wg.Wait()
if len(failures) > 0 {
return <-failures
}
} else {
for i, work := range win {
it := Media{Media: "movie", Identifiers: []Identifier{},
Title: jstr(work.record, "title", "original_title"),
Overview: jstr(work.record, "overview")}
it.Identifiers = addID(it.Identifiers, "tmdb", work.record["id"])
if y := yearOf(jstr(work.record, "release_date")); y != nil {
it.Year = y
}
if u := jstr(work.record, "poster_path"); u != "" {
it.ArtworkURIs = []string{u}
}
it.Credits = work.credits
items[i] = it
}
}
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}
func (o *op) discoverArtistAlbums(a *DiscoverArgs) *ToolResult {
if !nonempty(a.ArtistID) {
return o.invalid("artist_id", "artist_id must be a nonempty provider string")
}
raw, fail := o.call("GET",
"/api/v1/Search/music/artist/album/"+seg(a.ArtistID), nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, "album", "tmdb"))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) discoverAdvanced(a *DiscoverArgs) *ToolResult {
f := a.Filters
if f == nil {
return o.invalid("filters", "filters object is required")
}
if f.ReleaseYear != nil && *f.ReleaseYear < 1901 {
return o.invalid("filters.release_year",
"release_year must be 1901 or later; use decade for earlier periods")
}
if f.ReleaseYear != nil && f.Decade != nil {
if *f.ReleaseYear < *f.Decade || *f.ReleaseYear > *f.Decade+9 {
return o.invalid("filters",
"release_year %d does not fall inside decade %d", *f.ReleaseYear, *f.Decade)
}
}
if len(f.CompanyIDs) > 0 {
return o.invalid("filters.company_ids",
"company_ids is unsupported because Ombi does not apply it")
}
body := ombi.DiscoverModel{
Type: "movie",
ReleaseYear: f.ReleaseYear,
Decade: f.Decade,
GenreIDs: f.GenreIDs,
KeywordIDs: f.KeywordIDs,
WatchProviders: f.WatchProviderIDs,
}
pos, amt := bounds(a.Page)
raw, fail := o.call("POST",
fmt.Sprintf("/api/v2/Search/advancedSearch/movie/%d/%d", pos, amt), nil, body)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
items = append(items, projectSearchMedia(m, "movie", "tmdb"))
}
items = capItems(o, items)
pg := upstreamPage(o, a.Page, len(items), nil, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}