build spotify-to-navidrome migrator with recovery flow
This commit is contained in:
314
internal/spotify/auth.go
Normal file
314
internal/spotify/auth.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthConfig struct {
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
Scopes []string
|
||||
ManualCode bool
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func RefreshAccessToken(ctx context.Context, clientID, refreshToken string) (Token, error) {
|
||||
body := url.Values{
|
||||
"client_id": []string{strings.TrimSpace(clientID)},
|
||||
"grant_type": []string{"refresh_token"},
|
||||
"refresh_token": []string{strings.TrimSpace(refreshToken)},
|
||||
}
|
||||
|
||||
tok, err := requestToken(ctx, body)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("spotify refresh failed: %w", err)
|
||||
}
|
||||
if tok.RefreshToken == "" {
|
||||
tok.RefreshToken = strings.TrimSpace(refreshToken)
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func LoginWithPKCE(ctx context.Context, cfg AuthConfig) (Token, error) {
|
||||
redirectURL, err := url.Parse(cfg.RedirectURI)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("invalid redirect URI: %w", err)
|
||||
}
|
||||
|
||||
codeVerifier, err := randomURLSafe(64)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
state, err := randomURLSafe(24)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
|
||||
h := sha256.Sum256([]byte(codeVerifier))
|
||||
codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
|
||||
|
||||
authURL := "https://accounts.spotify.com/authorize?" + url.Values{
|
||||
"response_type": []string{"code"},
|
||||
"client_id": []string{cfg.ClientID},
|
||||
"scope": []string{strings.Join(cfg.Scopes, " ")},
|
||||
"redirect_uri": []string{cfg.RedirectURI},
|
||||
"state": []string{state},
|
||||
"code_challenge_method": []string{"S256"},
|
||||
"code_challenge": []string{codeChallenge},
|
||||
}.Encode()
|
||||
|
||||
if cfg.ManualCode {
|
||||
return loginManual(ctx, cfg, authURL, state, codeVerifier)
|
||||
}
|
||||
|
||||
tok, err := loginWithCallbackServer(ctx, cfg, redirectURL, authURL, state, codeVerifier)
|
||||
if err == nil {
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(err.Error()), "listen callback server") {
|
||||
fmt.Println("Local callback server unavailable; falling back to manual code entry.")
|
||||
return loginManual(ctx, cfg, authURL, state, codeVerifier)
|
||||
}
|
||||
|
||||
return Token{}, err
|
||||
}
|
||||
|
||||
func loginWithCallbackServer(ctx context.Context, cfg AuthConfig, redirectURL *url.URL, authURL, state, codeVerifier string) (Token, error) {
|
||||
codeCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server := &http.Server{
|
||||
Addr: redirectURL.Host,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
mux.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("state") != state {
|
||||
http.Error(w, "Invalid state", http.StatusBadRequest)
|
||||
select {
|
||||
case errCh <- fmt.Errorf("state mismatch in spotify callback"):
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if e := q.Get("error"); e != "" {
|
||||
http.Error(w, "Spotify authorization failed", http.StatusBadRequest)
|
||||
select {
|
||||
case errCh <- fmt.Errorf("spotify auth error: %s", e):
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "Missing authorization code", http.StatusBadRequest)
|
||||
select {
|
||||
case errCh <- fmt.Errorf("spotify callback missing code"):
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = io.WriteString(w, "Spotify authorization complete. You can close this tab.")
|
||||
select {
|
||||
case codeCh <- code:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
ln, err := net.Listen("tcp", redirectURL.Host)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("listen callback server: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if serveErr := server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed {
|
||||
select {
|
||||
case errCh <- serveErr:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
_ = openBrowser(authURL)
|
||||
fmt.Printf("Open this URL in your browser if it did not open automatically:\n%s\n\n", authURL)
|
||||
|
||||
var code string
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = server.Shutdown(context.Background())
|
||||
return Token{}, ctx.Err()
|
||||
case e := <-errCh:
|
||||
_ = server.Shutdown(context.Background())
|
||||
return Token{}, e
|
||||
case code = <-codeCh:
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
|
||||
return exchangeCode(ctx, cfg, code, codeVerifier)
|
||||
}
|
||||
|
||||
func loginManual(ctx context.Context, cfg AuthConfig, authURL, expectedState, codeVerifier string) (Token, error) {
|
||||
_ = openBrowser(authURL)
|
||||
fmt.Printf("Open this URL in your browser if it did not open automatically:\n%s\n\n", authURL)
|
||||
fmt.Printf("After Spotify redirects to %s, copy the full URL from your browser and paste it here.\n", cfg.RedirectURI)
|
||||
fmt.Println("If your browser only shows a code, you can paste that code directly.")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for {
|
||||
fmt.Print("Paste callback URL/code: ")
|
||||
if !scanner.Scan() {
|
||||
if scanner.Err() != nil {
|
||||
return Token{}, scanner.Err()
|
||||
}
|
||||
return Token{}, fmt.Errorf("stdin closed before spotify auth code was provided")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Token{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(scanner.Text())
|
||||
if input == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
code, err := extractAuthCode(input, expectedState)
|
||||
if err != nil {
|
||||
fmt.Printf("Could not parse code: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
return exchangeCode(ctx, cfg, code, codeVerifier)
|
||||
}
|
||||
}
|
||||
|
||||
func extractAuthCode(input, expectedState string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
if !strings.Contains(input, "code=") && !strings.Contains(input, "://") {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
queryString := ""
|
||||
if strings.Contains(input, "://") {
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid callback URL")
|
||||
}
|
||||
queryString = u.RawQuery
|
||||
} else {
|
||||
queryString = strings.TrimPrefix(input, "?")
|
||||
}
|
||||
|
||||
q, err := url.ParseQuery(queryString)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid callback query")
|
||||
}
|
||||
|
||||
if e := q.Get("error"); e != "" {
|
||||
return "", fmt.Errorf("spotify returned error: %s", e)
|
||||
}
|
||||
|
||||
if gotState := q.Get("state"); expectedState != "" && gotState != "" && gotState != expectedState {
|
||||
return "", fmt.Errorf("state mismatch")
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(q.Get("code"))
|
||||
if code == "" {
|
||||
return "", fmt.Errorf("missing code parameter")
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func exchangeCode(ctx context.Context, cfg AuthConfig, code, codeVerifier string) (Token, error) {
|
||||
body := url.Values{
|
||||
"client_id": []string{cfg.ClientID},
|
||||
"grant_type": []string{"authorization_code"},
|
||||
"code": []string{code},
|
||||
"redirect_uri": []string{cfg.RedirectURI},
|
||||
"code_verifier": []string{codeVerifier},
|
||||
}
|
||||
return requestToken(ctx, body)
|
||||
}
|
||||
|
||||
func requestToken(ctx context.Context, body url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://accounts.spotify.com/api/token", strings.NewReader(body.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return Token{}, fmt.Errorf("spotify token exchange failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
var tok Token
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if tok.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("spotify token response missing access_token")
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func randomURLSafe(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func openBrowser(u string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", u)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", u)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
|
||||
default:
|
||||
return fmt.Errorf("unsupported OS for auto-open")
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
277
internal/spotify/client.go
Normal file
277
internal/spotify/client.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"navimigrate/internal/model"
|
||||
)
|
||||
|
||||
const baseURL = "https://api.spotify.com/v1"
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
token string
|
||||
progress ProgressFunc
|
||||
}
|
||||
|
||||
type ProgressFunc func(message string)
|
||||
|
||||
func NewClient(token string) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetProgress(fn ProgressFunc) {
|
||||
c.progress = fn
|
||||
}
|
||||
|
||||
func (c *Client) FetchPlaylistsByID(ctx context.Context, ids []string) ([]model.Playlist, error) {
|
||||
out := make([]model.Playlist, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
pl, err := c.FetchPlaylistByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.notifyProgress(fmt.Sprintf("Spotify playlist URLs: %d/%d", i+1, len(ids)))
|
||||
out = append(out, pl)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchPlaylistByID(ctx context.Context, playlistID string) (model.Playlist, error) {
|
||||
type response struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/playlists/%s?fields=id,name,description", baseURL, url.PathEscape(playlistID))
|
||||
var meta response
|
||||
if err := c.getJSON(ctx, endpoint, &meta); err != nil {
|
||||
return model.Playlist{}, fmt.Errorf("fetch playlist metadata %s: %w", playlistID, err)
|
||||
}
|
||||
|
||||
tracks, err := c.fetchPlaylistTracks(ctx, meta.ID, meta.Name)
|
||||
if err != nil {
|
||||
return model.Playlist{}, fmt.Errorf("fetch playlist tracks %s: %w", meta.Name, err)
|
||||
}
|
||||
|
||||
return model.Playlist{
|
||||
SourceID: meta.ID,
|
||||
Name: meta.Name,
|
||||
Description: meta.Description,
|
||||
Tracks: tracks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchPlaylistTracks(ctx context.Context, playlistID, playlistName string) ([]model.Track, error) {
|
||||
type trackObj struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
ExternalIDs struct {
|
||||
ISRC string `json:"isrc"`
|
||||
} `json:"external_ids"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
type item struct {
|
||||
Track *trackObj `json:"track"`
|
||||
}
|
||||
type page struct {
|
||||
Items []item `json:"items"`
|
||||
Next string `json:"next"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
var out []model.Track
|
||||
next := fmt.Sprintf("%s/playlists/%s/tracks?limit=100", baseURL, url.PathEscape(playlistID))
|
||||
loadedTracks := 0
|
||||
totalTracks := 0
|
||||
for next != "" {
|
||||
var p page
|
||||
if err := c.getJSON(ctx, next, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Total > 0 {
|
||||
totalTracks = p.Total
|
||||
}
|
||||
for _, it := range p.Items {
|
||||
if it.Track == nil || it.Track.ID == "" {
|
||||
continue
|
||||
}
|
||||
loadedTracks++
|
||||
out = append(out, toModelTrack(
|
||||
it.Track.ID,
|
||||
it.Track.Name,
|
||||
it.Track.Album.Name,
|
||||
it.Track.DurationMS,
|
||||
it.Track.ExternalIDs.ISRC,
|
||||
it.Track.Explicit,
|
||||
it.Track.Artists,
|
||||
))
|
||||
}
|
||||
|
||||
if totalTracks > 0 {
|
||||
c.notifyProgress(fmt.Sprintf("Playlist (%s): tracks %d/%d", playlistName, loadedTracks, totalTracks))
|
||||
} else {
|
||||
c.notifyProgress(fmt.Sprintf("Playlist (%s): tracks %d", playlistName, loadedTracks))
|
||||
}
|
||||
|
||||
next = p.Next
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchLikedSongs(ctx context.Context) ([]model.Track, error) {
|
||||
type trackObj struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
ExternalIDs struct {
|
||||
ISRC string `json:"isrc"`
|
||||
} `json:"external_ids"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
type item struct {
|
||||
Track *trackObj `json:"track"`
|
||||
}
|
||||
type page struct {
|
||||
Items []item `json:"items"`
|
||||
Next string `json:"next"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
var out []model.Track
|
||||
next := baseURL + "/me/tracks?limit=50"
|
||||
loaded := 0
|
||||
total := 0
|
||||
for next != "" {
|
||||
var p page
|
||||
if err := c.getJSON(ctx, next, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Total > 0 {
|
||||
total = p.Total
|
||||
}
|
||||
for _, it := range p.Items {
|
||||
if it.Track == nil || it.Track.ID == "" {
|
||||
continue
|
||||
}
|
||||
loaded++
|
||||
out = append(out, toModelTrack(
|
||||
it.Track.ID,
|
||||
it.Track.Name,
|
||||
it.Track.Album.Name,
|
||||
it.Track.DurationMS,
|
||||
it.Track.ExternalIDs.ISRC,
|
||||
it.Track.Explicit,
|
||||
it.Track.Artists,
|
||||
))
|
||||
}
|
||||
|
||||
if total > 0 {
|
||||
c.notifyProgress(fmt.Sprintf("Liked songs: %d/%d", loaded, total))
|
||||
} else {
|
||||
c.notifyProgress(fmt.Sprintf("Liked songs: %d", loaded))
|
||||
}
|
||||
|
||||
next = p.Next
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) notifyProgress(msg string) {
|
||||
if c.progress != nil {
|
||||
c.progress(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func toModelTrack(id, name, album string, durationMS int, isrc string, explicit bool, artists []struct {
|
||||
Name string `json:"name"`
|
||||
}) model.Track {
|
||||
artistNames := make([]string, 0, len(artists))
|
||||
for _, a := range artists {
|
||||
if strings.TrimSpace(a.Name) != "" {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
}
|
||||
return model.Track{
|
||||
SourceID: id,
|
||||
Title: name,
|
||||
Artists: artistNames,
|
||||
Album: album,
|
||||
DurationMS: durationMS,
|
||||
ISRC: strings.ToUpper(strings.TrimSpace(isrc)),
|
||||
Explicit: explicit,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(ctx context.Context, endpoint string, out any) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
resp.Body.Close()
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("spotify api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("spotify request failed after retries")
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
43
internal/spotify/playlist_url.go
Normal file
43
internal/spotify/playlist_url.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParsePlaylistID(input string) (string, error) {
|
||||
s := strings.TrimSpace(input)
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("empty playlist input")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s, "spotify:playlist:") {
|
||||
id := strings.TrimSpace(strings.TrimPrefix(s, "spotify:playlist:"))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("invalid spotify URI")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
if !strings.Contains(s, "://") {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid playlist URL")
|
||||
}
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
if parts[i] == "playlist" {
|
||||
id := strings.TrimSpace(parts[i+1])
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("missing playlist id in URL")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find playlist id in URL")
|
||||
}
|
||||
24
internal/spotify/playlist_url_test.go
Normal file
24
internal/spotify/playlist_url_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package spotify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePlaylistID(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"spotify:playlist:16DZpOTLqZvdbqxEavLmWk", "16DZpOTLqZvdbqxEavLmWk"},
|
||||
{"https://open.spotify.com/playlist/16DZpOTLqZvdbqxEavLmWk?si=abc", "16DZpOTLqZvdbqxEavLmWk"},
|
||||
{"16DZpOTLqZvdbqxEavLmWk", "16DZpOTLqZvdbqxEavLmWk"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := ParsePlaylistID(tt.in)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePlaylistID(%q) returned error: %v", tt.in, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ParsePlaylistID(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user