Files
streamrip-go/internal/provider/tidal/client.go

792 lines
20 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 atmosAudioQualities = []string{"HI_RES_LOSSLESS", "HI_RES", "LOSSLESS", "HIGH"}
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
}
if c.cfg.Session.Tidal.PreferAtmos {
if c.trackSupportsAtmos(ctx, trackID) {
if d, _ := c.getAtmosDownloadable(ctx, trackID); d != nil {
return d, nil
}
}
}
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) trackSupportsAtmos(ctx context.Context, trackID string) bool {
resp, status, err := c.apiRequest(ctx, "tracks/"+trackID, url.Values{}, c.baseURL)
if err != nil || status != http.StatusOK {
return false
}
if modes, ok := resp["audioModes"].([]any); ok {
for _, mode := range modes {
if strings.Contains(strings.ToUpper(stringify(mode)), "ATMOS") {
return true
}
}
}
if mm, ok := resp["mediaMetadata"].(map[string]any); ok {
if tags, ok := mm["tags"].([]any); ok {
for _, tag := range tags {
if strings.Contains(strings.ToUpper(stringify(tag)), "ATMOS") {
return true
}
}
}
}
return false
}
func (c *Client) getAtmosDownloadable(ctx context.Context, trackID string) (*provider.Downloadable, error) {
var lastErr error
for _, aq := range atmosAudioQualities {
params := url.Values{}
params.Set("audioquality", aq)
params.Set("playbackmode", "STREAM")
params.Set("assetpresentation", "FULL")
params.Set("immersiveaudio", "true")
resp, status, err := c.apiRequest(ctx, "tracks/"+trackID+"/playbackinfopostpaywall", params, c.baseURL)
if err != nil {
lastErr = err
continue
}
if status != http.StatusOK {
lastErr = fmt.Errorf("tidal atmos playbackinfo failed: status=%d", status)
continue
}
if !playbackLooksAtmos(resp) {
continue
}
if d := downloadableFromPlaybackManifest(resp); d != nil {
return d, nil
}
}
if d, err := c.getDownloadableFromTrackManifestForFormat(ctx, trackID, "EAC3_JOC"); err == nil {
return d, nil
} else if err != nil {
lastErr = err
}
if d, err := c.getDownloadableFromTrackManifestForFormat(ctx, trackID, "DOLBY_ATMOS"); err == nil {
return d, nil
} else if err != nil {
lastErr = err
}
if d, err := c.getDownloadableFromTrackManifestForFormat(ctx, trackID, "SONY_360RA"); err == nil {
return d, nil
} else if err != nil {
lastErr = err
}
return nil, lastErr
}
func playbackLooksAtmos(resp map[string]any) bool {
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
return true
}
if modes, ok := resp["audioModes"].([]any); ok {
for _, raw := range modes {
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
return true
}
}
}
manifestB64 := stringify(resp["manifest"])
if manifestB64 == "" {
return false
}
b, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil {
return false
}
manifest := map[string]any{}
if err = json.Unmarshal(b, &manifest); err != nil {
return false
}
if strings.Contains(strings.ToUpper(stringify(manifest["audioMode"])), "ATMOS") {
return true
}
if modes, ok := manifest["audioModes"].([]any); ok {
for _, raw := range modes {
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
return true
}
}
}
codec := strings.ToLower(stringify(manifest["codecs"]))
return strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos")
}
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 {
offset := 0
for {
params := url.Values{}
for k, values := range p.params {
for _, v := range values {
params.Add(k, v)
}
}
params.Set("offset", strconv.Itoa(offset))
resp, status, err := c.apiRequest(ctx, p.path, params, c.baseURL)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("tidal artist albums failed: status=%d offset=%d", status, offset)
}
items, _ := resp["items"].([]any)
if len(items) == 0 {
break
}
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)
}
if len(items) < 100 {
break
}
offset += 100
}
}
return out, nil
}
func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) {
format := qualityToFormat[quality]
return c.getDownloadableFromTrackManifestForFormat(ctx, trackID, format)
}
func (c *Client) getDownloadableFromTrackManifestForFormat(ctx context.Context, trackID, format string) (*provider.Downloadable, error) {
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 {
fv := strings.ToUpper(stringify(f))
if strings.Contains(fv, "FLAC") {
ext = "flac"
break
}
if strings.Contains(fv, "EAC3") || strings.Contains(fv, "ATMOS") || strings.Contains(fv, "JOC") {
ext = "mka"
}
}
return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, nil
}
func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) {
if !c.loggedIn {
return nil, errors.New("tidal client not logged in")
}
params := url.Values{}
params.Set("videoquality", "HIGH")
params.Set("playbackmode", "STREAM")
params.Set("assetpresentation", "FULL")
resp, status, err := c.apiRequest(ctx, "videos/"+videoID+"/playbackinfopostpaywall", params, c.baseURL)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("tidal video playbackinfo failed: status=%d", status)
}
manifestB64 := stringify(resp["manifest"])
if manifestB64 == "" {
return nil, errors.New("tidal video manifest missing")
}
b, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil {
return nil, fmt.Errorf("decode video manifest: %w", err)
}
manifest := map[string]any{}
if err = json.Unmarshal(b, &manifest); err != nil {
return nil, fmt.Errorf("parse video manifest json: %w", err)
}
urls, ok := manifest["urls"].([]any)
if !ok || len(urls) == 0 {
return nil, errors.New("tidal video manifest urls missing")
}
masterURL := stringify(urls[0])
if masterURL == "" {
return nil, errors.New("tidal video master url missing")
}
if err = c.limiter.Wait(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "streamrip-go/0.1")
respHTTP, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = respHTTP.Body.Close() }()
if respHTTP.StatusCode < 200 || respHTTP.StatusCode >= 300 {
return nil, fmt.Errorf("tidal video playlist fetch failed: status=%d", respHTTP.StatusCode)
}
body, err := io.ReadAll(respHTTP.Body)
if err != nil {
return nil, err
}
streamURL := bestHLSVariantURL(masterURL, string(body))
return &provider.Downloadable{URL: streamURL, Extension: "mp4", 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"
} else if strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos") {
ext = "mka"
}
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal"}
}
func bestHLSVariantURL(masterURL, playlist string) string {
lines := strings.Split(strings.ReplaceAll(playlist, "\r\n", "\n"), "\n")
best := strings.TrimSpace(masterURL)
for i := 0; i < len(lines)-1; i++ {
line := strings.TrimSpace(lines[i])
if !strings.HasPrefix(line, "#EXT-X-STREAM-INF:") {
continue
}
if strings.Contains(strings.ToLower(line), "codecs=\"jpeg") {
continue
}
next := strings.TrimSpace(lines[i+1])
if next == "" || strings.HasPrefix(next, "#") {
continue
}
best = resolvePlaylistURL(masterURL, next)
}
return best
}
func resolvePlaylistURL(baseRaw, refRaw string) string {
if strings.HasPrefix(refRaw, "http://") || strings.HasPrefix(refRaw, "https://") {
return refRaw
}
baseURL, err := url.Parse(baseRaw)
if err != nil {
return refRaw
}
refURL, err := url.Parse(refRaw)
if err != nil {
return refRaw
}
return baseURL.ResolveReference(refURL).String()
}
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
}
}