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