542 lines
17 KiB
Go
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)
|
|
}
|
|
}
|