package main import ( "context" "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/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}) } var spotifyProv *spotify.SpotifyProvider if cfg.Spotify.IsSet() { clientID := cfg.Spotify.ResolveClientID(spotify.DefaultClientID) spotifyProv = spotify.New(nil, clientID, cfg.Spotify.Bitrate) if spotifyProv != nil { providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv}) } else { fmt.Fprintln(os.Stderr, "Spotify is unavailable in this Windows build.") } } 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} } 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}, playlist.Track{Path: "http://radio.cliamp.stream/synthwave/stream", Title: "Synthwave Stream", Stream: true}, playlist.Track{Path: "http://radio.cliamp.stream/edm/stream", Title: "EDM Stream", Stream: 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) }) // 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 { return runDaemon(p, pl, localProv, cfg.AutoPlay) } themes := theme.LoadAll() luaMgr, luaErr := luaplugin.New(cfg.Plugins) if luaErr != nil { fmt.Fprintf(os.Stderr, "lua plugins: %v\n", luaErr) } if luaMgr != nil { defer luaMgr.Close() luaMgr.SetReservedKeys(model.ReservedKeys()) } m := model.New(p, pl, providers, defaultProvider, localProv, themes, luaMgr, config.SaveFunc{}) 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{URL: u}) }) defer spotify.SetAuthURLObserver(nil) } if qobuzProv != nil { qobuz.SetAuthURLObserver(func(u string) { prog.Send(model.ProvAuthURLMsg{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) { p.SetEQBand(band, 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.NewServer(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) })) 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 } func ipcSend(req ipc.Request) (ipc.Response, error) { resp, err := ipc.Send(ipc.DefaultSocketPath(), req) if err != nil { return resp, 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, 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) } }