Fix M4 discover browse, credits, and advanced search #22
@@ -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.
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
Reference in New Issue
Block a user