package main import ( "fmt" "os" "strings" "io/ioutil" "net/http" "log" "encoding/json" "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 URL" 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...) // return m.spinner.Tick } 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 // Change cursor mode case "ctrl+r": m.cursorMode++ if m.cursorMode > textinput.CursorHide { m.cursorMode = textinput.CursorBlink } cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { cmds[i] = m.inputs[i].SetCursorMode(m.cursorMode) } return m, tea.Batch(cmds...) // Set focus to next input 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)) // Only text inputs with Focus() set will respond, so it's safe to simply // update all of them here without any further logic. 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) } }