Files
ombi-mcp/internal/tools/media.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")})
}