Files
bjarneo--cliamp/main.go
T
Luguin 49c2cc7983 Enable Spotify support on Windows (#323)
go-librespot gained first-party WASAPI output for Windows in v0.9.0
(cliamp was pinned to v0.7.1, which doesn't compile on Windows). Bump
the dependency and remove the Windows CGO stub so the real provider
builds on all platforms.

CI now installs a MinGW toolchain via MSYS2 and builds/tests with
CGO_ENABLED=1 on windows-2025, including a workaround for an MSYS2
libogg packaging issue where libogg-0.dll's export table is missing
ogg_stream_iovecin even though it's present in the static libogg.a.

Verified locally end-to-end on Windows: native CGO build, full test
suite, and real Spotify Premium playback.

Fixes #299

Not in scope for this PR: release.yml still builds Windows with
CGO_ENABLED=0, so Releases binaries won't include Spotify until that
pipeline is updated separately (needs a packaging decision: bundle the
MSYS2 DLLs or pursue a fully static build).
2026-08-20 17:57:01 +02:00

575 lines
19 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"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"
"github.com/bjarneo/cliamp/external/navidrome"
"github.com/bjarneo/cliamp/external/netease"
"github.com/bjarneo/cliamp/external/plex"
"github.com/bjarneo/cliamp/external/qobuz"
"github.com/bjarneo/cliamp/external/radio"
"github.com/bjarneo/cliamp/external/radiometa"
"github.com/bjarneo/cliamp/external/soundcloud"
"github.com/bjarneo/cliamp/external/spotify"
"github.com/bjarneo/cliamp/external/ytmusic"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/appmeta"
"github.com/bjarneo/cliamp/internal/playback"
"github.com/bjarneo/cliamp/internal/resume"
"github.com/bjarneo/cliamp/ipc"
"github.com/bjarneo/cliamp/luaplugin"
"github.com/bjarneo/cliamp/mediactl"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/resolve"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
"github.com/bjarneo/cliamp/ui/model"
)
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version string
const (
defaultUIFPS = 20
lowPowerUIFPS = 5
)
func run(overrides config.Overrides, positional []string, daemon bool) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("config: %w", err)
}
overrides.Apply(&cfg)
closeLog, appliedLevel, logErr := initLogging(cfg.LogLevel)
defer closeLog()
if logErr != nil {
fmt.Fprintf(os.Stderr, "logging: %v (continuing without file log)\n", logErr)
applog.Status("logging: %v", logErr)
} else {
applog.Info("cliamp starting (version=%s level=%s)", appmeta.Version(), appliedLevel)
}
// Build provider list: Radio is always available, Navidrome and Spotify if configured.
radioProv := radio.New()
localProv := local.New()
var providers []model.ProviderEntry
providers = append(providers, model.ProviderEntry{Key: "radio", Name: "Radio", Provider: radioProv})
if localProv != nil {
providers = append(providers, model.ProviderEntry{Key: "local", Name: "Local", Provider: localProv})
}
var navClient *navidrome.NavidromeClient
if c := navidrome.NewFromConfig(cfg.Navidrome); c != nil {
navClient = c
} else if c := navidrome.NewFromEnv(); c != nil {
navClient = c
}
if navClient != nil {
providers = append(providers, model.ProviderEntry{Key: "navidrome", Name: "Navidrome", Provider: navClient})
}
if plexProv := plex.NewFromConfig(cfg.Plex); plexProv != nil {
providers = append(providers, model.ProviderEntry{Key: "plex", Name: "Plex", Provider: plexProv})
}
if jellyProv := jellyfin.NewFromConfig(cfg.Jellyfin); jellyProv != nil {
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})
}
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)
spotifyProv = spotify.New(nil, clientID, cfg.Spotify.Bitrate)
providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
}
var qobuzProv *qobuz.QobuzProvider
if cfg.Qobuz.IsSet() {
qobuzProv = qobuz.New(cfg.Qobuz.Quality)
providers = append(providers, model.ProviderEntry{Key: "qobuz", Name: "Qobuz", Provider: qobuzProv})
}
if scProv := soundcloud.NewFromConfig(soundcloud.Config{
Enabled: cfg.SoundCloud.Enabled,
User: cfg.SoundCloud.User,
CookiesFrom: cfg.SoundCloud.CookiesFrom,
}); scProv != nil {
// Provider constructors configure resolve-side yt-dlp cookies. Mirror
// cookies_from onto the player so streaming yt-dlp invocations use the
// same browser session. Last write wins when multiple providers set it.
if cfg.SoundCloud.CookiesFrom != "" {
player.SetYTDLCookiesFrom(cfg.SoundCloud.CookiesFrom)
}
providers = append(providers, model.ProviderEntry{Key: "soundcloud", Name: "SoundCloud", Provider: scProv})
}
if neProv := netease.NewFromConfig(netease.Config{
Enabled: cfg.NetEase.Enabled,
CookiesFrom: cfg.NetEase.CookiesFrom,
UserID: cfg.NetEase.UserID,
}); neProv != nil {
if cfg.NetEase.CookiesFrom != "" {
player.SetYTDLCookiesFrom(cfg.NetEase.CookiesFrom)
}
providers = append(providers, model.ProviderEntry{Key: "netease", Name: "NetEase", Provider: neProv})
}
var ytProviders ytmusic.Providers
ytWanted := cfg.YouTubeMusic.IsSetOrFallback(ytmusic.FallbackCredentials)
if !ytWanted {
switch cfg.Provider {
case "yt", "youtube", "ytmusic":
ytWanted = true
}
}
if ytWanted {
ytClientID, ytClientSecret := cfg.YouTubeMusic.ResolveCredentials(ytmusic.FallbackCredentials)
if cfg.YouTubeMusic.CookiesFrom != "" {
player.SetYTDLCookiesFrom(cfg.YouTubeMusic.CookiesFrom)
}
if ytClientID == "" || ytClientSecret == "" {
fmt.Fprintf(os.Stderr, "YouTube: no credentials available (configure client_id/client_secret in config.toml)\n")
} else {
if !player.YTDLPAvailable() {
fmt.Fprintf(os.Stderr, "\nYouTube requires yt-dlp for audio playback.\n")
fmt.Fprintf(os.Stderr, "Install command: %s\n\n", player.YtdlpInstallHint())
fmt.Fprintf(os.Stderr, "Press Enter to install automatically, or Ctrl+C to skip... ")
fmt.Scanln()
fmt.Fprintf(os.Stderr, "Installing yt-dlp...\n")
if err := player.InstallYTDLP(); err != nil {
fmt.Fprintf(os.Stderr, "Installation failed: %v\n", err)
fmt.Fprintf(os.Stderr, "YouTube providers disabled. Install manually and restart.\n\n")
} else {
fmt.Fprintf(os.Stderr, "yt-dlp installed successfully!\n\n")
}
}
if player.YTDLPAvailable() {
ytProviders = ytmusic.New(nil, ytClientID, ytClientSecret, cfg.YouTubeMusic.CookiesFrom != "")
providers = append(providers,
model.ProviderEntry{Key: "yt", Name: "YouTube (All)", Provider: ytProviders.All},
model.ProviderEntry{Key: "youtube", Name: "YouTube", Provider: ytProviders.Video},
model.ProviderEntry{Key: "ytmusic", Name: "YouTube Music", Provider: ytProviders.Music},
)
}
}
}
if spotifyProv != nil {
defer spotifyProv.Close()
}
if qobuzProv != nil {
defer qobuzProv.Close()
}
if ytProviders.Music != nil {
defer ytProviders.Music.Close()
}
if len(positional) > 0 && (positional[0] == "search" || positional[0] == "search-sc") {
if len(positional) == 1 {
return fmt.Errorf("search requires a query string (e.g. cliamp search \"never gonna give you up\")")
}
prefix := "ytsearch1:"
if positional[0] == "search-sc" {
prefix = "scsearch1:"
}
query := strings.Join(positional[1:], " ")
positional = []string{prefix + query}
}
if cfg.YouTubeMusic.ExpandPlaylist != nil {
resolve.ExpandYTPlaylist = *cfg.YouTubeMusic.ExpandPlaylist
}
resolved, err := resolve.Args(positional)
if err != nil {
return err
}
defaultProvider := cfg.Provider
if defaultProvider == "" {
defaultProvider = "radio"
}
defaultRadio := len(positional) == 0 && defaultProvider == "radio"
pl := playlist.New()
if cfg.Playlist != "" && localProv != nil {
tracks, err := localProv.Tracks(cfg.Playlist)
if err != nil {
return fmt.Errorf("playlist %q: %w", cfg.Playlist, err)
}
pl.Add(tracks...)
} else if defaultRadio {
pl.Add(
playlist.Track{Path: "http://radio.cliamp.stream/lofi/stream", Title: "Lofi Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/synthwave/stream", Title: "Synthwave Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/edm/stream", Title: "EDM Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs/stream", Title: "NCS Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-house/stream", Title: "NCS House Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-dubstep/stream", Title: "NCS Dubstep Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-dnb/stream", Title: "NCS Drum & Bass Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-trap/stream", Title: "NCS Trap Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-phonk/stream", Title: "NCS Phonk Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-pop/stream", Title: "NCS Pop Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-chill/stream", Title: "NCS Chill Stream", Stream: true, Realtime: true},
)
}
pl.Add(resolved.Tracks...)
// Daemon mode has no UI loop to drain pending URLs (feeds, M3U, yt-dlp),
// so resolve them synchronously here. The TUI path does this in the
// background via m.SetPendingURLs.
if daemon && len(resolved.Pending) > 0 {
fmt.Fprintf(os.Stderr, "cliamp: resolving %d remote URL(s)...\n", len(resolved.Pending))
remote, err := resolve.Remote(resolved.Pending)
if err != nil {
return fmt.Errorf("resolve remote: %w", err)
}
pl.Add(remote...)
}
if cfg.AudioDevice != "" {
cleanup := player.PrepareAudioDevice(cfg.AudioDevice)
defer cleanup()
}
sampleRate := cfg.SampleRate
if sampleRate == 0 {
if detected := player.DeviceSampleRate(); detected > 0 {
sampleRate = detected
} else {
sampleRate = 44100
}
}
p, err := player.New(player.Quality{
SampleRate: sampleRate,
BufferMs: cfg.BufferMs,
ResampleQuality: cfg.ResampleQuality,
BitDepth: cfg.BitDepth,
})
if err != nil {
return fmt.Errorf("player: %w", err)
}
defer p.Close()
if spotifyProv != nil {
p.RegisterStreamerFactory("spotify:", spotifyProv.NewStreamer)
}
p.RegisterBufferedURLMatcher(func(u string) bool {
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).
p.RegisterStreamMetadataResolver(radiometa.Resolver)
cfg.ApplyPlayer(p)
cfg.ApplyPlaylist(pl)
ui.SetPadding(cfg.PaddingH, cfg.PaddingV)
if daemon {
if cfg.EQPreset != "" && cfg.EQPreset != "Custom" {
if preset, ok := model.EQPresetByName(cfg.EQPreset); ok {
for i, gain := range preset.Bands {
p.SetEQBand(i, gain)
}
}
}
return runDaemon(p, pl, localProv, providers, cfg.AutoPlay, cfg.EQPreset)
}
themes := theme.LoadAll()
pluginBroker := ipc.NewBroker()
defer pluginBroker.Close()
luaMgr, luaErr := luaplugin.New(cfg.Plugins, pluginBroker)
if luaErr != nil {
fmt.Fprintf(os.Stderr, "lua plugins: %v\n", luaErr)
}
if luaMgr != nil {
luaMgr.SetReservedKeys(model.ReservedKeys())
defer luaMgr.Close()
}
m := model.New(p, pl, providers, defaultProvider, localProv, themes, luaMgr, config.SaveFunc{})
m.SetCustomEQBands(cfg.EQ)
m.SetVisVolumeLinked(cfg.VisVolumeLinked)
if luaMgr != nil {
luaMgr.SetStateProvider(luaplugin.StateProvider{
PlayerState: func() string {
if !p.IsPlaying() {
return "stopped"
}
if p.IsPaused() {
return "paused"
}
return "playing"
},
Position: func() float64 { return p.Position().Seconds() },
Duration: func() float64 { return p.Duration().Seconds() },
Volume: func() float64 { return p.Volume() },
Speed: func() float64 { return p.Speed() },
Mono: func() bool { return p.Mono() },
RepeatMode: func() string { return pl.Repeat().String() },
Shuffle: func() bool { return pl.Shuffled() },
EQBands: func() [10]float64 { return p.EQBands() },
TrackTitle: func() string { t, _ := pl.Current(); return t.Title },
TrackArtist: func() string { t, _ := pl.Current(); return t.Artist },
TrackAlbum: func() string { t, _ := pl.Current(); return t.Album },
TrackGenre: func() string { t, _ := pl.Current(); return t.Genre },
TrackYear: func() int { t, _ := pl.Current(); return t.Year },
TrackNumber: func() int { t, _ := pl.Current(); return t.TrackNumber },
TrackPath: func() string { t, _ := pl.Current(); return t.Path },
TrackIsStream: func() bool { t, _ := pl.Current(); return t.Stream },
TrackDuration: func() int { t, _ := pl.Current(); return t.DurationSecs },
PlaylistCount: func() int { return pl.Len() },
CurrentIndex: func() int { return pl.Index() },
QueueList: func() []luaplugin.QueueEntry {
tracks := pl.Tracks()
out := make([]luaplugin.QueueEntry, len(tracks))
for i, t := range tracks {
out[i] = luaplugin.QueueEntry{
Title: t.Title,
Artist: t.Artist,
Album: t.Album,
Path: t.Path,
Index: i,
Queued: pl.QueuePosition(i) >= 0,
}
}
return out
},
})
}
if luaMgr != nil {
if names := luaMgr.Visualizers(); len(names) > 0 {
m.RegisterLuaVisualizers(names, luaMgr.RenderVis)
}
}
m.SetSeekStepLarge(cfg.SeekStepLargeDuration())
m.SetInitialDirectory(cfg.InitialDirectory)
m.SetPendingURLs(resolved.Pending)
if cfg.Playlist != "" && len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 {
m.SetLoadedPlaylist(cfg.Playlist)
}
if len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 && pl.Len() == 0 {
m.StartInProvider()
}
if cfg.EQPreset != "" && cfg.EQPreset != "Custom" {
m.SetEQPreset(cfg.EQPreset, nil)
}
if cfg.Theme != "" {
m.SetTheme(cfg.Theme)
}
if cfg.Visualizer != "" {
m.SetVisualizer(cfg.Visualizer)
}
if cfg.AutoPlay {
m.SetAutoPlay(true)
}
if cfg.LowPower {
m.SetLowPower(true)
}
if cfg.Compact {
m.SetCompact(true)
}
if !defaultRadio && len(positional) > 0 {
if rs := resume.Load(); rs.Path != "" && rs.PositionSec > 0 {
m.SetResume(rs.Path, rs.PositionSec)
}
}
progOpts := []tea.ProgramOption{tea.WithFPS(defaultUIFPS)}
if cfg.LowPower {
progOpts[0] = tea.WithFPS(lowPowerUIFPS)
}
prog := tea.NewProgram(m, progOpts...)
if spotifyProv != nil {
spotify.SetAuthURLObserver(func(u string) {
prog.Send(model.ProvAuthURLMsg{ProviderName: spotifyProv.Name(), URL: u})
})
defer spotify.SetAuthURLObserver(nil)
}
if qobuzProv != nil {
qobuz.SetAuthURLObserver(func(u string) {
prog.Send(model.ProvAuthURLMsg{ProviderName: qobuzProv.Name(), URL: u})
})
defer qobuz.SetAuthURLObserver(nil)
}
svc, svcErr := wireMediaCtl(prog)
if svcErr != nil {
applog.Warn("media control (MPRIS/NowPlaying) unavailable: %v", svcErr)
} else if svc != nil {
defer svc.Close()
}
if luaMgr != nil {
luaMgr.SetControlProvider(luaplugin.ControlProvider{
SetVolume: func(db float64) { p.SetVolume(db) },
SetSpeed: func(ratio float64) { p.SetSpeed(ratio) },
SetEQBand: func(band int, db float64) { prog.Send(model.SetEQBandMsg{Band: band, Gain: db}) },
ToggleMono: func() { p.ToggleMono() },
TogglePause: func() { p.TogglePause() },
Stop: func() { p.Stop() },
Seek: func(secs float64) {
_ = p.Seek(time.Duration(secs * float64(time.Second)))
},
SetEQPreset: func(name string, bands *[10]float64) {
prog.Send(model.SetEQPresetMsg{Name: name, Bands: bands})
},
Next: func() { prog.Send(playback.NextMsg{}) },
Prev: func() { prog.Send(playback.PrevMsg{}) },
QueueAdd: func(path string) {
prog.Send(model.PluginQueueMsg{Op: "add", Path: path})
},
QueueJump: func(index int) {
prog.Send(model.PluginQueueMsg{Op: "jump", Index: index})
},
QueueRemove: func(index int) {
prog.Send(model.PluginQueueMsg{Op: "remove", Index: index})
},
QueueMove: func(from, to int) {
prog.Send(model.PluginQueueMsg{Op: "move", Index: from, To: to})
},
})
luaMgr.SetUIProvider(luaplugin.UIProvider{
ShowMessage: func(text string, duration time.Duration) {
prog.Send(model.ShowStatusMsg{Text: text, Duration: duration})
},
})
}
ipcSrv, ipcErr := ipc.NewServerWithBroker(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) }), pluginBroker)
if ipcErr != nil {
fmt.Fprintf(os.Stderr, "ipc: %v\n", ipcErr)
} else {
defer ipcSrv.Close()
if luaMgr != nil {
ipcSrv.SetPluginDispatcher(luaMgr)
}
}
finalModel, err := mediactl.Run(prog, svc)
if err != nil {
return err
}
if fm, ok := finalModel.(model.Model); ok {
themeName := fm.ThemeName()
if themeName == theme.DefaultName {
themeName = ""
}
_ = config.Save("theme", fmt.Sprintf("%q", themeName))
if path, secs, pl := fm.ResumeState(); path != "" && secs > 0 {
resume.Save(path, secs, pl)
}
}
return nil
}
// initLogging always returns a non-nil close func so the caller can defer
// it unconditionally, plus the applied level as a string for diagnostics.
// Errors come back as the third return value; the close func is a no-op
// and the level string is empty in that case.
func initLogging(levelStr string) (func() error, string, error) {
noop := func() error { return nil }
level, err := applog.ParseLevel(levelStr)
if err != nil {
return noop, "", err
}
dir, err := appdir.Dir()
if err != nil {
return noop, "", fmt.Errorf("resolve config dir: %w", err)
}
closeFn, err := applog.Init(filepath.Join(dir, "cliamp.log"), level)
if err != nil {
return noop, "", err
}
return closeFn, level.String(), nil
}
func wireMediaCtl(prog *tea.Program) (*mediactl.Service, error) {
svc, err := mediactl.New(prog.Send)
if err != nil || svc == nil {
return svc, err
}
go prog.Send(model.AttachNotifier(svc))
return svc, nil
}
// userIPCError renders ipc.ErrNotRunning as the wording users see. The ipc
// package returns a bare sentinel, so all CLI copy stays in the command layer.
func userIPCError(err error) error {
if errors.Is(err, ipc.ErrNotRunning) {
return fmt.Errorf("cliamp is not running (no socket at %s)", ipc.DefaultSocketPath())
}
return err
}
func ipcSend(req ipc.Request) (ipc.Response, error) {
resp, err := ipc.Send(ipc.DefaultSocketPath(), req)
if err != nil {
return resp, userIPCError(err)
}
if !resp.OK {
return resp, fmt.Errorf("%s", resp.Error)
}
return resp, nil
}
// ipcSendLong is like ipcSend with a caller-chosen deadline, for plugin
// commands that can legitimately run for minutes (e.g. yt-dlp downloads).
func ipcSendLong(req ipc.Request, deadline time.Duration) (ipc.Response, error) {
resp, err := ipc.SendWithDeadline(ipc.DefaultSocketPath(), req, deadline)
if err != nil {
return resp, userIPCError(err)
}
if !resp.OK {
return resp, fmt.Errorf("%s", resp.Error)
}
return resp, nil
}
func main() {
appmeta.SetVersion(version)
app := buildApp()
if err := app.Run(context.Background(), os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}