package navidrome import ( "context" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "io" "math/rand" "net/http" "net/url" "strings" "time" ) const apiVersion = "1.16.1" const defaultClientName = "navimigrate" type Client struct { httpClient *http.Client baseURL string username string password string clientName string } type Track struct { ID string Title string Artist string Album string Duration int ISRCs []string } type subsonicError struct { Code int `json:"code"` Message string `json:"message"` } type isrcField []string func (f *isrcField) UnmarshalJSON(data []byte) error { if string(data) == "null" { *f = nil return nil } var one string if err := json.Unmarshal(data, &one); err == nil { *f = splitISRC(one) return nil } var many []string if err := json.Unmarshal(data, &many); err == nil { all := make([]string, 0, len(many)) for _, part := range many { all = append(all, splitISRC(part)...) } *f = uniqueStrings(all) return nil } return fmt.Errorf("invalid isrc field") } func NewClient(baseURL, username, password string) *Client { baseURL = strings.TrimSpace(baseURL) baseURL = strings.TrimRight(baseURL, "/") return &Client{ httpClient: &http.Client{Timeout: 30 * time.Second}, baseURL: baseURL, username: strings.TrimSpace(username), password: password, clientName: defaultClientName, } } func (c *Client) Ping(ctx context.Context) error { var out struct { SubsonicResponse struct { Status string `json:"status"` Error *subsonicError `json:"error"` } `json:"subsonic-response"` } if err := c.call(ctx, http.MethodGet, "/rest/ping.view", url.Values{}, &out); err != nil { return err } return nil } func (c *Client) SearchTracks(ctx context.Context, query string, limit int) ([]Track, error) { if limit <= 0 { limit = 8 } params := url.Values{} params.Set("query", strings.TrimSpace(query)) params.Set("songCount", fmt.Sprintf("%d", limit)) params.Set("songOffset", "0") params.Set("artistCount", "0") params.Set("albumCount", "0") var out struct { SubsonicResponse struct { Status string `json:"status"` Error *subsonicError `json:"error"` SearchResult3 struct { Song []struct { ID string `json:"id"` Title string `json:"title"` Artist string `json:"artist"` Album string `json:"album"` Duration int `json:"duration"` IsDir bool `json:"isDir"` ISRC isrcField `json:"isrc"` } `json:"song"` } `json:"searchResult3"` } `json:"subsonic-response"` } if err := c.call(ctx, http.MethodGet, "/rest/search3.view", params, &out); err != nil { return nil, err } res := make([]Track, 0, len(out.SubsonicResponse.SearchResult3.Song)) for _, s := range out.SubsonicResponse.SearchResult3.Song { if s.IsDir || strings.TrimSpace(s.ID) == "" { continue } res = append(res, Track{ ID: s.ID, Title: s.Title, Artist: s.Artist, Album: s.Album, Duration: s.Duration, ISRCs: []string(s.ISRC), }) } return res, nil } func (c *Client) CreatePlaylist(ctx context.Context, name string) (string, error) { params := url.Values{} params.Set("name", strings.TrimSpace(name)) var out struct { SubsonicResponse struct { Status string `json:"status"` Error *subsonicError `json:"error"` Playlist struct { ID string `json:"id"` } `json:"playlist"` } `json:"subsonic-response"` } if err := c.call(ctx, http.MethodGet, "/rest/createPlaylist.view", params, &out); err != nil { return "", err } id := strings.TrimSpace(out.SubsonicResponse.Playlist.ID) if id == "" { return "", fmt.Errorf("createPlaylist returned empty playlist ID") } return id, nil } func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error { if len(trackIDs) == 0 { return nil } playlistID = strings.TrimSpace(playlistID) if playlistID == "" { return fmt.Errorf("playlist ID cannot be empty") } chunks := chunk(trackIDs, 200) for _, ch := range chunks { params := url.Values{} params.Set("playlistId", playlistID) for _, id := range ch { id = strings.TrimSpace(id) if id == "" { continue } params.Add("songIdToAdd", id) } if len(params["songIdToAdd"]) == 0 { continue } var out struct { SubsonicResponse struct { Status string `json:"status"` Error *subsonicError `json:"error"` } `json:"subsonic-response"` } if err := c.call(ctx, http.MethodPost, "/rest/updatePlaylist.view", params, &out); err != nil { return err } } return nil } func (c *Client) DeletePlaylist(ctx context.Context, playlistID string) error { params := url.Values{} params.Set("id", strings.TrimSpace(playlistID)) var out struct { SubsonicResponse struct { Status string `json:"status"` Error *subsonicError `json:"error"` } `json:"subsonic-response"` } if err := c.call(ctx, http.MethodGet, "/rest/deletePlaylist.view", params, &out); err != nil { return err } return nil } func (c *Client) call(ctx context.Context, method, endpoint string, params url.Values, out any) error { params = cloneValues(params) c.addAuth(params) var req *http.Request var err error fullURL := c.baseURL + endpoint if method == http.MethodPost { req, err = http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(params.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { if len(params) > 0 { fullURL += "?" + params.Encode() } req, err = http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) if err != nil { return err } } req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode >= 300 { return fmt.Errorf("navidrome api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b))) } if err := json.Unmarshal(b, out); err != nil { return fmt.Errorf("decode navidrome response: %w", err) } failed, serr, err := parseSubsonicStatus(b) if err != nil { return err } if failed { if serr != nil { return fmt.Errorf("navidrome api failed (%d): %s", serr.Code, serr.Message) } return fmt.Errorf("navidrome api failed") } return nil } func parseSubsonicStatus(body []byte) (bool, *subsonicError, error) { var root struct { SubsonicResponse struct { Status string `json:"status"` Error *subsonicError `json:"error"` } `json:"subsonic-response"` } if err := json.Unmarshal(body, &root); err != nil { return false, nil, fmt.Errorf("decode subsonic envelope: %w", err) } if strings.EqualFold(strings.TrimSpace(root.SubsonicResponse.Status), "ok") { return false, root.SubsonicResponse.Error, nil } return true, root.SubsonicResponse.Error, nil } func (c *Client) addAuth(params url.Values) { salt := randomSalt(12) params.Set("u", c.username) params.Set("s", salt) params.Set("t", md5Hex(c.password+salt)) params.Set("v", apiVersion) params.Set("c", c.clientName) params.Set("f", "json") } func randomSalt(n int) string { const letters = "abcdefghijklmnopqrstuvwxyz0123456789" if n <= 0 { n = 12 } b := make([]byte, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } func md5Hex(s string) string { h := md5.Sum([]byte(s)) return hex.EncodeToString(h[:]) } func splitISRC(v string) []string { v = strings.TrimSpace(v) if v == "" { return nil } parts := strings.Split(v, ";") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.ToUpper(strings.TrimSpace(p)) if p != "" { out = append(out, p) } } return uniqueStrings(out) } func uniqueStrings(in []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(in)) for _, v := range in { if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) } return out } func chunk(ids []string, size int) [][]string { if size <= 0 { size = 200 } out := make([][]string, 0, (len(ids)+size-1)/size) for i := 0; i < len(ids); i += size { j := i + size if j > len(ids) { j = len(ids) } out = append(out, ids[i:j]) } return out } func cloneValues(v url.Values) url.Values { res := url.Values{} for k, values := range v { cp := make([]string, len(values)) copy(cp, values) res[k] = cp } return res } func init() { rand.Seed(time.Now().UnixNano()) }