From 8475ba6f72670f2b684c04aa95ffb09cbb3257ab Mon Sep 17 00:00:00 2001 From: Dominic Ricottone Date: Sun, 7 Apr 2024 13:59:27 -0500 Subject: [PATCH] Initial commit --- .gitignore | 4 + Makefile | 22 ++++++ app.go | 135 +++++++++++++++++++++++++++++++++ auth.go | 131 ++++++++++++++++++++++++++++++++ cache.go | 72 ++++++++++++++++++ config.go | 105 +++++++++++++++++++++++++ devices.go | 39 ++++++++++ events.go | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++ fetching.go | 105 +++++++++++++++++++++++++ go.mod | 22 ++++++ listing.go | 90 ++++++++++++++++++++++ main.go | 39 ++++++++++ tracks.go | 56 ++++++++++++++ 13 files changed, 1035 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 app.go create mode 100644 auth.go create mode 100644 cache.go create mode 100644 config.go create mode 100644 devices.go create mode 100644 events.go create mode 100644 fetching.go create mode 100644 go.mod create mode 100644 listing.go create mode 100644 main.go create mode 100644 tracks.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7058bfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +clientid.txt +clientsecret.txt +nspotify +go.sum diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b8708a --- /dev/null +++ b/Makefile @@ -0,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 diff --git a/app.go b/app.go new file mode 100644 index 0000000..a7580da --- /dev/null +++ b/app.go @@ -0,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? +} + diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..49922bb --- /dev/null +++ b/auth.go @@ -0,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 +} + diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..76d7656 --- /dev/null +++ b/cache.go @@ -0,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 +} + diff --git a/config.go b/config.go new file mode 100644 index 0000000..f342227 --- /dev/null +++ b/config.go @@ -0,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 +} + diff --git a/devices.go b/devices.go new file mode 100644 index 0000000..6eb558b --- /dev/null +++ b/devices.go @@ -0,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) + } +} + diff --git a/events.go b/events.go new file mode 100644 index 0000000..ef4e3af --- /dev/null +++ b/events.go @@ -0,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") +} + diff --git a/fetching.go b/fetching.go new file mode 100644 index 0000000..e91ca54 --- /dev/null +++ b/fetching.go @@ -0,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 +} + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e1cfbbe --- /dev/null +++ b/go.mod @@ -0,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 +) diff --git a/listing.go b/listing.go new file mode 100644 index 0000000..f44ee3a --- /dev/null +++ b/listing.go @@ -0,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 +} + diff --git a/main.go b/main.go new file mode 100644 index 0000000..b2dd92e --- /dev/null +++ b/main.go @@ -0,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() +} + diff --git a/tracks.go b/tracks.go new file mode 100644 index 0000000..86e171e --- /dev/null +++ b/tracks.go @@ -0,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} +} + -- 2.45.2