368 lines
11 KiB
Go
368 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"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 && !ratingsFallbackEligible(fail) {
|
|
return fail
|
|
}
|
|
if fail == nil {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return o.fail("UPSTREAM_SCHEMA_MISMATCH",
|
|
fmt.Sprintf("upstream response was not a JSON object: %s", sanitizeErr(err)), false)
|
|
}
|
|
if m != nil {
|
|
if ratings := ratingReferences(m); len(ratings) > 0 {
|
|
return o.ratingPage(media, a.Name, *a.Year, ratings)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ombi 4.53.x still exposes the Rotten Tomatoes routes above, but their
|
|
// private upstream endpoints have been removed. Fall back to rating
|
|
// metadata already returned by Ombi's normal search providers rather than
|
|
// turning that dependency failure into an unusable MCP action.
|
|
page, fallbackFail := o.mediaRatingsFromSearch(media, a.Name, *a.Year)
|
|
if fallbackFail != nil {
|
|
if fail != nil {
|
|
return fail
|
|
}
|
|
return fallbackFail
|
|
}
|
|
return o.ok(page)
|
|
}
|
|
|
|
func ratingsFallbackEligible(fail *ToolResult) bool {
|
|
if fail == nil || fail.Error == nil || fail.Error.HTTPStatus == nil {
|
|
return false
|
|
}
|
|
status := *fail.Error.HTTPStatus
|
|
return status == 404 || status >= 500
|
|
}
|
|
|
|
func ratingReferences(m map[string]any) []Reference {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
refs := []Reference{}
|
|
for _, k := range keys {
|
|
v := m[k]
|
|
if i, ok := toInt(v); ok {
|
|
refs = append(refs, Reference{Name: k, Value: i})
|
|
} else if s, ok := v.(string); ok && s != "" {
|
|
refs = append(refs, Reference{Name: k, Value: s})
|
|
}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
func (o *op) mediaRatingsFromSearch(media, name string, year int) (*MediaPage, *ToolResult) {
|
|
raw, fail := o.call("GET", "/api/v1/Search/"+media+"/"+seg(name), nil, nil)
|
|
if fail != nil {
|
|
return nil, fail
|
|
}
|
|
arr, fail := o.decodeArray(raw)
|
|
if fail != nil {
|
|
return nil, fail
|
|
}
|
|
|
|
for _, m := range arr {
|
|
title := jstr(m, "title", "name")
|
|
resultYear := yearOf(jstr(m, "releaseDate", "firstAired", "releaseYear"))
|
|
if !strings.EqualFold(strings.TrimSpace(title), strings.TrimSpace(name)) ||
|
|
resultYear == nil || *resultYear != year {
|
|
continue
|
|
}
|
|
|
|
refs := []Reference{}
|
|
source := ""
|
|
if media == "movie" {
|
|
source = "TMDB"
|
|
if v, ok := m["voteAverage"].(float64); ok {
|
|
refs = append(refs, Reference{Name: "tmdb_vote_average", Value: v})
|
|
}
|
|
if v, ok := toInt(m["voteCount"]); ok {
|
|
refs = append(refs, Reference{Name: "tmdb_vote_count", Value: v})
|
|
}
|
|
} else {
|
|
source = "TVMaze"
|
|
if v, ok := toInt(m["siteRating"]); ok {
|
|
refs = append(refs, Reference{Name: "tvmaze_site_rating", Value: v})
|
|
}
|
|
}
|
|
o.warnf("Ombi's Rotten Tomatoes ratings endpoint was unavailable; ratings were read from %s search metadata", source)
|
|
if len(refs) == 0 {
|
|
o.warnf("the matching %s search result did not include rating metadata", media)
|
|
}
|
|
return &MediaPage{Kind: "media_page", Items: []Media{{
|
|
Media: media, Identifiers: []Identifier{}, Title: title, Year: resultYear, Ratings: refs,
|
|
}}, Page: singlePage(1, "media")}, nil
|
|
}
|
|
|
|
o.warnf("Ombi's Rotten Tomatoes ratings endpoint was unavailable and search returned no exact title/year match")
|
|
return &MediaPage{Kind: "media_page", Items: []Media{{
|
|
Media: media, Identifiers: []Identifier{}, Title: name, Year: &year, Ratings: []Reference{},
|
|
}}, Page: singlePage(1, "media")}, nil
|
|
}
|
|
|
|
func (o *op) ratingPage(media, title string, year int, ratings []Reference) *ToolResult {
|
|
return o.ok(&MediaPage{Kind: "media_page", Items: []Media{{
|
|
Media: media, Identifiers: []Identifier{}, Title: title, Year: &year, Ratings: ratings,
|
|
}}, 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")})
|
|
}
|