Skip to content

Instantly share code, notes, and snippets.

@orels1
Last active March 26, 2022 23:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save orels1/a7de78c90019114c75f43e7a24d92fb1 to your computer and use it in GitHub Desktop.
Save orels1/a7de78c90019114c75f43e7a24d92fb1 to your computer and use it in GitHub Desktop.
A simple go-based program to grab and export all the tracks from a single spotify playlist

For this program to work - you'll need to get a spotify auth token, which you can get via many cli and UI tools, including things like spotify-token. This program does not cover getting the token for you

Made as a way to learn bubbletea TUI library

CleanShot 2022-03-27 at 03 11 16

package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
cursorStyle = focusedStyle.Copy()
noStyle = lipgloss.NewStyle()
helpStyle = blurredStyle.Copy()
cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
focusedButton = focusedStyle.Copy().Render("[ Load Tracks ]")
blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Load Tracks"))
headerStyle = lipgloss.NewStyle().
Bold(true).
Background(lipgloss.Color("#38363a")).
Foreground(lipgloss.Color("#4af2a1")).
PaddingTop(1).
PaddingBottom(1).
PaddingLeft(3).
PaddingRight(3)
boldStyle = lipgloss.NewStyle().Bold(true)
)
type model struct {
playlistUrl string
authToken string
fileName string
focusIndex int
inputs []textinput.Model
cursorMode textinput.CursorMode
spinner spinner.Model
loadingTracks bool
loadedTracks bool
playlistInfo SpotifyTracksResp
progress progress.Model
exporting bool
exportDone bool
tracksData []SpotifyTrack
}
type SpotifyTracksResp struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
Total int `json:"total"`
Items []SpotifyTrackItems `json:"items"`
}
type SpotifyTrackItems struct {
Track SpotifyTrack `json:"track"`
}
type SpotifyTrack struct {
Name string `json:"name"`
Id string `json:"id"`
Href string `json:"href"`
Album SpotifyAlbum `json:"album"`
Artists []SpotifyArtist `json:"artists"`
}
type SpotifyAlbum struct {
Name string `json:"name"`
Id string `json:"id"`
Href string `json:"href"`
}
type SpotifyArtist struct {
Name string `json:"name"`
Id string `json:"id"`
Href string `json:"href"`
}
type playlistData struct {
data SpotifyTracksResp
}
type exportStep struct {
data SpotifyTracksResp
offset int
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#4af2a1"))
m := model{
inputs: make([]textinput.Model, 3),
spinner: s,
progress: progress.New(progress.WithDefaultGradient()),
}
var t textinput.Model
for i := range m.inputs {
t = textinput.New()
t.CursorStyle = cursorStyle
switch i {
case 0:
t.Placeholder = "Playlist ID"
t.Focus()
t.PromptStyle = focusedStyle
t.TextStyle = focusedStyle
case 1:
t.Placeholder = "Auth Token"
case 2:
t.Placeholder = "File Name"
}
m.inputs[i] = t
}
return m
}
func (m model) Init() tea.Cmd {
cmds := make([]tea.Cmd, 2)
cmds[0] = textinput.Blink
cmds[1] = m.spinner.Tick
return tea.Batch(cmds...)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.progress.Width = msg.Width - 2*2 - 4
if m.progress.Width > 80 {
m.progress.Width = 80
}
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "tab", "shift+tab", "enter", "up", "down":
s := msg.String()
if !m.loadedTracks && s == "enter" && m.focusIndex == len(m.inputs) {
m.loadingTracks = true
m.focusIndex = 0
return m, loadTracks(m.inputs[0].Value(), m.inputs[1].Value())
}
if m.loadedTracks && !m.exportDone && !m.exporting && s == "enter" {
m.exporting = true
return m, exportTracks(m.inputs[0].Value(), 0, m.inputs[1].Value())
}
if m.exportDone && s == "enter" {
return m, tea.Quit
}
// Cycle indexes
if s == "up" || s == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex > len(m.inputs) {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.inputs)
}
cmds := make([]tea.Cmd, len(m.inputs))
for i := 0; i <= len(m.inputs)-1; i++ {
if i == m.focusIndex {
// Set focused state
cmds[i] = m.inputs[i].Focus()
m.inputs[i].PromptStyle = focusedStyle
m.inputs[i].TextStyle = focusedStyle
continue
}
// Remove focused state
m.inputs[i].Blur()
m.inputs[i].PromptStyle = noStyle
m.inputs[i].TextStyle = noStyle
}
return m, tea.Batch(cmds...)
}
case playlistData:
m.loadingTracks = false
m.loadedTracks = true
m.playlistInfo = msg.data
m.tracksData = make([]SpotifyTrack, m.playlistInfo.Total)
return m, nil
case exportStep:
for i := 0; i < 100; i++ {
if i < len(msg.data.Items) {
m.tracksData[i+msg.offset] = msg.data.Items[i].Track
}
}
if (msg.offset + 100) < m.playlistInfo.Total {
cmd := m.progress.IncrPercent(100.0 / float64(m.playlistInfo.Total))
return m, tea.Batch(exportTracks(m.inputs[0].Value(), msg.offset+100, m.inputs[1].Value()), cmd)
}
m.exporting = false
m.exportDone = true
bytes, _ := json.MarshalIndent(m.tracksData, "", "\t")
ioutil.WriteFile(m.inputs[2].Value(), bytes, 0644)
return m, m.progress.IncrPercent(100.0 / float64(m.playlistInfo.Total))
case progress.FrameMsg:
progressModel, cmd := m.progress.Update(msg)
m.progress = progressModel.(progress.Model)
return m, cmd
}
cmds := make([]tea.Cmd, 2)
cmds[0] = m.updateInputs(msg)
m.spinner, cmds[1] = m.spinner.Update(msg)
return m, tea.Batch(cmds...)
}
func (m *model) updateInputs(msg tea.Msg) tea.Cmd {
var cmds = make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return tea.Batch(cmds...)
}
func loadTracks(playlistId string, authToken string) tea.Cmd {
return func() tea.Msg {
client := &http.Client{}
url := fmt.Sprintf("https://api.spotify.com/v1/playlists/%s/tracks?limit=10&offset=0&fields=offset,limit,total,items(track(name,id,href,release_date,album(name,id,href),artists(name,href,id)))", playlistId)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken))
req.Header.Set("Content-Type", "application/json")
response, err := client.Do(req)
if err != nil {
fmt.Print(err.Error())
os.Exit(1)
}
responseData, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal(err)
}
var parsed SpotifyTracksResp
err = json.Unmarshal(responseData, &parsed)
if err != nil {
log.Fatal(err)
}
return playlistData{
data: parsed,
}
}
}
func exportTracks(playlistId string, offset int, authToken string) tea.Cmd {
return func() tea.Msg {
client := &http.Client{}
url := fmt.Sprintf("https://api.spotify.com/v1/playlists/%s/tracks?limit=100&offset=%d&fields=offset,limit,total,items(track(name,id,href,release_date,album(name,id,href),artists(name,href,id)))", playlistId, offset)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken))
req.Header.Set("Content-Type", "application/json")
response, err := client.Do(req)
if err != nil {
fmt.Print(err.Error())
os.Exit(1)
}
responseData, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal(err)
}
var parsed SpotifyTracksResp
err = json.Unmarshal(responseData, &parsed)
if err != nil {
log.Fatal(err)
}
time.Sleep(1 * time.Second)
return exportStep{
data: parsed,
offset: offset,
}
}
}
func (m model) View() string {
var b strings.Builder
b.WriteRune('\n')
b.WriteString(headerStyle.Render("🎧 Spotify Track Exporter"))
b.WriteRune('\n')
b.WriteRune('\n')
if !m.loadingTracks && !m.loadedTracks {
b.WriteString(boldStyle.Render("First, we need some information"))
b.WriteRune('\n')
b.WriteRune('\n')
}
if !m.loadedTracks && !m.loadingTracks {
for i := range m.inputs {
b.WriteString(m.inputs[i].View())
if i < len(m.inputs)-1 {
b.WriteRune('\n')
}
}
button := &blurredButton
if m.focusIndex == len(m.inputs) {
button = &focusedButton
}
fmt.Fprintf(&b, "\n\n%s\n\n", *button)
}
if m.loadingTracks {
b.WriteString(m.spinner.View())
b.WriteString(" Loading Playlist Data...")
b.WriteRune('\n')
}
if m.loadedTracks && !m.exporting && !m.exportDone {
b.WriteString(fmt.Sprintf("Found %d songs", m.playlistInfo.Total))
b.WriteRune('\n')
b.WriteRune('\n')
b.WriteString("Ready to export! Press Enter to start")
b.WriteRune('\n')
b.WriteRune('\n')
b.WriteRune('\n')
b.WriteString(focusedStyle.Copy().Render("[ Export ]"))
b.WriteRune('\n')
}
if m.exporting && !m.exportDone {
b.WriteRune('\n')
b.WriteRune('\n')
b.WriteString(m.progress.View())
b.WriteRune('\n')
b.WriteRune('\n')
}
if m.exportDone {
b.WriteString(fmt.Sprintf("Done! You can find your spotify tracks at %s", m.inputs[2].Value()))
b.WriteRune('\n')
b.WriteRune('\n')
b.WriteString(focusedStyle.Copy().Render("[ Exit ]"))
b.WriteRune('\n')
}
return b.String()
}
func main() {
m := initialModel()
p := tea.NewProgram(m)
if err := p.Start(); err != nil {
fmt.Printf("Alas, there has been an error: %v", err)
os.Exit(1)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment