290 lines
7.5 KiB
Go
290 lines
7.5 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|