A => .gitignore +4 -0
@@ 1,4 @@
+clientid.txt
+clientsecret.txt
+nspotify
+go.sum
A => Makefile +22 -0
@@ 1,22 @@
+go.mod:
+ go mod init git.dominic-ricottone.com/~dricottone/nspotify
+ go get github.com/zmb3/spotify/v2
+ go get github.com/sirupsen/logrus
+ go get golang.org/x/oauth2
+ go get github.com/rivo/tview
+
+GO_SRC!=find * -type f -name '*.go'
+
+GO_LDFLAGS:=
+GO_LDFLAGS+=-X main.CLIENTID=$(file < clientid.txt)
+GO_LDFLAGS+=-X main.CLIENTSECRET=$(file < clientsecret.txt)
+
+nspotify: go.mod $(GO_SRC)
+ go build -ldflags "$(GO_LDFLAGS)" .
+
+build: nspotify
+
+clean:
+ rm -f go.mod nspotify
+
+.PHONY: run build clean
A => app.go +135 -0
@@ 1,135 @@
+package main
+
+import (
+ "context"
+ "os"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/zmb3/spotify/v2"
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+// Start the core application and return once it terminates.
+func Start(ctx context.Context, rx <-chan *spotify.FullTrack, tx chan<- *Event) {
+ ctx, cancel := context.WithCancel(ctx)
+ app := tview.NewApplication()
+ pages := tview.NewPages()
+
+ logs := tview.NewTextView().SetDynamicColors(true).SetScrollable(false).ScrollToEnd()
+ log.SetOutput(tview.ANSIWriter(logs))
+ pages.AddPage("logs", logs, true, true)
+
+ // End user pressed one of `Escape`, `Enter`, `Tab`, or `Backtab` on the logs
+ // page.
+ logs.SetDoneFunc(func(key tcell.Key) {
+ pages.SwitchToPage("listing")
+ })
+
+ // Redraw if logs have been written.
+ logs.SetChangedFunc(func() {
+ app.Draw()
+ })
+
+ listing := tview.NewTable().SetSelectable(true, false).Select(0, 0)
+ go ListingManager(ctx, listing, rx)
+ pages.AddPage("listing", listing, true, false)
+
+ // End user pressed `Enter` on the listing page.
+ listing.SetSelectedFunc(func(row, _ int) {
+ uri, ok := listing.GetCell(row, 0).GetReference().(spotify.URI)
+ if !ok {
+ log.Error("invalid URI")
+ } else {
+ tx <- RequestPlayURI(uri)
+ }
+ })
+
+ // End user pressed one of `Escape`, `Tab`, or `Backtab` on the listing page.
+ // Also triggers if user pressed `Enter` when nothing is selected.
+ listing.SetDoneFunc(func(key tcell.Key) {
+ })
+
+ listing.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
+ if ev.Key() == tcell.KeyRune {
+ switch ev.Rune() {
+
+ // End user pressed `p` on the listing.
+ case 'p':
+ tx <- RequestToggle()
+
+ // End user pressed `f` on the listing.
+ case 'f':
+ tx <- RequestPlayNext()
+
+ // End user pressed `b` on the listing.
+ case 'b':
+ tx <- RequestPlayPrevious()
+
+ // End user pressed `Space` on the listing.
+ case ' ':
+ row, _ := listing.GetSelection()
+ uri, ok := listing.GetCell(row, 0).GetReference().(spotify.URI)
+ if !ok {
+ log.Error("invalid URI")
+ } else {
+ tx <- RequestQueueURI(uri)
+ }
+
+ }
+ }
+
+ // Pass event back to primitive for other handlers.
+ return ev
+ })
+
+ app.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
+ switch ev.Key() {
+
+ // End user pressed `F1` anywhere.
+ case tcell.KeyF1:
+ pages.SwitchToPage("listing")
+ return nil
+
+ // End user pressed `F2` anywhere.
+ case tcell.KeyF2:
+ pages.SwitchToPage("logs")
+ return nil
+
+ // End user pressed `F3` anywhere.
+ case tcell.KeyF3:
+ // show help
+
+ case tcell.KeyRune:
+
+ switch ev.Rune() {
+
+ // End user pressed `?` anywhere.
+ case '?':
+ // show help
+
+ // End user pressed `q` anywhere.
+ case 'q':
+ app.Stop()
+ return nil
+ }
+ }
+
+ // Pass event back to application, will be routed to focused
+ // primitive.
+ return ev
+ })
+
+ // This will block until the application dies.
+
+ err := app.SetRoot(pages, true).Run()
+ cancel()
+
+ log.SetOutput(os.Stdout)
+ if err != nil {
+ log.WithError(err).Fatal("application died")
+ }
+
+ // TODO: for long-running programs, maybe need to periodically run cli.RefreshToken?
+}
+
A => auth.go +131 -0
@@ 1,131 @@
+package main
+
+// Prompt end users to authenticate with Spotify. Needed at startup.
+
+import (
+ "context"
+ "net/http"
+ "fmt"
+
+ "github.com/zmb3/spotify/v2"
+ auth "github.com/zmb3/spotify/v2/auth"
+ log "github.com/sirupsen/logrus"
+)
+
+// These should be set by the linker.
+// e.g. `go build -ldflags="-X 'main.CLIENTID=foobarbaz'"`
+var (
+ CLIENTID = ""
+ CLIENTSECRET = ""
+)
+
+// Pull the cache directory information from the context.
+func cache_info(ctx context.Context) string {
+ dir, ok := ctx.Value("cachedir").(string)
+ if !ok {
+ dir = ""
+ }
+
+ return dir
+}
+
+// Pull the authentication URI information from the context.
+func uri_info(ctx context.Context) (string, string) {
+ port, ok := ctx.Value("authport").(int)
+ if !ok {
+ log.Warn("authenticating web server port is required; falling back to default :8080")
+ port = 8080
+ }
+
+ full_uri := fmt.Sprintf("http://localhost:%d", port)
+ short_uri := fmt.Sprintf(":%d", port)
+
+ return full_uri, short_uri
+}
+
+// Serves the authenticator at `http://localhost:[authport]`. Prints that address
+// and some instructions to STDOUT for the end user.
+func ServeAuthenticator(ctx context.Context, ch chan<- *spotify.Client) *http.Server {
+ full_uri, short_uri := uri_info(ctx)
+ state := "nspotify"
+ authenticator := auth.New(
+ auth.WithClientID(CLIENTID),
+ auth.WithClientSecret(CLIENTSECRET),
+ auth.WithRedirectURL(full_uri),
+ auth.WithScopes(auth.ScopeUserLibraryRead, auth.ScopeUserReadPlaybackState, auth.ScopeUserModifyPlaybackState))
+ srv := &http.Server{Addr: short_uri}
+
+ // Address and instructions for end user.
+ fmt.Println("Log in to Spotify at:", authenticator.AuthURL(state))
+
+ http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ tok, err := authenticator.Token(r.Context(), state, r)
+ if err != nil {
+ http.Error(w, "Failed to get token", http.StatusForbidden)
+ log.WithError(err).Fatal("failed to get token")
+ }
+
+ if s := r.FormValue("state"); s != state {
+ http.NotFound(w, r)
+ log.Fatalf("invalid state: %s\n", s)
+ }
+
+ log.Debugf("login succeeded")
+
+ client := spotify.New(authenticator.Client(ctx, tok))
+
+ dir := cache_info(ctx)
+ if dir != "" {
+ WriteCache(dir, tok)
+ }
+
+ ch <- client
+ })
+
+ // Start server.
+ go func() {
+ if err := srv.ListenAndServe(); err != http.ErrServerClosed {
+ log.WithError(err).Fatal("authenticating web server died")
+ }
+ }()
+
+ return srv
+}
+
+// Authenticate with a cached token from `[cachedir]/token.json`.
+func CachedAuthentication(ctx context.Context) *spotify.Client {
+ dir := cache_info(ctx)
+ if dir == "" {
+ return nil
+ }
+
+ tok, err := ReadCache(dir)
+ if err != nil {
+ return nil
+ }
+
+ authenticator := auth.New(auth.WithScopes(auth.ScopeUserLibraryRead))
+
+ return spotify.New(authenticator.Client(ctx, tok))
+
+}
+
+// Authenticates the end user.
+func Authenticate(ctx context.Context) *spotify.Client {
+ // Try cache first.
+ if cached := CachedAuthentication(ctx); cached != nil {
+ return cached
+ }
+
+ // Start server.
+ ch := make(chan *spotify.Client)
+ srv := ServeAuthenticator(ctx, ch)
+
+ cli := <-ch
+
+ // Kill server.
+ srv.Shutdown(ctx)
+
+ return cli
+}
+
A => cache.go +72 -0
@@ 1,72 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "encoding/json"
+
+ "golang.org/x/oauth2"
+ log "github.com/sirupsen/logrus"
+)
+
+// Get the default cache directory.
+func default_cache_dir() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ log.WithError(err).Warn("failed to identify a home directory")
+ return ""
+ }
+
+ return filepath.Join(home, ".local", "nspotify")
+}
+
+// Try to cache an access token.
+func WriteCache(dir string, tok *oauth2.Token) error {
+ err := os.Mkdir(dir, 0700)
+ if err != nil && !os.IsExist(err) {
+ log.WithError(err).Warnf("failed to make cache directory: %s", dir)
+ return err
+ }
+
+ data, err := json.Marshal(tok)
+ if err != nil {
+ log.WithError(err).Warn("failed to marshall token")
+ return err
+ }
+
+ err = os.WriteFile(filepath.Join(dir, "token.json"), data, 0600)
+ if err != nil {
+ log.WithError(err).Warn("failed to write cache file")
+ return err
+ }
+
+ log.Debug("succeeded in caching token")
+
+ return nil
+}
+
+// Try to read a cache file for an access token.
+func ReadCache(dir string) (*oauth2.Token, error) {
+ tok := &oauth2.Token{}
+
+ full_path := filepath.Join(dir, "token.json")
+
+ data, err := os.ReadFile(full_path)
+ if err != nil {
+ log.WithError(err).Warnf("failed to read cache file: %s", full_path)
+ return nil, err
+ }
+
+ log.Debugf("found cache file: %s", full_path)
+
+ err = json.Unmarshal(data, tok)
+ if err != nil {
+ log.WithError(err).Warn("failed to unmarshall token")
+ return nil, err
+ }
+
+ log.Debug("succeeded in reading cached token")
+
+ return tok, nil
+}
+
A => config.go +105 -0
@@ 1,105 @@
+package main
+
+import (
+ "context"
+ "flag"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// Number of tracks to fetch into a buffer.
+const fetchingBuffer = 100
+
+// Number of seconds to wait before rechecking the lazy fetcher.
+const fetchingTimeout = 3
+
+// Number of tracks to eagerly load.
+const loadEager = 50
+
+// Number of tracks to lazily load ahead of the cursor.
+const loadLookahead = 50
+
+// Number of seconds to wait before rechecking how many trackers are loaded
+// ahead of the cursor.
+const loadTimeout = 3
+
+// Command line options.
+var (
+ verbose = flag.Bool("verbose", false, "Show debugging messages (same as '-log-level=trace')")
+ quiet = flag.Bool("quiet", false, "Suppress messages (same as '-log-level=panic')")
+ log_level = flag.String("log-level", "", "Set logging level to `[trace|debug|info|warning|error|fatal|panic]`")
+ //TODO: log_file = flag.String("log-file", "", "Log to `file`")
+ color = flag.Bool("color", true, "Display in color")
+ port = flag.Int("port", 8080, "Spotify authenticator port")
+ cache = flag.String("cache", "", "Cache `directory`")
+ no_cache = flag.Bool("no-cache", false, "Do not use cached authentication, do not cache authentication")
+ device = flag.String("device", "", "Spotify device `ID`")
+ list_devices = flag.Bool("list-devices", false, "List available Spotify devices and exit")
+)
+
+// Credit to `https://stackoverflow.com/a/30716481`:
+func Pointer[T any](v T) *T {
+ return &v
+}
+
+func ConfiguredContext() context.Context {
+ ctx := context.Background()
+
+ flag.Parse()
+
+ // If `-color` or `-color=true:
+ if *color {
+ log.SetFormatter(&log.TextFormatter{ForceColors: true, FullTimestamp: false})
+ } else {
+ log.SetFormatter(&log.TextFormatter{DisableColors: true, FullTimestamp: false})
+ }
+
+ // Prioritize explicit `-log-level`, then `-quiet`, then `-verbose`.
+ if *log_level == "" {
+ if *verbose {
+ log_level = Pointer("trace")
+ }
+ if *quiet {
+ log_level = Pointer("panic")
+ }
+ }
+ switch *log_level {
+ case "trace":
+ log.SetLevel(log.TraceLevel)
+ case "debug":
+ log.SetLevel(log.DebugLevel)
+ case "info":
+ log.SetLevel(log.InfoLevel)
+ case "warn":
+ log.SetLevel(log.WarnLevel)
+ case "error":
+ log.SetLevel(log.ErrorLevel)
+ case "fatal":
+ log.SetLevel(log.FatalLevel)
+ case "panic":
+ log.SetLevel(log.PanicLevel)
+ default:
+ log.SetLevel(log.ErrorLevel)
+ }
+
+ ctx = context.WithValue(ctx, "authport", *port)
+
+ if *cache == "" {
+ cache = Pointer(default_cache_dir())
+ }
+
+ // Signal `-no-cache` by forcing `-cache=''`.
+ if *no_cache {
+ cache = Pointer("")
+ }
+ ctx = context.WithValue(ctx, "cachedir", *cache)
+
+ // Signal `-list-devices` by forcing `-device=''`.
+ if *list_devices {
+ device = Pointer("")
+ }
+ ctx = context.WithValue(ctx, "device", *device)
+
+ return ctx
+}
+
A => devices.go +39 -0
@@ 1,39 @@
+package main
+
+// Spotify device interactions.
+
+import (
+ "context"
+ "fmt"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/zmb3/spotify/v2"
+)
+
+// Fetch and report the devices available to Spotify.
+func ListDevices(ctx context.Context, cli *spotify.Client) {
+ dev, err := cli.PlayerDevices(ctx)
+ if err != nil {
+ log.WithError(err).Fatal("failed to list devices")
+ }
+
+ fmt.Printf("%-30s %s\n", "Device (*=active)", "ID (pass this with `-device=ID`)")
+
+ for _, device := range dev {
+ id := device.ID.String()
+ if id == "" {
+ id = "?"
+ }
+ if device.Restricted {
+ id = "X"
+ }
+
+ name := device.Name
+ if device.Active {
+ name += " (*)"
+ }
+
+ fmt.Printf("%-30s %s\n", name, id)
+ }
+}
+
A => events.go +215 -0
@@ 1,215 @@
+package main
+
+// Manager for events.
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/zmb3/spotify/v2"
+)
+
+// Types of player events to expect.
+type EventType int
+
+const (
+ // Player event requesting that playback begin with a specific URI.
+ PlayURI EventType = 0
+
+ // Player event requesting that a specific URI be enqueued for future
+ // playback.
+ QueueURI EventType = 1
+
+ // Player event requesting that playback play (resume).
+ Play EventType = 2
+
+ // Player event requesting that playback pause.
+ Pause EventType = 3
+
+ // Player event requesting that playback toggle (play/pause).
+ Toggle EventType = 4
+
+ // Player event requesting that the previous song play.
+ PlayPrevious EventType = 5
+
+ // Player event requesting that the next song play.
+ PlayNext EventType = 6
+)
+
+// Convert a player event to a printable (debug-able) string.
+func debugEvent(ev EventType) string {
+ switch ev {
+ case PlayURI:
+ return "PlayURI"
+
+ case QueueURI:
+ return "QueueURI"
+
+ case Play:
+ return "Play"
+
+ case Pause:
+ return "Pause"
+
+ case Toggle:
+ return "Toggle"
+
+ case PlayPrevious:
+ return "PlayPrevious"
+
+ case PlayNext:
+ return "PlayNext"
+ }
+
+ return fmt.Sprintf("%d", ev)
+}
+
+// A player event.
+type Event struct {
+ Type EventType
+ URI spotify.URI
+}
+
+// Creates an `Event` of type `PlayURI`.
+func RequestPlayURI(uri spotify.URI) *Event {
+ return &Event{
+ Type: PlayURI,
+ URI: uri,
+ }
+}
+
+// Creates an `Event` of type `QueueURI`.
+func RequestQueueURI(uri spotify.URI) *Event {
+ return &Event{
+ Type: QueueURI,
+ URI: uri,
+ }
+}
+
+// Creates an `Event` of type `Play`.
+func RequestPlay() *Event {
+ return &Event{
+ Type: Play,
+ URI: spotify.URI(""),
+ }
+}
+
+// Creates an `Event` of type `Pause`.
+func RequestPause() *Event {
+ return &Event{
+ Type: Pause,
+ URI: spotify.URI(""),
+ }
+}
+
+// Creates an `Event` of type `Toggle`.
+func RequestToggle() *Event {
+ return &Event{
+ Type: Toggle,
+ URI: spotify.URI(""),
+ }
+}
+
+// Creates an `Event` of type `PlayPrevious`.
+func RequestPlayPrevious() *Event {
+ return &Event{
+ Type: PlayPrevious,
+ URI: spotify.URI(""),
+ }
+}
+
+// Creates an `Event` of type `PlayNext`.
+func RequestPlayNext() *Event {
+ return &Event{
+ Type: PlayNext,
+ URI: spotify.URI(""),
+ }
+}
+
+// Reusable handler for an `Event` of type `Play`.
+func handlePlayEvent(ctx context.Context, cli *spotify.Client) {
+ err := cli.Play(ctx)
+ if err != nil {
+ log.WithError(err).Error("request to play failed")
+ }
+}
+
+// Reusable handler for an `Event` of type `Pause`.
+func handlePauseEvent(ctx context.Context, cli *spotify.Client) {
+ err := cli.Pause(ctx)
+ if err != nil {
+ log.WithError(err).Error("request to pause failed")
+ }
+}
+
+// Manager for player events.
+func EventsManager(ctx context.Context, cli *spotify.Client, ch <-chan *Event) {
+ sdev, ok := ctx.Value("device").(string)
+ dev := spotify.ID(sdev)
+ if !ok || (dev == "") {
+ log.Errorf("invalid device: %s", sdev)
+ }
+
+ for ev := range ch {
+ switch ev.Type {
+ case PlayURI:
+ opts := &spotify.PlayOptions{
+ DeviceID: &dev,
+ URIs: []spotify.URI{ev.URI},
+ }
+ err := cli.PlayOpt(ctx, opts)
+ if err != nil {
+ log.WithError(err).Error("request to play URI failed")
+ }
+
+ case QueueURI:
+ suri := string(ev.URI)
+ sid := strings.Replace(suri, "spotify:track:", "", 1)
+ id := spotify.ID(sid)
+
+ err := cli.QueueSong(ctx, id)
+ if err != nil {
+ log.WithError(err).Error("request to queue URI failed")
+ }
+
+ case Play:
+ handlePlayEvent(ctx, cli)
+
+ case Pause:
+ handlePauseEvent(ctx, cli)
+
+ case Toggle:
+ status, err := cli.PlayerCurrentlyPlaying(ctx)
+ if err != nil {
+ log.WithError(err).Error("request to determine playback status, so assuming that status is playing")
+ handlePauseEvent(ctx, cli)
+ } else {
+ if status.Playing {
+ handlePauseEvent(ctx, cli)
+ } else {
+ handlePlayEvent(ctx, cli)
+ }
+ }
+
+ case PlayNext:
+ err := cli.Next(ctx)
+ if err != nil {
+ log.WithError(err).Error("request to play next failed")
+ }
+
+ case PlayPrevious:
+ err := cli.Previous(ctx)
+ if err != nil {
+ log.WithError(err).Error("request to play previous failed")
+ }
+
+ default:
+ log.Errorf("unhandled event: %s", debugEvent(ev.Type))
+ }
+ }
+
+ log.Trace("no more events, terminating events manager")
+}
+
A => fetching.go +105 -0
@@ 1,105 @@
+package main
+
+// Manager for fetching tracks.
+
+import (
+ "context"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/zmb3/spotify/v2"
+)
+
+// Actually fetch tracks from Spotify.
+func fetchingWorker(ctx context.Context, cli *spotify.Client, ch chan<- *spotify.FullTrack, done chan<- bool) {
+ // Fetch first page.
+ log.Trace("fetching first page...")
+ page, err := cli.CurrentUsersTracks(ctx)
+ if err != nil {
+ // Cleanup.
+ done <- true
+ close(ch)
+
+ // Terminate immediately.
+ log.WithError(err).Fatal("failed to fetch first page")
+ }
+ log.Trace("fetched first page")
+
+ // Fetch additional pages.
+ for {
+ select {
+
+ // Context is cancelled.
+ case <-ctx.Done():
+ break
+
+ // Continue fetching.
+ default:
+ for _, track := range page.Tracks {
+ // If the channel buffer is full, this will
+ // block. This is intentional. Manager will
+ // ensure that the buffer is emptied if the
+ // worker needs to terminate.
+ ch <- &track.FullTrack
+ }
+
+ log.Trace("fetching a new page...")
+ err = cli.NextPage(ctx, page)
+
+ // Reached end of pages.
+ if err == spotify.ErrNoMorePages {
+ log.Debug("no more pages")
+ break
+ }
+
+ // Other error?
+ if err != nil {
+ log.WithError(err).Error("failed to fetch a page")
+ break
+ }
+ }
+
+ }
+
+ // Cleanup.
+ done <- true
+ close(ch)
+}
+
+// Manage fetching tracks from Spotify.
+func FetchingManager(ctx context.Context, cli *spotify.Client, ch chan *spotify.FullTrack) {
+ done := make(chan bool)
+ go fetchingWorker(ctx, cli, ch, done)
+
+ for {
+ select {
+
+ // Fetch worker is terminating faster than the manager.
+ case <-done:
+ log.Trace("fetch worker terminated, terminating fetch manager")
+ break
+
+ // Context is cancelled.
+ case <-ctx.Done():
+ log.Trace("context closed, cleaning up fetch worker...")
+
+ // Drain channel to unblock worker. Worker should then
+ // notice the context is cancelled, too.
+ for _ = range ch {
+ }
+
+ log.Trace("cleanup complete, terminating fetch manager")
+ break
+
+ // Wait before re-checking the above situations.
+ default:
+ log.Trace("track manager & worker still running...")
+ time.Sleep(fetchingTimeout * time.Second)
+ }
+
+ }
+
+ // Block until worker is cleaned up.
+ <- done
+}
+
A => go.mod +22 -0
@@ 1,22 @@
+module git.dominic-ricottone.com/~dricottone/nspotify
+
+go 1.22.1
+
+require (
+ github.com/gdamore/encoding v1.0.0 // indirect
+ github.com/gdamore/tcell/v2 v2.7.1 // indirect
+ github.com/golang/protobuf v1.5.3 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/rivo/tview v0.0.0-20240403142647-a22293bda944 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/zmb3/spotify/v2 v2.4.1 // indirect
+ golang.org/x/net v0.22.0 // indirect
+ golang.org/x/oauth2 v0.18.0 // indirect
+ golang.org/x/sys v0.18.0 // indirect
+ golang.org/x/term v0.18.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+ google.golang.org/appengine v1.6.7 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
+)
A => listing.go +90 -0
@@ 1,90 @@
+package main
+
+// Manager for the track listing.
+
+import (
+ "context"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/zmb3/spotify/v2"
+ "github.com/rivo/tview"
+)
+
+// Actually append tracks to the listing.
+//
+// NOTE: `ch` is for receiving tracks from the `FetchingManager`.
+// `quit` is for receiving a termination signal from the `ListingManager`.
+// `done` is for the reverse, sending a termination signal to the `ListingManager`.
+func listingWorker(listing *tview.Table, ch <-chan *spotify.FullTrack, quit <-chan bool, done chan<- bool) {
+ for {
+ select {
+
+ // Terminate loader.
+ case <-quit:
+ break
+
+ default:
+ cursor, _ := listing.GetSelection()
+ length := listing.GetRowCount()
+
+ if (length - cursor) < loadLookahead {
+ track, ok := <- ch
+
+ // Channel is closed; terminate now.
+ if !ok {
+ log.Trace("no more tracks to load")
+ break
+ }
+
+ log.Tracef("loading %s...", track.Name)
+ for col, cell := range IntoCells(track) {
+ listing.SetCell(length, col, cell)
+ }
+
+ continue
+ }
+
+ // Wait before retrying
+ time.Sleep(loadTimeout * time.Second)
+ }
+ }
+
+ done <- true
+}
+
+// Manager for appending tracks to the listing.
+func ListingManager(ctx context.Context, listing *tview.Table, ch <-chan *spotify.FullTrack) {
+ // Load first N tracks eagerly.
+ log.Tracef("loading %d tracks...", loadEager)
+ for i := 0; i < loadEager; i++ {
+ track := <- ch
+ for col, cell := range IntoCells(track) {
+ listing.SetCell(i, col, cell)
+ }
+ }
+ log.Tracef("loaded %d tracks", loadEager)
+
+ // Load more tracks lazily.
+ quit := make(chan bool)
+ done := make(chan bool)
+ go listingWorker(listing, ch, quit, done)
+
+ for {
+ select {
+
+ // Somehow track loader is terminating faster than this loop.
+ case <-done:
+ log.Trace("track renderer stopped running")
+ break
+
+ // Context is cancelled; terminate track loader.
+ case <-ctx.Done():
+ break
+
+ }
+ }
+
+ quit <- true
+}
+
A => main.go +39 -0
@@ 1,39 @@
+package main
+
+import (
+ "context"
+
+ "github.com/zmb3/spotify/v2"
+)
+
+func main() {
+ ctx := ConfiguredContext()
+ ctx, cancel := context.WithCancel(ctx)
+
+ // Authenticate with Spotify.
+ cli := Authenticate(ctx)
+ //TODO: incorporate rate limiting? "set the AutoRetry field on the Client struct to true"
+
+ // List devices mode.
+ sdev, ok := ctx.Value("device").(string)
+ dev := spotify.ID(sdev)
+ if !ok || (dev == "") {
+ ListDevices(ctx, cli)
+ cancel()
+ return
+ }
+
+ // Fetch user tracks with Spotify client. Will continue to run in
+ // background.
+ fetchCh := make(chan *spotify.FullTrack, fetchingBuffer)
+ go FetchingManager(ctx, cli, fetchCh)
+
+ evCh := make(chan *Event)
+ go EventsManager(ctx, cli, evCh)
+
+ // Run terminal application. Will block until application terminates.
+ Start(ctx, fetchCh, evCh)
+
+ cancel()
+}
+
A => tracks.go +56 -0
@@ 1,56 @@
+package main
+
+// Spotify tracks interactions.
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/zmb3/spotify/v2"
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+// Format an array of Spotify artists into a `&`-delimited list of artist
+// names.
+func FormatArtists(artists []spotify.SimpleArtist) string {
+ buff := []string{}
+ for _, artist := range artists {
+ buff = append(buff, artist.Name)
+ //artist_id := artist.ID
+ //artist_uri := artist.URI
+ }
+
+ return strings.Join(buff, " & ")
+}
+
+// Format a duration in milliseconds into `[HH]:[MM]:[SS]` (or `[MM]:[SS]` if
+// duration is less than 1 hour).
+func FormatDuration(ms int) string {
+ ms /= 1000
+ s := ms % 60
+ ms /= 60
+ m := ms % 60
+ h := ms / 60
+ if h == 0 {
+ return fmt.Sprintf("%d:%02d", m, s)
+ }
+ return fmt.Sprintf("%d:%02d:%02d", h, m, s)
+}
+
+// Create a row of table cells from a Spotify track.
+func IntoCells(track *spotify.FullTrack) []*tview.TableCell {
+ name := tview.NewTableCell(track.Name).SetTextColor(tcell.ColorWhite).SetReference(track.URI)
+ artist := tview.NewTableCell(FormatArtists(track.Artists)).SetTextColor(tcell.ColorWhite)
+ album := tview.NewTableCell(track.Album.Name).SetTextColor(tcell.ColorWhite)
+ duration := tview.NewTableCell(FormatDuration(track.Duration)).SetAlign(tview.AlignRight).SetTextColor(tcell.ColorWhite)
+
+ //id := track.ID
+ //number := track.TrackNumber
+ //album_id := track.Album.ID
+ //album_uri := track.Album.URI
+ //album_date := track.Album.ReleaseDate
+
+ return []*tview.TableCell{name, artist, album, duration}
+}
+