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.
219 lines
6.1 KiB
Go
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")})
|
|
}
|