moved clients into seperate package & fixed error in playlist sync
This commit is contained in:
318
internal/clients/navidrome/navidrome.go
Normal file
318
internal/clients/navidrome/navidrome.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package navidrome
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"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"`
|
||||
}
|
||||
|
||||
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 navidromeLoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type navidromeLoginResponse 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, fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *NavidromeClient) login() error {
|
||||
body, _ := json.Marshal(navidromeLoginRequest{Username: c.username, Password: c.password})
|
||||
|
||||
resp, err := c.httpClient.Post(
|
||||
c.baseURL+"/auth/login",
|
||||
"application/json",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var lr navidromeLoginResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = lr.Token
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lists all playlists without the tracks
|
||||
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
|
||||
}
|
||||
|
||||
// Get a single playlist without the tracks
|
||||
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
|
||||
}
|
||||
|
||||
return &pl, nil
|
||||
}
|
||||
|
||||
// Get all tracks inside a playlist
|
||||
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
|
||||
}
|
||||
|
||||
// Search songs based on a custom tag named "spotifyid"
|
||||
func (c *NavidromeClient) GetSongBySpotifyID(spotifyID string) ([]NavidromeTrack, error) {
|
||||
params := url.Values{
|
||||
"spotifyid": {spotifyID},
|
||||
}
|
||||
var songs []NavidromeTrack
|
||||
err := c.getList("/api/song", params, &songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
// Add tracks to a playlist
|
||||
func (c *NavidromeClient) AddTracks(playlistID string, songIDs []string) error {
|
||||
body := map[string]any{"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)},
|
||||
}
|
||||
|
||||
maps.Copy(params, extra)
|
||||
|
||||
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("status code: %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var page []json.RawMessage
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
|
||||
return fmt.Errorf("decode: %w", 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("status code: %d: %s", 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("decode: %w", 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("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()
|
||||
}
|
||||
}
|
||||
}
|
||||
166
internal/clients/spotify/spotify.go
Normal file
166
internal/clients/spotify/spotify.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const spotifyAPIBase = "https://api.spotify.com/v1"
|
||||
|
||||
type SpotifyPlaylist struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Public bool `json:"public"`
|
||||
Images []struct {
|
||||
Url string `json:"url"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
} `json:"images"`
|
||||
Items struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
Items []struct {
|
||||
Item SpotifyTrack `json:"item"`
|
||||
} `json:"items"`
|
||||
} `json:"items"`
|
||||
ExternalURLs struct {
|
||||
Spotify string `json:"spotify"`
|
||||
} `json:"external_urls"`
|
||||
}
|
||||
|
||||
type SpotifyTrack struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
ExternalIDs struct {
|
||||
Isrc string `json:"isrc"`
|
||||
} `json:"external_ids"`
|
||||
}
|
||||
|
||||
type SpotifyClient struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
token string
|
||||
mu sync.Mutex
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type spotifyTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func NewSpotifyClient(clientID, clientSecret string) (*SpotifyClient, error) {
|
||||
c := &SpotifyClient{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
|
||||
if err := c.renewToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyClient) GetPlaylist(playlistID string) (*SpotifyPlaylist, error) {
|
||||
// TODO: handle rate limit
|
||||
// TODO: handle request limits
|
||||
|
||||
reqURL := fmt.Sprintf("%s/playlists/%s", spotifyAPIBase, playlistID)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("status code: %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var playlist SpotifyPlaylist
|
||||
if err := json.Unmarshal(body, &playlist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &playlist, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyClient) renewToken() error {
|
||||
creds := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret))
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "client_credentials")
|
||||
|
||||
req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token",
|
||||
strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Basic "+creds)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("auth failed: %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var token spotifyTokenResponse
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.token = token.AccessToken
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SpotifyClient) do(req *http.Request) (*http.Response, error) {
|
||||
resp, err := c.doOnce(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
resp.Body.Close()
|
||||
if err := c.renewToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.doOnce(req)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyClient) doOnce(req *http.Request) (*http.Response, error) {
|
||||
c.mu.Lock()
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
c.mu.Unlock()
|
||||
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
Reference in New Issue
Block a user