From 44a486aa466987f2544493aa51c4baf24163e459 Mon Sep 17 00:00:00 2001 From: Niklas Kapelle Date: Sat, 2 May 2026 23:56:33 +0200 Subject: [PATCH] WIP playlist sync --- cmd/ripsort.go | 7 + internal/playlist/navidrome.go | 289 +++++++++++++++++++++++++++++++++ internal/playlist/playlist.go | 2 + internal/playlist/spotify.go | 113 +++++++++++++ internal/syncPlaylists.go | 5 + 5 files changed, 416 insertions(+) create mode 100644 internal/playlist/navidrome.go create mode 100644 internal/playlist/playlist.go create mode 100644 internal/playlist/spotify.go create mode 100644 internal/syncPlaylists.go diff --git a/cmd/ripsort.go b/cmd/ripsort.go index 7dc1f46..cd7a652 100644 --- a/cmd/ripsort.go +++ b/cmd/ripsort.go @@ -16,6 +16,10 @@ type SortCmd struct { Path string `arg:"positional,required"` } +type SyncPlaylists struct { + Url string `arg:"positional,required"` +} + type FixCommentCmd struct { Path string `arg:"positional,required"` } @@ -24,6 +28,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"` Verbose bool `arg:"-v" default:"false"` DryRun bool `arg:"--dry-run" default:"false"` } @@ -43,6 +48,8 @@ func Run() { ripsort.Sort(args.Sort.Dst, args.Sort.Path) case args.FixCommentTag != nil: ripsort.FixComment(args.FixCommentTag.Path, args.DryRun) + case args.SyncPlaylists != nil: + ripsort.SyncPlaylists(args.SyncPlaylists.Url) default: p.Fail("Must specify command") } diff --git a/internal/playlist/navidrome.go b/internal/playlist/navidrome.go new file mode 100644 index 0000000..be9bce6 --- /dev/null +++ b/internal/playlist/navidrome.go @@ -0,0 +1,289 @@ +package playlist + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +type NavidromePlaylist struct { + ID string `json:"id"` + Name string `json:"name"` + Comment string `json:"comment"` + OwnerName string `json:"ownerName"` + Public bool `json:"public"` + SongCount int `json:"songCount"` + DurationSec float64 `json:"duration"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CoverArtID string `json:"coverArtId"` + Tracks []NavidromePlaylistTrack `json:"-"` // populated by GetPlaylist +} + +type NavidromeTrack struct { + Title string `json:"title"` + Artist string `json:"artist"` + ArtistID string `json:"artistId"` + Album string `json:"album"` + AlbumID string `json:"albumId"` + AlbumArtist string `json:"albumArtist"` + TrackNumber int `json:"trackNumber"` + DiscNumber int `json:"discNumber"` + Year int `json:"year"` + Genre string `json:"genre"` + DurationSec float64 `json:"duration"` + BitRate int `json:"bitRate"` + Size int64 `json:"size"` + ContentType string `json:"contentType"` + Suffix string `json:"suffix"` + Path string `json:"path"` + PlayCount int `json:"playCount"` + PlayDate string `json:"playDate"` + Starred bool `json:"starred"` + Rating int `json:"rating"` + BPM int `json:"bpm"` + Comment string `json:"comment"` + Lyrics string `json:"lyrics"` + SortTitle string `json:"sortTitle"` + SortArtist string `json:"sortArtistName"` + SortAlbum string `json:"sortAlbumName"` + MBTrackID string `json:"mbzTrackId"` + MBAlbumID string `json:"mbzAlbumId"` + MBAlbumArtistID string `json:"mbzAlbumArtistId"` + MBArtistID string `json:"mbzArtistId"` + + CustomTags map[string][]string `json:"tags,omitempty"` // All custom tags are treated as arrays +} + +type NavidromePlaylistTrack struct { + NavidromeTrack + + ID string `json:"id"` // position ID within the playlist + MediaFileID string `json:"mediaFileId"` // the actual song ID +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type loginResponse struct { + Token string `json:"token"` + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` +} + +type NavidromeClient struct { + baseURL string + username string + password string + token string + mu sync.Mutex + httpClient *http.Client +} + +func NewNavidromeClient(baseURL, username, password string) (*NavidromeClient, error) { + c := &NavidromeClient{ + baseURL: strings.TrimRight(baseURL, "/"), + username: username, + password: password, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + + if err := c.login(); err != nil { + return nil, err + } + + return c, nil +} + +func (c *NavidromeClient) login() error { + body, _ := json.Marshal(loginRequest{Username: c.username, Password: c.password}) + + resp, err := c.httpClient.Post( + c.baseURL+"/auth/login", + "application/json", + bytes.NewReader(body), + ) + if err != nil { + return fmt.Errorf("navidrome: login: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("navidrome: login: HTTP %d", resp.StatusCode) + } + + var lr loginResponse + if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { + return fmt.Errorf("navidrome: login decode: %w", err) + } + + c.mu.Lock() + c.token = lr.Token + c.mu.Unlock() + + return nil +} + +func (c *NavidromeClient) GetPlaylists() ([]NavidromePlaylist, error) { + var playlists []NavidromePlaylist + + if err := c.getList("/api/playlist", nil, &playlists); err != nil { + return nil, err + } + + return playlists, nil +} + +func (c *NavidromeClient) GetPlaylist(id string) (*NavidromePlaylist, error) { + var pl NavidromePlaylist + if err := c.request(http.MethodGet, "/api/playlist/"+id, nil, &pl); err != nil { + return nil, err + } + + tracks, err := c.getPlaylistTracks(id) + if err != nil { + return nil, err + } + + pl.Tracks = tracks + + return &pl, nil +} + +func (c *NavidromeClient) getPlaylistTracks(playlistID string) ([]NavidromePlaylistTrack, error) { + var tracks []NavidromePlaylistTrack + if err := c.getList("/api/playlist/"+playlistID+"/tracks", nil, &tracks); err != nil { + return nil, err + } + return tracks, nil +} + +func (c *NavidromeClient) getList(path string, extra url.Values, dest any) error { + const pageSize = 500 + var all []json.RawMessage + + for start := 0; ; start += pageSize { + params := url.Values{ + "_start": {strconv.Itoa(start)}, + "_end": {strconv.Itoa(start + pageSize)}, + } + for k, vs := range extra { + params[k] = vs + } + + httpResp, err := c.do(http.MethodGet, path+"?"+params.Encode(), nil) + if err != nil { + return err + } + defer httpResp.Body.Close() + c.refreshToken(httpResp) + + if httpResp.StatusCode != http.StatusOK && httpResp.StatusCode != http.StatusPartialContent { + return fmt.Errorf("navidrome: GET %s: HTTP %d", path, httpResp.StatusCode) + } + + var page []json.RawMessage + if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil { + return fmt.Errorf("navidrome: decode %s: %w", path, err) + } + all = append(all, page...) + + if len(page) < pageSize { + break + } + } + + merged, _ := json.Marshal(all) + return json.Unmarshal(merged, dest) +} + +func (c *NavidromeClient) request(method, path string, body interface{}, dest interface{}) error { + httpResp, err := c.do(method, path, body) + if err != nil { + return err + } + defer httpResp.Body.Close() + c.refreshToken(httpResp) + + if httpResp.StatusCode >= 300 { + raw, _ := io.ReadAll(httpResp.Body) + return fmt.Errorf("navidrome: %s %s: HTTP %d: %s", + method, path, httpResp.StatusCode, strings.TrimSpace(string(raw))) + } + if dest != nil && httpResp.ContentLength != 0 { + if err := json.NewDecoder(httpResp.Body).Decode(dest); err != nil { + return fmt.Errorf("navidrome: decode %s: %w", path, err) + } + } + return nil +} + +func (c *NavidromeClient) do(method, path string, body any) (*http.Response, error) { + bodyBytes, err := marshalBody(body) + if err != nil { + return nil, err + } + + resp, err := c.doOnce(method, path, bodyBytes) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() + if err := c.login(); err != nil { + return nil, err + } + return c.doOnce(method, path, bodyBytes) + } + return resp, nil +} + +func (c *NavidromeClient) doOnce(method, path string, bodyBytes []byte) (*http.Response, error) { + var bodyReader io.Reader + if bodyBytes != nil { + bodyReader = bytes.NewReader(bodyBytes) + } + req, err := http.NewRequest(method, c.baseURL+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("navidrome: build request: %w", err) + } + if bodyBytes != nil { + req.Header.Set("Content-Type", "application/json") + } + c.mu.Lock() + req.Header.Set("X-ND-Authorization", "Bearer "+c.token) + c.mu.Unlock() + return c.httpClient.Do(req) +} + +func marshalBody(body any) ([]byte, error) { + if body == nil { + return nil, nil + } + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("navidrome: marshal body: %w", err) + } + return b, nil +} + +func (c *NavidromeClient) refreshToken(resp *http.Response) { + if h := resp.Header.Get("X-ND-Authorization"); h != "" { + token := strings.TrimPrefix(h, "Bearer ") + if token != "" { + c.mu.Lock() + c.token = token + c.mu.Unlock() + } + } +} diff --git a/internal/playlist/playlist.go b/internal/playlist/playlist.go new file mode 100644 index 0000000..ae8ef54 --- /dev/null +++ b/internal/playlist/playlist.go @@ -0,0 +1,2 @@ +package playlist + diff --git a/internal/playlist/spotify.go b/internal/playlist/spotify.go new file mode 100644 index 0000000..9717f5c --- /dev/null +++ b/internal/playlist/spotify.go @@ -0,0 +1,113 @@ +package playlist + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +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"` + Public bool `json:"public"` + Images []struct { + Url string `json:"url"` + Height int `json:"height"` + Width int `json:"width"` + } `json:"images"` + Items struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` + Items []struct { + Item SpotifyTrack `json:"item"` + } `json:"items"` + } `json:"items"` + ExternalURLs struct { + Spotify string `json:"spotify"` + } `json:"external_urls"` +} + +type SpotifyTrack struct { + ID string `json:"id"` + Name string `json:"name"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + ExternalIDs struct { + Isrc string `json:"isrc"` + } `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 +} + +func GetPlaylist(token, 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) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, body) + } + + var playlist SpotifyPlaylist + if err := json.Unmarshal(body, &playlist); err != nil { + return nil, err + } + return &playlist, nil +} diff --git a/internal/syncPlaylists.go b/internal/syncPlaylists.go new file mode 100644 index 0000000..0bd77d9 --- /dev/null +++ b/internal/syncPlaylists.go @@ -0,0 +1,5 @@ +package ripsort + +func SyncPlaylists(spotifyUrl string) { + // TODO +}