From 8cdd2ed0ec352e82f1ba4363f84fded2dad7db37 Mon Sep 17 00:00:00 2001 From: Niklas Kapelle Date: Wed, 6 May 2026 23:48:54 +0200 Subject: [PATCH] sync playlists between spotify and navidrome --- cmd/ripsort.go | 24 ++++++- internal/playlist/navidrome.go | 23 +++++- internal/playlist/spotify.go | 127 +++++++++++++++++++++++---------- internal/syncPlaylists.go | 116 +++++++++++++++++++++++++++++- 4 files changed, 244 insertions(+), 46 deletions(-) diff --git a/cmd/ripsort.go b/cmd/ripsort.go index cd7a652..cc2b87a 100644 --- a/cmd/ripsort.go +++ b/cmd/ripsort.go @@ -17,7 +17,19 @@ type SortCmd struct { } type SyncPlaylists struct { - Url string `arg:"positional,required"` + SpotifyArgs + NavidromeArgs +} + +type SpotifyArgs struct { + SpotifyClientID string `arg:"--spotify-client-id,env:SPOTIFY_CLIENT_ID,required"` + SpotifyClientSecret string `arg:"--spotify-client-secret,env:SPOTIFY_CLIENT_SECRET,required"` +} + +type NavidromeArgs struct { + NavidromeURL string `arg:"--navidrome-url,env:ND_URL,required"` + NavidromeUser string `arg:"--navidrome-user,env:ND_USER,required"` + NavidromePass string `arg:"--navidrome-pass,env:ND_PASS,required"` } type FixCommentCmd struct { @@ -28,7 +40,7 @@ type args struct { Info *InfoCmd `arg:"subcommand:info"` Sort *SortCmd `arg:"subcommand:sort"` FixCommentTag *FixCommentCmd `arg:"subcommand:fix-comment"` - SyncPlaylists *SyncPlaylists `arg:"subcommand:spotify"` + SyncPlaylists *SyncPlaylists `arg:"subcommand:sync-playlist"` Verbose bool `arg:"-v" default:"false"` DryRun bool `arg:"--dry-run" default:"false"` } @@ -49,7 +61,13 @@ func Run() { case args.FixCommentTag != nil: ripsort.FixComment(args.FixCommentTag.Path, args.DryRun) case args.SyncPlaylists != nil: - ripsort.SyncPlaylists(args.SyncPlaylists.Url) + ripsort.SyncPlaylists( + args.SyncPlaylists.SpotifyClientID, + args.SyncPlaylists.SpotifyClientSecret, + args.SyncPlaylists.NavidromeURL, + args.SyncPlaylists.NavidromeUser, + args.SyncPlaylists.NavidromePass, + ) default: p.Fail("Must specify command") } diff --git a/internal/playlist/navidrome.go b/internal/playlist/navidrome.go index be9bce6..edc6ee1 100644 --- a/internal/playlist/navidrome.go +++ b/internal/playlist/navidrome.go @@ -28,6 +28,7 @@ type NavidromePlaylist struct { } type NavidromeTrack struct { + ID string `json:"id"` // position ID within the playlist or id of the song. See MediaFileID Title string `json:"title"` Artist string `json:"artist"` ArtistID string `json:"artistId"` @@ -65,7 +66,6 @@ type NavidromeTrack struct { type NavidromePlaylistTrack struct { NavidromeTrack - ID string `json:"id"` // position ID within the playlist MediaFileID string `json:"mediaFileId"` // the actual song ID } @@ -168,6 +168,25 @@ func (c *NavidromeClient) getPlaylistTracks(playlistID string) ([]NavidromePlayl return tracks, nil } +func (c *NavidromeClient) GetSongBySpotifyID(spotifyID string) ([]NavidromeTrack, error) { + // https://navi.kapelle.org/api/song?_end=100&_order=ASC&_sort=title&_start=0&spotifyid=0NhcaHA0cyJyhk3g8tUsZP&missing=false + params := url.Values{ + "spotifyid": {spotifyID}, + } + var songs []NavidromeTrack + err := c.getList("/api/song", params, &songs) + if err != nil { + return nil, err + } + + return songs, nil +} + +func (c *NavidromeClient) AddTracks(playlistID string, songIDs []string) error { + body := map[string]interface{}{"ids": songIDs} + return c.request(http.MethodPost, "/api/playlist/"+playlistID+"/tracks", body, nil) +} + func (c *NavidromeClient) getList(path string, extra url.Values, dest any) error { const pageSize = 500 var all []json.RawMessage @@ -207,7 +226,7 @@ func (c *NavidromeClient) getList(path string, extra url.Values, dest any) error return json.Unmarshal(merged, dest) } -func (c *NavidromeClient) request(method, path string, body interface{}, dest interface{}) error { +func (c *NavidromeClient) request(method, path string, body any, dest any) error { httpResp, err := c.do(method, path, body) if err != nil { return err diff --git a/internal/playlist/spotify.go b/internal/playlist/spotify.go index 9717f5c..2cba23d 100644 --- a/internal/playlist/spotify.go +++ b/internal/playlist/spotify.go @@ -8,16 +8,12 @@ import ( "net/http" "net/url" "strings" + "sync" + "time" ) const spotifyAPIBase = "https://api.spotify.com/v1" -type SpotifyTokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` -} - type SpotifyPlaylist struct { Name string `json:"name"` Description string `json:"description"` @@ -51,50 +47,46 @@ type SpotifyTrack struct { } `json:"external_ids"` } -func GetSpotifyAccessToken(clientID, clientSecret string) (string, error) { - creds := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) - - data := url.Values{} - data.Set("grant_type", "client_credentials") - - req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", - strings.NewReader(data.Encode())) - if err != nil { - return "", err - } - req.Header.Set("Authorization", "Basic "+creds) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("auth failed (%d): %s", resp.StatusCode, body) - } - - var token SpotifyTokenResponse - if err := json.Unmarshal(body, &token); err != nil { - return "", err - } - return token.AccessToken, nil +type SpotifyClient struct { + clientID string + clientSecret string + token string + mu sync.Mutex + httpClient *http.Client } -func GetPlaylist(token, playlistID string) (*SpotifyPlaylist, error) { +type spotifyTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func NewSpotifyClient(clientID, clientSecret string) (*SpotifyClient, error) { + c := &SpotifyClient{ + clientID: clientID, + clientSecret: clientSecret, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + + if err := c.renewToken(); err != nil { + return nil, err + } + + return c, nil +} + +func (c *SpotifyClient) GetPlaylist(playlistID string) (*SpotifyPlaylist, error) { // TODO: handle rate limit // TODO: handle request limits + reqURL := fmt.Sprintf("%s/playlists/%s", spotifyAPIBase, playlistID) req, err := http.NewRequest("GET", reqURL, nil) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) + resp, err := c.do(req) if err != nil { return nil, err } @@ -111,3 +103,60 @@ func GetPlaylist(token, playlistID string) (*SpotifyPlaylist, error) { } return &playlist, nil } + +func (c *SpotifyClient) renewToken() error { + creds := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret)) + + data := url.Values{} + data.Set("grant_type", "client_credentials") + + req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", + strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Authorization", "Basic "+creds) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("auth failed (%d): %s", resp.StatusCode, body) + } + + var token spotifyTokenResponse + if err := json.Unmarshal(body, &token); err != nil { + return err + } + + c.token = token.AccessToken + + return nil +} + +func (c *SpotifyClient) do(req *http.Request) (*http.Response, error) { + resp, err := c.doOnce(req) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() + if err := c.renewToken(); err != nil { + return nil, err + } + return c.doOnce(req) + } + return resp, nil +} + +func (c *SpotifyClient) doOnce(req *http.Request) (*http.Response, error) { + c.mu.Lock() + req.Header.Set("Authorization", "Bearer "+c.token) + c.mu.Unlock() + return c.httpClient.Do(req) +} diff --git a/internal/syncPlaylists.go b/internal/syncPlaylists.go index 0bd77d9..722380e 100644 --- a/internal/syncPlaylists.go +++ b/internal/syncPlaylists.go @@ -1,5 +1,117 @@ package ripsort -func SyncPlaylists(spotifyUrl string) { - // TODO +import ( + "log/slog" + "os" + "regexp" + + "git.kapelle.org/niklas/ripsort/internal/playlist" +) + +func SyncPlaylists(spotifyClientID, spotifyClientSecret, navidromeBase, navidromeUser, navidromePass string) { + spotifyClient, err := playlist.NewSpotifyClient(spotifyClientID, spotifyClientSecret) + if err != nil { + slog.Error("Failed to create spotify client", "err", err) + os.Exit(1) + } + + navidromeClient, err := playlist.NewNavidromeClient(navidromeBase, navidromeUser, navidromePass) + if err != nil { + slog.Error("Failed to create navidrome client", "err", err) + os.Exit(1) + } + + allPlaylists, err := navidromeClient.GetPlaylists() + + for _, pl := range allPlaylists { + spotifyPlaylistID := spotifyIDForPlaylist(&pl) + if spotifyPlaylistID == "" { + continue + } + + slog.Info("Syncing playlist", "name", pl.Name) + + err = syncPlaylists(spotifyClient, navidromeClient, spotifyPlaylistID, pl.ID) + if err != nil { + slog.Error("Failed to sync playlist", "name", pl.Name, "err", err) + os.Exit(1) + } + } +} + +func spotifyIDForPlaylist(playlist *playlist.NavidromePlaylist) string { + var re = regexp.MustCompile(`(?m)https:\/\/open\.spotify\.com\/playlist\/([a-zA-Z0-9]*)`) + + matches := re.FindAllStringSubmatch(playlist.Comment, -1) + if len(matches) == 0 { + return "" + } + + return matches[0][1] +} + +func syncPlaylists(sp *playlist.SpotifyClient, nd *playlist.NavidromeClient, spotifyid string, navidromeID string) error { + spotifyPlaylist, err := sp.GetPlaylist(spotifyid) + if err != nil { + return err + } + + navidromeFullPlaylist, err := nd.GetPlaylist(navidromeID) + if err != nil { + return err + } + + songsToAdd := []string{} + + for _, spotifySong := range spotifyPlaylist.Items.Items { + if ndPlaylistContainsSong(navidromeFullPlaylist, spotifySong.Item.ID) { + slog.Debug("Track already in playlist", "id", spotifySong.Item.ID) + continue + } + + songs, err := nd.GetSongBySpotifyID(spotifySong.Item.ID) + if err != nil { + return err + } + + if len(songs) == 0 { + slog.Warn("No song found", "spotifyid", spotifySong.Item.ID, "title", spotifySong.Item.Name, "artists", spotifySong.Item.Artists) + // Missing song + // TODO: handle + continue + } + + if len(songs) != 1 { + slog.Warn("Found multiple songs for spotifyid", "spotifyid", spotifySong.Item.ID) + } + songsToAdd = append(songsToAdd, songs[0].ID) + } + + if len(songsToAdd) == 0 { + return nil + } + + slog.Info("Adding songs to playlist", "count", len(songsToAdd), "playlist", navidromeFullPlaylist.Name) + err = nd.AddTracks(navidromeID, songsToAdd) + if err != nil { + slog.Error("Failed to add songs", "err", err) + } + + return nil +} + +func ndPlaylistContainsSong(pl *playlist.NavidromePlaylist, spotifyID string) bool { + for _, song := range pl.Tracks { + tag := song.CustomTags["spotifyid"] + + if tag == nil || len(tag) == 0 { + continue + } + + if tag[0] == spotifyID { + return true + } + } + + return false }