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:
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.25–2.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
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
Vendored
+733
@@ -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
|
||||
}
|
||||
Vendored
+362
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+191
@@ -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
|
||||
}
|
||||
Vendored
+107
@@ -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}}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -33,4 +33,5 @@ type SortType struct {
|
||||
const (
|
||||
MetaNavidromeID = "navidrome.id"
|
||||
MetaJellyfinID = "jellyfin.id"
|
||||
MetaEmbyID = "emby.id"
|
||||
)
|
||||
|
||||
+18
-9
@@ -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 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 & 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 ★ / 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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user