Fix M4 discover browse, credits, and advanced search #22

Merged
gronod merged 1 commits from m4-discover-fixes into develop 2026-09-19 10:11:34 +01:00
10 changed files with 359 additions and 59 deletions
+1 -1
View File
@@ -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.
+7 -11
View File
@@ -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": [],
+1 -1
View File
@@ -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. |
+137
View File
@@ -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())
@@ -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{
+9 -9
View File
@@ -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 ---
+1
View File
@@ -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"`
+159 -25
View File
@@ -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",
+1 -1
View File
@@ -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
+7 -11
View File
@@ -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": [],