Re-adding the field in 1.0.12 made it required (go-sdk requires all non-omitempty fields), so callers that dropped the key failed validation. Mark it omitempty so it is accepted but never required.
343 lines
11 KiB
Go
343 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.i3omb.com/gronod/emby-mcp/internal/emby"
|
|
"git.i3omb.com/gronod/emby-mcp/internal/state"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// newToolTestClient wires an MCP client session to a server backed by the
|
|
// given Emby stub handler.
|
|
func newToolTestClient(t *testing.T, handler http.HandlerFunc) *mcp.ClientSession {
|
|
t.Helper()
|
|
embySrv := httptest.NewServer(handler)
|
|
t.Cleanup(embySrv.Close)
|
|
|
|
st := state.New(emby.NewClient(embySrv.URL, emby.Options{VerifySSL: true}), "u1", 0)
|
|
srv := New(st)
|
|
ctx := context.Background()
|
|
t1, t2 := mcp.NewInMemoryTransports()
|
|
if _, err := srv.Connect(ctx, t1, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil)
|
|
cs, err := client.Connect(ctx, t2, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { cs.Close() })
|
|
return cs
|
|
}
|
|
|
|
func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string {
|
|
t.Helper()
|
|
res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
return res.Content[0].(*mcp.TextContent).Text
|
|
}
|
|
|
|
func qg(q map[string][]string, k string) string {
|
|
if v := q[k]; len(v) > 0 {
|
|
return v[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sessionJSON(cmds []string) []map[string]any {
|
|
return []map[string]any{{
|
|
"Id": "sess1", "Client": "Emby Web", "PlayableMediaTypes": []string{"Video"},
|
|
"SupportedCommands": cmds,
|
|
"NowPlayingItem": map[string]any{
|
|
"Id": "ep1", "Name": "Ep1", "SeriesName": "Show", "MediaType": "Video",
|
|
"MediaSources": []map[string]any{
|
|
{"MediaStreams": []map[string]any{
|
|
{"Index": 0, "Type": "Video"},
|
|
{"Index": 1, "Type": "Audio", "Language": "eng", "DisplayTitle": "English"},
|
|
{"Index": 2, "Type": "Subtitle", "Language": "eng", "DisplayTitle": "English (SRT)"},
|
|
{"Index": 3, "Type": "Subtitle", "Language": "fra", "DisplayTitle": "French (SRT)", "IsForced": true},
|
|
}},
|
|
},
|
|
},
|
|
"PlayState": map[string]any{"PositionTicks": 610000000, "CanSeek": true, "AudioStreamIndex": 1},
|
|
}}
|
|
}
|
|
|
|
func TestRetrieveEpisodeListTool(t *testing.T) {
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/Shows/s1/Episodes" {
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"TotalRecordCount": 2,
|
|
"Items": []map[string]any{
|
|
{"Id": "e1", "Name": "Ep1", "Type": "Episode", "ParentIndexNumber": 1, "IndexNumber": 1},
|
|
{"Id": "e2", "Name": "Ep2", "Type": "Episode", "ParentIndexNumber": 1, "IndexNumber": 2,
|
|
"UserData": map[string]any{"Played": true}},
|
|
},
|
|
})
|
|
})
|
|
txt := callTool(t, cs, "retrieve_episode_list", map[string]any{"series_id": "s1", "season_id": ""})
|
|
var r chunkResult
|
|
if err := json.Unmarshal([]byte(txt), &r); err != nil {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
if len(r.Items) != 2 || r.Items[1].ItemID != "e2" || !r.Items[1].Played {
|
|
t.Fatalf("items = %+v", r.Items)
|
|
}
|
|
}
|
|
|
|
func TestRetrieveNextEpisodeByName(t *testing.T) {
|
|
var nextUpCalled bool
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/Users/u1/Items":
|
|
if r.URL.Query().Get("IncludeItemTypes") != "Series" {
|
|
t.Errorf("query = %v", r.URL.Query())
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{{"Id": "s1", "Name": "Family Guy", "Type": "Series"}},
|
|
})
|
|
case "/Shows/NextUp":
|
|
nextUpCalled = true
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{{
|
|
"Id": "e9", "Name": "Next Ep", "Type": "Episode",
|
|
"ParentIndexNumber": 24, "IndexNumber": 9, "SeriesName": "Family Guy",
|
|
}},
|
|
})
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
})
|
|
txt := callTool(t, cs, "retrieve_next_episode",
|
|
map[string]any{"series_name": "family guy", "series_id": "", "mode": ""})
|
|
if !nextUpCalled {
|
|
t.Fatal("NextUp not called")
|
|
}
|
|
var ep map[string]any
|
|
if err := json.Unmarshal([]byte(txt), &ep); err != nil {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
if ep["item_id"] != "e9" || ep["series_name"] != "Family Guy" || ep["series_id"] != "s1" {
|
|
t.Fatalf("ep = %v", ep)
|
|
}
|
|
}
|
|
|
|
func TestRetrieveNextEpisodeAmbiguous(t *testing.T) {
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{
|
|
{"Id": "s1", "Name": "Family Guy", "Type": "Series"},
|
|
{"Id": "s2", "Name": "Family Guy Specials", "Type": "Series"},
|
|
},
|
|
})
|
|
})
|
|
txt := callTool(t, cs, "retrieve_next_episode",
|
|
map[string]any{"series_name": "family", "series_id": "", "mode": "next_unplayed"})
|
|
if !strings.Contains(txt, "Candidates") || !strings.Contains(txt, "s2") {
|
|
t.Fatalf("expected ambiguity error, got %q", txt)
|
|
}
|
|
}
|
|
|
|
func TestRetrieveNextEpisodeLatest(t *testing.T) {
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/Shows/s1/Episodes" {
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
if r.URL.Query().Get("SortOrder") != "Descending" {
|
|
t.Errorf("query = %v", r.URL.Query())
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{{"Id": "e15", "Name": "Latest", "Type": "Episode"}},
|
|
})
|
|
})
|
|
txt := callTool(t, cs, "retrieve_next_episode",
|
|
map[string]any{"series_name": "", "series_id": "s1", "mode": "latest"})
|
|
if !strings.Contains(txt, `"item_id":"e15"`) {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
}
|
|
|
|
func TestRetrieveNowPlaying(t *testing.T) {
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(sessionJSON([]string{"SetSubtitleStreamIndex"}))
|
|
})
|
|
txt := callTool(t, cs, "retrieve_now_playing", map[string]any{"session_id": "sess1"})
|
|
var np nowPlaying
|
|
if err := json.Unmarshal([]byte(txt), &np); err != nil {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
if np.ItemID != "ep1" || len(np.SubtitleStreams) != 2 || len(np.AudioStreams) != 1 {
|
|
t.Fatalf("np = %+v", np)
|
|
}
|
|
if np.AudioStreams[0].IsSelected != true || np.PositionMilliseconds != 61000 {
|
|
t.Fatalf("np = %+v", np)
|
|
}
|
|
if np.SubtitleStreams[1].IsForced != true {
|
|
t.Fatalf("subs = %+v", np.SubtitleStreams)
|
|
}
|
|
}
|
|
|
|
func TestSetSubtitleSupported(t *testing.T) {
|
|
var cmdBody map[string]any
|
|
applied := false
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/Sessions":
|
|
s := sessionJSON([]string{"SetSubtitleStreamIndex"})
|
|
if applied {
|
|
s[0]["PlayState"].(map[string]any)["SubtitleStreamIndex"] = 3
|
|
}
|
|
json.NewEncoder(w).Encode(s)
|
|
case "/Sessions/sess1/Command":
|
|
json.NewDecoder(r.Body).Decode(&cmdBody)
|
|
applied = true
|
|
w.WriteHeader(200)
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
})
|
|
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "french"})
|
|
if txt != "Success" {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
if cmdBody["Name"] != "SetSubtitleStreamIndex" {
|
|
t.Fatalf("body = %v", cmdBody)
|
|
}
|
|
args := cmdBody["Arguments"].(map[string]any)
|
|
if args["Index"] != "3" {
|
|
t.Fatalf("args = %v", args)
|
|
}
|
|
}
|
|
|
|
func TestSetSubtitleOff(t *testing.T) {
|
|
var cmdBody map[string]any
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/Sessions" {
|
|
s := sessionJSON([]string{"SetSubtitleStreamIndex"})
|
|
s[0]["PlayState"].(map[string]any)["SubtitleStreamIndex"] = -1
|
|
json.NewEncoder(w).Encode(s)
|
|
return
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&cmdBody)
|
|
w.WriteHeader(200)
|
|
})
|
|
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "off"})
|
|
if txt != "Success" {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
if cmdBody["Arguments"].(map[string]any)["Index"] != "-1" {
|
|
t.Fatalf("body = %v", cmdBody)
|
|
}
|
|
}
|
|
|
|
func TestSetSubtitleFallbackRestart(t *testing.T) {
|
|
var playBody map[string]any
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/Sessions":
|
|
// No SupportedCommands — DLNA-style session.
|
|
json.NewEncoder(w).Encode(sessionJSON(nil))
|
|
case "/Sessions/sess1/Playing":
|
|
json.NewDecoder(r.Body).Decode(&playBody)
|
|
w.WriteHeader(200)
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
})
|
|
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "2"})
|
|
if !strings.Contains(txt, "Success") {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
if playBody["SubtitleStreamIndex"] != float64(2) {
|
|
t.Fatalf("body = %v", playBody)
|
|
}
|
|
if playBody["StartPositionTicks"] != float64(610000000) {
|
|
t.Fatalf("expected resume position, body = %v", playBody)
|
|
}
|
|
}
|
|
|
|
func TestSetSubtitleIgnoredCommandFallsBack(t *testing.T) {
|
|
var playBody map[string]any
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/Sessions":
|
|
// Claims support but PlayState never reflects the selection.
|
|
json.NewEncoder(w).Encode(sessionJSON([]string{"SetSubtitleStreamIndex"}))
|
|
case "/Sessions/sess1/Command":
|
|
w.WriteHeader(200)
|
|
case "/Sessions/sess1/Playing":
|
|
json.NewDecoder(r.Body).Decode(&playBody)
|
|
w.WriteHeader(200)
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
})
|
|
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "2"})
|
|
if !strings.Contains(txt, "restarted") {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
if playBody["SubtitleStreamIndex"] != float64(2) {
|
|
t.Fatalf("body = %v", playBody)
|
|
}
|
|
}
|
|
|
|
func TestSetSubtitleNoMatch(t *testing.T) {
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(sessionJSON(nil))
|
|
})
|
|
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "klingon"})
|
|
if !strings.Contains(txt, "ERROR") || !strings.Contains(txt, "Available tracks") {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
}
|
|
|
|
func TestSearchForItemItemTypes(t *testing.T) {
|
|
var q map[string][]string
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
q = r.URL.Query()
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{{"Id": "s1", "Name": "Family Guy", "Type": "Series"}},
|
|
})
|
|
})
|
|
// No library selected — allowed because item_types is set.
|
|
txt := callTool(t, cs, "search_for_item", map[string]any{
|
|
"title_or_album": "family guy", "artist_name": "", "genre_name": "",
|
|
"broadcast_release_years": "", "lyrics_or_description": "", "item_types": "Series",
|
|
})
|
|
if !strings.Contains(txt, "Family Guy") {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
if qg(q, "IncludeItemTypes") != "Series" {
|
|
t.Fatalf("query = %v", q)
|
|
}
|
|
if _, ok := q["MediaTypes"]; ok {
|
|
t.Fatal("MediaTypes should be omitted when item_types is set")
|
|
}
|
|
}
|
|
|
|
func TestSearchForItemWithoutLegacyLyricsArg(t *testing.T) {
|
|
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{{"Id": "s1", "Name": "Family Guy", "Type": "Series"}},
|
|
})
|
|
})
|
|
// lyrics_or_description is optional: omitting it must not fail validation.
|
|
txt := callTool(t, cs, "search_for_item", map[string]any{
|
|
"title_or_album": "family guy", "artist_name": "", "genre_name": "",
|
|
"broadcast_release_years": "", "item_types": "Series",
|
|
})
|
|
if !strings.Contains(txt, "Family Guy") {
|
|
t.Fatalf("result = %q", txt)
|
|
}
|
|
}
|