Files
streamrip-go/internal/provider/tidal/client.go
2026-04-19 21:11:38 +02:00

550 lines
13 KiB
Go

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