350 lines
13 KiB
Go
350 lines
13 KiB
Go
package embyapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bjarneo/cliamp/internal/appmeta"
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
)
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
|
|
|
func mock(c *Client, fn roundTripFunc) *Client {
|
|
c.SetHTTPClient(&http.Client{Transport: fn})
|
|
return c
|
|
}
|
|
|
|
func jsonResponse(body string) *http.Response {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Status: "200 OK",
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(bytes.NewBufferString(body)),
|
|
}
|
|
}
|
|
|
|
func noContentResponse() *http.Response {
|
|
return &http.Response{
|
|
StatusCode: http.StatusNoContent,
|
|
Status: "204 No Content",
|
|
Body: io.NopCloser(bytes.NewBuffer(nil)),
|
|
}
|
|
}
|
|
|
|
// --- Dialect-specific: Ping endpoint ---
|
|
|
|
func TestEmbyPingUsesSystemInfo(t *testing.T) {
|
|
c := mock(NewEmbyClient("https://emby.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path != "/System/Info" {
|
|
t.Fatalf("Ping path = %s, want /System/Info", req.URL.Path)
|
|
}
|
|
return jsonResponse(`{"ServerName":"My Emby","Version":"4.8.0.0"}`), nil
|
|
})
|
|
if err := c.Ping(); err != nil {
|
|
t.Fatalf("Ping() error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJellyfinPingUsesUsersMe(t *testing.T) {
|
|
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path != "/Users/Me" {
|
|
t.Fatalf("Ping path = %s, want /Users/Me", req.URL.Path)
|
|
}
|
|
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
|
})
|
|
if err := c.Ping(); err != nil {
|
|
t.Fatalf("Ping() error: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Dialect-specific: Emby API-key user-id fallback ---
|
|
|
|
func TestEmbyUserIDAPIKeyFallback(t *testing.T) {
|
|
// /Users/Me returns 500 for server-level API keys; fall back to /Users.
|
|
c := mock(NewEmbyClient("https://emby.example.com", "tok", "", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/Users/Me":
|
|
return &http.Response{StatusCode: 500, Status: "500 Internal Server Error", Body: io.NopCloser(bytes.NewBuffer(nil))}, nil
|
|
case "/Users":
|
|
return jsonResponse(`[{"Id":"user-1","Name":"Alice"},{"Id":"user-2","Name":"Bob"}]`), nil
|
|
case "/Users/user-1/Views":
|
|
return jsonResponse(`{"Items":[{"Id":"lib-1","Name":"Music","CollectionType":"music"}]}`), nil
|
|
default:
|
|
t.Fatalf("unexpected path %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})
|
|
libs, err := c.MusicLibraries()
|
|
if err != nil {
|
|
t.Fatalf("MusicLibraries() error: %v", err)
|
|
}
|
|
if c.userID != "user-1" {
|
|
t.Fatalf("userID = %q after API key fallback, want user-1", c.userID)
|
|
}
|
|
if len(libs) != 1 || libs[0].ID != "lib-1" {
|
|
t.Fatalf("libraries = %+v", libs)
|
|
}
|
|
}
|
|
|
|
// --- Dialect-specific: auth header scheme ---
|
|
|
|
func TestEmbyAuthHeaderScheme(t *testing.T) {
|
|
c := mock(NewEmbyClient("https://emby.example.com", "tok", "", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/Users/Me":
|
|
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
|
case "/Users/user-1/Views":
|
|
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
|
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
|
}
|
|
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") {
|
|
t.Fatalf("Authorization = %q, want Emby scheme", got)
|
|
}
|
|
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
|
default:
|
|
t.Fatalf("unexpected path %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})
|
|
if _, err := c.MusicLibraries(); err != nil {
|
|
t.Fatalf("MusicLibraries() error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJellyfinAuthHeaderScheme(t *testing.T) {
|
|
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/Users/Me":
|
|
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
|
case "/Users/user-1/Views":
|
|
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
|
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
|
}
|
|
if got := req.Header.Get("X-Emby-Authorization"); !strings.HasPrefix(got, "MediaBrowser ") {
|
|
t.Fatalf("X-Emby-Authorization = %q, want MediaBrowser scheme", got)
|
|
}
|
|
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
|
default:
|
|
t.Fatalf("unexpected path %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})
|
|
if _, err := c.MusicLibraries(); err != nil {
|
|
t.Fatalf("MusicLibraries() error: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Dialect-specific: password auth ---
|
|
|
|
func TestEmbyAuthenticatesWithPassword(t *testing.T) {
|
|
c := mock(NewEmbyClient("https://emby.example.com", "", "", "alice", "s3cret"), func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/Users/AuthenticateByName":
|
|
if req.Method != http.MethodPost {
|
|
t.Fatalf("method = %s, want POST", req.Method)
|
|
}
|
|
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") {
|
|
t.Fatalf("auth request Authorization = %q, want Emby scheme", got)
|
|
}
|
|
return jsonResponse(`{"User":{"Id":"user-1"},"AccessToken":"tok-1"}`), nil
|
|
case "/Users/user-1/Views":
|
|
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") || !strings.Contains(got, `Token="tok-1"`) {
|
|
t.Fatalf("Authorization = %q, want Emby scheme with token", got)
|
|
}
|
|
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
|
default:
|
|
t.Fatalf("unexpected path %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})
|
|
if _, err := c.MusicLibraries(); err != nil {
|
|
t.Fatalf("MusicLibraries() error: %v", err)
|
|
}
|
|
if c.token != "tok-1" || c.userID != "user-1" {
|
|
t.Fatalf("client auth state = token:%q userID:%q", c.token, c.userID)
|
|
}
|
|
}
|
|
|
|
func TestJellyfinAuthenticatesWithPassword(t *testing.T) {
|
|
c := mock(NewJellyfinClient("https://jf.example.com", "", "", "finamp", "1qazxsw2"), func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Path {
|
|
case "/Users/AuthenticateByName":
|
|
if got := req.Header.Get("X-Emby-Authorization"); !strings.HasPrefix(got, "MediaBrowser ") {
|
|
t.Fatalf("auth request X-Emby-Authorization = %q, want MediaBrowser scheme", got)
|
|
}
|
|
return jsonResponse(`{"User":{"Id":"user-1"},"AccessToken":"tok-1"}`), nil
|
|
case "/Users/user-1/Views":
|
|
if got := req.Header.Get("X-Emby-Token"); got != "tok-1" {
|
|
t.Fatalf("X-Emby-Token = %q, want tok-1", got)
|
|
}
|
|
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
|
default:
|
|
t.Fatalf("unexpected path %s", req.URL.Path)
|
|
return nil, nil
|
|
}
|
|
})
|
|
if _, err := c.MusicLibraries(); err != nil {
|
|
t.Fatalf("MusicLibraries() error: %v", err)
|
|
}
|
|
if c.token != "tok-1" || c.userID != "user-1" {
|
|
t.Fatalf("client auth state = token:%q userID:%q", c.token, c.userID)
|
|
}
|
|
}
|
|
|
|
// --- Dialect-specific: scrobble metadata key + auth header ---
|
|
|
|
func TestEmbyReportNowPlaying(t *testing.T) {
|
|
appmeta.SetVersion("v1.31.2")
|
|
t.Cleanup(func() { appmeta.SetVersion("dev") })
|
|
c := mock(NewEmbyClient("https://emby.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path != "/Sessions/Playing" {
|
|
t.Fatalf("path = %s, want /Sessions/Playing", req.URL.Path)
|
|
}
|
|
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") || !strings.Contains(got, `Version="v1.31.2"`) {
|
|
t.Fatalf("Authorization = %q, want Emby scheme with release version", got)
|
|
}
|
|
var payload playbackInfo
|
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode payload: %v", err)
|
|
}
|
|
if payload.ItemID != "track-1" || !payload.CanSeek || payload.PositionTicks != 15*time.Second.Nanoseconds()/100 {
|
|
t.Fatalf("payload = %+v", payload)
|
|
}
|
|
return noContentResponse(), nil
|
|
})
|
|
track := playlist.Track{ProviderMeta: map[string]string{provider.MetaEmbyID: "track-1"}}
|
|
if err := c.ReportNowPlaying(track, 15*time.Second, true); err != nil {
|
|
t.Fatalf("ReportNowPlaying() error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJellyfinReportNowPlaying(t *testing.T) {
|
|
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path != "/Sessions/Playing" {
|
|
t.Fatalf("path = %s, want /Sessions/Playing", req.URL.Path)
|
|
}
|
|
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
|
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
|
}
|
|
var payload playbackInfo
|
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode payload: %v", err)
|
|
}
|
|
if payload.ItemID != "track-1" {
|
|
t.Fatalf("payload ItemID = %q, want track-1 (from jellyfin meta key)", payload.ItemID)
|
|
}
|
|
return noContentResponse(), nil
|
|
})
|
|
track := playlist.Track{ProviderMeta: map[string]string{provider.MetaJellyfinID: "track-1"}}
|
|
if err := c.ReportNowPlaying(track, 15*time.Second, true); err != nil {
|
|
t.Fatalf("ReportNowPlaying() error: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Shared behavior (parsing/caching/URLs): tested once, dialect-agnostic ---
|
|
|
|
func TestAlbumsByLibrary(t *testing.T) {
|
|
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
q := req.URL.Query()
|
|
if req.URL.Path != "/Items" || q.Get("parentId") != "lib-1" || q.Get("includeItemTypes") != "MusicAlbum" {
|
|
t.Fatalf("unexpected request %s?%s", req.URL.Path, req.URL.RawQuery)
|
|
}
|
|
return jsonResponse(`{"Items":[{"Id":"album-1","Name":"Kind of Blue","AlbumArtist":"Miles Davis","AlbumArtists":[{"Id":"artist-1","Name":"Miles Davis"}],"ProductionYear":1959,"ChildCount":5}]}`), nil
|
|
})
|
|
albums, err := c.AlbumsByLibrary("lib-1")
|
|
if err != nil {
|
|
t.Fatalf("AlbumsByLibrary() error: %v", err)
|
|
}
|
|
if len(albums) != 1 {
|
|
t.Fatalf("expected 1 album, got %d", len(albums))
|
|
}
|
|
a := albums[0]
|
|
if a.ID != "album-1" || a.Name != "Kind of Blue" || a.Artist != "Miles Davis" || a.ArtistID != "artist-1" || a.Year != 1959 || a.TrackCount != 5 {
|
|
t.Fatalf("album = %+v", a)
|
|
}
|
|
}
|
|
|
|
func TestTracksParsing(t *testing.T) {
|
|
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Path != "/Items" || req.URL.Query().Get("includeItemTypes") != "Audio" {
|
|
t.Fatalf("unexpected request %s?%s", req.URL.Path, req.URL.RawQuery)
|
|
}
|
|
return jsonResponse(`{"Items":[{"Id":"track-1","Name":"So What","Album":"Kind of Blue","Artists":["Miles Davis"],"ProductionYear":1959,"IndexNumber":1,"RunTimeTicks":5650000000}]}`), nil
|
|
})
|
|
tracks, err := c.Tracks("album-1")
|
|
if err != nil {
|
|
t.Fatalf("Tracks() error: %v", err)
|
|
}
|
|
if len(tracks) != 1 {
|
|
t.Fatalf("expected 1 track, got %d", len(tracks))
|
|
}
|
|
tr := tracks[0]
|
|
if tr.ID != "track-1" || tr.Name != "So What" || tr.Artist != "Miles Davis" || tr.Album != "Kind of Blue" || tr.Year != 1959 || tr.TrackNumber != 1 || tr.DurationSecs != 565 {
|
|
t.Fatalf("track = %+v", tr)
|
|
}
|
|
}
|
|
|
|
func TestStreamURL(t *testing.T) {
|
|
c := NewEmbyClient("https://emby.example.com", "tok", "user-1", "", "")
|
|
u := c.StreamURL("track-1")
|
|
if !strings.HasPrefix(u, "https://emby.example.com/Items/track-1/Download?") {
|
|
t.Fatalf("URL = %q, want Download route prefix", u)
|
|
}
|
|
if !strings.Contains(u, "api_key=tok") {
|
|
t.Fatalf("URL missing api_key: %q", u)
|
|
}
|
|
}
|
|
|
|
func TestReportScrobble(t *testing.T) {
|
|
c := NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", "")
|
|
call := 0
|
|
mock(c, func(req *http.Request) (*http.Response, error) {
|
|
call++
|
|
switch call {
|
|
case 1:
|
|
if req.URL.Path != "/Sessions/Playing/Progress" {
|
|
t.Fatalf("progress path = %s", req.URL.Path)
|
|
}
|
|
case 2:
|
|
if req.URL.Path != "/Sessions/Playing/Stopped" {
|
|
t.Fatalf("stopped path = %s", req.URL.Path)
|
|
}
|
|
var payload playbackStopInfo
|
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode stop payload: %v", err)
|
|
}
|
|
if payload.ItemID != "track-1" || payload.PositionTicks != 42*time.Second.Nanoseconds()/100 || payload.Failed {
|
|
t.Fatalf("stop payload = %+v", payload)
|
|
}
|
|
default:
|
|
t.Fatalf("unexpected extra call %d", call)
|
|
}
|
|
return noContentResponse(), nil
|
|
})
|
|
track := playlist.Track{ProviderMeta: map[string]string{provider.MetaJellyfinID: "track-1"}}
|
|
if err := c.ReportScrobble(track, 42*time.Second, true); err != nil {
|
|
t.Fatalf("ReportScrobble() error: %v", err)
|
|
}
|
|
if call != 2 {
|
|
t.Fatalf("call count = %d, want 2", call)
|
|
}
|
|
}
|
|
|
|
func TestIsStreamURL(t *testing.T) {
|
|
if !IsStreamURL("https://x/Items/abc/Download?api_key=z") {
|
|
t.Fatal("download URL should be a stream URL")
|
|
}
|
|
if IsStreamURL("https://x/Items/abc") {
|
|
t.Fatal("non-download URL should not be a stream URL")
|
|
}
|
|
}
|