Bake the former Python bridge.py into emby-mcp as internal/bridge.
/mcp and /call/{tool} share one process. Session init errors are
returned instead of swallowed, and 401/400/404 trigger a fresh
initialize plus retry with the caller's Authorization header.
319 lines
9.6 KiB
Go
319 lines
9.6 KiB
Go
package emby
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
func qg(q map[string][]string, k string) string {
|
|
if v := q[k]; len(v) > 0 {
|
|
return v[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func episodeItem(id, name string, season, ep int, played bool, pct float64) map[string]any {
|
|
return map[string]any{
|
|
"Id": id, "Name": name, "Type": "Episode", "MediaType": "Video",
|
|
"ParentIndexNumber": season, "IndexNumber": ep, "SeriesName": "Show",
|
|
"UserData": map[string]any{
|
|
"Played": played,
|
|
"PlayedPercentage": pct,
|
|
"PlaybackPositionTicks": int64(5000000000),
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestGetSeasons(t *testing.T) {
|
|
var q map[string][]string
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/Shows/s1/Seasons" {
|
|
t.Errorf("path = %s", r.URL.Path)
|
|
}
|
|
q = r.URL.Query()
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"TotalRecordCount": 2,
|
|
"Items": []map[string]any{
|
|
{"Id": "se1", "Name": "Season 1", "Type": "Season", "IndexNumber": 1},
|
|
{"Id": "se2", "Name": "Season 2", "Type": "Season", "IndexNumber": 2},
|
|
},
|
|
})
|
|
})
|
|
items, err := c.GetSeasons(context.Background(), "u1", "s1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if qg(q, "UserId") != "u1" {
|
|
t.Errorf("UserId = %q", qg(q, "UserId"))
|
|
}
|
|
if len(items) != 2 || items[0].ItemID != "se1" || items[1].ItemType != "Season" {
|
|
t.Fatalf("items = %+v", items)
|
|
}
|
|
}
|
|
|
|
func TestGetEpisodes(t *testing.T) {
|
|
var q map[string][]string
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/Shows/s1/Episodes" {
|
|
t.Errorf("path = %s", r.URL.Path)
|
|
}
|
|
q = r.URL.Query()
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"TotalRecordCount": 1,
|
|
"Items": []map[string]any{episodeItem("e1", "Pilot", 1, 1, true, 0)},
|
|
})
|
|
})
|
|
items, err := c.GetEpisodes(context.Background(), "u1", "s1", "se1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if qg(q, "SeasonId") != "se1" || qg(q, "EnableUserData") != "true" {
|
|
t.Errorf("query = %v", q)
|
|
}
|
|
if len(items) != 1 || !items[0].Played || items[0].SeriesName != "Show" {
|
|
t.Fatalf("items = %+v", items)
|
|
}
|
|
if items[0].DiskNumber != 1 || items[0].TrackNumber != 1 {
|
|
t.Errorf("disk/track = %v/%v", items[0].DiskNumber, items[0].TrackNumber)
|
|
}
|
|
if items[0].ResumePositionMilliseconds != 500000 {
|
|
t.Errorf("resume_ms = %v", items[0].ResumePositionMilliseconds)
|
|
}
|
|
}
|
|
|
|
func TestGetChildrenNoMediaTypeFilter(t *testing.T) {
|
|
var q map[string][]string
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/Users/u1/Items" {
|
|
t.Errorf("path = %s", r.URL.Path)
|
|
}
|
|
q = r.URL.Query()
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{{"Id": "se1", "Name": "Season 1", "Type": "Season"}},
|
|
})
|
|
})
|
|
items, err := c.GetChildren(context.Background(), "u1", "s1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := q["MediaTypes"]; ok {
|
|
t.Errorf("MediaTypes must not be sent, query = %v", q)
|
|
}
|
|
if qg(q, "ParentId") != "s1" || qg(q, "Recursive") != "false" {
|
|
t.Errorf("query = %v", q)
|
|
}
|
|
if len(items) != 1 || items[0].ItemType != "Season" {
|
|
t.Fatalf("items = %+v", items)
|
|
}
|
|
}
|
|
|
|
func TestGetNextUp(t *testing.T) {
|
|
var q map[string][]string
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/Shows/NextUp" {
|
|
t.Errorf("path = %s", r.URL.Path)
|
|
}
|
|
q = r.URL.Query()
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"TotalRecordCount": 1,
|
|
"Items": []map[string]any{episodeItem("e3", "Ep3", 1, 3, false, 40)},
|
|
})
|
|
})
|
|
ep, err := c.GetNextUp(context.Background(), "u1", "s1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if qg(q, "SeriesId") != "s1" || qg(q, "UserId") != "u1" || qg(q, "Limit") != "1" {
|
|
t.Errorf("query = %v", q)
|
|
}
|
|
if ep == nil || ep.ItemID != "e3" {
|
|
t.Fatalf("ep = %+v", ep)
|
|
}
|
|
if ep.PlayedPercentage != float64(40) {
|
|
t.Errorf("played_percentage = %v", ep.PlayedPercentage)
|
|
}
|
|
}
|
|
|
|
func TestGetNextUpEmpty(t *testing.T) {
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{"Items": []any{}, "TotalRecordCount": 0})
|
|
})
|
|
ep, err := c.GetNextUp(context.Background(), "u1", "s1")
|
|
if err != nil || ep != nil {
|
|
t.Fatalf("ep = %+v err = %v", ep, err)
|
|
}
|
|
}
|
|
|
|
func TestGetLatestEpisodeSkipsVirtual(t *testing.T) {
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("SortBy") != "PremiereDate" || r.URL.Query().Get("SortOrder") != "Descending" {
|
|
t.Errorf("query = %v", r.URL.Query())
|
|
}
|
|
missing := episodeItem("vm", "Missing", 2, 9, false, 0)
|
|
missing["LocationType"] = "Virtual"
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{missing, episodeItem("e8", "Real", 2, 8, false, 0)},
|
|
})
|
|
})
|
|
ep, err := c.GetLatestEpisode(context.Background(), "u1", "s1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ep == nil || ep.ItemID != "e8" {
|
|
t.Fatalf("ep = %+v", ep)
|
|
}
|
|
}
|
|
|
|
func TestFindSeries(t *testing.T) {
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("IncludeItemTypes") != "Series" {
|
|
t.Errorf("query = %v", r.URL.Query())
|
|
}
|
|
if _, ok := r.URL.Query()["MediaTypes"]; ok {
|
|
t.Error("MediaTypes must be omitted when IncludeItemTypes is set")
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Items": []map[string]any{
|
|
{"Id": "s9", "Name": "Family Guy", "Type": "Series"},
|
|
{"Id": "s8", "Name": "Family Guy Remixed", "Type": "Series"},
|
|
},
|
|
})
|
|
})
|
|
exact, _, err := c.FindSeries(context.Background(), "u1", "family guy")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exact == nil || exact.ItemID != "s9" {
|
|
t.Fatalf("exact = %+v", exact)
|
|
}
|
|
|
|
exact, candidates, _ := c.FindSeries(context.Background(), "u1", "family")
|
|
if exact != nil || len(candidates) != 2 {
|
|
t.Fatalf("exact = %+v candidates = %+v", exact, candidates)
|
|
}
|
|
}
|
|
|
|
func TestGetItem(t *testing.T) {
|
|
var q map[string][]string
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/Users/u1/Items/i1" {
|
|
t.Errorf("path = %s", r.URL.Path)
|
|
}
|
|
q = r.URL.Query()
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"Id": "i1", "Name": "Movie",
|
|
"MediaSources": []map[string]any{
|
|
{"MediaStreams": []map[string]any{
|
|
{"Index": 0, "Type": "Video"},
|
|
{"Index": 1, "Type": "Audio", "Language": "eng"},
|
|
{"Index": 2, "Type": "Subtitle", "Language": "eng", "DisplayTitle": "English (SRT)"},
|
|
}},
|
|
},
|
|
})
|
|
})
|
|
item, err := c.GetItem(context.Background(), "u1", "i1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if qg(q, "Fields") == "" || qg(q, "EnableUserData") != "true" {
|
|
t.Errorf("query = %v", q)
|
|
}
|
|
streams := item.MediaSources[0].MediaStreams
|
|
if len(streams) != 3 || streams[2].DisplayTitle != "English (SRT)" || streams[2].Index != 2 {
|
|
t.Fatalf("streams = %+v", streams)
|
|
}
|
|
}
|
|
|
|
func TestSendGeneralCommand(t *testing.T) {
|
|
var path string
|
|
var body map[string]any
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
path = r.URL.Path
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
w.WriteHeader(200)
|
|
})
|
|
err := c.SendGeneralCommand(context.Background(), "s1", "SetSubtitleStreamIndex",
|
|
map[string]string{"Index": "2"}, "u1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if path != "/Sessions/s1/Command" {
|
|
t.Errorf("path = %s", path)
|
|
}
|
|
if body["Name"] != "SetSubtitleStreamIndex" || body["ControllingUserId"] != "u1" {
|
|
t.Errorf("body = %v", body)
|
|
}
|
|
args, _ := body["Arguments"].(map[string]any)
|
|
if args["Index"] != "2" {
|
|
t.Errorf("args = %v", args)
|
|
}
|
|
}
|
|
|
|
func TestPlayNowWithOptions(t *testing.T) {
|
|
var body map[string]any
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
w.WriteHeader(200)
|
|
})
|
|
sub, pos := 2, int64(123450000)
|
|
err := c.PlayNowWithOptions(context.Background(), "s1", "i1", "u1",
|
|
PlayOptions{SubtitleStreamIndex: &sub, StartPositionTicks: &pos})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["SubtitleStreamIndex"] != float64(2) || body["StartPositionTicks"] != float64(123450000) {
|
|
t.Errorf("body = %v", body)
|
|
}
|
|
if _, ok := body["AudioStreamIndex"]; ok {
|
|
t.Errorf("AudioStreamIndex should be omitted: %v", body)
|
|
}
|
|
}
|
|
|
|
func TestGetSessionAndSupportsCommand(t *testing.T) {
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode([]map[string]any{
|
|
{"Id": "s1", "SupportedCommands": []string{"SetSubtitleStreamIndex"}},
|
|
{"Id": "s2", "SupportedCommands": []string{}},
|
|
})
|
|
})
|
|
s, err := c.GetSession(context.Background(), "s2")
|
|
if err != nil || s == nil || s.ID != "s2" {
|
|
t.Fatalf("s = %+v err = %v", s, err)
|
|
}
|
|
if s.SupportsCommand("SetSubtitleStreamIndex") {
|
|
t.Error("s2 should not support the command")
|
|
}
|
|
s, _ = c.GetSession(context.Background(), "s1")
|
|
if !s.SupportsCommand("setsubtitlestreamindex") {
|
|
t.Error("s1 should support the command (case-insensitive)")
|
|
}
|
|
if s, _ := c.GetSession(context.Background(), "nope"); s != nil {
|
|
t.Error("expected nil for unknown session")
|
|
}
|
|
}
|
|
|
|
func TestSessionSupportedCommandsDecode(t *testing.T) {
|
|
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode([]map[string]any{
|
|
{
|
|
"Id": "s1", "DeviceId": "dev-1", "UserId": "u1",
|
|
"SupportedCommands": []string{"SetSubtitleStreamIndex"},
|
|
"PlayState": map[string]any{"SubtitleStreamIndex": 2, "AudioStreamIndex": 1, "CanSeek": true},
|
|
},
|
|
})
|
|
})
|
|
uid, err := c.SessionUserID(context.Background())
|
|
if err != nil || uid != "u1" {
|
|
t.Fatalf("uid = %q err = %v", uid, err)
|
|
}
|
|
s, _ := c.GetSession(context.Background(), "s1")
|
|
if s.PlayState == nil || s.PlayState.SubtitleStreamIndex == nil || *s.PlayState.SubtitleStreamIndex != 2 {
|
|
t.Fatalf("playstate = %+v", s.PlayState)
|
|
}
|
|
if !s.PlayState.CanSeek || !s.SupportsCommand("SetSubtitleStreamIndex") {
|
|
t.Fatalf("session = %+v", s)
|
|
}
|
|
}
|