Files
wehub-resource-sync ead81af521
CI / go (push) Waiting to run
CI / build (darwin, macos-14) (push) Waiting to run
CI / build (windows, windows-2025) (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

542 lines
17 KiB
Go

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)
}
}