From 019caca1b30138f13966d0c3f7baa6a7a1b9de32 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 19 Sep 2026 10:07:10 +0100 Subject: [PATCH] Fix M4 discover browse and search contracts --- docs/schema/02-tool-mapping.md | 2 +- docs/schema/03-input-schemas.md | 18 +- docs/schema/04-results.md | 2 +- internal/integration_test/contract_test.go | 137 +++++++++++++++ internal/integration_test/mockombi_test.go | 36 ++++ internal/ombi/wiretypes.go | 18 +- internal/tools/args_rest.go | 1 + internal/tools/discover.go | 184 ++++++++++++++++++--- internal/tools/registry.go | 2 +- internal/tools/schemas.json | 18 +- 10 files changed, 359 insertions(+), 59 deletions(-) diff --git a/docs/schema/02-tool-mapping.md b/docs/schema/02-tool-mapping.md index 6234fdc..a8adbd7 100644 --- a/docs/schema/02-tool-mapping.md +++ b/docs/schema/02-tool-mapping.md @@ -109,7 +109,7 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie `similar` is POST v2 movie/similar with `{theMovieDbId, languageCode?}`. A verified v1 POST equivalent can preserve language; a v1 GET cannot preserve a language argument. `collection` returns the collection's members/basic metadata, without creating requests. `credits` chooses actor/{actorId}/movie or /tv. `artist_albums` uses v1 music/artist/album/{foreignArtistId}. -`advanced_movie` POSTs the exact DiscoverModel property names: `release_year→releaseYear`, `genre_ids→genreIds`, `keyword_ids→keywordIds`, `watch_provider_ids→watchProviders`, `company_ids→companies`, and decade unchanged. Do not invent a `query` requirement for discovery. The optional upstream `type` field is omitted until its semantics are verified; release year and decade must agree if both supplied. +`advanced_movie` POSTs the exact DiscoverModel property names: `type:"movie"`, `release_year→releaseYear`, `genre_ids→genreIds`, `keyword_ids→keywordIds`, `watch_provider_ids→watchProviders`, and decade unchanged. Ombi 4.53.x accepts but ignores `companies`, so `company_ids` is not advertised and legacy calls fail explicitly. Do not invent a `query` requirement for discovery. Release year and decade must agree if both are supplied. `read_media.details` chooses v2 movie TMDB/IMDb, TV TVDB/TMDB, artist, or artist/album routes. TV `tmdb` uses v2 `Search/tv/moviedb/{id}`; TV `tvdb` uses the legacy v1 `Search/tv/info/{tvdbId}` route — the v2 `Search/tv/{tvdbId}` route is a TMDB-keyed alias of moviedb despite its parameter name and must never serve a TVDB lookup. IMDb path placeholder spelling differs from its parameter declaration; substitute the actual path placeholder. `by_request` chooses movie/request, tv/request or artist/request; the TV namespace of this particular upstream route requires adapter verification and must not be guessed from list results. `movie_localized` uses POST v1 movie/info with `{theMovieDbId, languageCode}`. `ratings` uses title and year, not a numeric media ID. `streaming` uses TMDB even for TV. Cast/crew are projections of detailed metadata where present, not invented standalone endpoints. diff --git a/docs/schema/03-input-schemas.md b/docs/schema/03-input-schemas.md index 8ae3e5d..3f4c5bb 100644 --- a/docs/schema/03-input-schemas.md +++ b/docs/schema/03-input-schemas.md @@ -318,6 +318,11 @@ Browse supported lists, related movies, actor credits, artist albums, or advance "type": "integer", "minimum": 1 }, + "person_name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "media": { "type": "string", "enum": [ @@ -332,6 +337,7 @@ Browse supported lists, related movies, actor credits, artist albums, or advance "required": [ "action", "person_id", + "person_name", "media" ], "additionalProperties": false @@ -368,7 +374,7 @@ Browse supported lists, related movies, actor credits, artist albums, or advance "properties": { "release_year": { "type": "integer", - "minimum": 1870, + "minimum": 1901, "maximum": 9999 }, "decade": { @@ -406,16 +412,6 @@ Browse supported lists, related movies, actor credits, artist albums, or advance "minItems": 1, "maxItems": 100, "uniqueItems": true - }, - "company_ids": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1 - }, - "minItems": 1, - "maxItems": 100, - "uniqueItems": true } }, "required": [], diff --git a/docs/schema/04-results.md b/docs/schema/04-results.md index 1f7ad59..26a24e2 100644 --- a/docs/schema/04-results.md +++ b/docs/schema/04-results.md @@ -16,7 +16,7 @@ Each per-tool output schema is intentionally a bounded projection, not the recur | Family | Projection rules | |---|---| -| media_page | Search/discovery/details return zero or more normalized media records. Details usually has one item. Keep every known ID namespace; never emit the same namespace+value twice. On TV the upstream `theMovieDbId` field name lies: TVMaze-backed v1 routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) carry the TVDB id there (emit `tvdb`, plus `seriesId`→`tvmaze`); TMDB-keyed v2 routes carry the TMDB id (emit `tmdb`) and must not label `seriesId` as `tvmaze` — on those routes it echoes the TMDB id. When `theMovieDbId` is absent, `id` is labelled with the same origin namespace (v2 browse/collection members and MovieFullInfoViewModel). `belongsToCollection.id` is the collection's TMDB id, not the movie's. Multi-search `mediaType` is matched case-insensitively (`Artist`→`artist`/`musicbrainz`). The namespace label is per origin route, never per field name. Map cast/crew into credits; title-specific streaming into providers; rating fields into named rating references. Never claim a global provider catalogue is a title's availability. Collections keep their own collection identity and returned members. | +| media_page | Search/discovery/details return zero or more normalized media records. Details usually has one item. Keep every known ID namespace; never emit the same namespace+value twice. On TV the upstream `theMovieDbId` field name lies: TVMaze-backed v1 routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) carry the TVDB id there (emit `tvdb`, plus `seriesId`→`tvmaze`); TMDB-keyed v2 routes carry the TMDB id (emit `tmdb`) and must not label `seriesId` as `tvmaze` — on those routes it echoes the TMDB id. When `theMovieDbId` is absent, `id` is labelled with the same origin namespace (v2 browse/collection members and MovieFullInfoViewModel). `belongsToCollection.id` is the collection's TMDB id, not the movie's. Multi-search `mediaType` is matched case-insensitively (`Artist`→`artist`/`musicbrainz`). The namespace label is per origin route, never per field name. Credit calls require the caller-supplied person name because Ombi returns only the person ID; TV credit titles are enriched from their TMDB detail records. If requested browse falls back to Ombi's bounded recently-requested feed, mark it truncated and leave total/continuation unknown. Map cast/crew into credits; title-specific streaming into providers; rating fields into named rating references. Never claim a global provider catalogue is a title's availability. Collections keep their own collection identity and returned members. | | request_page | Map the Ombi request id to target kind and ID: prefer `requestId` over `id`. v2 TV list items are children and `parentRequestId` is preserved; v1 parent records stay parents. On `recent`, `RecentlyRequestedModel.requestId` is the target id for every kind; a provider-shaped `id` (common on TV) is never the target — provider values go in `identifiers` (`mediaId`, `tvDbId`, `externalProviderId`). Include standard and 4K state separately. Never infer one combined lifecycle status when booleans disagree. | | issue_page | Project writable/display fields plus IDs/timestamps. Wire `resovledDate` maps to `resolved_date` without changing upstream spelling. Omit nested user objects and comments unless requested separately. | | group_page | v2 issue summaries are provider groups. Count and page units describe groups, not individual issues. Truncate nested issues with a warning. | diff --git a/internal/integration_test/contract_test.go b/internal/integration_test/contract_test.go index 3f2e866..e0c8c32 100644 --- a/internal/integration_test/contract_test.go +++ b/internal/integration_test/contract_test.go @@ -442,6 +442,143 @@ func TestReadDiscoverCollectionMembersEmitTMDBIdentifier(t *testing.T) { assertNoLeak(t, out.Raw) } +func TestReadDiscoverCreditsUsesSuppliedNameAndEnrichesTV(t *testing.T) { + mock := newMockOmbi(t, "jwt") + c := spawnServer(t, mock.env()) + c.handshake(t) + + for _, media := range []string{"movie", "tv"} { + out := c.callTool(t, "read_discover", map[string]any{ + "action": "credits", "media": media, + "person_id": 10205, "person_name": "Sigourney Weaver", + }) + data := requireOK(t, out) + var page struct { + Items []struct { + Title string `json:"title"` + Credits []struct { + Name string `json:"name"` + Role string `json:"role"` + Department string `json:"department"` + } `json:"credits"` + } `json:"items"` + } + if err := json.Unmarshal(data, &page); err != nil { + t.Fatalf("%s credits decode: %v\n%s", media, err, data) + } + if len(page.Items) != 1 || page.Items[0].Title == "" { + t.Fatalf("%s credits missing title: %s", media, data) + } + for _, credit := range page.Items[0].Credits { + if credit.Name != "Sigourney Weaver" { + t.Errorf("%s credit name = %q", media, credit.Name) + } + } + if media == "movie" && (len(page.Items[0].Credits) != 2 || + page.Items[0].Credits[0].Role != "Cobb" || + page.Items[0].Credits[1].Department != "Production") { + t.Errorf("movie cast/crew roles were not preserved: %s", data) + } + assertNoLeak(t, out.Raw) + } + if n := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/1396"); n != 1 { + t.Errorf("TV credit detail calls = %d, want 1", n) + } + out := c.callTool(t, "read_discover", map[string]any{ + "action": "credits", "media": "movie", "person_id": 10205, + }) + e := requireErr(t, out, "INVALID_ARGUMENT") + if e.Field != "person_name" { + t.Errorf("missing person_name field = %q", e.Field) + } +} + +func TestReadDiscoverRequestedFallsBackToRecentFeed(t *testing.T) { + mock := newMockOmbi(t, "jwt") + c := spawnServer(t, mock.env()) + c.handshake(t) + + for _, media := range []string{"movie", "tv"} { + out := c.callTool(t, "read_discover", map[string]any{ + "action": "browse", "media": media, "category": "requested", + }) + data := requireOK(t, out) + var page struct { + Items []struct { + Media string `json:"media"` + Title string `json:"title"` + Requested *bool `json:"requested"` + Identifiers []struct { + Namespace string `json:"namespace"` + Value string `json:"value"` + } `json:"identifiers"` + } `json:"items"` + Page struct { + Mode string `json:"mode"` + Total *int `json:"total"` + } `json:"page"` + } + if err := json.Unmarshal(data, &page); err != nil { + t.Fatalf("%s requested browse decode: %v\n%s", media, err, data) + } + if len(page.Items) == 0 || page.Items[0].Media != media || + page.Items[0].Requested == nil || !*page.Items[0].Requested { + t.Fatalf("bad %s requested fallback: %s", media, data) + } + if page.Page.Mode != "local" || page.Page.Total != nil || !out.Envelope.Truncated || len(out.Envelope.Warnings) == 0 { + t.Errorf("fallback paging/warning incorrect: %s", out.Raw) + } + ids := map[string]string{} + for _, id := range page.Items[0].Identifiers { + ids[id.Namespace] = id.Value + } + if ids["tmdb"] == "" { + t.Errorf("%s fallback missing tmdb identifier: %s", media, data) + } + assertNoLeak(t, out.Raw) + } + if n := mock.countCalls("GET", "/api/v2/Requests/recentlyRequested"); n != 2 { + t.Errorf("recently requested calls = %d, want 2", n) + } +} + +func TestReadDiscoverAdvancedMovieSendsTypeAndRejectsCompanies(t *testing.T) { + mock := newMockOmbi(t, "jwt") + c := spawnServer(t, mock.env()) + c.handshake(t) + + out := c.callTool(t, "read_discover", map[string]any{ + "action": "advanced_movie", + "filters": map[string]any{"genre_ids": []int{28}, "release_year": 1979}, + }) + data := requireOK(t, out) + if !strings.Contains(string(data), "Inception") { + t.Fatalf("advanced result missing fixture movie: %s", data) + } + body := mock.lastBody(t, "POST", "/api/v2/Search/advancedSearch/movie/") + if body["type"] != "movie" || body["releaseYear"] != float64(1979) { + t.Errorf("advanced body = %#v", body) + } + if _, ok := body["companies"]; ok { + t.Errorf("unsupported companies leaked into body: %#v", body) + } + + out = c.callTool(t, "read_discover", map[string]any{ + "action": "advanced_movie", "filters": map[string]any{"company_ids": []int{401}}, + }) + e := requireErr(t, out, "INVALID_ARGUMENT") + if e.Field != "filters.company_ids" { + t.Errorf("company error field = %q", e.Field) + } + out = c.callTool(t, "read_discover", map[string]any{ + "action": "advanced_movie", "filters": map[string]any{"release_year": 1900}, + }) + e = requireErr(t, out, "INVALID_ARGUMENT") + if e.Field != "filters.release_year" { + t.Errorf("release-year error field = %q", e.Field) + } +} + func TestReadSearchMultiArtistMapsMusicBrainz(t *testing.T) { mock := newMockOmbi(t, "jwt") c := spawnServer(t, mock.env()) diff --git a/internal/integration_test/mockombi_test.go b/internal/integration_test/mockombi_test.go index 11f4ea1..f420f55 100644 --- a/internal/integration_test/mockombi_test.go +++ b/internal/integration_test/mockombi_test.go @@ -63,11 +63,16 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi { mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/requestDate/{order}", m.wrap(m.tvList)) mux.HandleFunc("GET /api/v2/Search/movie/{id}", m.wrap(m.movieDetails)) mux.HandleFunc("GET /api/v2/Search/movie/collection/{id}", m.wrap(m.movieCollection)) + mux.HandleFunc("GET /api/v2/Search/movie/requested/{pos}/{amt}", m.wrap(m.emptyBrowse)) mux.HandleFunc("GET /api/v2/Search/tv/moviedb/{id}", m.wrap(m.tvDetailsTMDB)) mux.HandleFunc("GET /api/v2/Search/tv/{id}", m.wrap(m.tvDetailsTVDB)) mux.HandleFunc("GET /api/v2/Search/tv/popular/{pos}/{amt}", m.wrap(m.tvBrowse)) mux.HandleFunc("GET /api/v2/Search/tv/anticipated/{pos}/{amt}", m.wrap(m.tvBrowse)) mux.HandleFunc("GET /api/v2/Search/tv/trending/{pos}/{amt}", m.wrap(m.tvBrowse)) + mux.HandleFunc("GET /api/v2/Search/tv/requested/{pos}/{amt}", m.wrap(m.emptyBrowse)) + mux.HandleFunc("GET /api/v2/Search/actor/{id}/movie", m.wrap(m.actorMovieCredits)) + mux.HandleFunc("GET /api/v2/Search/actor/{id}/tv", m.wrap(m.actorTVCredits)) + mux.HandleFunc("POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}", m.wrap(m.advancedMovie)) mux.HandleFunc("POST /api/v2/Search/multi/{term}", m.wrap(m.multiSearch)) mux.HandleFunc("GET /api/v2/Requests/recentlyRequested", m.wrap(m.recentlyRequested)) mux.HandleFunc("GET /api/v1/Search/tv/info/{id}", m.wrap(m.tvInfoTVDB)) @@ -618,6 +623,37 @@ func (m *mockOmbi) movieCollection(w http.ResponseWriter, r *http.Request) { })(w, r) } +func (m *mockOmbi) emptyBrowse(w http.ResponseWriter, r *http.Request) { + m.json([]any{})(w, r) +} + +func (m *mockOmbi) actorMovieCredits(w http.ResponseWriter, r *http.Request) { + m.json(map[string]any{ + "id": r.PathValue("id"), + "cast": []any{map[string]any{ + "id": 27205, "title": "Inception", "overview": "A thief who steals corporate secrets.", + "release_date": "2010-07-15", "poster_path": "/inception.jpg", "character": "Cobb", + }}, + "crew": []any{map[string]any{ + "id": 27205, "title": "Inception", "job": "Producer", "department": "Production", + }}, + })(w, r) +} + +func (m *mockOmbi) actorTVCredits(w http.ResponseWriter, r *http.Request) { + // Ombi's ActorCredits DTO discards TMDB's TV name/original_name fields. + m.json(map[string]any{ + "id": r.PathValue("id"), + "cast": []any{map[string]any{ + "id": 1396, "character": "Walter White", "overview": "A chemistry teacher.", + }}, + })(w, r) +} + +func (m *mockOmbi) advancedMovie(w http.ResponseWriter, r *http.Request) { + m.json([]any{mockMovieDetail()})(w, r) +} + func (m *mockOmbi) multiSearch(w http.ResponseWriter, r *http.Request) { m.json([]any{ map[string]any{ diff --git a/internal/ombi/wiretypes.go b/internal/ombi/wiretypes.go index 407d0e3..913c8c4 100644 --- a/internal/ombi/wiretypes.go +++ b/internal/ombi/wiretypes.go @@ -160,16 +160,16 @@ type SearchMovieExtraInfoRefineModel struct { LanguageCode string `json:"languageCode"` } -// DiscoverModel — POST /api/v2/Search/advancedSearch/movie/{pos}/{amt} -// The optional upstream `type` field is intentionally omitted until -// its semantics are verified. +// DiscoverModel — POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}. +// Ombi uses Type to choose its downstream TMDB discover route; omitting it +// produces an empty result set. type DiscoverModel struct { - ReleaseYear *int `json:"releaseYear,omitempty"` - Decade *int `json:"decade,omitempty"` - GenreIDs []int `json:"genreIds,omitempty"` - KeywordIDs []int `json:"keywordIds,omitempty"` - WatchProviders []int `json:"watchProviders,omitempty"` - Companies []int `json:"companies,omitempty"` + Type string `json:"type"` + ReleaseYear *int `json:"releaseYear,omitempty"` + Decade *int `json:"decade,omitempty"` + GenreIDs []int `json:"genreIds,omitempty"` + KeywordIDs []int `json:"keywordIds,omitempty"` + WatchProviders []int `json:"watchProviders,omitempty"` } // --- User / admin bodies --- diff --git a/internal/tools/args_rest.go b/internal/tools/args_rest.go index f0d4a8b..ea0b351 100644 --- a/internal/tools/args_rest.go +++ b/internal/tools/args_rest.go @@ -26,6 +26,7 @@ type DiscoverArgs struct { Language string `json:"language,omitempty"` CollectionID *int `json:"collection_id,omitempty"` PersonID *int `json:"person_id,omitempty"` + PersonName string `json:"person_name,omitempty"` ArtistID string `json:"artist_id,omitempty"` Filters *DiscoverFilters `json:"filters,omitempty"` Page *Page `json:"page,omitempty"` diff --git a/internal/tools/discover.go b/internal/tools/discover.go index a8b7e8f..553bc4c 100644 --- a/internal/tools/discover.go +++ b/internal/tools/discover.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "strings" + "sync" "ombi-mcp/internal/ombi" ) @@ -78,6 +80,9 @@ func (o *op) discoverBrowse(a *DiscoverArgs) *ToolResult { 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. @@ -88,6 +93,65 @@ func (o *op) discoverBrowse(a *DiscoverArgs) *ToolResult { 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)) + 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" { + kind = "tv_child" + } + 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) + } + 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") @@ -146,6 +210,10 @@ 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": @@ -162,10 +230,15 @@ func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult { if fail != nil { return fail } - // ActorCredits.cast/crew entries are credited works; emit each as a - // media record keeping the TMDB id and the person's role as a credit. - items := []Media{} - seen := map[int]bool{} + // 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) @@ -173,39 +246,92 @@ func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult { continue } id, _ := toInt(cm["id"]) - if id > 0 && seen[id] { + if id < 1 { continue } - if id > 0 { - seen[id] = true - } - it := Media{Media: media, Identifiers: []Identifier{}, - Title: jstr(cm, "title", "original_title", "name"), - Overview: jstr(cm, "overview")} - it.Identifiers = addID(it.Identifiers, "tmdb", cm["id"]) - if y := yearOf(jstr(cm, "release_date", "first_air_date")); y != nil { - it.Year = y - } - if u := jstr(cm, "poster_path"); u != "" { - it.ArtworkURIs = []string{u} - } - cr := Credit{PersonID: a.PersonID} + 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") } - if cr.Role != "" || cr.Department != "" { - it.Credits = []Credit{cr} + idx, exists := byID[id] + if !exists { + idx = len(works) + byID[id] = idx + works = append(works, creditWork{record: cm}) } - items = append(items, it) + works[idx].credits = append(works[idx].credits, cr) } } add(jarr(m, "cast"), false) add(jarr(m, "crew"), true) - win, pg := localWindow(o, items, a.Page, "media") - return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg}) + 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 { @@ -234,19 +360,27 @@ func (o *op) discoverAdvanced(a *DiscoverArgs) *ToolResult { 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, - Companies: f.CompanyIDs, } pos, amt := bounds(a.Page) raw, fail := o.call("POST", diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 03c1dec..f45d095 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -7,7 +7,7 @@ import ( "strings" ) -//go:generate go run ../../tools/schemagen +//go:generate sh -c "cd ../.. && go run ./tools/schemagen" //go:embed schemas.json var schemasJSON []byte diff --git a/internal/tools/schemas.json b/internal/tools/schemas.json index 0d0593c..ba7c3f3 100644 --- a/internal/tools/schemas.json +++ b/internal/tools/schemas.json @@ -117,6 +117,11 @@ "type": "integer", "minimum": 1 }, + "person_name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "media": { "type": "string", "enum": [ @@ -131,6 +136,7 @@ "required": [ "action", "person_id", + "person_name", "media" ], "additionalProperties": false @@ -167,7 +173,7 @@ "properties": { "release_year": { "type": "integer", - "minimum": 1870, + "minimum": 1901, "maximum": 9999 }, "decade": { @@ -205,16 +211,6 @@ "minItems": 1, "maxItems": 100, "uniqueItems": true - }, - "company_ids": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1 - }, - "minItems": 1, - "maxItems": 100, - "uniqueItems": true } }, "required": [], -- 2.39.5