feat: register the audiobookshelf provider

This commit is contained in:
coryshaw1
2026-08-17 11:02:26 -04:00
parent 872afc4803
commit 8979c60863
8 changed files with 94 additions and 6 deletions
+44
View File
@@ -25,6 +25,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/external/audiobookshelf"
"github.com/bjarneo/cliamp/external/emby"
"github.com/bjarneo/cliamp/external/jellyfin"
"github.com/bjarneo/cliamp/external/navidrome"
@@ -89,6 +90,7 @@ type pickerOption struct {
const (
keyJellyfinAuth = "_auth"
keyEmbyAuth = "_emby_auth"
keyABSAuth = "_abs_auth"
keyNetEaseBrowser = "_netease_browser"
keyYTMusicMode = "_mode"
keySpotifyMode = "_spotify_mode"
@@ -233,6 +235,48 @@ func providers() []providerSpec {
return strings.Join(lines, "\n")
},
},
{
key: "audiobookshelf",
name: "Audiobookshelf",
section: "audiobookshelf",
intro: []string{
"Audiobooks and podcasts from your own Audiobookshelf server.",
"Authenticate with an API key (Settings → Users → API Keys)",
"or with your username and password.",
},
picker: &pickerSpec{
key: keyABSAuth,
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://abs.example.com", required: true},
{key: "token", label: "API key", required: true, secret: true,
onlyIf: func(v map[string]string) bool { return v[keyABSAuth] == "token" }},
{key: "user", label: "Username", required: true,
onlyIf: func(v map[string]string) bool { return v[keyABSAuth] == "password" }},
{key: "password", label: "Password", required: true, secret: true,
onlyIf: func(v map[string]string) bool { return v[keyABSAuth] == "password" }},
},
validate: func(v map[string]string) error {
return audiobookshelf.NewClient(v["url"], v["token"], v["user"], v["password"], nil).Ping()
},
body: func(v map[string]string) string {
lines := []string{fmt.Sprintf("url = %q", v["url"])}
if v[keyABSAuth] == "token" {
lines = append(lines, fmt.Sprintf("token = %q", v["token"]))
} 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)",
+8 -5
View File
@@ -33,7 +33,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, emby, spotify, qobuz, soundcloud, netease, yt, youtube, ytmusic"},
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, emby, spotify, qobuz, soundcloud, netease, audiobookshelf, abs, 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"},
@@ -154,11 +154,14 @@ func overridesFromFlags(c *cli.Command) (config.Overrides, error) {
}
if c.IsSet("provider") {
v := strings.ToLower(c.String("provider"))
if v == "abs" {
v = "audiobookshelf"
}
switch v {
case "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "netease", "yt", "youtube", "ytmusic":
case "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "audiobookshelf", "soundcloud", "netease", "yt", "youtube", "ytmusic":
ov.Provider = &v
default:
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, qobuz, plex, jellyfin, emby, soundcloud, netease, yt, youtube, or ytmusic (got %q)", v)
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, qobuz, plex, jellyfin, emby, audiobookshelf, soundcloud, netease, yt, youtube, or ytmusic (got %q)", v)
}
}
if c.IsSet("start-theme") {
@@ -331,8 +334,8 @@ func setupCommand() *cli.Command {
Name: "setup",
Usage: "interactive wizard to configure remote providers",
Description: "Walks through configuring Navidrome, Plex, Jellyfin, Spotify,\n" +
"Qobuz, NetEase, and YouTube Music. Validates connections and writes\n" +
"~/.config/cliamp/config.toml.",
"Qobuz, NetEase, Audiobookshelf, and YouTube Music. Validates connections\n" +
"and writes ~/.config/cliamp/config.toml.",
Action: func(ctx context.Context, c *cli.Command) error {
return cmd.Setup()
},
+6 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/bjarneo/cliamp/applog"
"github.com/bjarneo/cliamp/config"
"github.com/bjarneo/cliamp/external/audiobookshelf"
"github.com/bjarneo/cliamp/external/emby"
"github.com/bjarneo/cliamp/external/jellyfin"
"github.com/bjarneo/cliamp/external/local"
@@ -95,6 +96,10 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
providers = append(providers, model.ProviderEntry{Key: "emby", Name: "Emby", Provider: embyProv})
}
if absProv := audiobookshelf.NewFromConfig(cfg.Audiobookshelf); absProv != nil {
providers = append(providers, model.ProviderEntry{Key: "audiobookshelf", Name: "Audiobookshelf", Provider: absProv})
}
var spotifyProv *spotify.SpotifyProvider
if cfg.Spotify.IsSet() {
clientID := cfg.Spotify.ResolveClientID(spotify.DefaultClientID)
@@ -280,7 +285,7 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
}
p.RegisterBufferedURLMatcher(func(u string) bool {
return navidrome.IsSubsonicStreamURL(u) || jellyfin.IsStreamURL(u) || emby.IsStreamURL(u) || plex.IsStreamURL(u) || qobuz.IsStreamURL(u)
return navidrome.IsSubsonicStreamURL(u) || jellyfin.IsStreamURL(u) || emby.IsStreamURL(u) || plex.IsStreamURL(u) || qobuz.IsStreamURL(u) || audiobookshelf.IsStreamURL(u)
})
// Pull now-playing for stations that carry no inline ICY metadata (NTS, FIP).
+28
View File
@@ -0,0 +1,28 @@
package model
import "testing"
func TestProviderKeyForShortcutB(t *testing.T) {
if got := providerKeyForShortcut("B"); got != "audiobookshelf" {
t.Fatalf("providerKeyForShortcut(\"B\") = %q, want audiobookshelf", got)
}
}
func TestAudiobookshelfEmptyStateHint(t *testing.T) {
hint, ok := providerEmptyStateHint["audiobookshelf"]
if !ok {
t.Fatal("providerEmptyStateHint has no audiobookshelf entry")
}
if hint == "" {
t.Fatal("audiobookshelf hint is empty")
}
}
func TestAudiobookshelfCommandRegistryEntry(t *testing.T) {
for _, c := range commandRegistry {
if c.Mode == commandModeMain && len(c.Keys) == 1 && c.Keys[0] == "B" {
return
}
}
t.Fatal("commandRegistry has no main-mode B entry")
}
+1
View File
@@ -109,6 +109,7 @@ var commandRegistry = []commandSpec{
{Mode: commandModeMain, Keys: []string{"M"}, KeyLabel: "M", Label: "Open NetEase provider", Keymap: true},
{Mode: commandModeMain, Keys: []string{"J"}, KeyLabel: "J", Label: "Open Jellyfin provider", Keymap: true},
{Mode: commandModeMain, Keys: []string{"E"}, KeyLabel: "E", Label: "Open Emby provider", Keymap: true},
{Mode: commandModeMain, Keys: []string{"B"}, KeyLabel: "B", Label: "Open Audiobookshelf provider", Keymap: true},
{Mode: commandModeMain, Keys: []string{"Q"}, KeyLabel: "Q", Label: "Open Qobuz provider", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+j"}, KeyLabel: "Ctrl+J", Label: "Jump to time", Keymap: true},
{Mode: commandModeMain, Keys: []string{"p"}, KeyLabel: "p", Label: "Playlist manager", Keymap: true},
+4
View File
@@ -394,6 +394,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.switchToProvider("jellyfin")
case "E":
return m.switchToProvider("emby")
case "B":
return m.switchToProvider("audiobookshelf")
case "S":
return m.switchToProvider("spotify")
case "P":
@@ -731,6 +733,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.switchToProvider("jellyfin")
case "E":
return m.switchToProvider("emby")
case "B":
return m.switchToProvider("audiobookshelf")
case "p":
if m.localProvider != nil {
m.openPlaylistManager()
+2
View File
@@ -139,6 +139,8 @@ func providerKeyForShortcut(key string) string {
return "jellyfin"
case "E":
return "emby"
case "B":
return "audiobookshelf"
case "Y":
return "yt"
case "C":
+1
View File
@@ -36,6 +36,7 @@ var providerEmptyStateHint = map[string]string{
"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.",
"audiobookshelf": "Verify [audiobookshelf] url and token or username/password in config.toml.",
"plex": "Verify [plex] server URL and token or library filter in config.toml.",
"youtube music": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"ytmusic": "Run `cliamp ytmusic-login` to authorize, then refresh.",