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}.
|
`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.
|
`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",
|
"type": "integer",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
},
|
},
|
||||||
|
"person_name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 200
|
||||||
|
},
|
||||||
"media": {
|
"media": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -332,6 +337,7 @@ Browse supported lists, related movies, actor credits, artist albums, or advance
|
|||||||
"required": [
|
"required": [
|
||||||
"action",
|
"action",
|
||||||
"person_id",
|
"person_id",
|
||||||
|
"person_name",
|
||||||
"media"
|
"media"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -368,7 +374,7 @@ Browse supported lists, related movies, actor credits, artist albums, or advance
|
|||||||
"properties": {
|
"properties": {
|
||||||
"release_year": {
|
"release_year": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1870,
|
"minimum": 1901,
|
||||||
"maximum": 9999
|
"maximum": 9999
|
||||||
},
|
},
|
||||||
"decade": {
|
"decade": {
|
||||||
@@ -406,16 +412,6 @@ Browse supported lists, related movies, actor credits, artist albums, or advance
|
|||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"maxItems": 100,
|
"maxItems": 100,
|
||||||
"uniqueItems": true
|
"uniqueItems": true
|
||||||
},
|
|
||||||
"company_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1
|
|
||||||
},
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Each per-tool output schema is intentionally a bounded projection, not the recur
|
|||||||
|
|
||||||
| Family | Projection rules |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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)
|
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) {
|
func TestReadSearchMultiArtistMapsMusicBrainz(t *testing.T) {
|
||||||
mock := newMockOmbi(t, "jwt")
|
mock := newMockOmbi(t, "jwt")
|
||||||
c := spawnServer(t, mock.env())
|
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/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/{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/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/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/{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/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/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/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("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/v2/Requests/recentlyRequested", m.wrap(m.recentlyRequested))
|
||||||
mux.HandleFunc("GET /api/v1/Search/tv/info/{id}", m.wrap(m.tvInfoTVDB))
|
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)
|
})(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) {
|
func (m *mockOmbi) multiSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
m.json([]any{
|
m.json([]any{
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|||||||
@@ -160,16 +160,16 @@ type SearchMovieExtraInfoRefineModel struct {
|
|||||||
LanguageCode string `json:"languageCode"`
|
LanguageCode string `json:"languageCode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiscoverModel — POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}
|
// DiscoverModel — POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}.
|
||||||
// The optional upstream `type` field is intentionally omitted until
|
// Ombi uses Type to choose its downstream TMDB discover route; omitting it
|
||||||
// its semantics are verified.
|
// produces an empty result set.
|
||||||
type DiscoverModel struct {
|
type DiscoverModel struct {
|
||||||
ReleaseYear *int `json:"releaseYear,omitempty"`
|
Type string `json:"type"`
|
||||||
Decade *int `json:"decade,omitempty"`
|
ReleaseYear *int `json:"releaseYear,omitempty"`
|
||||||
GenreIDs []int `json:"genreIds,omitempty"`
|
Decade *int `json:"decade,omitempty"`
|
||||||
KeywordIDs []int `json:"keywordIds,omitempty"`
|
GenreIDs []int `json:"genreIds,omitempty"`
|
||||||
WatchProviders []int `json:"watchProviders,omitempty"`
|
KeywordIDs []int `json:"keywordIds,omitempty"`
|
||||||
Companies []int `json:"companies,omitempty"`
|
WatchProviders []int `json:"watchProviders,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- User / admin bodies ---
|
// --- User / admin bodies ---
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type DiscoverArgs struct {
|
|||||||
Language string `json:"language,omitempty"`
|
Language string `json:"language,omitempty"`
|
||||||
CollectionID *int `json:"collection_id,omitempty"`
|
CollectionID *int `json:"collection_id,omitempty"`
|
||||||
PersonID *int `json:"person_id,omitempty"`
|
PersonID *int `json:"person_id,omitempty"`
|
||||||
|
PersonName string `json:"person_name,omitempty"`
|
||||||
ArtistID string `json:"artist_id,omitempty"`
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
Filters *DiscoverFilters `json:"filters,omitempty"`
|
Filters *DiscoverFilters `json:"filters,omitempty"`
|
||||||
Page *Page `json:"page,omitempty"`
|
Page *Page `json:"page,omitempty"`
|
||||||
|
|||||||
+159
-25
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"ombi-mcp/internal/ombi"
|
"ombi-mcp/internal/ombi"
|
||||||
)
|
)
|
||||||
@@ -78,6 +80,9 @@ func (o *op) discoverBrowse(a *DiscoverArgs) *ToolResult {
|
|||||||
if fail != nil {
|
if fail != nil {
|
||||||
return fail
|
return fail
|
||||||
}
|
}
|
||||||
|
if a.Category == "requested" && len(arr) == 0 {
|
||||||
|
return o.discoverRequestedFallback(a, media)
|
||||||
|
}
|
||||||
items := make([]Media, 0, len(arr))
|
items := make([]Media, 0, len(arr))
|
||||||
for _, m := range arr {
|
for _, m := range arr {
|
||||||
// v2 browse routes are TMDB-keyed for both movie and tv.
|
// 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})
|
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 {
|
func (o *op) discoverSimilar(a *DiscoverArgs) *ToolResult {
|
||||||
if a.TmdbID == nil || *a.TmdbID < 1 {
|
if a.TmdbID == nil || *a.TmdbID < 1 {
|
||||||
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
|
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 {
|
if a.PersonID == nil || *a.PersonID < 1 {
|
||||||
return o.invalid("person_id", "person_id must be a positive integer")
|
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
|
var media string
|
||||||
switch a.Media {
|
switch a.Media {
|
||||||
case "movie", "tv":
|
case "movie", "tv":
|
||||||
@@ -162,10 +230,15 @@ func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult {
|
|||||||
if fail != nil {
|
if fail != nil {
|
||||||
return fail
|
return fail
|
||||||
}
|
}
|
||||||
// ActorCredits.cast/crew entries are credited works; emit each as a
|
// Ombi's ActorCredits model omits the person's name and, for TV, drops
|
||||||
// media record keeping the TMDB id and the person's role as a credit.
|
// TMDB's name fields. Keep each work's roles together, then enrich only
|
||||||
items := []Media{}
|
// the requested TV window through the TMDB-keyed details route.
|
||||||
seen := map[int]bool{}
|
type creditWork struct {
|
||||||
|
record map[string]any
|
||||||
|
credits []Credit
|
||||||
|
}
|
||||||
|
works := []creditWork{}
|
||||||
|
byID := map[int]int{}
|
||||||
add := func(arr []any, isCrew bool) {
|
add := func(arr []any, isCrew bool) {
|
||||||
for _, v := range arr {
|
for _, v := range arr {
|
||||||
cm, ok := v.(map[string]any)
|
cm, ok := v.(map[string]any)
|
||||||
@@ -173,39 +246,92 @@ func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id, _ := toInt(cm["id"])
|
id, _ := toInt(cm["id"])
|
||||||
if id > 0 && seen[id] {
|
if id < 1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if id > 0 {
|
cr := Credit{Name: personName, PersonID: a.PersonID}
|
||||||
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}
|
|
||||||
if isCrew {
|
if isCrew {
|
||||||
cr.Role = jstr(cm, "job")
|
cr.Role = jstr(cm, "job")
|
||||||
cr.Department = jstr(cm, "department")
|
cr.Department = jstr(cm, "department")
|
||||||
} else {
|
} else {
|
||||||
cr.Role = jstr(cm, "character")
|
cr.Role = jstr(cm, "character")
|
||||||
}
|
}
|
||||||
if cr.Role != "" || cr.Department != "" {
|
idx, exists := byID[id]
|
||||||
it.Credits = []Credit{cr}
|
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, "cast"), false)
|
||||||
add(jarr(m, "crew"), true)
|
add(jarr(m, "crew"), true)
|
||||||
win, pg := localWindow(o, items, a.Page, "media")
|
win, pg := localWindow(o, works, a.Page, "media")
|
||||||
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
|
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 {
|
func (o *op) discoverArtistAlbums(a *DiscoverArgs) *ToolResult {
|
||||||
@@ -234,19 +360,27 @@ func (o *op) discoverAdvanced(a *DiscoverArgs) *ToolResult {
|
|||||||
if f == nil {
|
if f == nil {
|
||||||
return o.invalid("filters", "filters object is required")
|
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 != nil && f.Decade != nil {
|
||||||
if *f.ReleaseYear < *f.Decade || *f.ReleaseYear > *f.Decade+9 {
|
if *f.ReleaseYear < *f.Decade || *f.ReleaseYear > *f.Decade+9 {
|
||||||
return o.invalid("filters",
|
return o.invalid("filters",
|
||||||
"release_year %d does not fall inside decade %d", *f.ReleaseYear, *f.Decade)
|
"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{
|
body := ombi.DiscoverModel{
|
||||||
|
Type: "movie",
|
||||||
ReleaseYear: f.ReleaseYear,
|
ReleaseYear: f.ReleaseYear,
|
||||||
Decade: f.Decade,
|
Decade: f.Decade,
|
||||||
GenreIDs: f.GenreIDs,
|
GenreIDs: f.GenreIDs,
|
||||||
KeywordIDs: f.KeywordIDs,
|
KeywordIDs: f.KeywordIDs,
|
||||||
WatchProviders: f.WatchProviderIDs,
|
WatchProviders: f.WatchProviderIDs,
|
||||||
Companies: f.CompanyIDs,
|
|
||||||
}
|
}
|
||||||
pos, amt := bounds(a.Page)
|
pos, amt := bounds(a.Page)
|
||||||
raw, fail := o.call("POST",
|
raw, fail := o.call("POST",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate go run ../../tools/schemagen
|
//go:generate sh -c "cd ../.. && go run ./tools/schemagen"
|
||||||
//go:embed schemas.json
|
//go:embed schemas.json
|
||||||
var schemasJSON []byte
|
var schemasJSON []byte
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,11 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
},
|
},
|
||||||
|
"person_name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 200
|
||||||
|
},
|
||||||
"media": {
|
"media": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -131,6 +136,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"action",
|
"action",
|
||||||
"person_id",
|
"person_id",
|
||||||
|
"person_name",
|
||||||
"media"
|
"media"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -167,7 +173,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"release_year": {
|
"release_year": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1870,
|
"minimum": 1901,
|
||||||
"maximum": 9999
|
"maximum": 9999
|
||||||
},
|
},
|
||||||
"decade": {
|
"decade": {
|
||||||
@@ -205,16 +211,6 @@
|
|||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"maxItems": 100,
|
"maxItems": 100,
|
||||||
"uniqueItems": true
|
"uniqueItems": true
|
||||||
},
|
|
||||||
"company_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1
|
|
||||||
},
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [],
|
||||||
|
|||||||
Reference in New Issue
Block a user