Files
ombi-mcp/internal/tools/media.go
gronod d07ef4f818 Treat requestId 0 as absent in the TV details overlay gate
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.
2026-09-19 16:19:02 +01:00

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")})
}