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 { 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"` 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 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) 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 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 any, dest any) 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() } } }