~dricottone/nspotify

8475ba6f72670f2b684c04aa95ffb09cbb3257ab — Dominic Ricottone 8 months ago
Initial commit
13 files changed, 1035 insertions(+), 0 deletions(-)

A .gitignore
A Makefile
A app.go
A auth.go
A cache.go
A config.go
A devices.go
A events.go
A fetching.go
A go.mod
A listing.go
A main.go
A tracks.go
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}
}