diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/cmd/pipe.go b/cmd/pipe.go old mode 100755 new mode 100644 index 18e9908..7575436 --- a/cmd/pipe.go +++ b/cmd/pipe.go @@ -1,12 +1,13 @@ package cmd import ( - "errors" "fmt" "os" "sptlrx/config" + "sptlrx/lyrics" "sptlrx/pool" - "sptlrx/spotify" + "sptlrx/services/hosted" + "sptlrx/services/spotify" "strings" "github.com/muesli/coral" @@ -14,18 +15,15 @@ import ( "github.com/muesli/reflow/wrap" ) -var ( - FlagLength int - FlagOverflow string - FlagIgnoreErrors bool -) - var pipeCmd = &coral.Command{ Use: "pipe", Short: "Start printing the current lines to stdout", RunE: func(cmd *coral.Command, args []string) error { - var conf *config.Config + if cmd.Flags().Changed("config") { + // custom config path + config.Path = FlagConfig + } conf, err := config.Load() if err != nil { @@ -42,34 +40,35 @@ var pipeCmd = &coral.Command{ conf.Cookie = envCookie } - if conf.Cookie == "" { - return errors.New("couldn't find cookie") + if cmd.Flags().Changed("player") { + conf.Player = FlagPlayer } - client, err := spotify.NewClient(conf.Cookie) + player, err := config.GetPlayer(conf) if err != nil { - return fmt.Errorf("couldn't create client: %w", err) - } - - if cmd.Flags().Changed("length") { - conf.Pipe.Length = FlagLength - } - if cmd.Flags().Changed("overflow") { - conf.Pipe.Overflow = FlagOverflow - } - if cmd.Flags().Changed("ignore-errors") { - conf.Pipe.IgnoreErrors = FlagIgnoreErrors + return err } - if cmd.Flags().Changed("tinterval") { - conf.TimerInterval = FlagTimerInterval - } - if cmd.Flags().Changed("uinterval") { - conf.UpdateInterval = FlagUpdateInterval + var provider lyrics.Provider + if conf.Cookie != "" { + if spt, ok := player.(*spotify.Client); ok { + // use existing spotify client + provider = spt + } else { + // create new client + client, err := spotify.New(conf.Cookie) + if err != nil { + return err + } + provider = client + } + } else { + // use hosted provider + provider = hosted.New() } - ch := make(chan pool.Update) - go pool.Listen(client, conf, ch) + var ch = make(chan pool.Update) + go pool.Listen(player, provider, conf, ch) for update := range ch { if update.Err != nil { @@ -78,7 +77,7 @@ var pipeCmd = &coral.Command{ } continue } - if update.Lines == nil || !update.Lines.Timesynced() { + if update.Lines == nil || !lyrics.Timesynced(update.Lines) { fmt.Println("") continue } @@ -108,9 +107,3 @@ var pipeCmd = &coral.Command{ return nil }, } - -func init() { - pipeCmd.Flags().IntVar(&FlagLength, "length", 0, "max length of line") - pipeCmd.Flags().StringVar(&FlagOverflow, "overflow", "word", "how to wrap an overflowed line (none/word/ellipsis)") - pipeCmd.Flags().BoolVar(&FlagIgnoreErrors, "ignore-errors", true, "don't print errors") -} diff --git a/cmd/root.go b/cmd/root.go old mode 100755 new mode 100644 index e385f31..734f9fc --- a/cmd/root.go +++ b/cmd/root.go @@ -1,14 +1,15 @@ package cmd import ( - "bufio" + "errors" "fmt" - "log" "os" "sptlrx/config" - "sptlrx/spotify" + "sptlrx/lyrics" + "sptlrx/pool" + "sptlrx/services/hosted" + "sptlrx/services/spotify" "sptlrx/ui" - "strings" tea "github.com/charmbracelet/bubbletea" "github.com/muesli/coral" @@ -23,46 +24,41 @@ const banner = ` |_| ` -const help = ` -How to get setup: - - 1. Open your browser. +const help = ` 1. Open your browser. 2. Press F12, open the 'Network' tab and go to open.spotify.com. 3. Click on the first request to open.spotify.com. 4. Scroll down to the 'Request Headers', right click the 'cookie' field and select 'Copy value'. -` + 5. Paste it into your config file.` var ( FlagCookie string - - FlagStyleBefore string - FlagStyleCurrent string - FlagStyleAfter string - FlagHAlignment string - - FlagTimerInterval int - FlagUpdateInterval int + FlagPlayer string + FlagConfig string ) var rootCmd = &coral.Command{ Use: "sptlrx", - Short: "Spotify lyrics in your terminal", - Long: "A CLI app that shows time-synced Spotify lyrics in your terminal.", + Short: "Time-synced lyrics in your terminal", + Long: "A CLI app that shows time-synced lyrics in your terminal", Version: "v0.2.0", SilenceUsage: true, RunE: func(cmd *coral.Command, args []string) error { - var conf *config.Config + if cmd.Flags().Changed("config") { + // custom config path + config.Path = FlagConfig + } conf, err := config.Load() if err != nil { - return fmt.Errorf("couldn't load config: %w", err) - } - if conf == nil { - conf = config.New() - fmt.Print(banner) - fmt.Printf("Config will be stored in %s\n", config.Directory) - config.Save(conf) + if !cmd.Flags().Changed("config") && errors.Is(err, os.ErrNotExist) { + conf = config.New() + fmt.Print(banner + "\n") + fmt.Printf("Config will be stored in %s\n", config.Directory) + config.Save(conf) + } else { + return fmt.Errorf("couldn't load config: %w", err) + } } if FlagCookie != "" { @@ -71,41 +67,44 @@ var rootCmd = &coral.Command{ conf.Cookie = envCookie } - if conf.Cookie == "" { - fmt.Print(help) - ask("Enter your cookie:", &conf.Cookie) - config.Save(conf) + if cmd.Flags().Changed("player") { + conf.Player = FlagPlayer } - client, err := spotify.NewClient(conf.Cookie) + player, err := config.GetPlayer(conf) if err != nil { - return fmt.Errorf("couldn't create client: %w", err) + if errors.Is(err, spotify.ErrInvalidCookie) { + fmt.Println("If you want to use Spotify as your player, you need to set up your cookie.") + fmt.Println(help) + } + return err } - if cmd.Flags().Changed("before") { - conf.Style.Before = parseStyleFlag(FlagStyleBefore) - } - if cmd.Flags().Changed("current") { - conf.Style.Current = parseStyleFlag(FlagStyleCurrent) - } - if cmd.Flags().Changed("after") { - conf.Style.After = parseStyleFlag(FlagStyleAfter) - } - if cmd.Flags().Changed("halign") { - conf.Style.HAlignment = FlagHAlignment + var provider lyrics.Provider + if conf.Cookie != "" { + if spt, ok := player.(*spotify.Client); ok { + // use existing spotify client + provider = spt + } else { + // create new client + client, err := spotify.New(conf.Cookie) + if err != nil { + return err + } + provider = client + } + } else { + // use hosted provider + provider = hosted.New() } - if cmd.Flags().Changed("tinterval") { - conf.TimerInterval = FlagTimerInterval - } - if cmd.Flags().Changed("uinterval") { - conf.UpdateInterval = FlagUpdateInterval - } + var ch = make(chan pool.Update) + go pool.Listen(player, provider, conf, ch) p := tea.NewProgram( &ui.Model{ - Client: client, - Config: conf, + Channel: ch, + Config: conf, }, tea.WithAltScreen(), ) @@ -113,66 +112,10 @@ var rootCmd = &coral.Command{ }, } -func ask(what string, answer *string) { - var ok bool - scanner := bufio.NewScanner(os.Stdin) - for !ok { - fmt.Println("\n" + what) - scanner.Scan() - - if err := scanner.Err(); err != nil { - log.Fatal(err) - } - - line := strings.TrimSpace(scanner.Text()) - - if line != "" { - ok = true - *answer = line - } else { - fmt.Println("The value can't be empty.") - } - } -} - -func parseStyleFlag(value string) config.StyleConfig { - var style config.StyleConfig - - for _, part := range strings.Split(value, ",") { - switch part { - case "bold": - style.Bold = true - case "italic": - style.Italic = true - case "underline": - style.Undeline = true - case "strikethrough": - style.Strikethrough = true - case "blink": - style.Blink = true - case "faint": - style.Faint = true - default: - if style.Foreground == "" { - style.Foreground = part - } else if style.Background == "" { - style.Background = part - } - } - } - return style -} - func init() { - rootCmd.PersistentFlags().StringVar(&FlagCookie, "cookie", "", "your cookie") - - rootCmd.Flags().StringVar(&FlagStyleBefore, "before", "bold", "style of the lines before the current ones") - rootCmd.Flags().StringVar(&FlagStyleCurrent, "current", "bold", "style of the current lines") - rootCmd.Flags().StringVar(&FlagStyleAfter, "after", "faint", "style of the lines after the current ones") - rootCmd.Flags().StringVar(&FlagHAlignment, "halign", "center", "initial horizontal alignment (left/center/right)") - - rootCmd.PersistentFlags().IntVar(&FlagTimerInterval, "tinterval", 200, "interval for the internal timer (ms)") - rootCmd.PersistentFlags().IntVar(&FlagUpdateInterval, "uinterval", 200, "interval for updating playback status (ms)") + rootCmd.PersistentFlags().StringVarP(&FlagCookie, "cookie", "c", "", "your cookie") + rootCmd.PersistentFlags().StringVarP(&FlagPlayer, "player", "p", "spotify", "what player to use") + rootCmd.PersistentFlags().StringVar(&FlagConfig, "config", config.Path, "path to config file") rootCmd.AddCommand(pipeCmd) } diff --git a/config/config.go b/config/config.go old mode 100755 new mode 100644 index 7261a01..a416d1c --- a/config/config.go +++ b/config/config.go @@ -2,9 +2,15 @@ package config import ( "errors" + "fmt" "io/ioutil" "os" "path" + "sptlrx/player" + "sptlrx/services/mopidy" + "sptlrx/services/mpd" + "sptlrx/services/mpris" + "sptlrx/services/spotify" "strconv" "strings" @@ -14,6 +20,7 @@ import ( ) var Directory string +var Path string func init() { d, err := os.UserConfigDir() @@ -21,20 +28,21 @@ func init() { panic(err) } Directory = path.Join(d, "sptlrx") + Path = path.Join(Directory, "config.yaml") } type Config struct { - Cookie string `yaml:"cookie"` - // Player string `default:"spotify" yaml:"player"` - TimerInterval int `default:"200" yaml:"timerInterval"` - UpdateInterval int `default:"3000" yaml:"updateInterval"` + Cookie string `yaml:"cookie"` + Player string `default:"spotify" yaml:"player"` + TimerInterval int `default:"200" yaml:"timerInterval"` + UpdateInterval int `default:"3000" yaml:"updateInterval"` Style struct { HAlignment string `default:"center" yaml:"hAlignment"` - Before StyleConfig `default:"{\"bold\": true}" yaml:"before"` - Current StyleConfig `default:"{\"bold\": true}" yaml:"current"` - After StyleConfig `default:"{\"faint\": true}" yaml:"after"` + Before Style `default:"{\"bold\": true}" yaml:"before"` + Current Style `default:"{\"bold\": true}" yaml:"current"` + After Style `default:"{\"faint\": true}" yaml:"after"` } `yaml:"style"` Pipe struct { @@ -43,16 +51,14 @@ type Config struct { IgnoreErrors bool `default:"true" yaml:"ignoreErrors"` } `yaml:"pipe"` - // Mpd struct { - // Hostname string `default:"127.0.0.1" yaml:"hostname"` - // Port int `default:"6600" yaml:"port"` - // Password string `yaml:"password"` - // } `yaml:"mpd"` + Mpd struct { + Address string `default:"127.0.0.1:6600" yaml:"address"` + Password string `yaml:"password"` + } `yaml:"mpd"` - // Mopidy struct { - // Hostname string `default:"127.0.0.1" yaml:"hostname"` - // Port int `default:"6680" yaml:"port"` - // } `yaml:"mopidy"` + Mopidy struct { + Address string `default:"127.0.0.1:6680" yaml:"address"` + } `yaml:"mopidy"` } func New() *Config { @@ -62,28 +68,27 @@ func New() *Config { } func Load() (*Config, error) { - file, err := os.Open(path.Join(Directory, "config.yaml")) + file, err := os.Open(Path) if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return nil, err - } - - // workaround for compatibility with old versions - if cookieFile, err := os.Open(path.Join(Directory, "cookie.txt")); err == nil { - b, err := ioutil.ReadAll(cookieFile) - cookieFile.Close() - - os.Remove(path.Join(Directory, "cookie.txt")) - - if err == nil && b != nil { - config := New() - config.Cookie = string(b) - Save(config) - - return config, nil + if errors.Is(err, os.ErrNotExist) { + // workaround for compatibility with old versions + cookiePath := path.Join(Directory, "cookie.txt") + if cookieFile, err := os.Open(cookiePath); err == nil { + b, err := ioutil.ReadAll(cookieFile) + cookieFile.Close() + + os.Remove(cookiePath) + + if err == nil && b != nil { + config := New() + config.Cookie = string(b) + Save(config) + + return config, nil + } } } - return nil, nil + return nil, err } defer file.Close() @@ -98,7 +103,7 @@ func Save(config *Config) error { return err } - file, err := os.Create(path.Join(Directory, "config.yaml")) + file, err := os.Create(Path) if err != nil { return err } @@ -119,7 +124,7 @@ func (c *Config) UnmarshalYAML(f func(interface{}) error) error { return nil } -type StyleConfig struct { +type Style struct { Background string `yaml:"background"` Foreground string `yaml:"foreground"` @@ -131,7 +136,7 @@ type StyleConfig struct { Faint bool `yaml:"faint"` } -func (s StyleConfig) Parse() gloss.Style { +func (s Style) Parse() gloss.Style { var style gloss.Style if s.Background != "" && validateColor(s.Background) { style = style.Background(gloss.Color(s.Background)) @@ -172,3 +177,23 @@ func validateColor(color string) bool { } return false } + +// GetPlayer returns a player based on config values +func GetPlayer(conf *Config) (player.Player, error) { + switch conf.Player { + case "spotify": + if conf.Cookie == "" { + return nil, spotify.ErrInvalidCookie + } + return spotify.New(conf.Cookie) + case "mpd": + return mpd.New(conf.Mpd.Address, conf.Mpd.Password) + case "mopidy": + return mopidy.New( + conf.Mopidy.Address, + ) + case "mpris": + return mpris.New() + } + return nil, fmt.Errorf("unknown player: \"%s\"", conf.Player) +} diff --git a/demo.svg b/demo.svg old mode 100755 new mode 100644 diff --git a/go.mod b/go.mod old mode 100755 new mode 100644 index 4c63247..8c42253 --- a/go.mod +++ b/go.mod @@ -3,15 +3,21 @@ module sptlrx go 1.16 require ( - github.com/charmbracelet/bubbletea v0.19.1 - github.com/charmbracelet/lipgloss v0.4.0 + github.com/charmbracelet/bubbletea v0.22.0 + github.com/charmbracelet/lipgloss v0.5.0 ) require ( - github.com/creasty/defaults v1.5.2 + github.com/Pauloo27/go-mpris v1.4.0 + github.com/creasty/defaults v1.6.0 + github.com/fhs/gompd v1.0.1 + github.com/godbus/dbus/v5 v5.1.0 + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/coral v1.0.0 github.com/muesli/reflow v0.3.0 - golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71 // indirect - golang.org/x/term v0.0.0-20210422114643-f5beecf764ed + github.com/muesli/termenv v0.12.0 // indirect + golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d // indirect + golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum old mode 100755 new mode 100644 index fbf0118..66aa62f --- a/go.sum +++ b/go.sum @@ -1,47 +1,60 @@ -github.com/charmbracelet/bubbletea v0.19.1 h1:VHuzkJbnTAkxhOfi9+Lb5PYfNM9+Oh+qhP8uDX5ReOU= -github.com/charmbracelet/bubbletea v0.19.1/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= -github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= -github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= -github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE= -github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= +github.com/Pauloo27/go-mpris v1.4.0 h1:KWNTZuXeOdOdIVdzwG/JOOZHlveNiMjiSaK0AWi220c= +github.com/Pauloo27/go-mpris v1.4.0/go.mod h1:+9otYxTLPRTVZ6i2k6VrG1Y0RzMbBXGuEUQM4ZSvjxU= +github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc= +github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creasty/defaults v1.5.2 h1:/VfB6uxpyp6h0fr7SPp7n8WJBoV8jfxQXPCnkVSjyls= -github.com/creasty/defaults v1.5.2/go.mod h1:FPZ+Y0WNrbqOVw+c6av63eyHUAl6pMHZwqLPvXUZGfY= +github.com/creasty/defaults v1.6.0 h1:ltuE9cfphUtlrBeomuu8PEyISTXnxqkBIoQfXgv7BSc= +github.com/creasty/defaults v1.6.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +github.com/fhs/gompd v1.0.1 h1:kBcAhjnAPJQAylZXR0TeH+d2vpjawXlTtKYguqNlF4A= +github.com/fhs/gompd v1.0.1/go.mod h1:b219/mNa9PvRqvkUip51b23hGL3iX4d4q3gNXdtrD04= +github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= -github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/coral v1.0.0 h1:odyqkoEg4aJAINOzvnjN4tUsdp+Zleccs7tRIAkkYzU= github.com/muesli/coral v1.0.0/go.mod h1:bf91M/dkp7iHQw73HOoR9PekdTJMTD6ihJgWoDitde8= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= -github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= +github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71 h1:X/2sJAybVknnUnV7AD2HdT6rm2p5BP6eH2j+igduWgk= -golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= -golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d h1:/m5NbqQelATgoSPVC2Z23sR4kVNokFwDDyWh/3rGY+I= +golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/lyrics/lyrics.go b/lyrics/lyrics.go new file mode 100644 index 0000000..a1507fe --- /dev/null +++ b/lyrics/lyrics.go @@ -0,0 +1,14 @@ +package lyrics + +type Provider interface { + Lyrics(id, query string) ([]Line, error) +} + +type Line struct { + Time int `json:"time"` + Words string `json:"words"` +} + +func Timesynced(lines []Line) bool { + return len(lines) > 1 && lines[1].Time != 0 +} diff --git a/main.go b/main.go old mode 100755 new mode 100644 diff --git a/player/player.go b/player/player.go new file mode 100644 index 0000000..a6ae10d --- /dev/null +++ b/player/player.go @@ -0,0 +1,16 @@ +package player + +type Player interface { + State() (*State, error) +} + +type State struct { + // ID of the current track. + ID string + // Query is a string that can be used to find lyrics. + Query string + // Position of the current track in ms. + Position int + // Playing means whether the track is playing at the moment. + Playing bool +} diff --git a/pool/pool.go b/pool/pool.go old mode 100755 new mode 100644 index 16863a3..fb7e70d --- a/pool/pool.go +++ b/pool/pool.go @@ -2,38 +2,44 @@ package pool import ( "sptlrx/config" - "sptlrx/spotify" + "sptlrx/lyrics" + "sptlrx/player" "time" ) type Update struct { - Lines spotify.LyricsLines + Lines []lyrics.Line Index int Playing bool Err error } -type statusUpdate struct { - status *spotify.CurrentlyPlaying - err error +type stateUpdate struct { + state *player.State + err error } -func Listen(client *spotify.SpotifyClient, conf *config.Config, ch chan Update) { +func Listen( + player player.Player, + provider lyrics.Provider, + conf *config.Config, + ch chan Update, +) { var id string var playing bool var position int - var lines spotify.LyricsLines + var lines []lyrics.Line var index int var ( timerCh = make(chan int, 1) - updateCh = make(chan statusUpdate, 1) + updateCh = make(chan stateUpdate, 1) ) go listenTimer(timerCh, conf.TimerInterval) - go listenUpdate(client, updateCh, conf.UpdateInterval) + go listenUpdate(player, updateCh, conf.UpdateInterval) var lastUpdate = time.Now() @@ -51,7 +57,7 @@ func Listen(client *spotify.SpotifyClient, conf *config.Config, ch chan Update) break } - if update.status == nil { + if update.state == nil { if lines != nil { changed = true id = "" @@ -61,10 +67,10 @@ func Listen(client *spotify.SpotifyClient, conf *config.Config, ch chan Update) } break } - if update.status.ID != id { + if update.state.ID != id { changed = true - id = update.status.ID - newLines, err := client.Lyrics(id) + id = update.state.ID + newLines, err := provider.Lyrics(id, update.state.Query) if err != nil { ch <- Update{ Err: err, @@ -74,11 +80,11 @@ func Listen(client *spotify.SpotifyClient, conf *config.Config, ch chan Update) lines = newLines index = 0 } - if update.status.Playing != playing { + if update.state.Playing != playing { changed = true - playing = update.status.Playing + playing = update.state.Playing } - position = update.status.Position + position = update.state.Position newIndex := getIndex(position, index, lines) if newIndex != index { changed = true @@ -86,7 +92,7 @@ func Listen(client *spotify.SpotifyClient, conf *config.Config, ch chan Update) } case <-timerCh: - if playing && lines.Timesynced() { + if playing && lyrics.Timesynced(lines) { now := time.Now() position += int(now.Sub(lastUpdate).Milliseconds()) lastUpdate = now @@ -117,18 +123,19 @@ func listenTimer(ch chan int, interval int) { } } -func listenUpdate(client *spotify.SpotifyClient, ch chan statusUpdate, interval int) { +func listenUpdate(player player.Player, ch chan stateUpdate, interval int) { for { - status, err := client.Current() - ch <- statusUpdate{ - status: status, - err: err, + state, err := player.State() + ch <- stateUpdate{ + state: state, + err: err, } time.Sleep(time.Millisecond * time.Duration(interval)) } } -func getIndex(position, curIndex int, lines []*spotify.LyricsLine) int { +// getIndex is an effective alghoritm to get current line's index +func getIndex(position, curIndex int, lines []lyrics.Line) int { if len(lines) <= 1 { return 0 } diff --git a/services/hosted/hosted.go b/services/hosted/hosted.go new file mode 100644 index 0000000..110f344 --- /dev/null +++ b/services/hosted/hosted.go @@ -0,0 +1,34 @@ +package hosted + +import ( + "encoding/json" + "net/http" + "net/url" + "sptlrx/lyrics" +) + +// Host your own: https://github.com/raitonoberu/lyricsapi +const URL = "https://lyricsapi.vercel.app/api/lyrics?" + +func New() *Client { + return &Client{} +} + +// Client implements lyrics.Provider +type Client struct{} + +func (c *Client) Lyrics(id, query string) ([]lyrics.Line, error) { + var url = URL + url.Values{ + "name": {query}, + }.Encode() + req, _ := http.NewRequest("GET", url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result []lyrics.Line + err = json.NewDecoder(resp.Body).Decode(&result) + return result, err +} diff --git a/services/mopidy/mopidy.go b/services/mopidy/mopidy.go new file mode 100644 index 0000000..19e3105 --- /dev/null +++ b/services/mopidy/mopidy.go @@ -0,0 +1,109 @@ +package mopidy + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "sptlrx/player" +) + +func New(address string) (*Client, error) { + c := &Client{ + address: address, + } + return c, nil +} + +// Client implements player.Player +type Client struct { + address string +} + +func (c *Client) get(method string, out interface{}) error { + body := requestBody{ + JsonRPC: "2.0", + ID: 1, + Method: method, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return err + } + + url := fmt.Sprintf("http://%s/mopidy/rpc", c.address) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) State() (*player.State, error) { + var state stateResponse + err := c.get("core.playback.get_state", &state) + if err != nil { + return nil, err + } + + var current currentResponse + err = c.get("core.playback.get_current_track", ¤t) + if err != nil { + return nil, err + } + + var position positionResponse + err = c.get("core.playback.get_time_position", &position) + if err != nil { + return nil, err + } + + var artist string + for i, a := range current.Result.Artists { + if i != 0 { + artist += " " + } + artist += a.Name + } + + query := artist + " " + current.Result.Name + + return &player.State{ + ID: current.Result.URI, + Query: query, + Position: position.Result, + Playing: state.Result == "playing", + }, err +} + +type requestBody struct { + JsonRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` +} + +type currentResponse struct { + Result *struct { + URI string `json:"uri"` + Name string `json:"name"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + } `json:"result"` +} + +type stateResponse struct { + Result string `json:"result"` +} + +type positionResponse struct { + Result int `json:"result"` +} diff --git a/services/mpd/mpd.go b/services/mpd/mpd.go new file mode 100644 index 0000000..11851e7 --- /dev/null +++ b/services/mpd/mpd.go @@ -0,0 +1,83 @@ +package mpd + +import ( + "sptlrx/player" + "strconv" + + "github.com/fhs/gompd/mpd" +) + +func New(address, password string) (*Client, error) { + c := &Client{ + address: address, + password: password, + } + return c, c.connect() +} + +// Client implements player.Player +type Client struct { + address string + password string + client *mpd.Client +} + +func (c *Client) connect() error { + if c.client != nil { + c.client.Close() + } + client, err := mpd.DialAuthenticated("tcp", c.address, c.password) + if err != nil { + c.client = nil + return err + } + c.client = client + return nil +} + +func (c *Client) checkConnection() error { + if c.client == nil || c.client.Ping() != nil { + return c.connect() + } + return nil +} + +func (c *Client) State() (*player.State, error) { + if err := c.checkConnection(); err != nil { + return nil, err + } + + status, err := c.client.Status() + if err != nil { + return nil, err + } + current, err := c.client.CurrentSong() + if err != nil { + return nil, err + } + elapsed, _ := strconv.ParseFloat(status["elapsed"], 32) + + var title string + if t, ok := current["Title"]; ok { + title = t + } + + var artist string + if a, ok := current["Artist"]; ok { + artist = a + } + + var query string + if artist != "" { + query = artist + " " + title + } else { + query = title + } + + return &player.State{ + ID: status["songid"], + Query: query, + Playing: status["state"] == "play", + Position: int(elapsed) * 1000, + }, nil +} diff --git a/services/mpris/mpris.go b/services/mpris/mpris.go new file mode 100644 index 0000000..81bfae9 --- /dev/null +++ b/services/mpris/mpris.go @@ -0,0 +1,90 @@ +package mpris + +import ( + "errors" + "runtime" + "sptlrx/player" + "strings" + + "github.com/Pauloo27/go-mpris" + "github.com/godbus/dbus/v5" +) + +func New() (*Client, error) { + if runtime.GOOS == "windows" { + return nil, errors.New("windows is not supported") + } + + conn, err := dbus.SessionBus() + if err != nil { + return nil, err + } + return &Client{conn}, nil +} + +// Client implements player.Player +type Client struct { + conn *dbus.Conn +} + +func (p *Client) getPlayer() (*mpris.Player, error) { + names, err := mpris.List(p.conn) + if err != nil { + return nil, err + } + if len(names) == 0 { + return nil, nil + } + + return mpris.New(p.conn, names[0]), nil +} + +func (p *Client) State() (*player.State, error) { + pl, err := p.getPlayer() + if err != nil { + return nil, err + } + if pl == nil { + return nil, nil + } + + status, err := pl.GetPlaybackStatus() + if err != nil { + return nil, err + } + position, err := pl.GetPosition() + if err != nil { + return nil, err + } + meta, err := pl.GetMetadata() + if err != nil { + return nil, err + } + + var title string + if t, ok := meta["xesam:title"].Value().(string); ok { + title = t + } + + var artist string + switch a := meta["xesam:artist"].Value(); a.(type) { + case string: + artist = a.(string) + case []string: + artist = strings.Join(a.([]string), " ") + } + + var query string + if artist != "" { + query = artist + " " + title + } else { + query = title + } + + return &player.State{ + ID: query, // use query as id since mpris:trackid is broken + Query: query, + Position: int(position * 1000), // secs to ms + Playing: status == mpris.PlaybackPlaying, + }, err +} diff --git a/spotify/spotify.go b/services/spotify/spotify.go old mode 100755 new mode 100644 similarity index 62% rename from spotify/spotify.go rename to services/spotify/spotify.go index afe7c75..a912f6f --- a/spotify/spotify.go +++ b/services/spotify/spotify.go @@ -5,36 +5,43 @@ import ( "errors" "io" "net/http" + "net/url" + "sptlrx/lyrics" + "sptlrx/player" + "strings" "time" ) var ( - ErrInvalidCookie = errors.New("invalid cookie provided") + ErrInvalidCookie = errors.New("invalid or empty cookie provided") ) const tokenUrl = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player" const lyricsUrl = "https://spclient.wg.spotify.com/color-lyrics/v2/track/" -const currentUrl = "https://api.spotify.com/v1/me/player/currently-playing" +const stateUrl = "https://api.spotify.com/v1/me/player/currently-playing" +const searchUrl = "https://api.spotify.com/v1/search?" -func NewClient(cookie string) (*SpotifyClient, error) { - c := &SpotifyClient{ +func New(cookie string) (*Client, error) { + c := &Client{ cookie: cookie, } return c, c.checkToken() } -type SpotifyClient struct { +// Client implements both player.Player and lyrics.Provider +type Client struct { cookie string token string expiresIn time.Time } -func (c *SpotifyClient) Current() (*CurrentlyPlaying, error) { +func (c *Client) State() (*player.State, error) { err := c.checkToken() if err != nil { return nil, err } - req, _ := http.NewRequest("GET", currentUrl, nil) + + req, _ := http.NewRequest("GET", stateUrl, nil) req.Header = http.Header{ "referer": {"https://open.spotify.com/"}, "origin": {"https://open.spotify.com/"}, @@ -63,14 +70,55 @@ func (c *SpotifyClient) Current() (*CurrentlyPlaying, error) { if result.Item == nil { return nil, nil } - return &CurrentlyPlaying{ - ID: result.Item.ID, + return &player.State{ + ID: "spotify:" + result.Item.ID, Position: result.Progress, Playing: result.Playing, }, nil } -func (c *SpotifyClient) Lyrics(spotifyID string) (LyricsLines, error) { +func (c *Client) Lyrics(id, query string) ([]lyrics.Line, error) { + if strings.HasPrefix(id, "spotify:") { + return c.lyrics(id[8:]) + } + id, err := c.search(query) + if err != nil { + return nil, err + } + return c.lyrics(id) +} + +func (c *Client) search(query string) (string, error) { + err := c.checkToken() + if err != nil { + return "", err + } + + url := searchUrl + url.Values{ + "limit": {"1"}, + "type": {"track"}, + "q": {query}, + }.Encode() + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+c.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + result := &searchBody{} + err = json.NewDecoder(resp.Body).Decode(result) + if err != nil { + return "", err + } + if result.Tracks.Total == 0 { + return "", nil + } + return result.Tracks.Items[0].ID, nil +} + +func (c *Client) lyrics(spotifyID string) ([]lyrics.Line, error) { err := c.checkToken() if err != nil { return nil, err @@ -102,25 +150,27 @@ func (c *SpotifyClient) Lyrics(spotifyID string) (LyricsLines, error) { } return nil, err } - if result.Lyrics.Lines == nil { - // not found - return nil, nil + + lines := make([]lyrics.Line, len(result.Lyrics.Lines)) + for i, l := range result.Lyrics.Lines { + lines[i] = lyrics.Line(l) } - return result.Lyrics.Lines, nil + + return lines, nil } -func (c *SpotifyClient) checkToken() error { +func (c *Client) checkToken() error { if !c.tokenExpired() { return nil } return c.updateToken() } -func (c *SpotifyClient) tokenExpired() bool { +func (c *Client) tokenExpired() bool { return c.token == "" || time.Now().After(c.expiresIn) } -func (c *SpotifyClient) updateToken() error { +func (c *Client) updateToken() error { req, _ := http.NewRequest("GET", tokenUrl, nil) req.Header = http.Header{ "referer": {"https://open.spotify.com/"}, @@ -168,7 +218,10 @@ type tokenBody struct { type lyricsBody struct { Lyrics struct { - Lines []*LyricsLine `json:"lines"` + Lines []struct { + Time int `json:"startTimeMs,string"` + Words string `json:"words"` + } `json:"lines"` } `json:"lyrics"` } @@ -179,3 +232,13 @@ type currentBody struct { ID string `json:"id"` } `json:"item"` } + +type searchBody struct { + Tracks struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"items"` + Total int `json:"total"` + } `json:"tracks"` +} diff --git a/spotify/model.go b/spotify/model.go deleted file mode 100755 index d71e45d..0000000 --- a/spotify/model.go +++ /dev/null @@ -1,18 +0,0 @@ -package spotify - -type CurrentlyPlaying struct { - ID string - Position int - Playing bool -} - -type LyricsLine struct { - Time int `json:"startTimeMs,string"` - Words string `json:"words"` -} - -type LyricsLines []*LyricsLine - -func (l LyricsLines) Timesynced() bool { - return len(l) > 1 && l[1].Time != 0 -} diff --git a/ui/ui.go b/ui/ui.go old mode 100755 new mode 100644 index 6bacd07..40c1327 --- a/ui/ui.go +++ b/ui/ui.go @@ -4,8 +4,8 @@ import ( "os" "runtime" "sptlrx/config" + "sptlrx/lyrics" "sptlrx/pool" - "sptlrx/spotify" "strings" tea "github.com/charmbracelet/bubbletea" @@ -16,8 +16,8 @@ import ( type updateMsg pool.Update type Model struct { - Client *spotify.SpotifyClient - Config *config.Config + Config *config.Config + Channel chan pool.Update styleBefore gloss.Style styleCurrent gloss.Style @@ -26,9 +26,7 @@ type Model struct { w, h int - channel chan pool.Update - - lines spotify.LyricsLines + lines []lyrics.Line index int playing bool err error @@ -47,9 +45,7 @@ func (m *Model) Init() tea.Cmd { m.hAlignment = 1 } - m.channel = make(chan pool.Update) - go pool.Listen(m.Client, m.Config, m.channel) - return tea.Batch(waitForUpdate(m.channel), tea.HideCursor) + return tea.Batch(waitForUpdate(m.Channel), tea.HideCursor) } func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { @@ -72,7 +68,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.w, m.h = w, h } } - cmd = waitForUpdate(m.channel) + cmd = waitForUpdate(m.Channel) case tea.KeyMsg: switch msg.String() { @@ -91,14 +87,14 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } case "up": - if !m.playing || !m.lines.Timesynced() { + if !m.playing || !lyrics.Timesynced(m.lines) { m.index -= 1 if m.index < 0 { m.index = 0 } } case "down": - if !m.playing || !m.lines.Timesynced() { + if !m.playing || !lyrics.Timesynced(m.lines) { m.index += 1 if m.index >= len(m.lines) { m.index = len(m.lines) - 1