build spotify-to-navidrome migrator with recovery flow
This commit is contained in:
381
internal/navidrome/client.go
Normal file
381
internal/navidrome/client.go
Normal file
@@ -0,0 +1,381 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user