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
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.
666 lines
19 KiB
Go
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)
|
|
}
|