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>
This commit is contained in:
tallsam
2026-05-06 00:09:42 +08:00
committed by GitHub
parent cf38560434
commit 620d1b20f8
20 changed files with 1661 additions and 23 deletions
+51
View File
@@ -24,6 +24,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"cliamp/external/emby"
"cliamp/external/jellyfin"
"cliamp/external/navidrome"
"cliamp/external/plex"
@@ -85,6 +86,7 @@ type pickerOption struct {
// leading underscore distinguishes them from TOML field names.
const (
keyJellyfinAuth = "_auth"
keyEmbyAuth = "_emby_auth"
keyYTMusicMode = "_mode"
)
@@ -177,6 +179,55 @@ func providers() []providerSpec {
return strings.Join(lines, "\n")
},
},
{
key: "emby",
name: "Emby",
section: "emby",
intro: []string{
"Authenticate with an API key (Dashboard → API Keys)",
"or with your username and password.",
},
picker: &pickerSpec{
key: keyEmbyAuth,
label: "Authentication",
options: []pickerOption{
{value: "token", label: "API key"},
{value: "password", label: "Username + password"},
},
},
fields: []fieldSpec{
{key: "url", label: "Server URL", help: "e.g. https://emby.example.com", required: true},
{key: "token", label: "API key", required: true, secret: true,
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "token" }},
{key: "user", label: "Username (optional)", help: "multi-user servers: picks your account from /Users",
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "token" }},
{key: "user", label: "Username", required: true,
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "password" }},
{key: "password", label: "Password", required: true, secret: true,
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "password" }},
},
validate: func(v map[string]string) error {
if err := emby.NewClient(v["url"], v["token"], "", v["user"], v["password"]).Ping(); err != nil {
return fmt.Errorf("emby: validation: %w", err)
}
return nil
},
body: func(v map[string]string) string {
lines := []string{fmt.Sprintf("url = %q", v["url"])}
if v[keyEmbyAuth] == "token" {
lines = append(lines, fmt.Sprintf("token = %q", v["token"]))
if v["user"] != "" {
lines = append(lines, fmt.Sprintf("user = %q", v["user"]))
}
} else {
lines = append(lines,
fmt.Sprintf("user = %q", v["user"]),
fmt.Sprintf("password = %q", v["password"]),
)
}
return strings.Join(lines, "\n")
},
},
{
key: "spotify",
name: "Spotify (Premium)",
+58
View File
@@ -97,6 +97,64 @@ func TestPickerSelectionFiltersFields(t *testing.T) {
}
}
// TestEmbyPickerSelectionFiltersFields mirrors TestPickerSelectionFiltersFields
// for the Emby provider, which uses the same token/password picker shape.
func TestEmbyPickerSelectionFiltersFields(t *testing.T) {
m := newSetupModel()
embyIdx := -1
for i, p := range m.provs {
if p.section == "emby" {
embyIdx = i
break
}
}
if embyIdx < 0 {
t.Fatal("emby spec missing")
}
tests := []struct {
name string
pickerCursor int
wantVisible []string
wantHidden []string
}{
{"API key", 0, []string{"url", "token", "user"}, []string{"password"}},
{"password", 1, []string{"url", "user", "password"}, []string{"token"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m.menuCursor = embyIdx
m.stage = stageMenu
m.values = map[string]string{}
m.handleKey(keyPress(tea.KeyEnter, "")) // open picker
if m.stage != stagePicker {
t.Fatalf("stage = %v, want stagePicker", m.stage)
}
m.pickerCursor = tc.pickerCursor
m.handleKey(keyPress(tea.KeyEnter, "")) // select picker option
if m.stage != stageForm {
t.Fatalf("stage = %v, want stageForm", m.stage)
}
visible := map[string]bool{}
for _, idx := range m.visible {
visible[m.provs[embyIdx].fields[idx].key] = true
}
for _, k := range tc.wantVisible {
if !visible[k] {
t.Errorf("field %q not visible; got %v", k, visible)
}
}
for _, k := range tc.wantHidden {
if visible[k] {
t.Errorf("field %q should be hidden; got %v", k, visible)
}
}
})
}
}
// TestRequiredFieldBlocksSubmit ensures pressing Enter on the last field
// without filling required values produces an error result rather than
// silently saving.
+3 -3
View File
@@ -32,7 +32,7 @@ func buildApp() *cli.Command {
&cli.BoolFlag{Name: "no-mono", Usage: "disable mono output"},
&cli.BoolFlag{Name: "auto-play", Usage: "start playback immediately"},
&cli.BoolFlag{Name: "compact", Usage: "compact mode (80 columns)"},
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, spotify, soundcloud, yt, youtube, ytmusic"},
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, emby, spotify, soundcloud, yt, youtube, ytmusic"},
&cli.StringFlag{Name: "start-theme", Usage: "UI theme name"},
&cli.StringFlag{Name: "visualizer", Usage: "visualizer mode"},
&cli.StringFlag{Name: "eq-preset", Usage: "EQ preset name"},
@@ -147,10 +147,10 @@ func overridesFromFlags(c *cli.Command) (config.Overrides, error) {
if c.IsSet("provider") {
v := strings.ToLower(c.String("provider"))
switch v {
case "radio", "navidrome", "spotify", "plex", "jellyfin", "soundcloud", "yt", "youtube", "ytmusic":
case "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "soundcloud", "yt", "youtube", "ytmusic":
ov.Provider = &v
default:
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, plex, jellyfin, soundcloud, yt, youtube, or ytmusic (got %q)", v)
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, plex, jellyfin, emby, soundcloud, yt, youtube, or ytmusic (got %q)", v)
}
}
if c.IsSet("start-theme") {
+12 -1
View File
@@ -32,7 +32,7 @@ eq_preset = "Flat"
# Only used when eq_preset is "Custom" or empty
eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# Default provider on startup: "radio", "navidrome", "spotify", "plex", "jellyfin", "soundcloud", or a YouTube provider
# Default provider on startup: "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "soundcloud", or a YouTube provider
# provider = "radio"
# 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]
# password = "secret"
# token = "optional-api-token"
# user_id = "optional-user-id"
# ---
# Emby server (optional)
# Authenticate either with an API key or with your username/password.
# user_id is optional and is discovered automatically when omitted.
# [emby]
# url = "https://emby.example.com"
# user = "alice"
# password = "secret"
# token = "optional-api-key"
# user_id = "optional-user-id"
+31 -1
View File
@@ -175,6 +175,22 @@ func (j JellyfinConfig) IsSet() bool {
return j.URL != "" && (j.Token != "" || (j.User != "" && j.Password != ""))
}
// EmbyConfig holds credentials for an Emby server.
// URL is required. Authenticate either with Token, or with User+Password.
// UserID is optional and can be discovered lazily.
type EmbyConfig struct {
URL string // e.g. "https://emby.example.com"
Token string // API access token
User string // optional username for password-based login
Password string // optional password for password-based login
UserID string // optional user id to skip discovery via /Users/Me
}
// IsSet reports whether the Emby provider is configured.
func (e EmbyConfig) IsSet() bool {
return e.URL != "" && (e.Token != "" || (e.User != "" && e.Password != ""))
}
// Config holds user preferences loaded from the config file.
type Config struct {
Volume float64 // dB, range [-30, +6]
@@ -186,7 +202,7 @@ type Config struct {
Speed float64 // playback speed ratio: 0.252.0 (default 1.0)
AutoPlay bool // start playback automatically on launch (radio streams, CLI tracks)
SeekStepLarge int // seconds for Shift+Left/Right seek jumps
Provider string // default provider: "radio", "navidrome", "spotify", "plex", "jellyfin", "ytmusic" (default "radio")
Provider string // default provider: "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "ytmusic" (default "radio")
Theme string // theme name, or "" for ANSI default
Visualizer string // visualizer mode name, or "" for default (Bars)
SampleRate int // output sample rate: 22050, 44100, 48000, 96000, 192000
@@ -203,6 +219,7 @@ type Config struct {
YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider
Plex PlexConfig // optional Plex Media Server credentials
Jellyfin JellyfinConfig // optional Jellyfin server credentials
Emby EmbyConfig // optional Emby server credentials
SoundCloud SoundCloudConfig // SoundCloud provider (search always available; user enables browse)
Plugins map[string]map[string]string // per-plugin config from [plugins.*] sections
LogLevel string // log level: debug, info, warn, error (default "info")
@@ -355,6 +372,19 @@ func Load() (Config, error) {
case "user_id":
cfg.Jellyfin.UserID = parseString(val)
}
case "emby":
switch key {
case "url":
cfg.Emby.URL = parseString(val)
case "token":
cfg.Emby.Token = parseString(val)
case "user":
cfg.Emby.User = parseString(val)
case "password":
cfg.Emby.Password = parseString(val)
case "user_id":
cfg.Emby.UserID = parseString(val)
}
default:
// Handle [plugins] and [plugins.*] sections.
if section == "plugins" || strings.HasPrefix(section, "plugins.") {
+1 -1
View File
@@ -98,7 +98,7 @@ CLI flags override config file values for the current session only. They are not
## Setup wizard
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.
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.
```sh
cliamp setup
+6 -2
View File
@@ -1,6 +1,6 @@
# Configuration
For remote providers (Navidrome, Plex, Jellyfin, Spotify, YouTube Music), the fastest path is the interactive wizard:
For remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, YouTube Music), the fastest path is the interactive wizard:
```sh
cliamp setup
@@ -77,6 +77,10 @@ token = "$PLEX_TOKEN"
url = "https://jelly.example.com"
token = "${JELLYFIN_TOKEN}"
[emby]
url = "https://emby.example.com"
token = "${EMBY_TOKEN}"
[ytmusic]
client_id = "${YTMUSIC_CLIENT_ID}"
client_secret = "${YTMUSIC_CLIENT_SECRET}"
@@ -97,7 +101,7 @@ Set which provider to start with:
provider = "radio"
```
Valid values: `radio` (default), `navidrome`, `spotify`, `plex`, `jellyfin`, `soundcloud`, `yt`, `youtube`, `ytmusic`.
Valid values: `radio` (default), `navidrome`, `spotify`, `plex`, `jellyfin`, `emby`, `soundcloud`, `yt`, `youtube`, `ytmusic`.
You can also override from the CLI: `cliamp --provider jellyfin`.
+67
View File
@@ -0,0 +1,67 @@
# Emby
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.
> **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.
## Prerequisites
- A reachable Emby server
- At least one library with `CollectionType = music`
- An Emby API key or user credentials
## Configuration
Add an `[emby]` section to `~/.config/cliamp/config.toml`:
```toml
[emby]
url = "https://emby.example.com"
user = "alice"
password = "your_password_here"
# optional alternatives:
# token = "xxxxxxxxxxxxxxxxxxxx"
# user_id = "00000000000000000000000000000000"
```
| Key | Description |
|-----|-------------|
| `url` | Base URL of your Emby server |
| `user` | Emby username — used for password login, and to select the matching account when using an API key |
| `password` | Emby password for password-based login |
| `token` | Emby API key — alternative to username/password |
| `user_id` | Optional Emby user id to skip discovery |
## Usage
Once configured, **Emby** appears as a provider alongside Radio, Navidrome, Plex, Jellyfin, Spotify, and the YouTube providers.
To start cliamp with Emby selected:
```bash
cliamp --provider emby
```
Or set it in config:
```toml
provider = "emby"
```
The provider exposes a flat list of albums:
```text
Artist — Album Title (Year)
```
Select an album to load its tracks, then play as normal. Press `E` anywhere in the UI to switch to Emby quickly.
## How it works
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.
## Known limitations
- **Album list is flat**: no artist drill-down yet
- **Token-based access**: store the API key carefully
- **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.
+5 -4
View File
@@ -49,7 +49,7 @@ Press `Ctrl+K` in the player to see all keybindings.
| Key | Action |
|---|---|
| `f` | Toggle bookmark ★ on selected track (or favorite radio station in radio browser) |
| `Ctrl+F` | Search — active provider's native search (Spotify, Navidrome, Jellyfin, Plex, Local) or YouTube fallback. Available from playlist and provider-browser views. |
| `Ctrl+F` | Search — active provider's native search (Spotify, Navidrome, Jellyfin, Emby, Plex, Local) or YouTube fallback. Available from playlist and provider-browser views. |
| `u` | Load URL (stream/playlist) |
| `y` | Show lyrics |
| `Ctrl+S` | Save track to ~/Music |
@@ -59,6 +59,7 @@ Press `Ctrl+K` in the player to see all keybindings.
| `S` | Open Spotify provider |
| `P` | Open Plex provider |
| `J` | Open Jellyfin provider |
| `E` | Open Emby provider |
| `Y` | Open YouTube provider |
| `C` | Open SoundCloud provider |
@@ -87,7 +88,7 @@ Press `Ctrl+K` in the player to see all keybindings.
## Provider browser (`N` key)
When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Spotify, YouTube Music), the album/artist/track screens use:
When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Emby, Spotify, YouTube Music), the album/artist/track screens use:
| Key | Action |
|---|---|
@@ -99,7 +100,7 @@ When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Spotify,
| `a` | Append all visible tracks to the queue |
| `q` | Queue the highlighted track to play next |
| `s` | Cycle album sort (album list only) |
| `S` `N` `P` `J` `Y` `L` `R` | Quick-switch to that provider without going back through the main pane |
| `S` `N` `P` `J` `E` `Y` `L` `R` | Quick-switch to that provider without going back through the main pane |
| `Esc` `b` | Walk back one level / close the browser |
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,
| `/` | Filter the playlist list |
| `Ctrl+F` | Online/server search (Spotify/Navidrome/etc.'s own search) |
| `Ctrl+R` | Refresh — re-pull the playlist list from the provider |
| `S` `N` `P` `J` `Y` `L` `R` | Switch to that provider |
| `S` `N` `P` `J` `E` `Y` `L` `R` | Switch to that provider |
| `Tab` | Switch focus to EQ |
| `Esc` `b` | Back to the playlist pane |
+733
View File
@@ -0,0 +1,733 @@
package emby
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"sort"
"strconv"
"strings"
"time"
"cliamp/internal/appmeta"
"cliamp/playlist"
"cliamp/provider"
)
var apiClient = &http.Client{Timeout: 30 * time.Second}
// maxResponseBody limits API responses to 10 MB to prevent unbounded memory growth.
const maxResponseBody = 10 << 20
// Client speaks to an Emby server over its HTTP API.
type Client struct {
baseURL string
token string
userID string
user string
password string
deviceID string
albumCache []Album // cached after first Albums() call
}
// NewClient returns a Client for the given server URL and API token.
func NewClient(baseURL, token, userID, user, password string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
userID: userID,
user: user,
password: password,
deviceID: "cliamp",
}
}
// Library represents an Emby music library view.
type Library struct {
ID string
Name string
}
const (
SortAlbumsByName = "name"
SortAlbumsByArtist = "artist"
SortAlbumsByYear = "year"
)
var albumSortTypes = []provider.SortType{
{ID: SortAlbumsByName, Label: "Alphabetical by Name"},
{ID: SortAlbumsByArtist, Label: "Alphabetical by Artist"},
{ID: SortAlbumsByYear, Label: "By Year"},
}
// Album represents an Emby album entry.
type Album struct {
ID string
Name string
Artist string
ArtistID string
Year int
TrackCount int
}
// Track represents an Emby track entry.
type Track struct {
ID string
Name string
Artist string
Album string
Year int
TrackNumber int
DurationSecs int
}
type userDTO struct {
ID string `json:"Id"`
Name string `json:"Name"`
}
type itemsResponseDTO struct {
Items []itemDTO `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
}
type itemDTO struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
CollectionType string `json:"CollectionType,omitempty"`
Album string `json:"Album,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`
AlbumArtists []nameIDDTO `json:"AlbumArtists,omitempty"`
Artists []string `json:"Artists,omitempty"`
ArtistItems []nameIDDTO `json:"ArtistItems,omitempty"`
ProductionYear int `json:"ProductionYear,omitempty"`
ChildCount int `json:"ChildCount,omitempty"`
IndexNumber int `json:"IndexNumber,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
}
type nameIDDTO struct {
ID string `json:"Id"`
Name string `json:"Name"`
}
type systemInfoDTO struct {
ServerName string `json:"ServerName"`
Version string `json:"Version"`
}
type authResponseDTO struct {
User struct {
ID string `json:"Id"`
} `json:"User"`
AccessToken string `json:"AccessToken"`
}
type playbackInfo struct {
CanSeek bool `json:"CanSeek"`
ItemID string `json:"ItemId"`
IsPaused bool `json:"IsPaused"`
IsMuted bool `json:"IsMuted"`
PositionTicks int64 `json:"PositionTicks,omitempty"`
PlayMethod string `json:"PlayMethod,omitempty"`
}
type playbackStopInfo struct {
ItemID string `json:"ItemId"`
PositionTicks int64 `json:"PositionTicks,omitempty"`
Failed bool `json:"Failed"`
}
// Ping checks that the server is reachable and the token is accepted.
// Uses /System/Info because Emby API keys are server-level credentials
// with no user context, so /Users/Me returns 500 for API key auth.
func (c *Client) Ping() error {
var info systemInfoDTO
return c.get("/System/Info", nil, &info)
}
// UserID returns the active user id, discovering it lazily when needed.
// For password-based logins the ID comes from the auth response. For API
// key auth (no user context), it falls back to listing /Users and using
// the first user whose name matches the configured user, or the first
// admin user when no name was configured.
func (c *Client) UserID() (string, error) {
if c.userID != "" {
return c.userID, nil
}
if err := c.ensureAuth(); err != nil {
return "", err
}
if c.userID != "" {
return c.userID, nil
}
// Try /Users/Me first (works for session tokens from password auth).
var me userDTO
if err := c.get("/Users/Me", nil, &me); err == nil && me.ID != "" {
c.userID = me.ID
return c.userID, nil
}
// Fall back to /Users for API key auth (server-level key has no "me").
var users []userDTO
if err := c.get("/Users", nil, &users); err != nil {
return "", fmt.Errorf("emby: could not discover user id (set user_id in config): %w", err)
}
// Prefer user matching the configured username; otherwise take first entry.
for _, u := range users {
if strings.EqualFold(u.Name, c.user) {
c.userID = u.ID
return c.userID, nil
}
}
if c.user != "" {
return "", fmt.Errorf("emby: user %q not found — check the user name in config", c.user)
}
if len(users) > 0 && users[0].ID != "" {
c.userID = users[0].ID
return c.userID, nil
}
return "", fmt.Errorf("emby: could not discover user id — set user_id in config")
}
// MusicLibraries returns all user views whose collection type is music.
func (c *Client) MusicLibraries() ([]Library, error) {
userID, err := c.UserID()
if err != nil {
return nil, err
}
var resp itemsResponseDTO
if err := c.get("/Users/"+url.PathEscape(userID)+"/Views", nil, &resp); err != nil {
return nil, err
}
var libs []Library
for _, it := range resp.Items {
if strings.EqualFold(it.CollectionType, "music") {
libs = append(libs, Library{ID: it.ID, Name: it.Name})
}
}
return libs, nil
}
// Albums returns all albums across every Emby music library.
// Results are cached after the first successful call.
func (c *Client) Albums() ([]Album, error) {
if c.albumCache != nil {
return c.albumCache, nil
}
libs, err := c.MusicLibraries()
if err != nil {
return nil, err
}
var out []Album
for _, lib := range libs {
albums, err := c.AlbumsByLibrary(lib.ID)
if err != nil {
return nil, err
}
out = append(out, albums...)
}
c.albumCache = out
return out, nil
}
// Artists returns a derived artist list built from the server's album catalog.
func (c *Client) Artists() ([]provider.ArtistInfo, error) {
albums, err := c.Albums()
if err != nil {
return nil, err
}
type artistKey struct {
id string
name string
}
seen := make(map[artistKey]*provider.ArtistInfo)
for _, album := range albums {
key := artistKey{id: canonicalArtistID(album.ArtistID, album.Artist), name: album.Artist}
if key.id == "" && key.name == "" {
continue
}
info, ok := seen[key]
if !ok {
info = &provider.ArtistInfo{
ID: key.id,
Name: key.name,
}
seen[key] = info
}
info.AlbumCount++
}
artists := make([]provider.ArtistInfo, 0, len(seen))
for _, artist := range seen {
artists = append(artists, *artist)
}
sort.Slice(artists, func(i, j int) bool {
return strings.ToLower(artists[i].Name) < strings.ToLower(artists[j].Name)
})
return artists, nil
}
// ArtistAlbums returns all albums for one artist, derived from the full album list.
func (c *Client) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
albums, err := c.Albums()
if err != nil {
return nil, err
}
var out []provider.AlbumInfo
for _, album := range albums {
if artistID != "" && album.ArtistID != artistID {
if canonicalArtistID(album.ArtistID, album.Artist) != artistID {
continue
}
}
out = append(out, provider.AlbumInfo{
ID: album.ID,
Name: album.Name,
Artist: album.Artist,
ArtistID: canonicalArtistID(album.ArtistID, album.Artist),
Year: album.Year,
TrackCount: album.TrackCount,
})
}
sortAlbums(out, SortAlbumsByName)
return out, nil
}
// AlbumList returns one page from the full album catalog, sorted client-side.
func (c *Client) AlbumList(sortType string, offset, size int) ([]provider.AlbumInfo, error) {
albums, err := c.Albums()
if err != nil {
return nil, err
}
out := make([]provider.AlbumInfo, 0, len(albums))
for _, album := range albums {
out = append(out, provider.AlbumInfo{
ID: album.ID,
Name: album.Name,
Artist: album.Artist,
ArtistID: canonicalArtistID(album.ArtistID, album.Artist),
Year: album.Year,
TrackCount: album.TrackCount,
})
}
sortAlbums(out, sortType)
if offset < 0 {
offset = 0
}
if offset >= len(out) {
return nil, nil
}
end := len(out)
if size > 0 && offset+size < end {
end = offset + size
}
return out[offset:end], nil
}
func (c *Client) AlbumSortTypes() []provider.SortType {
return albumSortTypes
}
func (c *Client) DefaultAlbumSort() string {
return SortAlbumsByName
}
// AlbumsByLibrary returns all albums under one Emby music library view.
func (c *Client) AlbumsByLibrary(libraryID string) ([]Album, error) {
userID, err := c.UserID()
if err != nil {
return nil, err
}
params := url.Values{
"userId": {userID},
"parentId": {libraryID},
"recursive": {"true"},
"includeItemTypes": {"MusicAlbum"},
"sortBy": {"SortName"},
"sortOrder": {"Ascending"},
"enableTotalRecordCount": {"false"},
}
var resp itemsResponseDTO
if err := c.get("/Items", params, &resp); err != nil {
return nil, err
}
out := make([]Album, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, albumFromItem(it))
}
return out, nil
}
// Tracks returns all audio tracks contained by an album item.
func (c *Client) Tracks(albumID string) ([]Track, error) {
userID, err := c.UserID()
if err != nil {
return nil, err
}
params := url.Values{
"userId": {userID},
"parentId": {albumID},
"includeItemTypes": {"Audio"},
"sortBy": {"ParentIndexNumber,IndexNumber,SortName"},
"sortOrder": {"Ascending"},
"fields": {"RunTimeTicks"},
"enableTotalRecordCount": {"false"},
}
var resp itemsResponseDTO
if err := c.get("/Items", params, &resp); err != nil {
return nil, err
}
out := make([]Track, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, trackFromItem(it))
}
return out, nil
}
// Search searches the user's audio library for tracks matching query and
// returns up to limit results.
func (c *Client) Search(query string, limit int) ([]Track, error) {
userID, err := c.UserID()
if err != nil {
return nil, err
}
if limit <= 0 {
limit = 50
}
params := url.Values{
"userId": {userID},
"searchTerm": {query},
"includeItemTypes": {"Audio"},
"recursive": {"true"},
"limit": {strconv.Itoa(limit)},
"fields": {"RunTimeTicks"},
"enableTotalRecordCount": {"false"},
}
var resp itemsResponseDTO
if err := c.get("/Items", params, &resp); err != nil {
return nil, err
}
out := make([]Track, 0, len(resp.Items))
for _, it := range resp.Items {
out = append(out, trackFromItem(it))
}
return out, nil
}
// IsStreamURL reports whether the given URL looks like an Emby item download
// endpoint. Used by the player to route these URLs through the buffered ffmpeg
// pipeline instead of native HTTP streaming.
func IsStreamURL(path string) bool {
u, err := url.Parse(path)
if err != nil {
return false
}
p := strings.ToLower(u.Path)
return strings.Contains(p, "/items/") && strings.HasSuffix(p, "/download")
}
// StreamURL returns an authenticated Emby audio URL for a track item.
func (c *Client) StreamURL(itemID string) string {
_ = c.ensureAuth()
v := url.Values{
"api_key": {c.token},
}
u := c.baseURL + path.Join("/", "Items", itemID, "Download")
if enc := v.Encode(); enc != "" {
u += "?" + enc
}
return u
}
func (c *Client) ReportNowPlaying(track playlist.Track, position time.Duration, canSeek bool) error {
return c.postJSON("/Sessions/Playing", playbackInfo{
CanSeek: canSeek,
ItemID: track.Meta(provider.MetaEmbyID),
IsPaused: false,
IsMuted: false,
PositionTicks: toTicks(position),
PlayMethod: "DirectPlay",
})
}
func (c *Client) ReportScrobble(track playlist.Track, elapsed time.Duration, canSeek bool) error {
progress := playbackInfo{
CanSeek: canSeek,
ItemID: track.Meta(provider.MetaEmbyID),
IsPaused: false,
IsMuted: false,
PositionTicks: toTicks(elapsed),
PlayMethod: "DirectPlay",
}
if err := c.postJSON("/Sessions/Playing/Progress", progress); err != nil {
return err
}
return c.postJSON("/Sessions/Playing/Stopped", playbackStopInfo{
ItemID: track.Meta(provider.MetaEmbyID),
PositionTicks: toTicks(elapsed),
Failed: false,
})
}
func (c *Client) get(p string, params url.Values, out any) error {
if err := c.ensureAuth(); err != nil {
return err
}
req, err := c.newRequest(http.MethodGet, p, params)
if err != nil {
return err
}
resp, err := apiClient.Do(req)
if err != nil {
return fmt.Errorf("emby: %s: %w", p, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
default:
return fmt.Errorf("emby: %s: http status %s", p, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return fmt.Errorf("emby: %s: %w", p, err)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("emby: %s: %w", p, err)
}
return nil
}
func (c *Client) postJSON(p string, payload any) error {
if err := c.ensureAuth(); err != nil {
return fmt.Errorf("emby: %s: %w", p, err)
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("emby: %s: %w", p, err)
}
req, err := c.newRequestWithBody(http.MethodPost, p, nil, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("emby: %s: %w", p, err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := apiClient.Do(req)
if err != nil {
return fmt.Errorf("emby: %s: %w", p, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("emby: %s: http status %s", p, resp.Status)
}
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
return nil
}
func (c *Client) ensureAuth() error {
if c.token != "" {
return nil
}
if c.user == "" || c.password == "" {
return fmt.Errorf("emby: missing token or user/password")
}
body, err := json.Marshal(map[string]string{
"Username": c.user,
"Pw": c.password,
})
if err != nil {
return fmt.Errorf("emby: auth: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/Users/AuthenticateByName", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("emby: auth: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", c.unauthHeader())
resp, err := apiClient.Do(req)
if err != nil {
return fmt.Errorf("emby: auth: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("emby: auth: http status %s", resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return fmt.Errorf("emby: auth: %w", err)
}
var out authResponseDTO
if err := json.Unmarshal(data, &out); err != nil {
return fmt.Errorf("emby: auth: %w", err)
}
if out.AccessToken == "" {
return fmt.Errorf("emby: auth: missing access token")
}
c.token = out.AccessToken
if c.userID == "" {
c.userID = out.User.ID
}
return nil
}
func (c *Client) newRequest(method, p string, params url.Values) (*http.Request, error) {
return c.newRequestWithBody(method, p, params, nil)
}
func (c *Client) newRequestWithBody(method, p string, params url.Values, body io.Reader) (*http.Request, error) {
u := c.baseURL + p
if len(params) > 0 {
u += "?" + params.Encode()
}
req, err := http.NewRequest(method, u, body)
if err != nil {
return nil, fmt.Errorf("emby: %s: %w", p, err)
}
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("X-Emby-Token", c.token)
req.Header.Set("Authorization", c.authHeader())
} else {
req.Header.Set("Authorization", c.unauthHeader())
}
return req, nil
}
// unauthHeader returns the Emby Authorization header value for unauthenticated
// requests (no token or user id yet).
func (c *Client) unauthHeader() string {
return fmt.Sprintf(`Emby Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
appmeta.ClientName(), appmeta.DeviceName(), c.deviceID, appmeta.Version())
}
// authHeader returns the Emby Authorization header value for authenticated
// requests, including the token and user id when available.
func (c *Client) authHeader() string {
if c.userID != "" {
return fmt.Sprintf(`Emby UserId="%s", Client="%s", Device="%s", DeviceId="%s", Version="%s", Token="%s"`,
c.userID, appmeta.ClientName(), appmeta.DeviceName(), c.deviceID, appmeta.Version(), c.token)
}
return fmt.Sprintf(`Emby Client="%s", Device="%s", DeviceId="%s", Version="%s", Token="%s"`,
appmeta.ClientName(), appmeta.DeviceName(), c.deviceID, appmeta.Version(), c.token)
}
func albumFromItem(it itemDTO) Album {
a := Album{
ID: it.ID,
Name: it.Name,
Artist: it.AlbumArtist,
Year: it.ProductionYear,
TrackCount: it.ChildCount,
}
if len(it.AlbumArtists) > 0 {
if a.Artist == "" {
a.Artist = it.AlbumArtists[0].Name
}
a.ArtistID = it.AlbumArtists[0].ID
}
if a.Artist == "" && len(it.ArtistItems) > 0 {
a.Artist = it.ArtistItems[0].Name
a.ArtistID = it.ArtistItems[0].ID
}
return a
}
func trackFromItem(it itemDTO) Track {
t := Track{
ID: it.ID,
Name: it.Name,
Album: it.Album,
Year: it.ProductionYear,
TrackNumber: it.IndexNumber,
DurationSecs: int(it.RunTimeTicks / 10_000_000),
}
if len(it.Artists) > 0 {
t.Artist = it.Artists[0]
} else if len(it.ArtistItems) > 0 {
t.Artist = it.ArtistItems[0].Name
}
return t
}
func sortAlbums(albums []provider.AlbumInfo, sortType string) {
switch sortType {
case "", SortAlbumsByName:
sort.Slice(albums, func(i, j int) bool {
if strings.EqualFold(albums[i].Name, albums[j].Name) {
return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
}
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
})
case SortAlbumsByArtist:
sort.Slice(albums, func(i, j int) bool {
if strings.EqualFold(albums[i].Artist, albums[j].Artist) {
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
}
return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
})
case SortAlbumsByYear:
sort.Slice(albums, func(i, j int) bool {
if albums[i].Year == albums[j].Year {
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
}
return albums[i].Year > albums[j].Year
})
default:
sortAlbums(albums, SortAlbumsByName)
}
}
func canonicalArtistID(id, name string) string {
if id != "" {
return id
}
if name == "" {
return ""
}
return "name:" + strings.ToLower(name)
}
func toTicks(d time.Duration) int64 {
if d <= 0 {
return 0
}
return d.Nanoseconds() / 100
}
+362
View File
@@ -0,0 +1,362 @@
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)
}
}
+191
View File
@@ -0,0 +1,191 @@
package emby
import (
"context"
"fmt"
"sync"
"time"
"cliamp/config"
"cliamp/playlist"
"cliamp/provider"
)
var (
_ provider.ArtistBrowser = (*Provider)(nil)
_ provider.AlbumBrowser = (*Provider)(nil)
_ provider.AlbumTrackLoader = (*Provider)(nil)
_ provider.PlaybackReporter = (*Provider)(nil)
_ provider.Searcher = (*Provider)(nil)
)
// Provider implements playlist.Provider for an Emby server.
// Playlists() returns albums across all music views.
// Tracks() returns the tracks for a given album item.
type Provider struct {
client *Client
mu sync.Mutex
playlistCache []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
}
func newProvider(client *Client) *Provider {
return &Provider{client: client}
}
// NewFromConfig returns a Provider from an EmbyConfig, or nil if URL or token is missing.
func NewFromConfig(cfg config.EmbyConfig) *Provider {
if !cfg.IsSet() {
return nil
}
return newProvider(NewClient(cfg.URL, cfg.Token, cfg.UserID, cfg.User, cfg.Password))
}
// Name returns the display name used in the provider selector.
func (p *Provider) Name() string { return "Emby" }
func (p *Provider) Artists() ([]provider.ArtistInfo, error) {
artists, err := p.client.Artists()
if err != nil {
return nil, fmt.Errorf("artists: %w", err)
}
return artists, nil
}
func (p *Provider) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
albums, err := p.client.ArtistAlbums(artistID)
if err != nil {
return nil, fmt.Errorf("artist albums: %w", err)
}
return albums, nil
}
func (p *Provider) AlbumList(sortType string, offset, size int) ([]provider.AlbumInfo, error) {
albums, err := p.client.AlbumList(sortType, offset, size)
if err != nil {
return nil, fmt.Errorf("album list: %w", err)
}
return albums, nil
}
func (p *Provider) AlbumSortTypes() []provider.SortType {
return p.client.AlbumSortTypes()
}
func (p *Provider) DefaultAlbumSort() string {
return p.client.DefaultAlbumSort()
}
func (p *Provider) AlbumTracks(albumID string) ([]playlist.Track, error) {
return p.Tracks(albumID)
}
func (p *Provider) CanReportPlayback(track playlist.Track) bool {
return track.Meta(provider.MetaEmbyID) != ""
}
func (p *Provider) ReportNowPlaying(track playlist.Track, position time.Duration, canSeek bool) {
_ = p.client.ReportNowPlaying(track, position, canSeek)
}
func (p *Provider) ReportScrobble(track playlist.Track, elapsed, _ time.Duration, canSeek bool) {
_ = p.client.ReportScrobble(track, elapsed, canSeek)
}
// Playlists returns all albums across all Emby music views.
// Results are cached after the first successful call.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
p.mu.Lock()
if p.playlistCache != nil {
cached := p.playlistCache
p.mu.Unlock()
return cached, nil
}
p.mu.Unlock()
albums, err := p.client.Albums()
if err != nil {
return nil, fmt.Errorf("playlists: %w", err)
}
out := make([]playlist.PlaylistInfo, 0, len(albums))
for _, a := range albums {
name := a.Name
if a.Artist != "" {
name = a.Artist + " — " + a.Name
}
if a.Year > 0 {
name = fmt.Sprintf("%s (%d)", name, a.Year)
}
out = append(out, playlist.PlaylistInfo{
ID: a.ID,
Name: name,
TrackCount: a.TrackCount,
})
}
p.mu.Lock()
p.playlistCache = out
p.mu.Unlock()
return out, nil
}
// SearchTracks searches the Emby music library for tracks matching query.
// Implements provider.Searcher.
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
embyTracks, err := p.client.Search(query, limit)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
return p.toPlaylistTracks(embyTracks), nil
}
// Tracks returns the tracks for one album item.
// Results are cached per album id.
func (p *Provider) Tracks(albumID string) ([]playlist.Track, error) {
p.mu.Lock()
if p.trackCache != nil {
if cached, ok := p.trackCache[albumID]; ok {
p.mu.Unlock()
return cached, nil
}
}
p.mu.Unlock()
embyTracks, err := p.client.Tracks(albumID)
if err != nil {
return nil, fmt.Errorf("tracks: %w", err)
}
out := p.toPlaylistTracks(embyTracks)
p.mu.Lock()
if p.trackCache == nil {
p.trackCache = make(map[string][]playlist.Track)
}
p.trackCache[albumID] = out
p.mu.Unlock()
return out, nil
}
// toPlaylistTracks converts Emby Tracks to playlist.Tracks, attaching the
// authenticated stream URL and Emby item ID metadata.
func (p *Provider) toPlaylistTracks(embyTracks []Track) []playlist.Track {
out := make([]playlist.Track, 0, len(embyTracks))
for _, t := range embyTracks {
out = append(out, playlist.Track{
Path: p.client.StreamURL(t.ID),
Title: t.Name,
Artist: t.Artist,
Album: t.Album,
Year: t.Year,
TrackNumber: t.TrackNumber,
DurationSecs: t.DurationSecs,
Stream: true,
ProviderMeta: map[string]string{provider.MetaEmbyID: t.ID},
})
}
return out
}
+107
View File
@@ -0,0 +1,107 @@
package emby
import (
"net/http"
"testing"
"cliamp/playlist"
"cliamp/provider"
)
func TestProviderName(t *testing.T) {
p := newProvider(NewClient("https://emby.example.com", "tok", "user-1", "", ""))
if p.Name() != "Emby" {
t.Fatalf("Name() = %q, want Emby", p.Name())
}
}
func TestProviderPlaylists(t *testing.T) {
p := newProvider(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":
return jsonResponse(`{"Items":[{"Id":"lib-1","Name":"Music","CollectionType":"music"}]}`), nil
case "/Items":
return jsonResponse(`{"Items":[{"Id":"album-1","Name":"Kind of Blue","AlbumArtist":"Miles Davis","ProductionYear":1959,"ChildCount":5}]}`), nil
default:
t.Fatalf("unexpected path %s", req.URL.Path)
return nil, nil
}
})
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists() error: %v", err)
}
if len(lists) != 1 {
t.Fatalf("expected 1 playlist, got %d", len(lists))
}
if lists[0].ID != "album-1" || lists[0].TrackCount != 5 {
t.Fatalf("playlist = %+v", lists[0])
}
if lists[0].Name != "Miles Davis — Kind of Blue (1959)" {
t.Fatalf("playlist name = %q", lists[0].Name)
}
}
func TestProviderTracks(t *testing.T) {
p := newProvider(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)
}
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 := p.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.Title != "So What" || tr.Artist != "Miles Davis" || tr.Album != "Kind of Blue" || tr.TrackNumber != 1 || !tr.Stream {
t.Fatalf("track = %+v", tr)
}
if got := tr.Meta(provider.MetaEmbyID); got != "track-1" {
t.Fatalf("track meta emby id = %q, want track-1", got)
}
}
func TestProviderCanReportPlayback(t *testing.T) {
p := newProvider(NewClient("https://emby.example.com", "tok", "user-1", "", ""))
tests := []struct {
name string
track playlist.Track
want bool
}{
{"emby track", trackWithMeta(provider.MetaEmbyID, "track-1"), true},
{"non-emby track", trackWithMeta(provider.MetaNavidromeID, "nav-1"), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := p.CanReportPlayback(tc.track); got != tc.want {
t.Fatalf("CanReportPlayback() = %v, want %v", got, tc.want)
}
})
}
}
func trackWithMeta(key, value string) playlist.Track {
return playlist.Track{ProviderMeta: map[string]string{key: value}}
}
+6 -1
View File
@@ -12,6 +12,7 @@ import (
"cliamp/applog"
"cliamp/config"
"cliamp/external/emby"
"cliamp/external/jellyfin"
"cliamp/external/local"
"cliamp/external/navidrome"
@@ -82,6 +83,10 @@ func run(overrides config.Overrides, positional []string) error {
providers = append(providers, model.ProviderEntry{Key: "jellyfin", Name: "Jellyfin", Provider: jellyProv})
}
if embyProv := emby.NewFromConfig(cfg.Emby); embyProv != nil {
providers = append(providers, model.ProviderEntry{Key: "emby", Name: "Emby", Provider: embyProv})
}
var spotifyProv *spotify.SpotifyProvider
if cfg.Spotify.IsSet() {
spotifyProv = spotify.New(nil, cfg.Spotify.ClientID, cfg.Spotify.Bitrate)
@@ -220,7 +225,7 @@ func run(overrides config.Overrides, positional []string) error {
}
p.RegisterBufferedURLMatcher(func(u string) bool {
return navidrome.IsSubsonicStreamURL(u) || jellyfin.IsStreamURL(u)
return navidrome.IsSubsonicStreamURL(u) || jellyfin.IsStreamURL(u) || emby.IsStreamURL(u)
})
cfg.ApplyPlayer(p)
+1
View File
@@ -33,4 +33,5 @@ type SortType struct {
const (
MetaNavidromeID = "navidrome.id"
MetaJellyfinID = "jellyfin.id"
MetaEmbyID = "emby.id"
)
+18 -9
View File
@@ -4,20 +4,20 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CLIAMP — Terminal Music Player</title>
<meta name="description" content="A retro terminal music player inspired by Winamp 2.x. Play local files, YouTube, Spotify, Plex, Jellyfin, Navidrome, SoundCloud, and 30,000+ radio stations with a spectrum visualizer and 10-band EQ.">
<meta name="description" content="A retro terminal music player inspired by Winamp 2.x. Play local files, YouTube, Spotify, Plex, Jellyfin, Emby, Navidrome, SoundCloud, and 30,000+ radio stations with a spectrum visualizer and 10-band EQ.">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="CLIAMP — Terminal Music Player">
<meta property="og:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Plex, Jellyfin, SoundCloud, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta property="og:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Plex, Jellyfin, Emby, SoundCloud, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta property="og:image" content="https://cliamp.stream/og-image.png">
<meta property="og:url" content="https://cliamp.stream">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="CLIAMP — Terminal Music Player">
<meta name="twitter:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Plex, Jellyfin, SoundCloud, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta name="twitter:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Plex, Jellyfin, Emby, SoundCloud, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta name="twitter:image" content="https://cliamp.stream/og-image.png">
<style>
@@ -1230,7 +1230,7 @@
<div class="hero-tagline">Terminal Music Player · <em>Winamp</em> for your shell</div>
<p class="hero-desc">
Playlists, EQ, visualizers, lyrics, remote control, and a Lua plugin system.
Streams from <em>Spotify</em>, YouTube Music, Plex, Jellyfin, Navidrome, and 30,000+ radio stations.
Streams from <em>Spotify</em>, YouTube Music, Plex, Jellyfin, Emby, Navidrome, and 30,000+ radio stations.
</p>
<!-- Terminal (cliamp TUI simulation) -->
@@ -1299,6 +1299,7 @@
<span class="t-pill-static">[Navidrome]</span>
<span class="t-pill-static">[Plex]</span>
<span class="t-pill-static">[Jellyfin]</span>
<span class="t-pill-static">[Emby]</span>
<span class="t-pill-static">[Spotify]</span>
<span class="t-pill-static">[YouTube]</span>
<span class="t-pill-static">[YT&nbsp;Music]</span>
@@ -1363,6 +1364,7 @@
<span class="marquee-item"><strong>YouTube</strong></span>
<span class="marquee-item"><strong>Plex</strong></span>
<span class="marquee-item"><strong>Jellyfin</strong></span>
<span class="marquee-item"><strong>Emby</strong></span>
<span class="marquee-item"><strong>Navidrome</strong></span>
<span class="marquee-item"><strong>SoundCloud</strong></span>
<span class="marquee-item"><strong>Bandcamp</strong></span>
@@ -1384,6 +1386,7 @@
<span class="marquee-item"><strong>YouTube</strong></span>
<span class="marquee-item"><strong>Plex</strong></span>
<span class="marquee-item"><strong>Jellyfin</strong></span>
<span class="marquee-item"><strong>Emby</strong></span>
<span class="marquee-item"><strong>Navidrome</strong></span>
<span class="marquee-item"><strong>SoundCloud</strong></span>
<span class="marquee-item"><strong>Bandcamp</strong></span>
@@ -1448,7 +1451,7 @@
<div class="next-step">
<div class="next-step-label">Next step · configure providers</div>
<h3>Run the setup wizard</h3>
<p>An interactive TUI walks you through Navidrome, Plex, Jellyfin, Spotify, and YouTube Music. It links to each provider's credential page, validates the connection, and writes the right block to your config file.</p>
<p>An interactive TUI walks you through Navidrome, Plex, Jellyfin, Emby, Spotify, and YouTube Music. It links to each provider's credential page, validates the connection, and writes the right block to your config file.</p>
<div class="install-box" onclick="copyCmd(this,'cliamp setup')">
<div class="install-platform">Setup</div>
<code><span class="prompt">$ </span>cliamp setup</code>
@@ -1467,7 +1470,7 @@
<span class="sh-line"></span>
</div>
<p class="sources-intro">
Stream from everywhere. Every provider runs through the same <em>playlist, EQ, visualizer, and lyrics pipeline</em> — your config follows you across services. Run <code>cliamp setup</code> for an interactive wizard that walks you through Navidrome, Plex, Jellyfin, Spotify, and YouTube Music.
Stream from everywhere. Every provider runs through the same <em>playlist, EQ, visualizer, and lyrics pipeline</em> — your config follows you across services. Run <code>cliamp setup</code> for an interactive wizard that walks you through Navidrome, Plex, Jellyfin, Emby, Spotify, and YouTube Music.
</p>
<div class="sources-grid">
<div class="source" style="--src-color:#1db954">
@@ -1495,6 +1498,11 @@
<div class="source-name">Jellyfin</div>
<div class="source-desc">Artists, albums, tracks — buffered gapless playback.</div>
</div>
<div class="source" style="--src-color:#52b54b">
<div class="source-badge">Media server</div>
<div class="source-name">Emby</div>
<div class="source-desc">Browse your Emby music library with API key or username/password auth.</div>
</div>
<div class="source" style="--src-color:#2ecc71">
<div class="source-badge">Subsonic API</div>
<div class="source-name">Navidrome</div>
@@ -1904,7 +1912,7 @@
<div class="keys-group-title">Search &amp; Browse</div>
<div class="keys-group-body">
<div class="key-row"><kbd>/</kbd><span>Search playlist</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search active provider (Spotify, Navidrome, Jellyfin, Plex, Local) or YouTube fallback</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search active provider (Spotify, Navidrome, Jellyfin, Emby, Plex, Local) or YouTube fallback</span></div>
<div class="key-row"><kbd>f</kbd><span>Toggle bookmark &#9733; / radio favorite</span></div>
<div class="key-row"><kbd>u</kbd><span>Load URL (stream / playlist)</span></div>
<div class="key-row"><kbd>o</kbd><span>Open file browser</span></div>
@@ -1935,7 +1943,7 @@
<div class="key-row"><kbd>/</kbd><span>Filter the visible playlists</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Online / server search via the provider's own search</span></div>
<div class="key-row"><kbd>Ctrl+R</kbd><span>Refresh — re-pull playlists from the provider</span></div>
<div class="key-row"><kbd>S</kbd> <kbd>N</kbd> <kbd>P</kbd> <kbd>J</kbd> <kbd>Y</kbd> <kbd>C</kbd> <kbd>L</kbd> <kbd>R</kbd><span>Switch to Spotify / Navidrome / Plex / Jellyfin / YouTube / SoundCloud / Local / Radio</span></div>
<div class="key-row"><kbd>S</kbd> <kbd>N</kbd> <kbd>P</kbd> <kbd>J</kbd> <kbd>Y</kbd> <kbd>C</kbd> <kbd>L</kbd> <kbd>R</kbd><span>Switch to Spotify / Navidrome / Plex / Jellyfin / Emby/ YouTube / SoundCloud / Local / Radio</span></div>
<div class="key-row"><kbd></kbd><span>Marker on the row whose tracks are currently loaded</span></div>
</div>
</div>
@@ -1951,7 +1959,7 @@
<div class="key-row"><kbd>q</kbd><span>Queue the highlighted track to play next</span></div>
<div class="key-row"><kbd>s</kbd><span>Cycle album sort (album list only)</span></div>
<div class="key-row"><kbd>/</kbd><span>Filter the visible list (search bar appears under the title)</span></div>
<div class="key-row"><kbd>S</kbd> <kbd>N</kbd> <kbd>P</kbd> <kbd>J</kbd> <kbd>Y</kbd> <kbd>L</kbd> <kbd>R</kbd><span>Quick-switch to another provider without going back to the main pane</span></div>
<div class="key-row"><kbd>S</kbd> <kbd>N</kbd> <kbd>P</kbd> <kbd>J</kbd> <kbd>E</kbd> <kbd>Y</kbd> <kbd>L</kbd> <kbd>R</kbd><span>Quick-switch to another provider without going back to the main pane</span></div>
<div class="key-row"><kbd>Esc</kbd><span>Walk back one level / close the browser</span></div>
</div>
</div>
@@ -1974,6 +1982,7 @@
<div class="key-row"><kbd>S</kbd><span>Spotify</span></div>
<div class="key-row"><kbd>P</kbd><span>Plex</span></div>
<div class="key-row"><kbd>J</kbd><span>Jellyfin</span></div>
<div class="key-row"><kbd>E</kbd><span>Emby</span></div>
<div class="key-row"><kbd>Y</kbd><span>YouTube</span></div>
<div class="key-row"><kbd>R</kbd><span>Radio</span></div>
<div class="key-row"><kbd>e / t / v</kbd><span>EQ preset / Theme / Visualizer</span></div>
+2 -1
View File
@@ -53,6 +53,7 @@ var keymapEntries = []keymapEntry{
{key: "Y", action: "Open YouTube provider"},
{key: "C", action: "Open SoundCloud provider"},
{key: "J", action: "Open Jellyfin provider"},
{key: "E", action: "Open Emby provider"},
{key: "Ctrl+J", action: "Jump to time"},
{key: "p", action: "Playlist manager"},
{key: "i", action: "Track info / metadata"},
@@ -101,7 +102,7 @@ var coreReservedKeys = []string{
// Features.
"r", "z", "m", "e", "a", "A",
"ctrl+s", "S", "/", "ctrl+f",
"ctrl+j", "J", "p", "t", "i", "y", "o", "u",
"ctrl+j", "J", "E", "p", "t", "i", "y", "o", "u",
"N", "L", "R", "P", "Y", "C",
"v", "V", "ctrl+x", "d", "ctrl+k",
"ctrl+r",
+4
View File
@@ -347,6 +347,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
m.openJumpMode()
case "J":
return m.switchToProvider("jellyfin")
case "E":
return m.switchToProvider("emby")
case "S":
return m.switchToProvider("spotify")
case "C":
@@ -670,6 +672,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
m.openJumpMode()
case "J":
return m.switchToProvider("jellyfin")
case "E":
return m.switchToProvider("emby")
case "p":
if m.localProvider != nil {
m.openPlaylistManager()
+2
View File
@@ -73,6 +73,8 @@ func providerKeyForShortcut(key string) string {
return "plex"
case "J":
return "jellyfin"
case "E":
return "emby"
case "Y":
return "yt"
case "L":
+1
View File
@@ -37,6 +37,7 @@ var providerEmptyStateHint = map[string]string{
"spotify": "Sign in via Spotify, or check SPOTIFY_REFRESH_TOKEN.",
"navidrome": "Verify [navidrome] url/username/password in config.toml.",
"jellyfin": "Verify [jellyfin] url and token in config.toml.",
"emby": "Verify [emby] url and token or username/password in config.toml.",
"plex": "Verify [plex] server URL and token in config.toml.",
"youtube music": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"ytmusic": "Run `cliamp ytmusic-login` to authorize, then refresh.",