package main import ( "context" "encoding/json" "fmt" "os" "strconv" "strings" "time" cli "github.com/urfave/cli/v3" "github.com/bjarneo/cliamp/applog" "github.com/bjarneo/cliamp/cmd" "github.com/bjarneo/cliamp/config" "github.com/bjarneo/cliamp/external/qobuz" "github.com/bjarneo/cliamp/external/spotify" "github.com/bjarneo/cliamp/ipc" "github.com/bjarneo/cliamp/player" "github.com/bjarneo/cliamp/pluginmgr" "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" "github.com/bjarneo/cliamp/upgrade" ) func buildApp() *cli.Command { rootFlags := []cli.Flag{ &cli.Float64Flag{Name: "vol", Usage: "startup volume in dB [-30, +6]"}, &cli.BoolFlag{Name: "shuffle", Usage: "shuffle playback"}, &cli.StringFlag{Name: "repeat", Usage: "repeat mode: off, all, one"}, &cli.BoolFlag{Name: "mono", Usage: "mono output"}, &cli.BoolFlag{Name: "no-mono", Usage: "disable mono output"}, &cli.BoolFlag{Name: "auto-play", Usage: "start playback immediately"}, &cli.BoolFlag{Name: "compact", Usage: "compact mode (80 columns)"}, &cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, emby, spotify, qobuz, soundcloud, netease, yt, youtube, ytmusic"}, &cli.StringFlag{Name: "start-theme", Usage: "UI theme name"}, &cli.StringFlag{Name: "visualizer", Usage: "visualizer mode"}, &cli.StringFlag{Name: "eq-preset", Usage: "EQ preset name"}, &cli.IntFlag{Name: "sample-rate", Usage: "output sample rate in Hz (0=auto)", HideDefault: true}, &cli.IntFlag{Name: "buffer-ms", Usage: "speaker buffer in milliseconds (50-500)", HideDefault: true}, &cli.IntFlag{Name: "resample-quality", Usage: "resample quality factor (1-4)", HideDefault: true}, &cli.IntFlag{Name: "bit-depth", Usage: "PCM bit depth: 16 or 32", HideDefault: true}, &cli.StringFlag{Name: "audio-device", Usage: "audio output device (use 'list' to show)"}, &cli.StringFlag{Name: "playlist", Usage: "load a local TOML playlist by name and start playing"}, &cli.StringFlag{Name: "log-level", Usage: "log level: debug, info, warn, error"}, &cli.BoolFlag{Name: "low-power", Usage: "low-power mode: reduce CPU by lowering UI cadence and disabling visualization"}, &cli.BoolFlag{Name: "daemon", Aliases: []string{"d"}, Usage: "run headless (no TUI), serving IPC for scripts/Waybar"}, } return &cli.Command{ Name: "cliamp", Usage: "retro terminal music player", Version: version, Flags: rootFlags, Action: func(ctx context.Context, c *cli.Command) error { if strings.EqualFold(c.String("audio-device"), "list") { return listAudioDevices() } ov, err := overridesFromFlags(c) if err != nil { return err } return run(ov, c.Args().Slice(), c.Bool("daemon")) }, Commands: []*cli.Command{ upgradeCommand(), pluginsCommand(), playlistCommand(), historyCommand(), setupCommand(), spotifyCommand(), qobuzCommand(), ipcSimpleCommand("play", "resume playback"), ipcSimpleCommand("pause", "pause playback"), ipcSimpleCommand("toggle", "play/pause toggle"), ipcSimpleCommand("next", "next track"), ipcSimpleCommand("prev", "previous track"), ipcSimpleCommand("stop", "stop playback"), statusCommand(), volumeCommand(), seekCommand(), loadCommand(), queueCommand(), themeCommand(), visCommand(), visStreamCommand(), shuffleCommand(), repeatCommand(), monoCommand(), speedCommand(), eqCommand(), deviceCommand(), }, } } func listAudioDevices() error { devices, err := player.ListAudioDevices() if err != nil { return err } if len(devices) == 0 { fmt.Println("No audio output devices found.") } else { for _, d := range devices { marker := " " if d.Active { marker = "* " } fmt.Printf("%s%-50s %s\n", marker, d.Description, d.Name) } } return nil } func overridesFromFlags(c *cli.Command) (config.Overrides, error) { var ov config.Overrides if c.IsSet("vol") { v := c.Float64("vol") ov.Volume = &v } if c.IsSet("shuffle") { v := c.Bool("shuffle") ov.Shuffle = &v } if c.IsSet("repeat") { v := strings.ToLower(c.String("repeat")) switch v { case "off", "all", "one": ov.Repeat = &v default: return ov, fmt.Errorf("--repeat must be off, all, or one (got %q)", v) } } if c.IsSet("mono") { v := true ov.Mono = &v } if c.IsSet("no-mono") { v := false ov.Mono = &v } if c.IsSet("auto-play") { v := true ov.Play = &v } if c.IsSet("compact") { v := true ov.Compact = &v } if c.IsSet("provider") { v := strings.ToLower(c.String("provider")) switch v { case "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "netease", "yt", "youtube", "ytmusic": ov.Provider = &v default: return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, qobuz, plex, jellyfin, emby, soundcloud, netease, yt, youtube, or ytmusic (got %q)", v) } } if c.IsSet("start-theme") { v := c.String("start-theme") ov.Theme = &v } if c.IsSet("visualizer") { v := c.String("visualizer") ov.Visualizer = &v } if c.IsSet("eq-preset") { v := c.String("eq-preset") ov.EQPreset = &v } if c.IsSet("sample-rate") { v := int(c.Int("sample-rate")) ov.SampleRate = &v } if c.IsSet("buffer-ms") { v := int(c.Int("buffer-ms")) ov.BufferMs = &v } if c.IsSet("resample-quality") { v := int(c.Int("resample-quality")) ov.ResampleQuality = &v } if c.IsSet("bit-depth") { v := int(c.Int("bit-depth")) ov.BitDepth = &v } if c.IsSet("audio-device") { v := c.String("audio-device") ov.AudioDevice = &v } if c.IsSet("playlist") { v := c.String("playlist") ov.Playlist = &v } if c.IsSet("log-level") { v := c.String("log-level") if _, err := applog.ParseLevel(v); err != nil { return ov, fmt.Errorf("--log-level: %w", err) } ov.LogLevel = &v } if c.IsSet("low-power") { v := c.Bool("low-power") ov.LowPower = &v } return ov, nil } func upgradeCommand() *cli.Command { return &cli.Command{ Name: "upgrade", Usage: "upgrade cliamp to the latest release", Action: func(ctx context.Context, c *cli.Command) error { return upgrade.Run(version) }, } } func pluginsCommand() *cli.Command { return &cli.Command{ Name: "plugins", Usage: "manage Lua plugins", Commands: []*cli.Command{ { Name: "list", Usage: "list installed plugins", Action: func(ctx context.Context, c *cli.Command) error { return pluginmgr.List() }, }, { Name: "install", Usage: "install a plugin", ArgsUsage: "", Flags: []cli.Flag{ &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "approve plugin trust without prompting"}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp plugins install ") } return pluginmgr.Install(c.Args().First(), c.Bool("yes")) }, }, { Name: "trust", Usage: "approve the current contents of an installed plugin", ArgsUsage: "", Flags: []cli.Flag{ &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "approve plugin trust without prompting"}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp plugins trust ") } return pluginmgr.Trust(c.Args().First(), c.Bool("yes")) }, }, { Name: "remove", Usage: "remove a plugin", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp plugins remove ") } return pluginmgr.Remove(c.Args().First()) }, }, { Name: "call", Usage: "invoke a plugin command in the running cliamp", ArgsUsage: " [args...]", Action: func(ctx context.Context, c *cli.Command) error { args := c.Args().Slice() if len(args) < 2 { return fmt.Errorf("usage: cliamp plugins call [args...]") } resp, err := ipcSendLong(ipc.Request{ Cmd: "plugin.call", Name: args[0], Sub: args[1], Args: args[2:], }, 6*time.Minute) if err != nil { return err } if resp.Output != "" { fmt.Println(resp.Output) } return nil }, }, { Name: "commands", Usage: "list plugin commands registered in the running cliamp", Action: func(ctx context.Context, c *cli.Command) error { resp, err := ipcSend(ipc.Request{Cmd: "plugin.commands"}) if err != nil { return err } if len(resp.Items) == 0 { fmt.Println("No plugin commands registered.") return nil } for _, item := range resp.Items { fmt.Println(item) } return nil }, }, }, } } func setupCommand() *cli.Command { return &cli.Command{ Name: "setup", Usage: "interactive wizard to configure remote providers", Description: "Walks through configuring Navidrome, Plex, Jellyfin, Spotify,\n" + "Qobuz, NetEase, and YouTube Music. Validates connections and writes\n" + "~/.config/cliamp/config.toml.", Action: func(ctx context.Context, c *cli.Command) error { return cmd.Setup() }, } } func spotifyCommand() *cli.Command { return &cli.Command{ Name: "spotify", Usage: "manage Spotify integration", Commands: []*cli.Command{ { Name: "reset", Usage: "clear stored Spotify credentials and force re-authentication", Action: func(ctx context.Context, c *cli.Command) error { path, err := spotify.CredsPath() if err != nil { return fmt.Errorf("locate credentials: %w", err) } removed, err := spotify.DeleteCreds() if err != nil { return fmt.Errorf("remove credentials: %w", err) } if !removed { fmt.Println("No stored Spotify credentials to remove.") return nil } fmt.Printf("Removed %s\n", path) fmt.Println("Restart cliamp and select Spotify to sign in again.") return nil }, }, }, } } func qobuzCommand() *cli.Command { return &cli.Command{ Name: "qobuz", Usage: "manage Qobuz integration", Commands: []*cli.Command{ { Name: "reset", Usage: "clear stored Qobuz credentials and force re-authentication", Action: func(ctx context.Context, c *cli.Command) error { path, err := qobuz.CredsPath() if err != nil { return fmt.Errorf("locate credentials: %w", err) } removed, err := qobuz.DeleteCreds() if err != nil { return fmt.Errorf("remove credentials: %w", err) } if !removed { fmt.Println("No stored Qobuz credentials to remove.") return nil } fmt.Printf("Removed %s\n", path) fmt.Println("Restart cliamp and select Qobuz to sign in again.") return nil }, }, }, } } func playlistCommand() *cli.Command { return &cli.Command{ Name: "playlist", Usage: "manage local playlists", Commands: []*cli.Command{ { Name: "list", Usage: "list playlists with track counts", Action: func(ctx context.Context, c *cli.Command) error { return cmd.PlaylistList() }, }, { Name: "create", Usage: "create a new playlist, optionally from files/directories", ArgsUsage: "\"Name\" [file|dir ...]", Flags: []cli.Flag{ &cli.StringFlag{Name: "ssh", Usage: "SSH host for remote directory walking"}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("playlist name is required") } name := c.Args().First() paths := c.Args().Slice()[1:] return cmd.PlaylistCreate(name, paths, c.String("ssh")) }, }, { Name: "rename", Usage: "rename a playlist", ArgsUsage: "\"Old\" \"New\"", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() != 2 { return fmt.Errorf("usage: cliamp playlist rename \"Old\" \"New\"") } args := c.Args().Slice() return cmd.PlaylistRename(args[0], args[1]) }, }, { Name: "add", Usage: "append tracks to an existing playlist", ArgsUsage: "\"Name\" [...]", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() < 2 { return fmt.Errorf("usage: cliamp playlist add \"Name\" file1 [file2 ...]") } return cmd.PlaylistAdd(c.Args().First(), c.Args().Slice()[1:]) }, }, { Name: "show", Usage: "display tracks in a playlist", ArgsUsage: "\"Name\"", Flags: []cli.Flag{ &cli.BoolFlag{Name: "json", Usage: "machine-readable JSON output"}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist show \"Name\" [--json]") } return cmd.PlaylistShow(c.Args().First(), c.Bool("json")) }, }, { Name: "remove", Usage: "remove a track by index", ArgsUsage: "\"Name\"", Flags: []cli.Flag{ &cli.IntFlag{Name: "index", Usage: "track index (1-based)", Required: true}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist remove \"Name\" --index N") } return cmd.PlaylistRemove(c.Args().First(), int(c.Int("index"))) }, }, { Name: "delete", Usage: "delete an entire playlist", ArgsUsage: "\"Name\"", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist delete \"Name\"") } return cmd.PlaylistDelete(c.Args().First()) }, }, { Name: "dedupe", Usage: "remove duplicate tracks by exact path", ArgsUsage: "\"Name\"", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist dedupe \"Name\"") } return cmd.PlaylistDedupe(c.Args().First()) }, }, { Name: "sort", Usage: "sort a playlist in place", ArgsUsage: "\"Name\"", Flags: []cli.Flag{ &cli.StringFlag{Name: "by", Usage: "sort key: track, title, artist, album, artist+album, path", Value: "title"}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist sort \"Name\" --by album") } return cmd.PlaylistSort(c.Args().First(), c.String("by")) }, }, { Name: "doctor", Usage: "report missing local files in playlists", ArgsUsage: "[Name]", Flags: []cli.Flag{ &cli.BoolFlag{Name: "fix", Usage: "prune missing local files"}, }, Action: func(ctx context.Context, c *cli.Command) error { name := "" if c.Args().Len() > 0 { name = c.Args().First() } return cmd.PlaylistDoctor(name, c.Bool("fix")) }, }, { Name: "export", Usage: "export a playlist as M3U or PLS", ArgsUsage: "\"Name\"", Flags: []cli.Flag{ &cli.StringFlag{Name: "format", Usage: "format: m3u or pls", Value: "m3u"}, &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "output file (default stdout)"}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist export \"Name\" [--format m3u|pls] [-o file]") } return cmd.PlaylistExport(c.Args().First(), c.String("format"), c.String("output")) }, }, { Name: "import", Usage: "import a local M3U or PLS file", ArgsUsage: "file.m3u", Flags: []cli.Flag{ &cli.StringFlag{Name: "name", Usage: "playlist name (default: file basename)"}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist import file.m3u [--name Name]") } return cmd.PlaylistImport(c.Args().First(), c.String("name")) }, }, { Name: "bookmark", Usage: "toggle bookmark on a track by index", ArgsUsage: "\"Name\"", Flags: []cli.Flag{ &cli.IntFlag{Name: "index", Usage: "track index (1-based)", Required: true}, }, Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist bookmark \"Name\" --index N") } return cmd.PlaylistBookmark(c.Args().First(), int(c.Int("index"))) }, }, { Name: "bookmarks", Usage: "list all bookmarked tracks across playlists", Action: func(ctx context.Context, c *cli.Command) error { return cmd.PlaylistBookmarks() }, }, { Name: "enrich", Usage: "probe duration and album metadata for SSH tracks", ArgsUsage: "\"Name\"", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp playlist enrich \"Name\"") } return cmd.PlaylistEnrich(c.Args().First()) }, }, }, } } func historyCommand() *cli.Command { return &cli.Command{ Name: "history", Usage: "show recently played tracks", Description: "Lists tracks that have been played past the scrobble threshold.\n" + "Browse the same data inside the TUI under Local Playlists →\n" + "\"Recently Played\".", Flags: []cli.Flag{ &cli.IntFlag{Name: "limit", Usage: "max entries to show (0 = all)", Value: 50}, &cli.BoolFlag{Name: "json", Usage: "machine-readable JSON output"}, }, Action: func(ctx context.Context, c *cli.Command) error { return cmd.HistoryShow(int(c.Int("limit")), c.Bool("json")) }, Commands: []*cli.Command{ { Name: "clear", Usage: "delete the history file", Action: func(ctx context.Context, c *cli.Command) error { return cmd.HistoryClear() }, }, }, } } // ipcSimpleCommand creates a fire-and-forget IPC command (play, pause, etc.). func ipcSimpleCommand(name, usage string) *cli.Command { return &cli.Command{ Name: name, Usage: usage, Action: func(ctx context.Context, c *cli.Command) error { _, err := ipcSend(ipc.Request{Cmd: name}) return err }, } } func statusCommand() *cli.Command { return &cli.Command{ Name: "status", Usage: "show current playback state", Flags: []cli.Flag{ &cli.BoolFlag{Name: "json", Usage: "machine-readable JSON output"}, }, Action: func(ctx context.Context, c *cli.Command) error { resp, err := ipcSend(ipc.Request{Cmd: "status"}) if err != nil { return err } if c.Bool("json") { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(resp) } state := resp.State if state == "" { state = "stopped" } fmt.Printf("State: %s\n", state) if resp.Track != nil { fmt.Printf("Track: %s\n", resp.Track.Title) if resp.Track.Artist != "" { fmt.Printf("Artist: %s\n", resp.Track.Artist) } } if resp.Duration > 0 { fmt.Printf("Position: %.0f / %.0f sec\n", resp.Position, resp.Duration) } fmt.Printf("Volume: %.0f dB\n", resp.Volume) if resp.Shuffle != nil { if *resp.Shuffle { fmt.Println("Shuffle: on") } else { fmt.Println("Shuffle: off") } } if resp.Repeat != "" { fmt.Printf("Repeat: %s\n", resp.Repeat) } if resp.Mono != nil { if *resp.Mono { fmt.Println("Mono: on") } else { fmt.Println("Mono: off") } } if resp.Speed > 0 { fmt.Printf("Speed: %.2fx\n", resp.Speed) } if resp.EQPreset != "" { fmt.Printf("EQ: %s\n", resp.EQPreset) } return nil }, } } func volumeCommand() *cli.Command { return &cli.Command{ Name: "volume", Usage: "adjust volume in dB", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp volume ") } db, err := strconv.ParseFloat(c.Args().First(), 64) if err != nil { return fmt.Errorf("invalid volume value %q", c.Args().First()) } _, err = ipcSend(ipc.Request{Cmd: "volume", Value: db}) return err }, } } func seekCommand() *cli.Command { return &cli.Command{ Name: "seek", Usage: "seek to position in seconds", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp seek ") } secs, err := strconv.ParseFloat(c.Args().First(), 64) if err != nil { return fmt.Errorf("invalid seek value %q", c.Args().First()) } _, err = ipcSend(ipc.Request{Cmd: "seek", Value: secs}) return err }, } } func loadCommand() *cli.Command { return &cli.Command{ Name: "load", Usage: "load a playlist into the player", ArgsUsage: "\"Playlist Name\"", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp load \"Playlist Name\"") } _, err := ipcSend(ipc.Request{Cmd: "load", Playlist: c.Args().First()}) return err }, } } func queueCommand() *cli.Command { return &cli.Command{ Name: "queue", Usage: "queue a track for playback", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp queue /path/to/file.mp3") } _, err := ipcSend(ipc.Request{Cmd: "queue", Path: c.Args().First()}) return err }, } } func themeCommand() *cli.Command { return &cli.Command{ Name: "theme", Usage: "set or list UI themes", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp theme ") } if strings.EqualFold(c.Args().First(), "list") { themes := theme.LoadAll() for _, t := range themes { fmt.Printf(" %s\n", t.Name) } return nil } _, err := ipcSend(ipc.Request{Cmd: "theme", Name: c.Args().First()}) if err != nil { return err } fmt.Printf("Theme: %s\n", c.Args().First()) return nil }, } } func visStreamCommand() *cli.Command { return &cli.Command{ Name: "visstream", Usage: "stream visualizer bands as NDJSON (one frame per line)", Flags: []cli.Flag{ &cli.IntFlag{Name: "fps", Value: 30, Usage: "frames per second (1-60)"}, }, Action: func(ctx context.Context, c *cli.Command) error { fps := c.Int("fps") if fps < 1 { fps = 1 } if fps > 60 { fps = 60 } return ipc.StreamBands(ctx, ipc.DefaultSocketPath(), time.Second/time.Duration(fps), os.Stdout) }, } } func visCommand() *cli.Command { return &cli.Command{ Name: "vis", Usage: "set or list visualizer modes", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp vis ") } if strings.EqualFold(c.Args().First(), "list") { var active string sockPath := ipc.DefaultSocketPath() if resp, err := ipc.Send(sockPath, ipc.Request{Cmd: "status"}); err == nil { active = resp.Visualizer } else { fmt.Fprintln(os.Stderr, "(cliamp not running — active marker unavailable)") } for _, name := range ui.VisModeNames() { marker := " " if strings.EqualFold(name, active) { marker = "* " } fmt.Printf("%s%s\n", marker, name) } return nil } resp, err := ipcSend(ipc.Request{Cmd: "vis", Name: c.Args().First()}) if err != nil { return err } fmt.Printf("Visualizer: %s\n", resp.Visualizer) return nil }, } } func shuffleCommand() *cli.Command { return &cli.Command{ Name: "shuffle", Usage: "toggle or set shuffle mode", ArgsUsage: "[on|off|toggle]", Action: func(ctx context.Context, c *cli.Command) error { name := "toggle" if c.Args().Len() > 0 { name = strings.ToLower(c.Args().First()) } resp, err := ipcSend(ipc.Request{Cmd: "shuffle", Name: name}) if err != nil { return err } if resp.Shuffle != nil && *resp.Shuffle { fmt.Println("Shuffle: on") } else { fmt.Println("Shuffle: off") } return nil }, } } func repeatCommand() *cli.Command { return &cli.Command{ Name: "repeat", Usage: "set or cycle repeat mode", ArgsUsage: "[off|all|one|cycle]", Action: func(ctx context.Context, c *cli.Command) error { name := "cycle" if c.Args().Len() > 0 { name = strings.ToLower(c.Args().First()) } resp, err := ipcSend(ipc.Request{Cmd: "repeat", Name: name}) if err != nil { return err } fmt.Printf("Repeat: %s\n", resp.Repeat) return nil }, } } func monoCommand() *cli.Command { return &cli.Command{ Name: "mono", Usage: "toggle or set mono output", ArgsUsage: "[on|off|toggle]", Action: func(ctx context.Context, c *cli.Command) error { name := "toggle" if c.Args().Len() > 0 { name = strings.ToLower(c.Args().First()) } resp, err := ipcSend(ipc.Request{Cmd: "mono", Name: name}) if err != nil { return err } if resp.Mono != nil && *resp.Mono { fmt.Println("Mono: on") } else { fmt.Println("Mono: off") } return nil }, } } func speedCommand() *cli.Command { return &cli.Command{ Name: "speed", Usage: "set playback speed (0.25-2.0)", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp speed (e.g. 1.0, 1.5, 0.75)") } ratio, err := strconv.ParseFloat(c.Args().First(), 64) if err != nil { return fmt.Errorf("invalid speed %q", c.Args().First()) } resp, err := ipcSend(ipc.Request{Cmd: "speed", Value: ratio}) if err != nil { return err } fmt.Printf("Speed: %.2fx\n", resp.Speed) return nil }, } } func eqCommand() *cli.Command { return &cli.Command{ Name: "eq", Usage: "set EQ preset or individual band", ArgsUsage: " [dB]", Flags: []cli.Flag{ &cli.IntFlag{Name: "band", Usage: "EQ band index (0-9)", Value: -1, HideDefault: true}, }, Action: func(ctx context.Context, c *cli.Command) error { band := int(c.Int("band")) if band >= 0 { // Set a specific band. if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp eq --band N ") } db, err := strconv.ParseFloat(c.Args().First(), 64) if err != nil { return fmt.Errorf("invalid dB value %q", c.Args().First()) } resp, err := ipcSend(ipc.Request{Cmd: "eq", Band: band, Value: db}) if err != nil { return err } fmt.Printf("EQ band %d: %.1f dB (preset: %s)\n", band, db, resp.EQPreset) return nil } // Apply a preset by name. if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp eq (e.g. Flat, Rock, Pop, Jazz)") } resp, err := ipcSend(ipc.Request{Cmd: "eq", Name: c.Args().First()}) if err != nil { return err } fmt.Printf("EQ: %s\n", resp.EQPreset) return nil }, } } func deviceCommand() *cli.Command { return &cli.Command{ Name: "device", Usage: "switch audio output device", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { if c.Args().Len() == 0 { return fmt.Errorf("usage: cliamp device ") } if strings.EqualFold(c.Args().First(), "list") { resp, err := ipcSend(ipc.Request{Cmd: "device", Name: "list"}) if err != nil { return err } fmt.Println(resp.Device) return nil } resp, err := ipcSend(ipc.Request{Cmd: "device", Name: c.Args().First()}) if err != nil { return err } fmt.Printf("Audio device: %s\n", resp.Device) return nil }, } }