Upstream Search/tv/info responses send requestId 0 (not a missing field) for shows without a linked request, so the #7 request-state overlay never fired on live instances — requested shows reported requested:false. Now that the parent scan decodes the real collection wrapper, gating on requestId nil-or-0 lets the overlay actually run.
273 lines
7.9 KiB
Go
273 lines
7.9 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"ombi-mcp/internal/ombi"
|
|
)
|
|
|
|
// read_media — details by explicit provider/request, localized movie
|
|
// info, ratings and streaming availability. Family: media_page.
|
|
func handleMedia(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
|
o := newOp(ctx, env, "read_media", false)
|
|
var a mediaCallArgs
|
|
if fail := o.args(raw, &a); fail != nil {
|
|
return fail
|
|
}
|
|
switch a.Action {
|
|
case "details":
|
|
return o.mediaDetails(&a)
|
|
case "by_request":
|
|
return o.mediaByRequest(&a)
|
|
case "movie_localized":
|
|
return o.mediaLocalized(&a)
|
|
case "ratings":
|
|
return o.mediaRatings(&a)
|
|
case "streaming":
|
|
return o.mediaStreaming(&a)
|
|
default:
|
|
return o.invalid("action", "unsupported action %q", a.Action)
|
|
}
|
|
}
|
|
|
|
// targetID decodes the external target id — an integer for
|
|
// tmdb/tvdb, a string for imdb/musicbrainz.
|
|
func (t *ExternalTarget) idInt() (int, bool) {
|
|
var i int
|
|
if err := json.Unmarshal(t.ID, &i); err == nil && i >= 1 {
|
|
return i, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func (t *ExternalTarget) idString() (string, bool) {
|
|
var s string
|
|
if err := json.Unmarshal(t.ID, &s); err == nil && s != "" {
|
|
return s, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (o *op) mediaDetails(a *mediaCallArgs) *ToolResult {
|
|
t := a.Target
|
|
if t == nil {
|
|
return o.invalid("target", "target is required")
|
|
}
|
|
var path string
|
|
var project func(map[string]any) Media
|
|
switch t.Media {
|
|
case "movie":
|
|
project = projectFullMovie
|
|
switch t.Provider {
|
|
case "tmdb":
|
|
id, ok := t.idInt()
|
|
if !ok {
|
|
return o.invalid("target.id", "tmdb id must be a positive integer")
|
|
}
|
|
path = "/api/v2/Search/movie/" + segInt(id)
|
|
case "imdb":
|
|
s, ok := t.idString()
|
|
if !ok || len(s) < 3 || s[:2] != "tt" {
|
|
return o.invalid("target.id", "imdb id must match ^tt[0-9]+$")
|
|
}
|
|
path = "/api/v2/Search/movie/imdb/" + seg(s)
|
|
default:
|
|
return o.invalid("target.provider", "movie provider must be tmdb or imdb")
|
|
}
|
|
case "tv":
|
|
id, ok := t.idInt()
|
|
if !ok {
|
|
return o.invalid("target.id", "tv provider id must be a positive integer")
|
|
}
|
|
// tvIDNS is the namespace theMovieDbId actually carries on the
|
|
// origin route: the v2 /Search/tv/{id} route is a TMDB-keyed
|
|
// alias despite its {tvdbId} name, so tvdb resolves through
|
|
// the legacy v1 TVMaze info route instead.
|
|
var tvIDNS string
|
|
switch t.Provider {
|
|
case "tvdb":
|
|
path = "/api/v1/Search/tv/info/" + segInt(id)
|
|
tvIDNS = "tvdb"
|
|
case "tmdb":
|
|
path = "/api/v2/Search/tv/moviedb/" + segInt(id)
|
|
tvIDNS = "tmdb"
|
|
default:
|
|
return o.invalid("target.provider", "tv provider must be tmdb or tvdb")
|
|
}
|
|
project = func(m map[string]any) Media { return o.projectFullTV(m, tvIDNS) }
|
|
case "artist":
|
|
if t.Provider != "musicbrainz" {
|
|
return o.invalid("target.provider", "artist provider must be musicbrainz")
|
|
}
|
|
s, ok := t.idString()
|
|
if !ok {
|
|
return o.invalid("target.id", "musicbrainz id must be a nonempty string")
|
|
}
|
|
project = func(m map[string]any) Media { return projectSearchMedia(m, "artist", "tmdb") }
|
|
path = "/api/v2/Search/artist/" + seg(s)
|
|
case "album":
|
|
if t.Provider != "musicbrainz" {
|
|
return o.invalid("target.provider", "album provider must be musicbrainz")
|
|
}
|
|
s, ok := t.idString()
|
|
if !ok {
|
|
return o.invalid("target.id", "musicbrainz id must be a nonempty string")
|
|
}
|
|
project = func(m map[string]any) Media { return projectSearchMedia(m, "album", "tmdb") }
|
|
path = "/api/v2/Search/artist/album/" + seg(s)
|
|
default:
|
|
return o.invalid("target.media", "media must be movie|tv|artist|album")
|
|
}
|
|
raw, fail := o.call("GET", path, nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
it := project(m)
|
|
|
|
// #7 request state overlay for tvdb
|
|
if t.Media == "tv" && t.Provider == "tvdb" {
|
|
reqVal := jbool(m, "requested")
|
|
// Upstream sends requestId 0 (not absent) on unrequested shows —
|
|
// a nil-only check keeps the overlay from ever firing on live.
|
|
rid := jint(m, "requestId")
|
|
if (reqVal == nil || !*reqVal) && (rid == nil || *rid == 0) {
|
|
id, _ := t.idInt()
|
|
o.overlayTVRequestState(&it, id, jstr(m, "imdbId"))
|
|
}
|
|
}
|
|
|
|
return o.ok(&MediaPage{Kind: "media_page",
|
|
Items: []Media{it}, Page: singlePage(1, "media")})
|
|
}
|
|
|
|
func (o *op) mediaByRequest(a *mediaCallArgs) *ToolResult {
|
|
if a.RequestID == nil || *a.RequestID < 1 {
|
|
return o.invalid("request_id", "request_id must be a positive integer")
|
|
}
|
|
var path string
|
|
var project func(map[string]any) Media
|
|
switch a.Media {
|
|
case "movie":
|
|
path = "/api/v2/Search/movie/request/"
|
|
project = projectFullMovie
|
|
case "tv":
|
|
// The TV request-ID namespace of this route requires adapter
|
|
// verification; surface results as TV media projections. The
|
|
// v2 engine is TMDB-keyed, so theMovieDbId labels as tmdb.
|
|
path = "/api/v2/Search/tv/request/"
|
|
project = func(m map[string]any) Media { return o.projectFullTV(m, "tmdb") }
|
|
case "album":
|
|
// MCP media=album maps to the literal artist route segment.
|
|
path = "/api/v2/Search/artist/request/"
|
|
project = func(m map[string]any) Media { return projectSearchMedia(m, "album", "tmdb") }
|
|
default:
|
|
return o.invalid("media", "media must be movie|tv|album")
|
|
}
|
|
raw, fail := o.call("GET", path+segInt(*a.RequestID), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.ok(&MediaPage{Kind: "media_page",
|
|
Items: []Media{project(m)}, Page: singlePage(1, "media")})
|
|
}
|
|
|
|
func (o *op) mediaLocalized(a *mediaCallArgs) *ToolResult {
|
|
if a.TmdbID == nil || *a.TmdbID < 1 {
|
|
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
|
|
}
|
|
if !nonempty(a.Language) {
|
|
return o.invalid("language", "language is required")
|
|
}
|
|
body := ombi.SearchMovieExtraInfoRefineModel{
|
|
TheMovieDbID: *a.TmdbID, LanguageCode: a.Language}
|
|
raw, fail := o.call("POST", "/api/v1/Search/movie/info", nil, body)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return o.ok(&MediaPage{Kind: "media_page",
|
|
Items: []Media{projectFullMovie(m)}, Page: singlePage(1, "media")})
|
|
}
|
|
|
|
func (o *op) mediaRatings(a *mediaCallArgs) *ToolResult {
|
|
var media string
|
|
switch a.Media {
|
|
case "movie", "tv":
|
|
media = a.Media
|
|
default:
|
|
return o.invalid("media", "media must be movie or tv")
|
|
}
|
|
if !nonempty(a.Name) {
|
|
return o.invalid("name", "name must contain non-whitespace text")
|
|
}
|
|
if a.Year == nil || *a.Year < 1870 || *a.Year > 9999 {
|
|
return o.invalid("year", "year must be within 1870..9999")
|
|
}
|
|
raw, fail := o.call("GET",
|
|
fmt.Sprintf("/api/v2/Search/ratings/%s/%s/%d", media, seg(a.Name), *a.Year),
|
|
nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
m, fail := o.decodeObject(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
it := Media{Media: media, Identifiers: []Identifier{}, Title: a.Name, Year: a.Year}
|
|
for k, v := range m {
|
|
if i, ok := toInt(v); ok {
|
|
it.Ratings = append(it.Ratings, Reference{Name: k, Value: i})
|
|
} else if s, ok := v.(string); ok && s != "" {
|
|
it.Ratings = append(it.Ratings, Reference{Name: k, Value: s})
|
|
}
|
|
}
|
|
return o.ok(&MediaPage{Kind: "media_page",
|
|
Items: []Media{it}, Page: singlePage(1, "media")})
|
|
}
|
|
|
|
func (o *op) mediaStreaming(a *mediaCallArgs) *ToolResult {
|
|
var media string
|
|
switch a.Media {
|
|
case "movie", "tv":
|
|
media = a.Media
|
|
default:
|
|
return o.invalid("media", "media must be movie or tv")
|
|
}
|
|
if a.TmdbID == nil || *a.TmdbID < 1 {
|
|
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
|
|
}
|
|
// Streaming lookup uses the TMDB namespace even for TV.
|
|
raw, fail := o.call("GET",
|
|
fmt.Sprintf("/api/v2/Search/stream/%s/%d", media, *a.TmdbID), nil, nil)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
arr, fail := o.decodeArray(raw)
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
it := Media{Media: media, Identifiers: []Identifier{}}
|
|
it.Identifiers = addID(it.Identifiers, "tmdb", *a.TmdbID)
|
|
for _, sm := range arr {
|
|
p := Provider{Name: jstr(sm, "streamingProvider")}
|
|
if p.Name != "" {
|
|
it.Providers = append(it.Providers, p)
|
|
}
|
|
}
|
|
return o.ok(&MediaPage{Kind: "media_page",
|
|
Items: []Media{it}, Page: singlePage(1, "media")})
|
|
}
|