build spotify-to-navidrome migrator with recovery flow
This commit is contained in:
341
internal/qobuz/client.go
Normal file
341
internal/qobuz/client.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const baseURL = "https://www.qobuz.com/api.json/0.2"
|
||||
|
||||
const defaultUA = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"
|
||||
const defaultAppVersion = "9.7.0.3"
|
||||
const defaultDevicePlatform = "android"
|
||||
const defaultDeviceModel = "Nexus 6P"
|
||||
const defaultDeviceOSVersion = "9"
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
appID string
|
||||
appSecret string
|
||||
token string
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
ID string
|
||||
Title string
|
||||
Version string
|
||||
Duration int
|
||||
ISRC string
|
||||
Artist string
|
||||
Album string
|
||||
AlbumID string
|
||||
AlbumArtist string
|
||||
}
|
||||
|
||||
type flexString string
|
||||
|
||||
func (f *flexString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*f = ""
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
*f = flexString(strings.TrimSpace(s))
|
||||
return nil
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(data, &n); err == nil {
|
||||
*f = flexString(strings.TrimSpace(n.String()))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid flexible id")
|
||||
}
|
||||
|
||||
func NewClient(appID, appSecret string) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
appID: appID,
|
||||
appSecret: appSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context, username, password string) error {
|
||||
type oauthResponse struct {
|
||||
OAuth2 struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
} `json:"oauth2"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
rawPassword := strings.TrimSpace(password)
|
||||
md5Password := md5Hex(rawPassword)
|
||||
|
||||
attempts := []struct {
|
||||
Method string
|
||||
Password string
|
||||
}{
|
||||
{Method: http.MethodGet, Password: md5Password},
|
||||
{Method: http.MethodGet, Password: rawPassword},
|
||||
{Method: http.MethodPost, Password: md5Password},
|
||||
{Method: http.MethodPost, Password: rawPassword},
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, a := range attempts {
|
||||
params := url.Values{}
|
||||
params.Set("username", username)
|
||||
params.Set("password", a.Password)
|
||||
|
||||
var out oauthResponse
|
||||
var err error
|
||||
if a.Method == http.MethodPost {
|
||||
err = c.postFormSigned(ctx, "/oauth2/login", params, &out)
|
||||
} else {
|
||||
err = c.getSigned(ctx, "/oauth2/login", params, &out)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(out.OAuth2.AccessToken)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(out.AccessToken)
|
||||
}
|
||||
if token == "" {
|
||||
lastErr = fmt.Errorf("qobuz login response missing access_token")
|
||||
continue
|
||||
}
|
||||
c.token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("qobuz login failed")
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (c *Client) VerifyAuth(ctx context.Context) error {
|
||||
var out map[string]any
|
||||
if err := c.getUnsigned(ctx, "/user/get", url.Values{}, &out); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := c.getSigned(ctx, "/user/get", url.Values{}, &out); err != nil {
|
||||
return fmt.Errorf("verify auth failed for both unsigned and signed user/get: %w", 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", query)
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
params.Set("offset", "0")
|
||||
|
||||
type response struct {
|
||||
Tracks struct {
|
||||
Items []struct {
|
||||
ID flexString `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Duration int `json:"duration"`
|
||||
ISRC string `json:"isrc"`
|
||||
Performer struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"performer"`
|
||||
Album struct {
|
||||
ID flexString `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Artist struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artist"`
|
||||
} `json:"album"`
|
||||
} `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
var out response
|
||||
if err := c.getSigned(ctx, "/track/search", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := make([]Track, 0, len(out.Tracks.Items))
|
||||
for _, it := range out.Tracks.Items {
|
||||
trackID := strings.TrimSpace(string(it.ID))
|
||||
albumID := strings.TrimSpace(string(it.Album.ID))
|
||||
if trackID == "" || albumID == "" {
|
||||
continue
|
||||
}
|
||||
res = append(res, Track{
|
||||
ID: trackID,
|
||||
Title: it.Title,
|
||||
Version: it.Version,
|
||||
Duration: it.Duration,
|
||||
ISRC: strings.ToUpper(strings.TrimSpace(it.ISRC)),
|
||||
Artist: it.Performer.Name,
|
||||
Album: it.Album.Title,
|
||||
AlbumID: albumID,
|
||||
AlbumArtist: it.Album.Artist.Name,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *Client) getSigned(ctx context.Context, path string, params url.Values, out any) error {
|
||||
query := cloneValues(params)
|
||||
ts, sig := signGet(path, c.appSecret, query)
|
||||
query.Set("app_id", c.appID)
|
||||
query.Set("request_ts", ts)
|
||||
query.Set("request_sig", sig)
|
||||
|
||||
return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
|
||||
}
|
||||
|
||||
func (c *Client) getUnsigned(ctx context.Context, path string, params url.Values, out any) error {
|
||||
query := cloneValues(params)
|
||||
query.Set("app_id", c.appID)
|
||||
return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
|
||||
}
|
||||
|
||||
func (c *Client) postFormSigned(ctx context.Context, path string, form url.Values, out any) error {
|
||||
for _, includeValues := range []bool{false, true} {
|
||||
query := url.Values{}
|
||||
ts, sig := signPost(path, c.appSecret, form, includeValues)
|
||||
query.Set("app_id", c.appID)
|
||||
query.Set("request_ts", ts)
|
||||
query.Set("request_sig", sig)
|
||||
|
||||
err := c.doJSON(ctx, http.MethodPost, path, query, form, out)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isSigError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("qobuz request signature rejected for %s", path)
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, path string, query, form url.Values, out any) error {
|
||||
u := baseURL + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
bodyEncoded := ""
|
||||
if method == http.MethodPost {
|
||||
bodyEncoded = form.Encode()
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
var body io.Reader
|
||||
if method == http.MethodPost {
|
||||
body = strings.NewReader(bodyEncoded)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUA)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-App-Id", c.appID)
|
||||
req.Header.Set("X-App-Version", defaultAppVersion)
|
||||
req.Header.Set("X-Device-Platform", defaultDevicePlatform)
|
||||
req.Header.Set("X-Device-Model", defaultDeviceModel)
|
||||
req.Header.Set("X-Device-Os-Version", defaultDeviceOSVersion)
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("X-User-Auth-Token", c.token)
|
||||
}
|
||||
if method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("qobuz api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("qobuz request failed after retries")
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func chunk(ids []int64, size int) [][]int64 {
|
||||
if size <= 0 {
|
||||
size = 100
|
||||
}
|
||||
out := make([][]int64, 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 isSigError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "signature") || strings.Contains(msg, "request_sig")
|
||||
}
|
||||
|
||||
func md5Hex(s string) string {
|
||||
h := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
23
internal/qobuz/client_test.go
Normal file
23
internal/qobuz/client_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFlexStringUnmarshal(t *testing.T) {
|
||||
var s flexString
|
||||
if err := json.Unmarshal([]byte(`"0724384960650"`), &s); err != nil {
|
||||
t.Fatalf("unmarshal string id failed: %v", err)
|
||||
}
|
||||
if string(s) != "0724384960650" {
|
||||
t.Fatalf("unexpected value %q", string(s))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(`12345`), &s); err != nil {
|
||||
t.Fatalf("unmarshal numeric id failed: %v", err)
|
||||
}
|
||||
if string(s) != "12345" {
|
||||
t.Fatalf("unexpected numeric value %q", string(s))
|
||||
}
|
||||
}
|
||||
101
internal/qobuz/signer.go
Normal file
101
internal/qobuz/signer.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func signGet(path, appSecret string, query url.Values) (requestTS, requestSig string) {
|
||||
ts := nowTS()
|
||||
method := methodName(path)
|
||||
|
||||
keys := make([]string, 0, len(query))
|
||||
for k := range query {
|
||||
if k == "app_id" || k == "request_ts" || k == "request_sig" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b := strings.Builder{}
|
||||
b.WriteString(method)
|
||||
for _, k := range keys {
|
||||
vals := query[k]
|
||||
if len(vals) == 0 {
|
||||
continue
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteString(vals[0])
|
||||
}
|
||||
b.WriteString(ts)
|
||||
b.WriteString(appSecret)
|
||||
|
||||
h := md5.Sum([]byte(b.String()))
|
||||
return ts, hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func signPost(path, appSecret string, form url.Values, includeValues bool) (requestTS, requestSig string) {
|
||||
ts := nowTS()
|
||||
method := methodName(path)
|
||||
|
||||
keys := make([]string, 0, len(form))
|
||||
for k := range form {
|
||||
if k == "app_id" || k == "request_ts" || k == "request_sig" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b := strings.Builder{}
|
||||
b.WriteString(method)
|
||||
for _, k := range keys {
|
||||
b.WriteString(k)
|
||||
if includeValues {
|
||||
vals := form[k]
|
||||
if len(vals) > 0 {
|
||||
b.WriteString(vals[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString(ts)
|
||||
b.WriteString(appSecret)
|
||||
|
||||
h := md5.Sum([]byte(b.String()))
|
||||
return ts, hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func methodName(path string) string {
|
||||
return strings.ReplaceAll(strings.Trim(path, "/"), "/", "")
|
||||
}
|
||||
|
||||
func nowTS() string {
|
||||
return strconvI64(time.Now().Unix())
|
||||
}
|
||||
|
||||
func strconvI64(v int64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
buf := [20]byte{}
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
Reference in New Issue
Block a user