mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
initial Go port of streamrip
This commit is contained in:
19
internal/provider/provider.go
Normal file
19
internal/provider/provider.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package provider
|
||||
|
||||
import "context"
|
||||
|
||||
type Downloadable struct {
|
||||
URL string
|
||||
Extension string
|
||||
Source string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Source() string
|
||||
Login(ctx context.Context) error
|
||||
LoggedIn() bool
|
||||
GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error)
|
||||
Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error)
|
||||
GetDownloadable(ctx context.Context, item string, quality int) (*Downloadable, error)
|
||||
Close() error
|
||||
}
|
||||
586
internal/provider/qobuz/client.go
Normal file
586
internal/provider/qobuz/client.go
Normal file
@@ -0,0 +1,586 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
const baseURL = "https://www.qobuz.com/api.json/0.2"
|
||||
|
||||
var (
|
||||
errMissingCredentials = errors.New("missing qobuz credentials")
|
||||
errNotLoggedIn = errors.New("qobuz client not logged in")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
loggedIn bool
|
||||
secret string
|
||||
uat string
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Source() string {
|
||||
return "qobuz"
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context) error {
|
||||
q := &c.cfg.Session.Qobuz
|
||||
if q.EmailOrUserID == "" || q.PasswordOrToken == "" {
|
||||
return errMissingCredentials
|
||||
}
|
||||
|
||||
if q.AppID == "" || len(q.Secrets) == 0 {
|
||||
appID, secrets, err := c.fetchAppIDAndSecrets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q.AppID = appID
|
||||
q.Secrets = secrets
|
||||
c.cfg.File.Qobuz.AppID = appID
|
||||
c.cfg.File.Qobuz.Secrets = append([]string(nil), secrets...)
|
||||
_ = c.cfg.SaveFile()
|
||||
}
|
||||
|
||||
headers := map[string]string{"X-App-Id": q.AppID}
|
||||
params := url.Values{}
|
||||
params.Set("app_id", q.AppID)
|
||||
if q.UseAuthToken {
|
||||
params.Set("user_id", q.EmailOrUserID)
|
||||
params.Set("user_auth_token", q.PasswordOrToken)
|
||||
} else {
|
||||
params.Set("email", q.EmailOrUserID)
|
||||
params.Set("password", q.PasswordOrToken)
|
||||
}
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "user/login", params, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("qobuz login failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
uat, _ := resp["user_auth_token"].(string)
|
||||
if uat == "" {
|
||||
return fmt.Errorf("qobuz login missing user_auth_token")
|
||||
}
|
||||
|
||||
headers["X-User-Auth-Token"] = uat
|
||||
validSecret, err := c.getValidSecret(ctx, q.Secrets, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.secret = validSecret
|
||||
c.uat = uat
|
||||
c.loggedIn = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if mediaType == "playlist" {
|
||||
return c.getPlaylist(ctx, item)
|
||||
}
|
||||
if mediaType == "label" {
|
||||
return c.getLabel(ctx, item)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set(mediaType+"_id", item)
|
||||
params.Set("limit", "500")
|
||||
params.Set("offset", "0")
|
||||
|
||||
switch mediaType {
|
||||
case "artist":
|
||||
params.Set("extra", "albums")
|
||||
case "playlist":
|
||||
params.Set("extra", "tracks")
|
||||
case "label":
|
||||
params.Set("extra", "albums")
|
||||
}
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, mediaType+"/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
msg, _ := resp["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "non-streamable"
|
||||
}
|
||||
return nil, fmt.Errorf("metadata error: %s", msg)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTrackMetadata(ctx context.Context, id string) (*TrackMetadata, error) {
|
||||
raw, err := c.GetMetadata(ctx, id, "track")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseTrackMetadata(raw)
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, mediaType+"/search", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: status=%d", status)
|
||||
}
|
||||
|
||||
return []map[string]any{resp}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if quality < 1 || quality > 4 {
|
||||
quality = c.cfg.Session.Qobuz.Quality
|
||||
}
|
||||
|
||||
formatID := qualityMap(quality)
|
||||
requestTS := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
sigRaw := "trackgetFileUrlformat_id" + strconv.Itoa(formatID) + "intentstreamtrack_id" + item + requestTS + c.secret
|
||||
hash := md5.Sum([]byte(sigRaw))
|
||||
requestSig := hex.EncodeToString(hash[:])
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("request_ts", requestTS)
|
||||
params.Set("request_sig", requestSig)
|
||||
params.Set("track_id", item)
|
||||
params.Set("format_id", strconv.Itoa(formatID))
|
||||
params.Set("intent", "stream")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "track/getFileUrl", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("downloadable lookup failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
streamURL, _ := resp["url"].(string)
|
||||
if streamURL == "" {
|
||||
return nil, fmt.Errorf("track is not streamable")
|
||||
}
|
||||
|
||||
ext := "mp3"
|
||||
if quality > 1 {
|
||||
ext = "flac"
|
||||
}
|
||||
|
||||
return &provider.Downloadable{
|
||||
URL: streamURL,
|
||||
Extension: ext,
|
||||
Source: "qobuz",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string]any, error) {
|
||||
pageLimit := 500
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set("playlist_id", playlistID)
|
||||
params.Set("limit", strconv.Itoa(pageLimit))
|
||||
params.Set("offset", "0")
|
||||
params.Set("extra", "tracks")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "playlist/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("playlist/get failed: status=%d", status)
|
||||
}
|
||||
|
||||
total, _ := intValue(resp["tracks_count"])
|
||||
if total <= pageLimit {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
tracksObj, ok := mapValue(resp["tracks"])
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
items, ok := tracksObj["items"].([]any)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
for offset := pageLimit; offset < total; offset += pageLimit {
|
||||
pageParams := url.Values{}
|
||||
pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
pageParams.Set("playlist_id", playlistID)
|
||||
pageParams.Set("limit", strconv.Itoa(pageLimit))
|
||||
pageParams.Set("offset", strconv.Itoa(offset))
|
||||
pageParams.Set("extra", "tracks")
|
||||
|
||||
pageResp, pageStatus, pageErr := c.apiRequest(ctx, "playlist/get", pageParams, c.authHeaders())
|
||||
if pageErr != nil {
|
||||
return nil, pageErr
|
||||
}
|
||||
if pageStatus != http.StatusOK {
|
||||
return nil, fmt.Errorf("playlist/get pagination failed: status=%d offset=%d", pageStatus, offset)
|
||||
}
|
||||
pageTracks, ok := mapValue(pageResp["tracks"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pageItems, ok := pageTracks["items"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
}
|
||||
|
||||
tracksObj["items"] = items
|
||||
resp["tracks"] = tracksObj
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) getLabel(ctx context.Context, labelID string) (map[string]any, error) {
|
||||
pageLimit := 500
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set("label_id", labelID)
|
||||
params.Set("limit", strconv.Itoa(pageLimit))
|
||||
params.Set("offset", "0")
|
||||
params.Set("extra", "albums")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "label/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("label/get failed: status=%d", status)
|
||||
}
|
||||
|
||||
total, _ := intValue(resp["albums_count"])
|
||||
if total <= pageLimit {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
albumsObj, ok := mapValue(resp["albums"])
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
items, ok := albumsObj["items"].([]any)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
for offset := pageLimit; offset < total; offset += pageLimit {
|
||||
pageParams := url.Values{}
|
||||
pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
pageParams.Set("label_id", labelID)
|
||||
pageParams.Set("limit", strconv.Itoa(pageLimit))
|
||||
pageParams.Set("offset", strconv.Itoa(offset))
|
||||
pageParams.Set("extra", "albums")
|
||||
|
||||
pageResp, pageStatus, pageErr := c.apiRequest(ctx, "label/get", pageParams, c.authHeaders())
|
||||
if pageErr != nil {
|
||||
return nil, pageErr
|
||||
}
|
||||
if pageStatus != http.StatusOK {
|
||||
return nil, fmt.Errorf("label/get pagination failed: status=%d offset=%d", pageStatus, offset)
|
||||
}
|
||||
pageAlbums, ok := mapValue(pageResp["albums"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pageItems, ok := pageAlbums["items"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
}
|
||||
|
||||
albumsObj["items"] = items
|
||||
resp["albums"] = albumsObj
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) authHeaders() map[string]string {
|
||||
headers := map[string]string{"X-App-Id": c.cfg.Session.Qobuz.AppID}
|
||||
if c.uat != "" {
|
||||
headers["X-User-Auth-Token"] = c.uat
|
||||
} else if c.cfg.Session.Qobuz.PasswordOrToken != "" && c.cfg.Session.Qobuz.UseAuthToken {
|
||||
headers["X-User-Auth-Token"] = c.cfg.Session.Qobuz.PasswordOrToken
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func (c *Client) getValidSecret(ctx context.Context, secrets []string, headers map[string]string) (string, error) {
|
||||
type candidate struct {
|
||||
secret string
|
||||
valid bool
|
||||
}
|
||||
|
||||
results := make([]candidate, 0, len(secrets))
|
||||
for _, secret := range secrets {
|
||||
ok := c.testSecret(ctx, secret, headers)
|
||||
results = append(results, candidate{secret: secret, valid: ok})
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if result.valid {
|
||||
return result.secret, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no valid qobuz app secret")
|
||||
}
|
||||
|
||||
func (c *Client) testSecret(ctx context.Context, secret string, headers map[string]string) bool {
|
||||
formatID := qualityMap(4)
|
||||
requestTS := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
sigRaw := "trackgetFileUrlformat_id" + strconv.Itoa(formatID) + "intentstreamtrack_id19512574" + requestTS + secret
|
||||
hash := md5.Sum([]byte(sigRaw))
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("request_ts", requestTS)
|
||||
params.Set("request_sig", hex.EncodeToString(hash[:]))
|
||||
params.Set("track_id", "19512574")
|
||||
params.Set("format_id", strconv.Itoa(formatID))
|
||||
params.Set("intent", "stream")
|
||||
|
||||
_, status, err := c.apiRequest(ctx, "track/getFileUrl", params, headers)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return status == http.StatusOK || status == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func (c *Client) apiRequest(ctx context.Context, endpoint string, params url.Values, headers map[string]string) (map[string]any, int, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
reqURL := baseURL + "/" + endpoint
|
||||
if c.baseURL != "" {
|
||||
reqURL = c.baseURL + "/" + endpoint
|
||||
}
|
||||
if len(params) > 0 {
|
||||
reqURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", "streamrip-go/0.1")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
|
||||
parsed := map[string]any{}
|
||||
if len(body) > 0 {
|
||||
if err = json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
|
||||
return parsed, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func qualityMap(quality int) int {
|
||||
mapVals := []int{5, 6, 7, 27}
|
||||
if quality < 1 || quality > 4 {
|
||||
return mapVals[2]
|
||||
}
|
||||
return mapVals[quality-1]
|
||||
}
|
||||
|
||||
func (c *Client) fetchAppIDAndSecrets(ctx context.Context) (string, []string, error) {
|
||||
loginReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://play.qobuz.com/login", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
loginResp, err := c.http.Do(loginReq)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer func() { _ = loginResp.Body.Close() }()
|
||||
|
||||
loginBody, err := io.ReadAll(loginResp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
bundleRe := regexp.MustCompile(`<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>`)
|
||||
bundleMatch := bundleRe.FindStringSubmatch(string(loginBody))
|
||||
if len(bundleMatch) < 2 {
|
||||
return "", nil, fmt.Errorf("could not find qobuz bundle js")
|
||||
}
|
||||
|
||||
bundleURL := "https://play.qobuz.com" + bundleMatch[1]
|
||||
bundleReq, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
bundleResp, err := c.http.Do(bundleReq)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer func() { _ = bundleResp.Body.Close() }()
|
||||
bundleBody, err := io.ReadAll(bundleResp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
bundle := string(bundleBody)
|
||||
appIDRe := regexp.MustCompile(`production:{api:{appId:"(?P<app_id>\d{9})",appSecret:"(\w{32})`)
|
||||
appIDMatch := appIDRe.FindStringSubmatch(bundle)
|
||||
if len(appIDMatch) < 2 {
|
||||
return "", nil, fmt.Errorf("could not parse qobuz app id")
|
||||
}
|
||||
appID := appIDMatch[1]
|
||||
|
||||
seedTZRe := regexp.MustCompile(`[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)`)
|
||||
infoExtrasTemplate := `name:"\w+/(?P<timezone>%s)",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"`
|
||||
|
||||
type seedParts struct {
|
||||
timezone string
|
||||
parts []string
|
||||
}
|
||||
|
||||
matches := seedTZRe.FindAllStringSubmatch(bundle, -1)
|
||||
idxSeed := seedTZRe.SubexpIndex("seed")
|
||||
idxTZ := seedTZRe.SubexpIndex("timezone")
|
||||
if len(matches) < 2 {
|
||||
return appID, nil, fmt.Errorf("could not parse qobuz secrets seeds")
|
||||
}
|
||||
|
||||
ordered := make([]seedParts, 0, len(matches))
|
||||
seen := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
tz := m[idxTZ]
|
||||
seed := m[idxSeed]
|
||||
if !seen[tz] {
|
||||
ordered = append(ordered, seedParts{timezone: tz, parts: []string{seed}})
|
||||
seen[tz] = true
|
||||
}
|
||||
}
|
||||
if len(ordered) >= 2 {
|
||||
ordered[0], ordered[1] = ordered[1], ordered[0]
|
||||
}
|
||||
|
||||
tzNames := make([]string, 0, len(ordered))
|
||||
for _, o := range ordered {
|
||||
tzNames = append(tzNames, strings.Title(o.timezone))
|
||||
}
|
||||
infoRe := regexp.MustCompile(fmt.Sprintf(infoExtrasTemplate, strings.Join(tzNames, "|")))
|
||||
idxInfo := infoRe.SubexpIndex("info")
|
||||
idxExtras := infoRe.SubexpIndex("extras")
|
||||
idxInfoTZ := infoRe.SubexpIndex("timezone")
|
||||
|
||||
byTZ := map[string][]string{}
|
||||
for _, o := range ordered {
|
||||
byTZ[o.timezone] = append([]string(nil), o.parts...)
|
||||
}
|
||||
|
||||
for _, m := range infoRe.FindAllStringSubmatch(bundle, -1) {
|
||||
tz := strings.ToLower(m[idxInfoTZ])
|
||||
byTZ[tz] = append(byTZ[tz], m[idxInfo], m[idxExtras])
|
||||
}
|
||||
|
||||
final := make([]string, 0, len(byTZ))
|
||||
for _, tz := range sortedKeys(byTZ) {
|
||||
joined := strings.Join(byTZ[tz], "")
|
||||
if len(joined) < 44 {
|
||||
continue
|
||||
}
|
||||
dec, err := base64.StdEncoding.DecodeString(joined[:len(joined)-44])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
secret := string(dec)
|
||||
if secret != "" {
|
||||
final = append(final, secret)
|
||||
}
|
||||
}
|
||||
|
||||
if len(final) == 0 {
|
||||
return appID, nil, fmt.Errorf("could not decode qobuz secrets")
|
||||
}
|
||||
|
||||
return appID, final, nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string][]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
174
internal/provider/qobuz/client_test.go
Normal file
174
internal/provider/qobuz/client_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
func TestQualityMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
in int
|
||||
want int
|
||||
}{
|
||||
{1, 5},
|
||||
{2, 6},
|
||||
{3, 7},
|
||||
{4, 27},
|
||||
{0, 7},
|
||||
{99, 7},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := qualityMap(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("qualityMap(%d)=%d want %d", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrackMetadata(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"id": "19512574",
|
||||
"title": "Dreams",
|
||||
"version": "Remastered",
|
||||
"track_number": float64(2),
|
||||
"media_number": float64(1),
|
||||
"parental_warning": false,
|
||||
"maximum_bit_depth": float64(24),
|
||||
"maximum_sampling_rate": float64(96),
|
||||
"performer": map[string]any{
|
||||
"name": "Fleetwood Mac",
|
||||
},
|
||||
"album": map[string]any{
|
||||
"title": "Rumours",
|
||||
},
|
||||
}
|
||||
|
||||
m, err := ParseTrackMetadata(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTrackMetadata() error = %v", err)
|
||||
}
|
||||
if m.ID != "19512574" || m.Title != "Dreams" || m.Album != "Rumours" || m.Artist != "Fleetwood Mac" {
|
||||
t.Fatalf("unexpected metadata: %+v", m)
|
||||
}
|
||||
if m.Quality != 3 {
|
||||
t.Fatalf("quality = %d, want 3", m.Quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlaylistPagination(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
if offset == "" {
|
||||
offset = "0"
|
||||
}
|
||||
|
||||
resp := map[string]any{}
|
||||
switch offset {
|
||||
case "0":
|
||||
resp = map[string]any{
|
||||
"tracks_count": 1200,
|
||||
"tracks": map[string]any{"items": makeItems(0, 500)},
|
||||
}
|
||||
case "500":
|
||||
resp = map[string]any{"tracks": map[string]any{"items": makeItems(500, 1000)}}
|
||||
case "1000":
|
||||
resp = map[string]any{"tracks": map[string]any{"items": makeItems(1000, 1200)}}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := newTestClient(t)
|
||||
c.loggedIn = true
|
||||
c.baseURL = ts.URL
|
||||
|
||||
raw, err := c.GetMetadata(context.Background(), "playlist-id", "playlist")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata() error = %v", err)
|
||||
}
|
||||
tracks, ok := mapValue(raw["tracks"])
|
||||
if !ok {
|
||||
t.Fatalf("tracks missing")
|
||||
}
|
||||
items, ok := tracks["items"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("items missing")
|
||||
}
|
||||
if len(items) != 1200 {
|
||||
t.Fatalf("len(items) = %d, want 1200", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLabelPagination(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
if offset == "" {
|
||||
offset = "0"
|
||||
}
|
||||
|
||||
resp := map[string]any{}
|
||||
switch offset {
|
||||
case "0":
|
||||
resp = map[string]any{
|
||||
"albums_count": 700,
|
||||
"albums": map[string]any{"items": makeItems(0, 500)},
|
||||
}
|
||||
case "500":
|
||||
resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 700)}}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := newTestClient(t)
|
||||
c.loggedIn = true
|
||||
c.baseURL = ts.URL
|
||||
|
||||
raw, err := c.GetMetadata(context.Background(), "label-id", "label")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata() error = %v", err)
|
||||
}
|
||||
albums, ok := mapValue(raw["albums"])
|
||||
if !ok {
|
||||
t.Fatalf("albums missing")
|
||||
}
|
||||
items, ok := albums["items"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("items missing")
|
||||
}
|
||||
if len(items) != 700 {
|
||||
t.Fatalf("len(items) = %d, want 700", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
d := config.DefaultConfigData()
|
||||
d.Qobuz.AppID = "12345"
|
||||
cfg := &config.Config{File: d, Session: d}
|
||||
return New(cfg)
|
||||
}
|
||||
|
||||
func makeItems(start, end int) []map[string]any {
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
for i := start; i < end; i++ {
|
||||
items = append(items, map[string]any{"id": i})
|
||||
}
|
||||
return items
|
||||
}
|
||||
102
internal/provider/qobuz/model.go
Normal file
102
internal/provider/qobuz/model.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package qobuz
|
||||
|
||||
import "fmt"
|
||||
|
||||
type TrackMetadata struct {
|
||||
ID string
|
||||
Title string
|
||||
Version string
|
||||
Artist string
|
||||
Album string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
Explicit bool
|
||||
BitDepth int
|
||||
SamplingRate float64
|
||||
Quality int
|
||||
}
|
||||
|
||||
func ParseTrackMetadata(resp map[string]any) (*TrackMetadata, error) {
|
||||
id, ok := stringValue(resp["id"])
|
||||
if !ok || id == "" {
|
||||
return nil, fmt.Errorf("missing track id")
|
||||
}
|
||||
|
||||
title, _ := stringValue(resp["title"])
|
||||
version, _ := stringValue(resp["version"])
|
||||
trackNumber, _ := intValue(resp["track_number"])
|
||||
discNumber, _ := intValue(resp["media_number"])
|
||||
explicit, _ := boolValue(resp["parental_warning"])
|
||||
|
||||
performer, _ := mapValue(resp["performer"])
|
||||
artist, _ := stringValue(performer["name"])
|
||||
|
||||
albumObj, _ := mapValue(resp["album"])
|
||||
album, _ := stringValue(albumObj["title"])
|
||||
|
||||
bitDepth, _ := intValue(resp["maximum_bit_depth"])
|
||||
samplingRate, _ := floatValue(resp["maximum_sampling_rate"])
|
||||
quality := qualityFrom(bitDepth, samplingRate)
|
||||
|
||||
return &TrackMetadata{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Version: version,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
TrackNumber: trackNumber,
|
||||
DiscNumber: discNumber,
|
||||
Explicit: explicit,
|
||||
BitDepth: bitDepth,
|
||||
SamplingRate: samplingRate,
|
||||
Quality: quality,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func qualityFrom(bitDepth int, samplingRate float64) int {
|
||||
if bitDepth >= 24 {
|
||||
if samplingRate > 96 {
|
||||
return 4
|
||||
}
|
||||
return 3
|
||||
}
|
||||
if bitDepth >= 16 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func stringValue(v any) (string, bool) {
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func mapValue(v any) (map[string]any, bool) {
|
||||
m, ok := v.(map[string]any)
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func intValue(v any) (int, bool) {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t, true
|
||||
case int32:
|
||||
return int(t), true
|
||||
case int64:
|
||||
return int(t), true
|
||||
case float64:
|
||||
return int(t), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func floatValue(v any) (float64, bool) {
|
||||
f, ok := v.(float64)
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func boolValue(v any) (bool, bool) {
|
||||
b, ok := v.(bool)
|
||||
return b, ok
|
||||
}
|
||||
549
internal/provider/tidal/client.go
Normal file
549
internal/provider/tidal/client.go
Normal file
@@ -0,0 +1,549 @@
|
||||
package tidal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://api.tidalhifi.com/v1"
|
||||
openAPIV2 = "https://openapi.tidal.com/v2"
|
||||
authURL = "https://auth.tidal.com/v1/oauth2"
|
||||
clientID = "fX2JxdmntZWK0ixT"
|
||||
clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
|
||||
)
|
||||
|
||||
var qualityMap = map[int]string{
|
||||
0: "LOW",
|
||||
1: "HIGH",
|
||||
2: "LOSSLESS",
|
||||
3: "HI_RES",
|
||||
4: "HI_RES_LOSSLESS",
|
||||
}
|
||||
|
||||
var qualityToFormat = map[int]string{
|
||||
0: "HEAACV1",
|
||||
1: "AACLC",
|
||||
2: "FLAC",
|
||||
3: "FLAC_HIRES",
|
||||
4: "FLAC_HIRES",
|
||||
}
|
||||
|
||||
var ErrMissingTidalToken = errors.New("missing tidal access_token")
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
loggedIn bool
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Source() string {
|
||||
return "tidal"
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context) error {
|
||||
if strings.TrimSpace(c.cfg.Session.Tidal.AccessToken) == "" {
|
||||
return ErrMissingTidalToken
|
||||
}
|
||||
if strings.TrimSpace(c.cfg.Session.Tidal.CountryCode) == "" {
|
||||
c.cfg.Session.Tidal.CountryCode = "US"
|
||||
}
|
||||
|
||||
if c.tokenNeedsRefresh() {
|
||||
if err := c.refreshAccessToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "sessions", url.Values{}, c.baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == http.StatusUnauthorized && strings.TrimSpace(c.cfg.Session.Tidal.RefreshToken) != "" {
|
||||
if err = c.refreshAccessToken(ctx); err != nil {
|
||||
return fmt.Errorf("tidal login failed and refresh failed: %w", err)
|
||||
}
|
||||
resp, status, err = c.apiRequest(ctx, "sessions", url.Values{}, c.baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("tidal login failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
if v := stringify(resp["countryCode"]); v != "" {
|
||||
c.cfg.Session.Tidal.CountryCode = v
|
||||
}
|
||||
if v := stringify(resp["userId"]); v != "" {
|
||||
c.cfg.Session.Tidal.UserID = v
|
||||
}
|
||||
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) tokenNeedsRefresh() bool {
|
||||
expiry := c.cfg.Session.Tidal.TokenExpiry
|
||||
if expiry <= 0 {
|
||||
return false
|
||||
}
|
||||
return time.Until(time.Unix(expiry, 0)) < 24*time.Hour
|
||||
}
|
||||
|
||||
func (c *Client) refreshAccessToken(ctx context.Context) error {
|
||||
refresh := strings.TrimSpace(c.cfg.Session.Tidal.RefreshToken)
|
||||
if refresh == "" {
|
||||
return errors.New("tidal refresh token missing")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("scope", "r_usr+w_usr+w_sub")
|
||||
|
||||
resp, status, err := c.apiPost(ctx, authURL+"/token", form, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("tidal token refresh failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
newToken := stringify(resp["access_token"])
|
||||
if newToken == "" {
|
||||
return errors.New("tidal token refresh missing access_token")
|
||||
}
|
||||
|
||||
newRefresh := stringify(resp["refresh_token"])
|
||||
expiresIn := int64(intFromAny(resp["expires_in"]))
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 7 * 24 * 3600
|
||||
}
|
||||
|
||||
c.cfg.Session.Tidal.AccessToken = newToken
|
||||
c.cfg.File.Tidal.AccessToken = newToken
|
||||
if newRefresh != "" {
|
||||
c.cfg.Session.Tidal.RefreshToken = newRefresh
|
||||
c.cfg.File.Tidal.RefreshToken = newRefresh
|
||||
}
|
||||
expiry := time.Now().Unix() + expiresIn
|
||||
c.cfg.Session.Tidal.TokenExpiry = expiry
|
||||
c.cfg.File.Tidal.TokenExpiry = expiry
|
||||
_ = c.cfg.SaveFile()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("tidal client not logged in")
|
||||
}
|
||||
|
||||
path := mediaType + "s/" + item
|
||||
resp, status, err := c.apiRequest(ctx, path, url.Values{}, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal metadata failed: status=%d", status)
|
||||
}
|
||||
|
||||
if mediaType == "album" || mediaType == "playlist" {
|
||||
itemsResp, itemErr := c.fetchAllItems(ctx, path+"/items")
|
||||
if itemErr != nil {
|
||||
return nil, fmt.Errorf("tidal fetch %s items failed: %w", mediaType, itemErr)
|
||||
}
|
||||
resp["tracks"] = map[string]any{"items": itemsResp}
|
||||
}
|
||||
|
||||
if mediaType == "artist" {
|
||||
albums, err := c.fetchArtistAlbums(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp["albums"] = map[string]any{"items": albums}
|
||||
}
|
||||
|
||||
enrichTidalImage(resp)
|
||||
if mediaType == "track" {
|
||||
if album, ok := resp["album"].(map[string]any); ok {
|
||||
enrichTidalImage(album)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("tidal client not logged in")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "search/"+mediaType+"s", params, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal search failed: status=%d", status)
|
||||
}
|
||||
|
||||
items, ok := resp["items"].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return []map[string]any{resp}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("tidal client not logged in")
|
||||
}
|
||||
if quality < 0 || quality > 4 {
|
||||
quality = c.cfg.Session.Tidal.Quality
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("audioquality", qualityMap[quality])
|
||||
params.Set("playbackmode", "STREAM")
|
||||
params.Set("assetpresentation", "FULL")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "tracks/"+trackID+"/playbackinfopostpaywall", params, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
if d := downloadableFromPlaybackManifest(resp); d != nil {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
return c.getDownloadableFromTrackManifest(ctx, trackID, quality)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllItems(ctx context.Context, path string) ([]map[string]any, error) {
|
||||
offset := 0
|
||||
all := make([]map[string]any, 0)
|
||||
for {
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
resp, status, err := c.apiRequest(ctx, path, params, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal items failed: status=%d", status)
|
||||
}
|
||||
itemsRaw, ok := resp["items"].([]any)
|
||||
if !ok || len(itemsRaw) == 0 {
|
||||
break
|
||||
}
|
||||
for _, raw := range itemsRaw {
|
||||
itemMap, ok := raw.(map[string]any)
|
||||
if ok {
|
||||
if wrapped, ok := itemMap["item"].(map[string]any); ok {
|
||||
all = append(all, wrapped)
|
||||
} else {
|
||||
all = append(all, itemMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(itemsRaw) < 100 {
|
||||
break
|
||||
}
|
||||
offset += 100
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[string]any, error) {
|
||||
paths := []struct {
|
||||
path string
|
||||
params url.Values
|
||||
}{
|
||||
{path: "artists/" + artistID + "/albums", params: url.Values{}},
|
||||
{path: "artists/" + artistID + "/albums", params: url.Values{"filter": []string{"EPSANDSINGLES"}}},
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0)
|
||||
seen := map[string]struct{}{}
|
||||
for _, p := range paths {
|
||||
resp, status, err := c.apiRequest(ctx, p.path, p.params, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal artist albums failed: status=%d", status)
|
||||
}
|
||||
items, _ := resp["items"].([]any)
|
||||
for _, raw := range items {
|
||||
itm, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if wrapped, ok := itm["item"].(map[string]any); ok {
|
||||
itm = wrapped
|
||||
}
|
||||
id := stringify(itm["id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, itm)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) {
|
||||
format := qualityToFormat[quality]
|
||||
params := url.Values{}
|
||||
params.Set("manifestType", "MPEG_DASH")
|
||||
params.Set("formats", format)
|
||||
params.Set("uriScheme", "HTTPS")
|
||||
params.Set("usage", "PLAYBACK")
|
||||
params.Set("adaptive", "false")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "trackManifests/"+trackID, params, openAPIV2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal trackManifests failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("tidal trackManifests missing data")
|
||||
}
|
||||
attrs, ok := data["attributes"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("tidal trackManifests missing attributes")
|
||||
}
|
||||
uri := stringify(attrs["uri"])
|
||||
if uri == "" {
|
||||
return nil, errors.New("tidal trackManifests missing uri")
|
||||
}
|
||||
formats, _ := attrs["formats"].([]any)
|
||||
ext := "m4a"
|
||||
for _, f := range formats {
|
||||
if strings.Contains(strings.ToUpper(stringify(f)), "FLAC") {
|
||||
ext = "flac"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, nil
|
||||
}
|
||||
|
||||
func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadable {
|
||||
manifestB64 := stringify(resp["manifest"])
|
||||
if manifestB64 == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
manifest := map[string]any{}
|
||||
if err = json.Unmarshal(b, &manifest); err != nil {
|
||||
return nil
|
||||
}
|
||||
urls, ok := manifest["urls"].([]any)
|
||||
if !ok || len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
streamURL := stringify(urls[0])
|
||||
if streamURL == "" {
|
||||
return nil
|
||||
}
|
||||
codec := strings.ToLower(stringify(manifest["codecs"]))
|
||||
ext := "m4a"
|
||||
if strings.Contains(codec, "flac") {
|
||||
ext = "flac"
|
||||
}
|
||||
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal"}
|
||||
}
|
||||
|
||||
func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
}
|
||||
if params.Get("countryCode") == "" {
|
||||
params.Set("countryCode", c.cfg.Session.Tidal.CountryCode)
|
||||
}
|
||||
if params.Get("limit") == "" {
|
||||
params.Set("limit", "100")
|
||||
}
|
||||
|
||||
reqURL := strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
if len(params) > 0 {
|
||||
reqURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken)
|
||||
req.Header.Set("User-Agent", "streamrip-go/0.1")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
parsed := map[string]any{}
|
||||
if len(body) > 0 {
|
||||
if err = json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
|
||||
return parsed, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values, basicAuth bool) (map[string]any, int, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "streamrip-go/0.1")
|
||||
if basicAuth {
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
out := map[string]any{}
|
||||
if len(body) > 0 {
|
||||
if err = json.Unmarshal(body, &out); err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
return out, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func stringify(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func enrichTidalImage(meta map[string]any) {
|
||||
if _, ok := meta["image"].(map[string]any); ok {
|
||||
return
|
||||
}
|
||||
cover := stringify(meta["cover"])
|
||||
if cover == "" {
|
||||
cover = stringify(meta["squareImage"])
|
||||
}
|
||||
if cover == "" {
|
||||
return
|
||||
}
|
||||
meta["image"] = tidalImageMap(cover)
|
||||
}
|
||||
|
||||
func tidalImageMap(cover string) map[string]any {
|
||||
parts := strings.ReplaceAll(cover, "-", "/")
|
||||
base := "https://resources.tidal.com/images/" + parts
|
||||
return map[string]any{
|
||||
"thumbnail": base + "/80x80.jpg",
|
||||
"small": base + "/160x160.jpg",
|
||||
"large": base + "/640x640.jpg",
|
||||
"extralarge": base + "/1280x1280.jpg",
|
||||
"original": base + "/1280x1280.jpg",
|
||||
}
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t
|
||||
case int64:
|
||||
return int(t)
|
||||
case float64:
|
||||
return int(t)
|
||||
case string:
|
||||
i, _ := strconv.Atoi(t)
|
||||
return i
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
52
internal/provider/tidal/client_test.go
Normal file
52
internal/provider/tidal/client_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tidal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
func TestLoginMissingToken(t *testing.T) {
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData.Tidal.AccessToken = ""
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
err := c.Login(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/sessions":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"countryCode": "US", "userId": 123})
|
||||
case "/v1/search/albums":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"id": 1, "title": "x"}}})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.baseURL = ts.URL + "/v1"
|
||||
|
||||
if err := c.Login(context.Background()); err != nil {
|
||||
t.Fatalf("login err = %v", err)
|
||||
}
|
||||
pages, err := c.Search(context.Background(), "album", "x", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("search err = %v", err)
|
||||
}
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("pages = %d", len(pages))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user