Build and publish / Test and build (darwin) (push) Successful in 1m49s
Build and publish / Test and build (linux) (push) Successful in 2m16s
Build and publish / Test and build (windows) (push) Successful in 2m55s
Build and publish / Build and publish Docker image (push) Successful in 1m53s
DLNA/Cast-bridged renderers can advertise SetSubtitleStreamIndex / SetAudioStreamIndex yet silently drop the command. After sending the GeneralCommand, poll PlayState briefly; if the requested index never appears, restart playback at the current position with the stream index in the PlayNow request.
327 lines
10 KiB
Go
327 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gordon/emby-mcp/internal/emby"
|
|
"github.com/gordon/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")
|
|
}
|
|
}
|