Files
bjarneo--cliamp/external/emby/client_test.go
T
tallsam 620d1b20f8 Add Emby provider (#207)
* Add Emby provider

Adds a new provider for Emby Media Server, mirroring the Jellyfin
provider but with Emby-specific API behaviour:

- Authorization header uses the 'Emby' scheme (Jellyfin uses 'MediaBrowser')
- Ping uses GET /System/Info — Emby API keys are server-level and return
  500 on /Users/Me, which Jellyfin's Ping calls
- UserID() falls back from /Users/Me to GET /Users for API key auth,
  preferring a user whose name matches the configured username
- Full test coverage: client (MusicLibraries, Albums, Tracks, StreamURL,
  password auth, NowPlaying, Scrobble, Ping, API key user fallback) and
  provider (Name, Playlists, Tracks, CanReportPlayback)

Also adds:
- 'E' keybinding to switch to Emby from anywhere in the UI
- cliamp setup wizard support (token / username+password picker)
- [emby] config section with same fields as [jellyfin]
- docs/emby.md, updates to docs/cli.md, docs/configuration.md,
  docs/keybindings.md, config.toml.example, and site/index.html

* Address CodeRabbit review: emby provider fixes

- postJSON: wrap json.Marshal error with path context
- UserID: return explicit error when configured user name not found in /Users
- AlbumList: clamp negative offset to 0
- Playlists: return copy of cache slice to prevent external mutation
- setup.go: wrap Emby ping error with "emby: validation:" prefix
- docs/emby.md: fix Quick start blurb (references /System/Info, not /Users/Me)
- site/index.html: add E key to Provider Browser quick-switch row

* Convert new Emby tests to table-driven style

* Wrap all bare errors in client.go with operation context

* Wrap provider-level browse errors with operation context

* Clarify that 'user' affects API key auth as well as password login

* Add optional username field to Emby API key setup mode

* Fix Emby empty-state hint to cover both auth modes

* Tweak Emby empty-state hint wording

* Fix Emby empty-state hint wording

* Return defensive copies from Playlists/Tracks cache; fix docs em dash

* Add emby to --provider flag valid values

* emby: drop double-prefixed errors and align cache returns with Jellyfin

Provider methods were wrapping client errors with `fmt.Errorf("emby: <op>: %w", err)`,
but client.go already prefixes every error with `emby: <path>:`. End-users saw
messages like `emby: artists: emby: /Items: http status 401`. Drop the redundant
package prefix from provider.go; keep the operation context.

Also drop the per-call defensive copies (`copyTracks`, the playlist slice
clone). The existing Jellyfin provider — which shares this same caching shape —
returns cached slices and maps directly, and no consumer in ui/model/ mutates
the returned tracks. Aliasing through `ProviderMeta` is theoretically possible
but would be a caller bug to fix at the caller, not papered over per-provider.
Aligning Emby with the Jellyfin pattern keeps the two providers behaviorally
identical and removes per-fetch allocations.

---------

Co-authored-by: Sam Hassell <yeehah@protonmail.com>
Co-authored-by: bjarneo <bjarneo@users.noreply.github.com>
Co-authored-by: Bjarne Øverli <bjarne.oeverli@gmail.com>
2026-05-05 18:09:42 +02:00

363 lines
11 KiB
Go

package emby
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"cliamp/internal/appmeta"
"cliamp/playlist"
"cliamp/provider"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func useTestClient(t *testing.T, fn roundTripFunc) {
t.Helper()
old := apiClient
apiClient = &http.Client{Transport: fn}
t.Cleanup(func() {
apiClient = old
})
}
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)),
}
}
func TestClientPing(t *testing.T) {
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
useTestClient(t, func(req *http.Request) (*http.Response, error) {
if req.URL.Path != "/System/Info" {
t.Fatalf("Ping() called unexpected 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 TestClientUserIDAPIKeyFallback(t *testing.T) {
// API key auth: /Users/Me returns 500, should fall back to /Users list.
c := NewClient("https://emby.example.com", "tok", "", "", "")
useTestClient(t, 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)
}
}
func TestClientMusicLibraries(t *testing.T) {
c := NewClient("https://emby.example.com", "tok", "", "", "")
useTestClient(t, 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"},
{"Id":"movies-1","Name":"Movies","CollectionType":"movies"}
]
}`), 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 len(libs) != 1 {
t.Fatalf("expected 1 music library, got %d", len(libs))
}
if libs[0].ID != "music-1" || libs[0].Name != "Music" {
t.Fatalf("library = %+v, want music-1/Music", libs[0])
}
}
func TestClientAlbumsByLibrary(t *testing.T) {
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
useTestClient(t, func(req *http.Request) (*http.Response, error) {
if req.URL.Path != "/Items" {
t.Fatalf("unexpected path %s", req.URL.Path)
}
q := req.URL.Query()
if got := q.Get("parentId"); got != "lib-1" {
t.Fatalf("parentId = %q, want lib-1", got)
}
if got := q.Get("includeItemTypes"); got != "MusicAlbum" {
t.Fatalf("includeItemTypes = %q, want MusicAlbum", got)
}
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 TestClientTracks(t *testing.T) {
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
useTestClient(t, func(req *http.Request) (*http.Response, error) {
if req.URL.Path != "/Items" {
t.Fatalf("unexpected path %s", req.URL.Path)
}
q := req.URL.Query()
if got := q.Get("parentId"); got != "album-1" {
t.Fatalf("parentId = %q, want album-1", got)
}
if got := q.Get("includeItemTypes"); got != "Audio" {
t.Fatalf("includeItemTypes = %q, want Audio", got)
}
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 TestClientStreamURL(t *testing.T) {
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
tests := []struct {
itemID string
wantPrefix string
}{
{"track-1", "https://emby.example.com/Items/track-1/Download?"},
{"album-99", "https://emby.example.com/Items/album-99/Download?"},
}
for _, tc := range tests {
t.Run(tc.itemID, func(t *testing.T) {
u := c.StreamURL(tc.itemID)
if !strings.HasPrefix(u, tc.wantPrefix) {
t.Fatalf("URL = %q, want prefix %q", u, tc.wantPrefix)
}
if !strings.Contains(u, "api_key=tok") {
t.Fatalf("URL missing api_key: %q", u)
}
})
}
}
func TestClientAuthenticatesWithPassword(t *testing.T) {
c := NewClient("https://emby.example.com", "", "", "alice", "s3cret")
authCalls := 0
useTestClient(t, func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/Users/AuthenticateByName":
authCalls++
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 (not MediaBrowser)", 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)
}
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
}
})
libs, err := c.MusicLibraries()
if err != nil {
t.Fatalf("MusicLibraries() error: %v", err)
}
if authCalls != 1 {
t.Fatalf("authCalls = %d, want 1", authCalls)
}
if c.token != "tok-1" || c.userID != "user-1" {
t.Fatalf("client auth state = token:%q userID:%q", c.token, c.userID)
}
if len(libs) != 1 || libs[0].ID != "music-1" {
t.Fatalf("libraries = %+v", libs)
}
}
func TestClientReportNowPlaying(t *testing.T) {
appmeta.SetVersion("v1.31.2")
t.Cleanup(func() { appmeta.SetVersion("dev") })
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
track := playlist.Track{
ProviderMeta: map[string]string{provider.MetaEmbyID: "track-1"},
}
useTestClient(t, func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", req.Method)
}
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)
}
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") {
t.Fatalf("Authorization scheme = %q, want Emby prefix", got)
}
if got := req.Header.Get("Authorization"); !strings.Contains(got, `Version="v1.31.2"`) {
t.Fatalf("Authorization = %q, want 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 || payload.PlayMethod != "DirectPlay" {
t.Fatalf("payload = %+v", payload)
}
return noContentResponse(), nil
})
if err := c.ReportNowPlaying(track, 15*time.Second, true); err != nil {
t.Fatalf("ReportNowPlaying() error: %v", err)
}
}
func TestClientReportScrobble(t *testing.T) {
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
track := playlist.Track{
ProviderMeta: map[string]string{provider.MetaEmbyID: "track-1"},
}
call := 0
useTestClient(t, 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)
}
var payload playbackInfo
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Fatalf("decode progress payload: %v", err)
}
if payload.ItemID != "track-1" || !payload.CanSeek || payload.PositionTicks != 42*time.Second.Nanoseconds()/100 {
t.Fatalf("progress payload = %+v", payload)
}
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
})
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)
}
}