Files

382 lines
8.5 KiB
Go

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())
}