Files
gronod 42570915ed
Build and publish / Test and build (darwin) (push) Successful in 1m43s
Build and publish / Test and build (linux) (push) Successful in 2m30s
Build and publish / Test and build (windows) (push) Successful in 3m5s
Build and publish / Build and publish Docker image (push) Successful in 1m46s
Add browse, next-episode, and track-control tools
Implements the three roadmap feature sections:

- Browse: retrieve_item_children, retrieve_season_list, retrieve_episode_list;
  search_for_item gains item_types (e.g. "Series") so shows are findable
  without a selected library. Items now expose item_type, series_name, and
  per-user played state/resume position.
- retrieve_next_episode: next_unplayed (Emby NextUp, resume counts) or latest
  (most recent present episode; virtual placeholders skipped).
- retrieve_now_playing + set_subtitle/set_audio_track: stream listing and
  track switching via SetSubtitleStreamIndex/SetAudioStreamIndex GeneralCommands,
  with a PlayNow restart-at-position fallback for sessions that cannot switch
  mid-play (DLNA/Chromecast bridges).

Conformance manifest grows to 34 call sites with three new documented spec
gaps (StartPositionTicks on PlayRequest, missing /Shows/{Id}/Episodes response
schema, undeclared Fields/EnableUserData on /Users/{UserId}/Items/{Id}).
2026-09-21 00:09:49 +01:00

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)
}
}