Skip to content

Commit 620d1b2

Browse files
tallsamSam Hassellbjarneo
authored
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>
1 parent cf38560 commit 620d1b2

20 files changed

Lines changed: 1661 additions & 23 deletions

cmd/setup.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
tea "charm.land/bubbletea/v2"
2525
"charm.land/lipgloss/v2"
2626

27+
"cliamp/external/emby"
2728
"cliamp/external/jellyfin"
2829
"cliamp/external/navidrome"
2930
"cliamp/external/plex"
@@ -85,6 +86,7 @@ type pickerOption struct {
8586
// leading underscore distinguishes them from TOML field names.
8687
const (
8788
keyJellyfinAuth = "_auth"
89+
keyEmbyAuth = "_emby_auth"
8890
keyYTMusicMode = "_mode"
8991
)
9092

@@ -177,6 +179,55 @@ func providers() []providerSpec {
177179
return strings.Join(lines, "\n")
178180
},
179181
},
182+
{
183+
key: "emby",
184+
name: "Emby",
185+
section: "emby",
186+
intro: []string{
187+
"Authenticate with an API key (Dashboard → API Keys)",
188+
"or with your username and password.",
189+
},
190+
picker: &pickerSpec{
191+
key: keyEmbyAuth,
192+
label: "Authentication",
193+
options: []pickerOption{
194+
{value: "token", label: "API key"},
195+
{value: "password", label: "Username + password"},
196+
},
197+
},
198+
fields: []fieldSpec{
199+
{key: "url", label: "Server URL", help: "e.g. https://emby.example.com", required: true},
200+
{key: "token", label: "API key", required: true, secret: true,
201+
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "token" }},
202+
{key: "user", label: "Username (optional)", help: "multi-user servers: picks your account from /Users",
203+
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "token" }},
204+
{key: "user", label: "Username", required: true,
205+
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "password" }},
206+
{key: "password", label: "Password", required: true, secret: true,
207+
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "password" }},
208+
},
209+
validate: func(v map[string]string) error {
210+
if err := emby.NewClient(v["url"], v["token"], "", v["user"], v["password"]).Ping(); err != nil {
211+
return fmt.Errorf("emby: validation: %w", err)
212+
}
213+
return nil
214+
},
215+
body: func(v map[string]string) string {
216+
lines := []string{fmt.Sprintf("url = %q", v["url"])}
217+
if v[keyEmbyAuth] == "token" {
218+
lines = append(lines, fmt.Sprintf("token = %q", v["token"]))
219+
if v["user"] != "" {
220+
lines = append(lines, fmt.Sprintf("user = %q", v["user"]))
221+
}
222+
} else {
223+
lines = append(lines,
224+
fmt.Sprintf("user = %q", v["user"]),
225+
fmt.Sprintf("password = %q", v["password"]),
226+
)
227+
}
228+
return strings.Join(lines, "\n")
229+
},
230+
},
180231
{
181232
key: "spotify",
182233
name: "Spotify (Premium)",

cmd/setup_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,64 @@ func TestPickerSelectionFiltersFields(t *testing.T) {
9797
}
9898
}
9999

100+
// TestEmbyPickerSelectionFiltersFields mirrors TestPickerSelectionFiltersFields
101+
// for the Emby provider, which uses the same token/password picker shape.
102+
func TestEmbyPickerSelectionFiltersFields(t *testing.T) {
103+
m := newSetupModel()
104+
105+
embyIdx := -1
106+
for i, p := range m.provs {
107+
if p.section == "emby" {
108+
embyIdx = i
109+
break
110+
}
111+
}
112+
if embyIdx < 0 {
113+
t.Fatal("emby spec missing")
114+
}
115+
116+
tests := []struct {
117+
name string
118+
pickerCursor int
119+
wantVisible []string
120+
wantHidden []string
121+
}{
122+
{"API key", 0, []string{"url", "token", "user"}, []string{"password"}},
123+
{"password", 1, []string{"url", "user", "password"}, []string{"token"}},
124+
}
125+
126+
for _, tc := range tests {
127+
t.Run(tc.name, func(t *testing.T) {
128+
m.menuCursor = embyIdx
129+
m.stage = stageMenu
130+
m.values = map[string]string{}
131+
m.handleKey(keyPress(tea.KeyEnter, "")) // open picker
132+
if m.stage != stagePicker {
133+
t.Fatalf("stage = %v, want stagePicker", m.stage)
134+
}
135+
m.pickerCursor = tc.pickerCursor
136+
m.handleKey(keyPress(tea.KeyEnter, "")) // select picker option
137+
if m.stage != stageForm {
138+
t.Fatalf("stage = %v, want stageForm", m.stage)
139+
}
140+
visible := map[string]bool{}
141+
for _, idx := range m.visible {
142+
visible[m.provs[embyIdx].fields[idx].key] = true
143+
}
144+
for _, k := range tc.wantVisible {
145+
if !visible[k] {
146+
t.Errorf("field %q not visible; got %v", k, visible)
147+
}
148+
}
149+
for _, k := range tc.wantHidden {
150+
if visible[k] {
151+
t.Errorf("field %q should be hidden; got %v", k, visible)
152+
}
153+
}
154+
})
155+
}
156+
}
157+
100158
// TestRequiredFieldBlocksSubmit ensures pressing Enter on the last field
101159
// without filling required values produces an error result rather than
102160
// silently saving.

commands.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func buildApp() *cli.Command {
3232
&cli.BoolFlag{Name: "no-mono", Usage: "disable mono output"},
3333
&cli.BoolFlag{Name: "auto-play", Usage: "start playback immediately"},
3434
&cli.BoolFlag{Name: "compact", Usage: "compact mode (80 columns)"},
35-
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, spotify, soundcloud, yt, youtube, ytmusic"},
35+
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, emby, spotify, soundcloud, yt, youtube, ytmusic"},
3636
&cli.StringFlag{Name: "start-theme", Usage: "UI theme name"},
3737
&cli.StringFlag{Name: "visualizer", Usage: "visualizer mode"},
3838
&cli.StringFlag{Name: "eq-preset", Usage: "EQ preset name"},
@@ -147,10 +147,10 @@ func overridesFromFlags(c *cli.Command) (config.Overrides, error) {
147147
if c.IsSet("provider") {
148148
v := strings.ToLower(c.String("provider"))
149149
switch v {
150-
case "radio", "navidrome", "spotify", "plex", "jellyfin", "soundcloud", "yt", "youtube", "ytmusic":
150+
case "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "soundcloud", "yt", "youtube", "ytmusic":
151151
ov.Provider = &v
152152
default:
153-
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, plex, jellyfin, soundcloud, yt, youtube, or ytmusic (got %q)", v)
153+
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, plex, jellyfin, emby, soundcloud, yt, youtube, or ytmusic (got %q)", v)
154154
}
155155
}
156156
if c.IsSet("start-theme") {

config.toml.example

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ eq_preset = "Flat"
3232
# Only used when eq_preset is "Custom" or empty
3333
eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
3434

35-
# Default provider on startup: "radio", "navidrome", "spotify", "plex", "jellyfin", "soundcloud", or a YouTube provider
35+
# Default provider on startup: "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "soundcloud", or a YouTube provider
3636
# provider = "radio"
3737

3838
# Compact mode: cap UI width at 80 columns (default: fluid/full-width)
@@ -95,3 +95,14 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
9595
# password = "secret"
9696
# token = "optional-api-token"
9797
# user_id = "optional-user-id"
98+
99+
# ---
100+
# Emby server (optional)
101+
# Authenticate either with an API key or with your username/password.
102+
# user_id is optional and is discovered automatically when omitted.
103+
# [emby]
104+
# url = "https://emby.example.com"
105+
# user = "alice"
106+
# password = "secret"
107+
# token = "optional-api-key"
108+
# user_id = "optional-user-id"

config/config.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,22 @@ func (j JellyfinConfig) IsSet() bool {
175175
return j.URL != "" && (j.Token != "" || (j.User != "" && j.Password != ""))
176176
}
177177

178+
// EmbyConfig holds credentials for an Emby server.
179+
// URL is required. Authenticate either with Token, or with User+Password.
180+
// UserID is optional and can be discovered lazily.
181+
type EmbyConfig struct {
182+
URL string // e.g. "https://emby.example.com"
183+
Token string // API access token
184+
User string // optional username for password-based login
185+
Password string // optional password for password-based login
186+
UserID string // optional user id to skip discovery via /Users/Me
187+
}
188+
189+
// IsSet reports whether the Emby provider is configured.
190+
func (e EmbyConfig) IsSet() bool {
191+
return e.URL != "" && (e.Token != "" || (e.User != "" && e.Password != ""))
192+
}
193+
178194
// Config holds user preferences loaded from the config file.
179195
type Config struct {
180196
Volume float64 // dB, range [-30, +6]
@@ -186,7 +202,7 @@ type Config struct {
186202
Speed float64 // playback speed ratio: 0.25–2.0 (default 1.0)
187203
AutoPlay bool // start playback automatically on launch (radio streams, CLI tracks)
188204
SeekStepLarge int // seconds for Shift+Left/Right seek jumps
189-
Provider string // default provider: "radio", "navidrome", "spotify", "plex", "jellyfin", "ytmusic" (default "radio")
205+
Provider string // default provider: "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "ytmusic" (default "radio")
190206
Theme string // theme name, or "" for ANSI default
191207
Visualizer string // visualizer mode name, or "" for default (Bars)
192208
SampleRate int // output sample rate: 22050, 44100, 48000, 96000, 192000
@@ -203,6 +219,7 @@ type Config struct {
203219
YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider
204220
Plex PlexConfig // optional Plex Media Server credentials
205221
Jellyfin JellyfinConfig // optional Jellyfin server credentials
222+
Emby EmbyConfig // optional Emby server credentials
206223
SoundCloud SoundCloudConfig // SoundCloud provider (search always available; user enables browse)
207224
Plugins map[string]map[string]string // per-plugin config from [plugins.*] sections
208225
LogLevel string // log level: debug, info, warn, error (default "info")
@@ -355,6 +372,19 @@ func Load() (Config, error) {
355372
case "user_id":
356373
cfg.Jellyfin.UserID = parseString(val)
357374
}
375+
case "emby":
376+
switch key {
377+
case "url":
378+
cfg.Emby.URL = parseString(val)
379+
case "token":
380+
cfg.Emby.Token = parseString(val)
381+
case "user":
382+
cfg.Emby.User = parseString(val)
383+
case "password":
384+
cfg.Emby.Password = parseString(val)
385+
case "user_id":
386+
cfg.Emby.UserID = parseString(val)
387+
}
358388
default:
359389
// Handle [plugins] and [plugins.*] sections.
360390
if section == "plugins" || strings.HasPrefix(section, "plugins.") {

docs/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ CLI flags override config file values for the current session only. They are not
9898

9999
## Setup wizard
100100

101-
Configure remote providers (Navidrome, Plex, Jellyfin, Spotify, YouTube Music) through a small TUI. Each provider page links to where to find the required credentials, validates the connection live, and writes the resulting `[provider]` block to `~/.config/cliamp/config.toml` without disturbing the rest of the file.
101+
Configure remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, YouTube Music) through a small TUI. Each provider page links to where to find the required credentials, validates the connection live, and writes the resulting `[provider]` block to `~/.config/cliamp/config.toml` without disturbing the rest of the file.
102102

103103
```sh
104104
cliamp setup

docs/configuration.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Configuration
22

3-
For remote providers (Navidrome, Plex, Jellyfin, Spotify, YouTube Music), the fastest path is the interactive wizard:
3+
For remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, YouTube Music), the fastest path is the interactive wizard:
44

55
```sh
66
cliamp setup
@@ -77,6 +77,10 @@ token = "$PLEX_TOKEN"
7777
url = "https://jelly.example.com"
7878
token = "${JELLYFIN_TOKEN}"
7979

80+
[emby]
81+
url = "https://emby.example.com"
82+
token = "${EMBY_TOKEN}"
83+
8084
[ytmusic]
8185
client_id = "${YTMUSIC_CLIENT_ID}"
8286
client_secret = "${YTMUSIC_CLIENT_SECRET}"
@@ -97,7 +101,7 @@ Set which provider to start with:
97101
provider = "radio"
98102
```
99103

100-
Valid values: `radio` (default), `navidrome`, `spotify`, `plex`, `jellyfin`, `soundcloud`, `yt`, `youtube`, `ytmusic`.
104+
Valid values: `radio` (default), `navidrome`, `spotify`, `plex`, `jellyfin`, `emby`, `soundcloud`, `yt`, `youtube`, `ytmusic`.
101105

102106
You can also override from the CLI: `cliamp --provider jellyfin`.
103107

docs/emby.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Emby
2+
3+
cliamp can stream music directly from an Emby server using Emby's authenticated HTTP API. The integration exposes your music libraries as a flat album list in the normal provider pane, following the same shape as the Jellyfin and Plex providers.
4+
5+
> **Quick start:** run `cliamp setup` for a guided TUI that lets you pick API-key or username+password auth, validates against `/System/Info`, and writes the `[emby]` block for you. Manual setup steps are below.
6+
7+
## Prerequisites
8+
9+
- A reachable Emby server
10+
- At least one library with `CollectionType = music`
11+
- An Emby API key or user credentials
12+
13+
## Configuration
14+
15+
Add an `[emby]` section to `~/.config/cliamp/config.toml`:
16+
17+
```toml
18+
[emby]
19+
url = "https://emby.example.com"
20+
user = "alice"
21+
password = "your_password_here"
22+
# optional alternatives:
23+
# token = "xxxxxxxxxxxxxxxxxxxx"
24+
# user_id = "00000000000000000000000000000000"
25+
```
26+
27+
| Key | Description |
28+
|-----|-------------|
29+
| `url` | Base URL of your Emby server |
30+
| `user` | Emby username — used for password login, and to select the matching account when using an API key |
31+
| `password` | Emby password for password-based login |
32+
| `token` | Emby API key — alternative to username/password |
33+
| `user_id` | Optional Emby user id to skip discovery |
34+
35+
## Usage
36+
37+
Once configured, **Emby** appears as a provider alongside Radio, Navidrome, Plex, Jellyfin, Spotify, and the YouTube providers.
38+
39+
To start cliamp with Emby selected:
40+
41+
```bash
42+
cliamp --provider emby
43+
```
44+
45+
Or set it in config:
46+
47+
```toml
48+
provider = "emby"
49+
```
50+
51+
The provider exposes a flat list of albums:
52+
53+
```text
54+
Artist — Album Title (Year)
55+
```
56+
57+
Select an album to load its tracks, then play as normal. Press `E` anywhere in the UI to switch to Emby quickly.
58+
59+
## How it works
60+
61+
cliamp authenticates with either a configured API key or the supplied username/password, resolves the active Emby user, enumerates music library views, fetches album items from those views, then fetches track items for the selected album. Playback uses Emby's authenticated download endpoint, so the existing cliamp HTTP pipeline can stream the result directly.
62+
63+
## Known limitations
64+
65+
- **Album list is flat**: no artist drill-down yet
66+
- **Token-based access**: store the API key carefully
67+
- **API key user selection**: Emby API keys are server-level (no "current user"). When no `user` is configured, cliamp picks the first user returned by `/Users`. On single-user servers this is always correct; on multi-user servers, set `user_id` explicitly in `[emby]` to target a specific account.

docs/keybindings.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Press `Ctrl+K` in the player to see all keybindings.
4949
| Key | Action |
5050
|---|---|
5151
| `f` | Toggle bookmark ★ on selected track (or favorite radio station in radio browser) |
52-
| `Ctrl+F` | Search — active provider's native search (Spotify, Navidrome, Jellyfin, Plex, Local) or YouTube fallback. Available from playlist and provider-browser views. |
52+
| `Ctrl+F` | Search — active provider's native search (Spotify, Navidrome, Jellyfin, Emby, Plex, Local) or YouTube fallback. Available from playlist and provider-browser views. |
5353
| `u` | Load URL (stream/playlist) |
5454
| `y` | Show lyrics |
5555
| `Ctrl+S` | Save track to ~/Music |
@@ -59,6 +59,7 @@ Press `Ctrl+K` in the player to see all keybindings.
5959
| `S` | Open Spotify provider |
6060
| `P` | Open Plex provider |
6161
| `J` | Open Jellyfin provider |
62+
| `E` | Open Emby provider |
6263
| `Y` | Open YouTube provider |
6364
| `C` | Open SoundCloud provider |
6465

@@ -87,7 +88,7 @@ Press `Ctrl+K` in the player to see all keybindings.
8788

8889
## Provider browser (`N` key)
8990

90-
When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Spotify, YouTube Music), the album/artist/track screens use:
91+
When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Emby, Spotify, YouTube Music), the album/artist/track screens use:
9192

9293
| Key | Action |
9394
|---|---|
@@ -99,7 +100,7 @@ When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Spotify,
99100
| `a` | Append all visible tracks to the queue |
100101
| `q` | Queue the highlighted track to play next |
101102
| `s` | Cycle album sort (album list only) |
102-
| `S` `N` `P` `J` `Y` `L` `R` | Quick-switch to that provider without going back through the main pane |
103+
| `S` `N` `P` `J` `E` `Y` `L` `R` | Quick-switch to that provider without going back through the main pane |
103104
| `Esc` `b` | Walk back one level / close the browser |
104105

105106
The track screen shows a `N tracks · 47:22` subtitle and right-aligned per-track durations when the provider returns them.
@@ -115,7 +116,7 @@ The playlists pane (visible when focus is on a provider — Spotify, Navidrome,
115116
| `/` | Filter the playlist list |
116117
| `Ctrl+F` | Online/server search (Spotify/Navidrome/etc.'s own search) |
117118
| `Ctrl+R` | Refresh — re-pull the playlist list from the provider |
118-
| `S` `N` `P` `J` `Y` `L` `R` | Switch to that provider |
119+
| `S` `N` `P` `J` `E` `Y` `L` `R` | Switch to that provider |
119120
| `Tab` | Switch focus to EQ |
120121
| `Esc` `b` | Back to the playlist pane |
121122

0 commit comments

Comments
 (0)