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.
425 lines
12 KiB
Go
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})
|
|
}
|