49c2cc7983
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).
575 lines
19 KiB
Go
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)
|
|
}
|
|
}
|