|
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) |
|
} |
|
} |