Files
ombi-mcp/internal/tools/library.go
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

219 lines
6.1 KiB
Go

package tools
import (
"context"
"encoding/json"
"strings"
)
// read_library — recent additions, calendar and artwork references.
// Families: media_page, calendar_page, artwork_page.
func handleLibrary(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
o := newOp(ctx, env, "read_library", false)
var a LibraryArgs
if fail := o.args(raw, &a); fail != nil {
return fail
}
switch a.Action {
case "recent":
return o.libraryRecent(&a)
case "calendar":
return o.libraryCalendar(&a)
case "image":
return o.libraryImage(&a)
case "tv_images":
return o.libraryTVImages(&a)
case "album_art":
return o.libraryAlbumArt(&a)
case "default_poster":
return o.artworkScalar("/api/v1/Images/poster", "default_poster")
case "random_background":
return o.artworkScalar("/api/v1/Images/background", "random_background")
case "background_info":
return o.libraryBackgroundInfo()
default:
return o.invalid("action", "unsupported action %q", a.Action)
}
}
func (o *op) libraryRecent(a *LibraryArgs) *ToolResult {
var path, media string
switch a.Media {
case "movie":
path, media = "/api/v1/RecentlyAdded/movies", "movie"
case "tv":
if a.Grouped == nil {
return o.invalid("grouped", "grouped is required for tv")
}
media = "tv"
path = "/api/v1/RecentlyAdded/tv"
if *a.Grouped {
path += "/grouped"
}
default:
return o.invalid("media", "media must be movie or tv")
}
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []Media{}
for _, m := range arr {
// RecentlyAdded tv id namespace is unverified upstream; the
// theMovieDbId label stays tmdb pending live confirmation.
items = append(items, projectSearchMedia(m, media, "tmdb"))
}
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) libraryCalendar(a *LibraryArgs) *ToolResult {
raw, fail := o.call("GET", "/api/v2/Calendar", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
items := []CalendarEntry{}
for _, m := range arr {
items = append(items, o.projectCalendarEntry(m))
}
win, pg := localWindow(o, items, a.Page, "calendar_entries")
return o.ok(&CalendarPage{Kind: "calendar_page", Items: win, Page: pg})
}
// libraryImage routes movie/tv image requests to the exact Images
// routes; responses are verified as URI strings before emission.
func (o *op) libraryImage(a *LibraryArgs) *ToolResult {
if a.ID == nil || *a.ID < 1 {
return o.invalid("id", "id must be a positive integer")
}
var path string
switch a.Media {
case "movie":
if a.Provider != "tmdb" {
return o.invalid("provider", "movie images use provider tmdb")
}
switch a.Kind {
case "poster", "background", "banner":
path = "/api/v1/Images/" + a.Kind + "/movie/" + segInt(*a.ID)
default:
return o.invalid("kind", "movie kind must be poster|background|banner")
}
case "tv":
switch a.Kind {
case "poster", "background":
default:
return o.invalid("kind", "tv kind must be poster|background")
}
switch a.Provider {
case "tvdb":
path = "/api/v1/Images/" + a.Kind + "/tv/" + segInt(*a.ID)
case "tmdb":
path = "/api/v1/Images/" + a.Kind + "/tv/tmdb/" + segInt(*a.ID)
default:
return o.invalid("provider", "tv provider must be tmdb or tvdb")
}
default:
return o.invalid("media", "media must be movie or tv")
}
return o.artworkScalar(path, a.Kind)
}
func (o *op) libraryTVImages(a *LibraryArgs) *ToolResult {
if a.TvdbID == nil || *a.TvdbID < 1 {
return o.invalid("tvdb_id", "tvdb_id must be a positive integer")
}
return o.artworkScalar("/api/v1/Images/tv/"+segInt(*a.TvdbID), "tv_images")
}
func (o *op) libraryAlbumArt(a *LibraryArgs) *ToolResult {
if !nonempty(a.MusicBrainzID) {
return o.invalid("musicbrainz_id", "musicbrainz_id must be nonempty")
}
raw, fail := o.call("GET",
"/api/v2/Search/releasegroupart/"+seg(a.MusicBrainzID), nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
it := Artwork{Name: a.MusicBrainzID, Kind: "album_art"}
if u := jstr(m, "image", "url", "uri"); u != "" && safeURI(u) {
it.URI = u
} else if u != "" {
o.warnf("upstream artwork reference was not a safe URI")
}
if mt := jstr(m, "mimeType", "contentType"); mt != "" {
it.MimeType = mt
}
return o.ok(&ArtworkPage{Kind: "artwork_page",
Items: []Artwork{it}, Page: singlePage(1, "artwork")})
}
// artworkScalar fetches an image route whose 200 body is a string
// representation; only safe URI strings are emitted as artwork URIs.
func (o *op) artworkScalar(path, kind string) *ToolResult {
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
v, fail := o.decodeScalar(raw)
if fail != nil {
return fail
}
s, _ := v.(string)
it := Artwork{Name: kind, Kind: kind}
if s != "" && safeURI(s) {
it.URI = s
} else if s != "" {
o.warnf("upstream returned a non-URI image representation")
}
return o.ok(&ArtworkPage{Kind: "artwork_page",
Items: []Artwork{it}, Page: singlePage(1, "artwork")})
}
// safeURI accepts only http(s) URLs or root-relative paths — never
// credential-bearing query strings or scriptable content.
func safeURI(s string) bool {
s = strings.TrimSpace(s)
if s == "" || strings.HasPrefix(s, "<") {
return false
}
if strings.Contains(s, "apikey=") || strings.Contains(s, "apiKey=") ||
strings.Contains(s, "ApiKey=") {
return false
}
low := strings.ToLower(s)
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") ||
strings.HasPrefix(s, "/")
}
func (o *op) libraryBackgroundInfo() *ToolResult {
raw, fail := o.call("GET", "/api/v1/Images/background/info", nil, nil)
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
it := Artwork{Name: "background_info", Kind: "background_info"}
if u := jstr(m, "url", "uri", "image"); u != "" && safeURI(u) {
it.URI = u
}
if t := jstr(m, "title", "name"); t != "" {
it.Name = t
}
return o.ok(&ArtworkPage{Kind: "artwork_page",
Items: []Artwork{it}, Page: singlePage(1, "artwork")})
}