Files
gronod eccbe48ff0
Build and publish / Test and build (darwin) (push) Successful in 2m3s
Build and publish / Test and build (linux) (push) Successful in 2m45s
Build and publish / Test and build (windows) (push) Successful in 3m13s
Build and publish / Build and publish Docker image (push) Successful in 2m30s
Route tvdb TV lookups through the v1 TVMaze info route
Ombi's v2 Search/tv/{tvdbId} route is a TMDB-keyed alias despite its parameter name, so read_media details and write_request_create season expansion resolved the wrong series for TVDB ids (Gitea issue #1). Repoint tvdb to the legacy v1 Search/tv/info route, label theMovieDbId by origin route (tvdb on TVMaze-backed v1 results, tmdb on the v2 engine) and emit the new tvmaze identifier namespace from seriesId.
2026-09-19 07:26:40 +01:00

666 lines
19 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate"
)
// write_request_create — movie, TV (tmdb/tvdb), album or explicit
// collection request. TV selections expand to explicit episode lists.
func handleRequestCreate(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_create", true)
var a RequestCreateArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "movie":
return o.createMovie(&a)
case "tv":
return o.createTV(&a)
case "album":
return o.createAlbum(&a)
case "collection":
return o.createCollection(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
// onBehalf resolves an MCP user id to the username upstream
// requestOnBehalf expects (Verify: id vs username — resolved
// internally and explicitly).
func (o *op) onBehalf(userID string) (string, *ToolResult) {
if !nonempty(userID) {
return "", nil
}
raw, fail := o.call("GET", "/api/v1/Identity/User/"+seg(userID), nil, nil)
if fail != nil {
return "", fail
}
u, fail := o.decodeObject(raw)
if fail != nil {
return "", fail
}
name := jstr(u, "userName", "username")
if name == "" {
return "", o.invalid("on_behalf_user_id",
"could not resolve a username for on_behalf_user_id")
}
return name, nil
}
func (o *op) createMovie(a *RequestCreateArgs) *ToolResult {
if a.TmdbID == nil || *a.TmdbID < 1 {
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
}
body := ombi.MovieRequestViewModel{
TheMovieDbID: *a.TmdbID,
LanguageCode: a.Language,
Is4KRequest: a.Is4K, // explicit false when omitted
}
if a.Overrides != nil {
body.RootFolderOverride = a.Overrides.RootFolderID
body.QualityPathOverride = a.Overrides.QualityProfileID
}
name, fail := o.onBehalf(a.OnBehalfUserID)
if fail != nil {
return fail
}
body.RequestOnBehalf = name
raw, fail := o.call("POST", "/api/v1/Request/movie", nil, body)
if fail != nil {
return fail
}
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
m, fail := o.engineMutation(&er, &OutTarget{Kind: "movie", ID: deref(er.RequestID)})
if fail != nil {
return fail
}
return o.ok(m)
}
func deref(p *int) int {
if p == nil {
return 0
}
return *p
}
// createTV builds the selection expansion shared by the TMDB (v2)
// and TVDB (v1) create routes.
func (o *op) createTV(a *RequestCreateArgs) *ToolResult {
if a.ID == nil || *a.ID < 1 {
return o.invalid("id", "id must be a positive integer")
}
if a.Selection == nil {
return o.invalid("selection", "selection is required")
}
var provider string
switch a.Provider {
case "tmdb", "tvdb":
provider = a.Provider
default:
return o.invalid("provider", "provider must be tmdb or tvdb")
}
sel, fail := o.expandTVSelection(a, provider)
if fail != nil {
return fail
}
name, fail := o.onBehalf(a.OnBehalfUserID)
if fail != nil {
return fail
}
if provider == "tmdb" {
body := ombi.TvRequestViewModelV2{
TheMovieDbID: *a.ID,
LanguageCode: a.Language,
RequestAll: sel.all,
FirstSeason: sel.first,
LatestSeason: sel.latest,
Seasons: sel.seasons,
LanguageProfile: a.LanguageProfileID,
RequestOnBehalf: name,
}
if a.Overrides != nil {
body.RootFolderOverride = a.Overrides.RootFolderID
body.QualityPathOverride = a.Overrides.QualityProfileID
}
return o.postEngineResult("/api/v2/Requests/tv", body, "tv_parent")
}
body := ombi.TvRequestViewModel{
TvDbID: *a.ID,
RequestAll: sel.all,
FirstSeason: sel.first,
LatestSeason: sel.latest,
Seasons: sel.seasons,
LanguageProfile: a.LanguageProfileID,
RequestOnBehalf: name,
}
if a.Overrides != nil {
body.RootFolderOverride = a.Overrides.RootFolderID
body.QualityPathOverride = a.Overrides.QualityProfileID
}
return o.postEngineResult("/api/v1/Request/tv", body, "tv_parent")
}
type tvSelection struct {
all, first, latest bool
seasons []ombi.SeasonsViewModel
}
// expandTVSelection validates the selection and expands season modes
// into explicit seasons[].episodes structures. `season` mode reads
// TV details privately and expands each listed season's full episode
// list; expansion is the default construction, never an
// empty-means-all assumption.
func (o *op) expandTVSelection(a *RequestCreateArgs, provider string) (*tvSelection, *ToolResult) {
s := a.Selection
out := &tvSelection{}
switch s.Mode {
case "all":
out.all = true
case "first_season":
out.first = true
case "latest_season":
out.latest = true
case "episodes":
seasons, fail := o.explicitSeasons(s)
if fail != nil {
return nil, fail
}
out.seasons = seasons
case "season":
seasons, fail := o.expandSeasonNumbers(a, provider)
if fail != nil {
return nil, fail
}
out.seasons = seasons
default:
return nil, o.invalid("selection.mode", "unsupported mode %q", s.Mode)
}
return out, nil
}
// explicitSeasons validates the caller's explicit season/episode
// picks: unique season numbers, nonempty unique episode lists and a
// 2000-episode budget per call.
func (o *op) explicitSeasons(s *TVSelect) ([]ombi.SeasonsViewModel, *ToolResult) {
if len(s.Seasons) == 0 {
return nil, o.invalid("selection.seasons", "seasons must be a nonempty array")
}
seen := map[int]bool{}
total := 0
out := []ombi.SeasonsViewModel{}
for _, se := range s.Seasons {
if se.SeasonNumber < 0 {
return nil, o.invalid("selection.seasons", "season_number must be >= 0")
}
if seen[se.SeasonNumber] {
return nil, o.invalid("selection.seasons",
"duplicate season_number %d", se.SeasonNumber)
}
seen[se.SeasonNumber] = true
if len(se.Episodes) == 0 {
return nil, o.invalid("selection.seasons",
"season %d has an empty episode list", se.SeasonNumber)
}
epSeen := map[int]bool{}
eps := []ombi.EpisodesViewModel{}
for _, ep := range se.Episodes {
if ep < 1 {
return nil, o.invalid("selection.seasons",
"episode numbers must be >= 1")
}
if epSeen[ep] {
return nil, o.invalid("selection.seasons",
"duplicate episode %d in season %d", ep, se.SeasonNumber)
}
epSeen[ep] = true
total++
eps = append(eps, ombi.EpisodesViewModel{EpisodeNumber: ep})
}
out = append(out, ombi.SeasonsViewModel{
SeasonNumber: se.SeasonNumber, Episodes: eps})
}
if total > 2000 {
return nil, o.invalid("selection.seasons",
"selection exceeds the 2000-episode budget")
}
return out, nil
}
// expandSeasonNumbers resolves each requested season number into its
// full episode list by reading TV details privately.
func (o *op) expandSeasonNumbers(a *RequestCreateArgs, provider string) ([]ombi.SeasonsViewModel, *ToolResult) {
s := a.Selection
if len(s.SeasonNumbers) == 0 {
return nil, o.invalid("selection.season_numbers",
"season_numbers must be a nonempty array")
}
seen := map[int]bool{}
for _, n := range s.SeasonNumbers {
if n < 0 {
return nil, o.invalid("selection.season_numbers",
"season numbers must be >= 0")
}
if seen[n] {
return nil, o.invalid("selection.season_numbers",
"duplicate season number %d", n)
}
seen[n] = true
}
var detailsPath string
if provider == "tmdb" {
detailsPath = "/api/v2/Search/tv/moviedb/" + segInt(*a.ID)
} else {
// The v2 /Search/tv/{id} route is a TMDB-keyed alias despite
// its {tvdbId} name; the legacy v1 info route is the only
// real TVDB lookup and carries seasonRequests/episodes.
detailsPath = "/api/v1/Search/tv/info/" + segInt(*a.ID)
}
raw, fail := o.call("GET", detailsPath, nil, nil)
if fail != nil {
return nil, fail
}
details, fail := o.decodeObject(raw)
if fail != nil {
return nil, fail
}
byNum := map[int][]ombi.EpisodesViewModel{}
for _, v := range jarr(details, "seasonRequests") {
sm, ok := v.(map[string]any)
if !ok {
continue
}
sn, ok := toInt(sm["seasonNumber"])
if !ok {
continue
}
eps := []ombi.EpisodesViewModel{}
for _, e := range jarr(sm, "episodes") {
if em, ok := e.(map[string]any); ok {
if en, ok := toInt(em["episodeNumber"]); ok {
eps = append(eps, ombi.EpisodesViewModel{EpisodeNumber: en})
}
}
}
byNum[sn] = eps
}
out := []ombi.SeasonsViewModel{}
total := 0
for _, n := range s.SeasonNumbers {
eps, ok := byNum[n]
if !ok {
return nil, o.invalid("selection.season_numbers",
"season %d was not found in the show details", n)
}
if len(eps) == 0 {
return nil, o.invalid("selection.season_numbers",
"season %d has no expandable episodes", n)
}
total += len(eps)
out = append(out, ombi.SeasonsViewModel{SeasonNumber: n, Episodes: eps})
}
if total > 2000 {
return nil, o.invalid("selection.season_numbers",
"selection exceeds the 2000-episode budget")
}
return out, nil
}
func (o *op) createAlbum(a *RequestCreateArgs) *ToolResult {
if !nonempty(a.MusicBrainzID) {
return o.invalid("musicbrainz_id", "musicbrainz_id must be a nonempty provider string")
}
body := ombi.MusicAlbumRequestViewModel{
ForeignAlbumID: a.MusicBrainzID,
RequestedByAlias: a.RequestedByAlias,
}
return o.postEngineResult("/api/v1/request/music", body, "album")
}
// createCollection posts to the documented collection route — never
// simulated with multiple movie POSTs. RAML documents a single
// RequestEngineResult; partial effects are reported, not claimed.
func (o *op) createCollection(a *RequestCreateArgs) *ToolResult {
if a.CollectionID == nil || *a.CollectionID < 1 {
return o.invalid("collection_id", "collection_id must be a positive integer")
}
raw, fail := o.call("POST",
"/api/v2/Requests/movie/collection/"+segInt(*a.CollectionID), nil, nil)
if fail != nil {
return fail
}
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
m, fail := o.engineMutation(&er, nil)
if fail != nil {
return fail
}
if er.RequestID == nil {
o.warnf("collection request returned no per-item outcomes")
}
return o.ok(m)
}
// postEngineResult posts a body and interprets the EngineResult.
func (o *op) postEngineResult(path string, body any, kind string) *ToolResult {
raw, fail := o.call("POST", path, nil, body)
if fail != nil {
return fail
}
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
var target *OutTarget
if er.RequestID != nil && kind != "" {
target = &OutTarget{Kind: kind, ID: *er.RequestID}
}
m, fail := o.engineMutation(&er, target)
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_subscribe — movie/TV subscribe and unsubscribe.
func handleRequestSubscribe(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_subscribe", true)
var a SubscribeArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
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.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
var verb string
switch a.Action {
case "subscribe", "unsubscribe":
verb = a.Action
default:
return o.invalid("action", "action must be subscribe or unsubscribe")
}
raw, fail := o.call("POST",
fmt.Sprintf("/api/v1/Request/%s/%s/%d", media, verb, a.RequestID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, &OutTarget{Kind: media, ID: a.RequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_moderate — approve/deny/availability across media.
// Movie uses {id,is4K} (+reason on deny); TV and album use {id}
// (+reason on deny). TV ids are child IDs per the contract.
func handleRequestModerate(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_moderate", true)
var a ModerateArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
var segName string
var isMovie bool
switch a.Media {
case "movie":
segName, isMovie = "movie", true
case "tv":
segName = "tv"
case "album":
segName = "request/music"
default:
return o.invalid("media", "media must be movie|tv|album")
}
switch a.Action {
case "approve", "mark_available", "mark_unavailable":
var route string
switch a.Action {
case "approve":
route = "approve"
case "mark_available":
route = "available"
case "mark_unavailable":
route = "unavailable"
}
var body any
if isMovie {
body = ombi.MovieUpdateModel{ID: a.RequestID, Is4K: a.Is4K}
} else {
body = ombi.UpdateModelID{ID: a.RequestID}
}
return o.moderatePost(fmt.Sprintf("/api/v1/Request/%s/%s", segName, route),
body, &a)
case "deny":
if !nonempty(a.Reason) {
return o.invalid("reason", "deny requires a nonempty reason")
}
var body any
if isMovie {
body = ombi.DenyMovieModel{Reason: a.Reason, ID: a.RequestID, Is4K: a.Is4K}
} else {
body = ombi.DenyModel{Reason: a.Reason, ID: a.RequestID}
}
raw, fail := o.call("PUT",
fmt.Sprintf("/api/v1/Request/%s/deny", segName), nil, body)
if fail != nil {
return fail
}
return o.finishModeration(raw, &a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) moderatePost(path string, body any, a *ModerateArgs) *ToolResult {
raw, fail := o.call("POST", path, nil, body)
if fail != nil {
return fail
}
return o.finishModeration(raw, a)
}
func (o *op) finishModeration(raw []byte, a *ModerateArgs) *ToolResult {
var er ombi.EngineResult
if fail := o.decodeJSON(raw, &er); fail != nil {
return fail
}
kind := a.Media
if kind == "tv" {
kind = "tv_child"
}
m, fail := o.engineMutation(&er, &OutTarget{Kind: kind, ID: a.RequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_delete — explicit single deletion distinguishing
// movie, album, TV parent and TV child. Parent deletion may affect
// all associated children.
func handleRequestDelete(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_delete", true)
var a DeleteArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
if a.Target == nil {
return o.invalid("target", "target is required")
}
if a.Target.ID < 1 {
return o.invalid("target.id", "target id must be a positive integer")
}
var path string
switch a.Target.Kind {
case "movie":
path = "/api/v1/Request/movie/" + segInt(a.Target.ID)
case "tv_parent":
path = "/api/v1/Request/tv/" + segInt(a.Target.ID)
o.warnf("deleting a TV parent may delete all associated children")
case "tv_child":
path = "/api/v1/Request/tv/child/" + segInt(a.Target.ID)
case "album":
path = "/api/v1/request/music/" + segInt(a.Target.ID)
default:
return o.invalid("target.kind",
"target kind must be movie|tv_parent|tv_child|album")
}
raw, fail := o.call("DELETE", path, nil, nil)
if fail != nil {
return fail
}
// Some delete routes return RequestEngineResult, others no body.
m, fail := o.writeResult(raw, &OutTarget{Kind: a.Target.Kind, ID: a.Target.ID})
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_options — typed routing/profile overrides.
func handleRequestOptions(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_options", true)
var a OptionsArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "advanced":
return o.optionsAdvanced(&a)
case "tv_root":
return o.optionsTVRoot(&a)
case "tv_quality":
return o.optionsTVQuality(&a)
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) optionsAdvanced(a *OptionsArgs) *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.RequestID == nil || *a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
if a.Options == nil {
return o.invalid("options", "options object is required")
}
if a.Options.RootFolderID == nil && a.Options.QualityProfileID == nil &&
a.Options.LanguageProfileID == nil {
return o.invalid("options", "options must set at least one field")
}
body := ombi.MediaAdvancedOptions{
RequestID: *a.RequestID,
RootPathOverride: a.Options.RootFolderID,
QualityOverride: a.Options.QualityProfileID,
LanguageProfile: a.Options.LanguageProfileID,
}
kind := media
if kind == "tv" {
kind = "tv_child"
}
return o.postEngineResult(
fmt.Sprintf("/api/v2/Requests/%s/advancedoptions", media), body, kind)
}
func (o *op) optionsTVRoot(a *OptionsArgs) *ToolResult {
if a.ParentRequestID == nil || *a.ParentRequestID < 1 {
return o.invalid("parent_request_id", "parent_request_id must be a positive integer")
}
if a.RootFolderID == nil || *a.RootFolderID < 1 {
return o.invalid("root_folder_id", "root_folder_id must be a positive integer")
}
// Both IDs are in the URL; there is no body.
raw, fail := o.call("PUT",
fmt.Sprintf("/api/v1/Request/tv/root/%d/%d", *a.ParentRequestID, *a.RootFolderID),
nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, &OutTarget{Kind: "tv_parent", ID: *a.ParentRequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
func (o *op) optionsTVQuality(a *OptionsArgs) *ToolResult {
if a.ParentRequestID == nil || *a.ParentRequestID < 1 {
return o.invalid("parent_request_id", "parent_request_id must be a positive integer")
}
if a.QualityProfileID == nil || *a.QualityProfileID < 1 {
return o.invalid("quality_profile_id", "quality_profile_id must be a positive integer")
}
raw, fail := o.call("PUT",
fmt.Sprintf("/api/v1/Request/tv/quality/%d/%d", *a.ParentRequestID, *a.QualityProfileID),
nil, nil)
if fail != nil {
return fail
}
m, fail := o.boolMutation(raw, &OutTarget{Kind: "tv_parent", ID: *a.ParentRequestID})
if fail != nil {
return fail
}
return o.ok(m)
}
// write_request_reprocess — POST reprocess/{type}/{requestId}/{is4K}
// with the numeric RequestType enum; is_4k is an explicit Boolean.
func handleRequestReprocess(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "write_request_reprocess", true)
var a ReprocessArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
rt, err := translate.RequestTypeToWire(a.RequestType)
if err != nil {
return o.invalid("request_type", "unsupported request_type %q", a.RequestType)
}
if a.RequestID < 1 {
return o.invalid("request_id", "request_id must be a positive integer")
}
raw, fail := o.call("POST",
fmt.Sprintf("/api/v2/Requests/reprocess/%d/%d/%t", rt, a.RequestID, a.Is4K),
nil, nil)
if fail != nil {
return fail
}
kind := a.RequestType
if kind == "tv" {
kind = "tv_child"
}
m, fail := o.writeResult(raw, &OutTarget{Kind: kind, ID: a.RequestID})
if fail != nil {
return fail
}
return o.ok(m)
}