Skip to content

Instantly share code, notes, and snippets.

@orels1
Last active March 26, 2022 23:12
Show Gist options
  • Save orels1/a7de78c90019114c75f43e7a24d92fb1 to your computer and use it in GitHub Desktop.
Save orels1/a7de78c90019114c75f43e7a24d92fb1 to your computer and use it in GitHub Desktop.

Revisions

  1. orels1 revised this gist Mar 26, 2022. 2 changed files with 234 additions and 235 deletions.
    3 changes: 1 addition & 2 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -2,5 +2,4 @@ For this program to work - you'll need to get a spotify auth token, which you ca

    _Made as a way to learn [bubbletea TUI library](https://github.com/charmbracelet/bubbletea)_

    ![CleanShot 2022-03-27 at 03 01 34](https://user-images.githubusercontent.com/3798928/160259959-4f1c0a78-4ada-4178-9538-d50bb2ff19f0.gif)

    ![CleanShot 2022-03-27 at 03 11 16](https://user-images.githubusercontent.com/3798928/160260075-cdcb1cfa-25f4-481d-b357-0ee3e47292f1.gif)
    466 changes: 233 additions & 233 deletions spotifyTrackExporter.go
    Original file line number Diff line number Diff line change
    @@ -1,18 +1,18 @@
    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"
    "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"
    )
    @@ -28,83 +28,83 @@ var (
    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)
    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
    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"`
    Offset int `json:"offset"`
    Limit int `json:"limit"`
    Total int `json:"total"`
    Items []SpotifyTrackItems `json:"items"`
    }

    type SpotifyTrackItems struct {
    Track SpotifyTrack `json:"track"`
    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"`
    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"`
    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"`
    Name string `json:"name"`
    Id string `json:"id"`
    Href string `json:"href"`
    }

    type playlistData struct {
    data SpotifyTracksResp
    data SpotifyTracksResp
    }

    type exportStep struct {
    data SpotifyTracksResp
    offset int
    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()),
    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
    @@ -131,16 +131,16 @@ func initialModel() model {
    }

    func (m model) Init() tea.Cmd {
    cmds := make([]tea.Cmd, 2)
    cmds[0] = textinput.Blink
    cmds[1] = m.spinner.Tick
    return tea.Batch(cmds...)
    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
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
    m.progress.Width = msg.Width - 2*2 - 4
    if m.progress.Width > 80 {
    m.progress.Width = 80
    }
    @@ -153,20 +153,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    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 && 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.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
    }
    if m.exportDone && s == "enter" {
    return m, tea.Quit
    }

    // Cycle indexes
    if s == "up" || s == "shift+tab" {
    @@ -197,45 +197,45 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    }

    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 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)
    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)
    }
    @@ -244,140 +244,140 @@ func (m *model) updateInputs(msg tea.Msg) tea.Cmd {
    }

    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,
    }
    }
    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,
    }
    }
    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')
    }
    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)
    }
    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)
    }
    }
  2. orels1 revised this gist Mar 26, 2022. 1 changed file with 2 additions and 18 deletions.
    20 changes: 2 additions & 18 deletions spotifyTrackExporter.go
    Original file line number Diff line number Diff line change
    @@ -114,7 +114,7 @@ func initialModel() model {

    switch i {
    case 0:
    t.Placeholder = "Playlist URL"
    t.Placeholder = "Playlist ID"
    t.Focus()
    t.PromptStyle = focusedStyle
    t.TextStyle = focusedStyle
    @@ -135,7 +135,6 @@ func (m model) Init() tea.Cmd {
    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) {
    @@ -151,19 +150,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    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()

    @@ -249,9 +235,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

    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)
    }
  3. orels1 created this gist Mar 26, 2022.
    6 changes: 6 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,6 @@
    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](https://github.com/charmbracelet/bubbletea)_

    ![CleanShot 2022-03-27 at 03 01 34](https://user-images.githubusercontent.com/3798928/160259959-4f1c0a78-4ada-4178-9538-d50bb2ff19f0.gif)

    399 changes: 399 additions & 0 deletions spotifyTrackExporter.go
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,399 @@
    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)
    }
    }