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

948 lines
26 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/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
)
const (
baseURL = "https://api.tidalhifi.com/v1"
lyricsAPIv1 = "https://api.tidal.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 qualityToFormats = map[int][]string{
0: {"HEAACV1"},
1: {"HEAACV1", "AACLC"},
2: {"HEAACV1", "AACLC", "FLAC"},
3: {"HEAACV1", "AACLC", "FLAC", "FLAC_HIRES"},
4: {"HEAACV1", "AACLC", "FLAC", "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
lyricsAPI string
openAPI string
loggedIn bool
}
func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL,
lyricsAPI: lyricsAPIv1,
openAPI: openAPIV2,
}
}
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(jsonutil.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)
}
if lyrics, lrc := c.fetchTrackLyrics(ctx, item); lyrics != "" || lrc != "" {
if lyrics != "" {
resp["lyrics"] = lyrics
}
if lrc != "" {
resp["lyrics_synced"] = lrc
}
}
}
return resp, nil
}
func (c *Client) fetchTrackLyrics(ctx context.Context, trackID string) (string, string) {
params := url.Values{}
params.Set("deviceType", "PHONE")
params.Set("locale", "en_US")
params.Set("platform", "ANDROID")
resp, status, err := c.apiRequest(ctx, "tracks/"+url.PathEscape(strings.TrimSpace(trackID))+"/lyrics", params, c.lyricsAPI)
if err != nil {
return "", ""
}
if status != http.StatusOK {
return "", ""
}
lyrics := strings.TrimSpace(stringify(resp["lyrics"]))
lrc := strings.TrimSpace(stringify(resp["subtitles"]))
return lyrics, lrc
}
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 {
if quality >= 2 && d.Extension == "m4a" {
if strict, strictErr := c.getDownloadableFromTrackManifest(ctx, trackID, quality); strictErr == nil && strict != nil {
return strict, 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) {
formats := formatsForQuality(quality, c.cfg.Session.Tidal.PreferAtmos)
return c.getDownloadableFromTrackManifestForFormats(ctx, trackID, formats)
}
func (c *Client) getDownloadableFromTrackManifestForFormat(ctx context.Context, trackID, format string) (*provider.Downloadable, error) {
return c.getDownloadableFromTrackManifestForFormats(ctx, trackID, []string{format})
}
func (c *Client) getDownloadableFromTrackManifestForFormats(ctx context.Context, trackID string, formats []string) (*provider.Downloadable, error) {
params := url.Values{}
params.Set("manifestType", "MPEG_DASH")
params.Set("formats", strings.Join(formats, ","))
params.Set("uriScheme", "HTTPS")
params.Set("usage", "PLAYBACK")
params.Set("adaptive", "false")
resp, status, err := c.apiRequest(ctx, "trackManifests/"+trackID, params, c.openAPI)
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")
}
attrFormats, _ := attrs["formats"].([]any)
ext := "m4a"
for _, f := range attrFormats {
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"
}
}
profile := tidalAudioProfileFromFormats(attrFormats)
if profile.Container == "" {
profile = tidalAudioProfileFromExtension(ext)
}
return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal", Audio: profile}, nil
}
func formatsForQuality(quality int, preferAtmos bool) []string {
base, ok := qualityToFormats[quality]
if !ok {
base = qualityToFormats[0]
}
out := append([]string(nil), base...)
if preferAtmos {
out = append(out, "EAC3_JOC")
}
return out
}
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"
}
profile := tidalAudioProfileFromCodec(codec)
if profile.Container == "" {
profile = tidalAudioProfileFromExtension(ext)
}
audioQuality := strings.ToUpper(strings.TrimSpace(stringify(resp["audioQuality"])))
if audioQuality == "" {
audioQuality = strings.ToUpper(strings.TrimSpace(stringify(manifest["audioQuality"])))
}
if audioQuality != "" {
profile = applyTidalAudioQuality(profile, audioQuality)
}
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
profile = tidalAtmosAudioProfile()
}
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal", Audio: profile}
}
func tidalAudioProfileFromFormats(formats []any) provider.AudioProfile {
best := provider.AudioProfile{}
for _, raw := range formats {
f := strings.ToUpper(strings.TrimSpace(stringify(raw)))
switch {
case strings.Contains(f, "EAC3") || strings.Contains(f, "JOC") || strings.Contains(f, "ATMOS"):
return tidalAtmosAudioProfile()
case strings.Contains(f, "FLAC_HIRES"):
best = provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "HI_RES_LOSSLESS", BitDepth: 24}
case strings.Contains(f, "FLAC"):
if best.Container == "" {
best = provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}
}
case strings.Contains(f, "AACLC"):
if best.Container == "" {
best = provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}
}
case strings.Contains(f, "HEAAC"):
if best.Container == "" {
best = provider.AudioProfile{Container: "M4A", Codec: "HEAACV1", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 96}
}
}
}
return best
}
func tidalAudioProfileFromCodec(codec string) provider.AudioProfile {
c := strings.ToLower(strings.TrimSpace(codec))
switch {
case strings.Contains(c, "ec-3") || strings.Contains(c, "eac3") || strings.Contains(c, "joc") || strings.Contains(c, "atmos"):
return tidalAtmosAudioProfile()
case strings.Contains(c, "flac"):
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}
case strings.Contains(c, "mp4a.40.5") || strings.Contains(c, "mp4a.40.29"):
return provider.AudioProfile{Container: "M4A", Codec: "HEAACV1", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 96}
case strings.Contains(c, "mp4a") || strings.Contains(c, "aac"):
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}
default:
return provider.AudioProfile{}
}
}
func tidalAtmosAudioProfile() provider.AudioProfile {
return provider.AudioProfile{Container: "MKA", Codec: "EAC3_JOC", Quality: "ATMOS", BitDepth: 32, SamplingRate: "48"}
}
func tidalAudioProfileFromExtension(ext string) provider.AudioProfile {
switch strings.ToLower(strings.TrimSpace(ext)) {
case "flac":
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}
case "mka":
return tidalAtmosAudioProfile()
case "m4a":
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}
default:
container := strings.ToUpper(strings.TrimSpace(ext))
if container == "" {
container = "M4A"
}
return provider.AudioProfile{Container: container, Codec: container}
}
}
func applyTidalAudioQuality(profile provider.AudioProfile, audioQuality string) provider.AudioProfile {
aq := strings.ToUpper(strings.TrimSpace(audioQuality))
if aq == "" {
return profile
}
profile.Quality = aq
switch aq {
case "HI_RES", "HI_RES_LOSSLESS":
if strings.EqualFold(profile.Container, "FLAC") {
if profile.BitDepth < 24 {
profile.BitDepth = 24
}
}
case "LOSSLESS":
if strings.EqualFold(profile.Container, "FLAC") {
if profile.BitDepth == 0 {
profile.BitDepth = 16
}
if profile.SamplingRate == "" {
profile.SamplingRate = "44.1"
}
}
case "HIGH":
if strings.EqualFold(profile.Container, "M4A") && profile.BitrateKbps == 0 {
profile.BitrateKbps = 320
}
case "LOW":
if strings.EqualFold(profile.Container, "M4A") {
profile.Codec = "HEAACV1"
if profile.BitrateKbps == 0 {
profile.BitrateKbps = 96
}
}
}
return profile
}
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",
}
}