382 lines
8.5 KiB
Go
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())
|
|
}
|