diff --git a/completions/zsh_autocomplete b/completions/zsh_autocomplete new file mode 100755 index 0000000..3177266 --- /dev/null +++ b/completions/zsh_autocomplete @@ -0,0 +1,20 @@ +#compdef gospt-ng + +_cli_zsh_autocomplete() { + local -a opts + local cur + cur=${words[-1]} + if [[ "$cur" == "-"* ]]; then + opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-bash-completion)}") + else + opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-bash-completion)}") + fi + + if [[ "${opts[1]}" != "" ]]; then + _describe 'values' opts + else + _files + fi +} + +compdef _cli_zsh_autocomplete gospt-ng diff --git a/go.mod b/go.mod index 37b19b0..ac377fd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( gfx.cafe/util/go/fxplus v0.0.0-20231226111635-bc00a6a250fb git.asdf.cafe/abs3nt/gunner v0.0.1 github.com/lmittmann/tint v1.0.4 + github.com/urfave/cli/v2 v2.27.1 github.com/zmb3/spotify/v2 v2.4.1 go.uber.org/fx v1.20.1 golang.org/x/exp v0.0.0-20240213143201-ec583247a57a @@ -13,10 +14,14 @@ require ( ) require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cristalhq/aconfig v0.18.5 // indirect github.com/cristalhq/aconfig/aconfigdotenv v0.17.1 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/joho/godotenv v1.4.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/dig v1.17.0 // indirect go.uber.org/multierr v1.6.0 // indirect diff --git a/go.sum b/go.sum index 036ba40..f9fa0c7 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cristalhq/aconfig v0.17.0/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E= github.com/cristalhq/aconfig v0.18.5 h1:QqXH/Gy2c4QUQJTV2BN8UAuL/rqZ3IwhvxeC8OgzquA= github.com/cristalhq/aconfig v0.18.5/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E= @@ -132,18 +134,22 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zmb3/spotify v1.3.0 h1:6Z2F1IMx0Hviq/dpf8nFwvKPppFEMXn8yfReSBVi16k= -github.com/zmb3/spotify v1.3.0/go.mod h1:GD7AAEMUJVYc2Z7p2a2S0E3/5f/KxM/vOnErNr4j+Tw= github.com/zmb3/spotify/v2 v2.4.1 h1:2ENzO3XQLOQBuxgT1Z9+PlCBSkjNgzFzmRaPns0tjM4= github.com/zmb3/spotify/v2 v2.4.1/go.mod h1:p3r7mCCxHepzVaJOe3w1dlx9SL+T8iiQR14tfXJpuTE= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= diff --git a/gospt-ng b/gospt-ng new file mode 100755 index 0000000..b178bb3 Binary files /dev/null and b/gospt-ng differ diff --git a/main.go b/main.go index 45b6b0c..bb60c41 100644 --- a/main.go +++ b/main.go @@ -4,14 +4,21 @@ import ( "go.uber.org/fx" "git.asdf.cafe/abs3nt/gospt-ng/src/app" + "git.asdf.cafe/abs3nt/gospt-ng/src/components/cli" "git.asdf.cafe/abs3nt/gospt-ng/src/components/commands" ) func main() { - fx.New( + var s fx.Shutdowner + app := fx.New( + fx.Populate(&s), app.Config, - fx.Invoke( + fx.Provide( commands.NewCommander, ), - ).Run() + fx.Invoke( + cli.Run, + ), + ) + app.Run() } diff --git a/src/app/fx.go b/src/app/fx.go index 6cde099..85a14e2 100644 --- a/src/app/fx.go +++ b/src/app/fx.go @@ -14,7 +14,7 @@ import ( ) var Services = fx.Options( - fxplus.WithLogger, + fx.NopLogger, fx.Provide( func() *slog.Logger { return slog.New(tint.NewHandler(os.Stdout, &tint.Options{ diff --git a/src/components/cli/cli.go b/src/components/cli/cli.go new file mode 100644 index 0000000..1dd339c --- /dev/null +++ b/src/components/cli/cli.go @@ -0,0 +1,104 @@ +package cli + +import ( + "log/slog" + "os" + + "github.com/urfave/cli/v2" + "go.uber.org/fx" + + "git.asdf.cafe/abs3nt/gospt-ng/src/components/commands" +) + +func Run(c *commands.Commander, s fx.Shutdowner) { + defer func() { + err := s.Shutdown() + if err != nil { + slog.Error("SHUTDOWN", "error shutting down", err) + } + }() + app := &cli.App{ + EnableBashCompletion: true, + Commands: []*cli.Command{ + { + Name: "play", + Aliases: []string{"p"}, + Usage: "Plays spotify", + Action: func(cCtx *cli.Context) error { + return c.Play() + }, + }, + { + Name: "pause", + Aliases: []string{"pa"}, + Usage: "Pauses spotify", + Action: func(cCtx *cli.Context) error { + return c.Pause() + }, + }, + { + Name: "toggleplay", + Aliases: []string{"t"}, + Usage: "Toggles play/pause", + Action: func(cCtx *cli.Context) error { + return c.TogglePlay() + }, + }, + { + Name: "link", + Aliases: []string{"l"}, + Action: func(cCtx *cli.Context) error { + return c.PrintLink() + }, + }, + { + Name: "next", + Aliases: []string{"n"}, + Action: func(cCtx *cli.Context) error { + return c.Next() + }, + }, + { + Name: "previous", + Aliases: []string{"b"}, + Action: func(cCtx *cli.Context) error { + return c.Previous() + }, + }, + { + Name: "like", + Aliases: []string{"lk"}, + Action: func(cCtx *cli.Context) error { + return c.Like() + }, + }, + { + Name: "unlike", + Aliases: []string{"ul"}, + Action: func(cCtx *cli.Context) error { + return c.UnLike() + }, + }, + { + Name: "nowplaying", + Aliases: []string{"np"}, + Action: func(cCtx *cli.Context) error { + return c.NowPlaying() + }, + }, + { + Name: "download_cover", + Aliases: []string{"dc"}, + Args: true, + ArgsUsage: "download_cover ", + Action: func(cCtx *cli.Context) error { + return c.DownloadCover(cCtx.Args().First()) + }, + }, + }, + } + if err := app.Run(os.Args); err != nil { + slog.Error("COMMANDER", "uh oh", err) + os.Exit(1) + } +} diff --git a/src/components/commands/activate_device.go b/src/components/commands/activate_device.go new file mode 100644 index 0000000..d6eb60f --- /dev/null +++ b/src/components/commands/activate_device.go @@ -0,0 +1,39 @@ +package commands + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + + "github.com/zmb3/spotify/v2" +) + +func (c *Commander) activateDevice(ctx context.Context) (spotify.ID, error) { + var device *spotify.PlayerDevice + configDir, _ := os.UserConfigDir() + if _, err := os.Stat(filepath.Join(configDir, "gospt/device.json")); err == nil { + deviceFile, err := os.Open(filepath.Join(configDir, "gospt/device.json")) + if err != nil { + return "", err + } + defer deviceFile.Close() + deviceValue, err := io.ReadAll(deviceFile) + if err != nil { + return "", err + } + err = json.Unmarshal(deviceValue, &device) + if err != nil { + return "", err + } + err = c.Client.TransferPlayback(ctx, device.ID, true) + if err != nil { + return "", err + } + } else { + slog.Error("COMMANDER", "failed to activated device", "YOU MUST RUN gospt setdevice FIRST") + } + return device.ID, nil +} diff --git a/src/components/commands/commander.go b/src/components/commands/commander.go index 56d8425..90247fb 100644 --- a/src/components/commands/commander.go +++ b/src/components/commands/commander.go @@ -2,7 +2,6 @@ package commands import ( "context" - "log/slog" "github.com/zmb3/spotify/v2" "go.uber.org/fx" @@ -31,10 +30,6 @@ func NewCommander(p CommanderParams) CommanderResult { Context: p.Context, Client: p.Client, } - err := c.Play() - if err != nil { - slog.Error("Error playing", err) - } return CommanderResult{ Commander: c, } diff --git a/src/components/commands/downloadCover.go b/src/components/commands/downloadCover.go new file mode 100644 index 0000000..0c97976 --- /dev/null +++ b/src/components/commands/downloadCover.go @@ -0,0 +1,23 @@ +package commands + +import ( + "os" + "path/filepath" +) + +func (c *Commander) DownloadCover(path string) error { + destinationPath := filepath.Clean(path) + state, err := c.Client.PlayerState(c.Context) + if err != nil { + return err + } + f, err := os.OpenFile(destinationPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return err + } + err = state.Item.Album.Images[0].Download(f) + if err != nil { + return err + } + return nil +} diff --git a/src/components/commands/errors.go b/src/components/commands/errors.go new file mode 100644 index 0000000..93002a6 --- /dev/null +++ b/src/components/commands/errors.go @@ -0,0 +1,7 @@ +package commands + +import "strings" + +func isNoActiveError(err error) bool { + return strings.Contains(err.Error(), "No active device found") +} diff --git a/src/components/commands/like.go b/src/components/commands/like.go new file mode 100644 index 0000000..b4b64c7 --- /dev/null +++ b/src/components/commands/like.go @@ -0,0 +1,9 @@ +package commands + +func (c *Commander) Like() error { + playing, err := c.Client.PlayerCurrentlyPlaying(c.Context) + if err != nil { + return err + } + return c.Client.AddTracksToLibrary(c.Context, playing.Item.ID) +} diff --git a/src/components/commands/link.go b/src/components/commands/link.go new file mode 100644 index 0000000..e3353e3 --- /dev/null +++ b/src/components/commands/link.go @@ -0,0 +1,12 @@ +package commands + +import "fmt" + +func (c *Commander) PrintLink() error { + state, err := c.Client.PlayerState(c.Context) + if err != nil { + return err + } + fmt.Println(state.Item.ExternalURLs["spotify"]) + return nil +} diff --git a/src/components/commands/next.go b/src/components/commands/next.go new file mode 100644 index 0000000..7e3f763 --- /dev/null +++ b/src/components/commands/next.go @@ -0,0 +1,25 @@ +package commands + +import ( + "github.com/zmb3/spotify/v2" +) + +func (c *Commander) Next() error { + err := c.Client.Next(c.Context) + if err != nil { + if isNoActiveError(err) { + deviceId, err := c.activateDevice(c.Context) + if err != nil { + return err + } + err = c.Client.NextOpt(c.Context, &spotify.PlayOptions{ + DeviceID: &deviceId, + }) + if err != nil { + return err + } + } + return err + } + return nil +} diff --git a/src/components/commands/nowPlaying.go b/src/components/commands/nowPlaying.go new file mode 100644 index 0000000..9fb9947 --- /dev/null +++ b/src/components/commands/nowPlaying.go @@ -0,0 +1,33 @@ +package commands + +import ( + "fmt" + + "github.com/zmb3/spotify/v2" +) + +func (c *Commander) NowPlaying() error { + current, err := c.Client.PlayerCurrentlyPlaying(c.Context) + if err != nil { + return err + } + str := FormatSong(current) + fmt.Println(str) + return nil +} + +func FormatSong(current *spotify.CurrentlyPlaying) string { + out := "▶" + if !current.Playing || current == nil { + out = "⏸" + } + if current != nil { + if current.Item != nil { + out += fmt.Sprintf(" %s", current.Item.Name) + if len(current.Item.Artists) > 0 { + out += fmt.Sprintf(" - %s", current.Item.Artists[0].Name) + } + } + } + return out +} diff --git a/src/components/commands/pause.go b/src/components/commands/pause.go new file mode 100644 index 0000000..99cd3ed --- /dev/null +++ b/src/components/commands/pause.go @@ -0,0 +1,5 @@ +package commands + +func (c *Commander) Pause() error { + return c.Client.Pause(c.Context) +} diff --git a/src/components/commands/play.go b/src/components/commands/play.go index 460bdd9..f799036 100644 --- a/src/components/commands/play.go +++ b/src/components/commands/play.go @@ -1,5 +1,24 @@ package commands +import "github.com/zmb3/spotify/v2" + func (c *Commander) Play() error { - return c.Client.Play(c.Context) + err := c.Client.Play(c.Context) + if err != nil { + if isNoActiveError(err) { + deviceID, err := c.activateDevice(c.Context) + if err != nil { + return err + } + err = c.Client.PlayOpt(c.Context, &spotify.PlayOptions{ + DeviceID: &deviceID, + }) + if err != nil { + return err + } + } else { + return err + } + } + return nil } diff --git a/src/components/commands/previous.go b/src/components/commands/previous.go new file mode 100644 index 0000000..e12934b --- /dev/null +++ b/src/components/commands/previous.go @@ -0,0 +1,25 @@ +package commands + +import ( + "github.com/zmb3/spotify/v2" +) + +func (c *Commander) Previous() error { + err := c.Client.Previous(c.Context) + if err != nil { + if isNoActiveError(err) { + deviceId, err := c.activateDevice(c.Context) + if err != nil { + return err + } + err = c.Client.PreviousOpt(c.Context, &spotify.PlayOptions{ + DeviceID: &deviceId, + }) + if err != nil { + return err + } + } + return err + } + return nil +} diff --git a/src/components/commands/toggle_play.go b/src/components/commands/toggle_play.go new file mode 100644 index 0000000..0824c4d --- /dev/null +++ b/src/components/commands/toggle_play.go @@ -0,0 +1,12 @@ +package commands + +func (c *Commander) TogglePlay() error { + state, err := c.Client.PlayerState(c.Context) + if err != nil { + return err + } + if state.Playing { + return c.Client.Pause(c.Context) + } + return c.Client.Play(c.Context) +} diff --git a/src/components/commands/unlike.go b/src/components/commands/unlike.go new file mode 100644 index 0000000..911025a --- /dev/null +++ b/src/components/commands/unlike.go @@ -0,0 +1,9 @@ +package commands + +func (c *Commander) UnLike() error { + playing, err := c.Client.PlayerCurrentlyPlaying(c.Context) + if err != nil { + return err + } + return c.Client.RemoveTracksFromLibrary(c.Context, playing.Item.ID) +}