initial Go port of streamrip

This commit is contained in:
2026-04-19 21:11:38 +02:00
commit 97e8b758b3
32 changed files with 7008 additions and 0 deletions

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}
}

View 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))
}
}