mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
967 lines
27 KiB
Go
967 lines
27 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)
|
|
}
|
|
// Lyrics live on a separate endpoint, so fetching them costs an extra
|
|
// rate-limited roundtrip per track. Users who don't embed lyrics can
|
|
// opt out via metadata.exclude = ["lyrics"].
|
|
if !c.lyricsExcluded() {
|
|
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) lyricsExcluded() bool {
|
|
for _, k := range c.cfg.Session.Metadata.Exclude {
|
|
if strings.EqualFold(strings.TrimSpace(k), "lyrics") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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 {
|
|
// No tracks/{id} pre-check: getAtmosDownloadable already validates
|
|
// each candidate response via playbackLooksAtmos and falls back
|
|
// through the format-specific trackManifests paths.
|
|
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 {
|
|
// Tidal's playbackinfo sometimes returns an m4a (HIGH/AAC)
|
|
// stream even when LOSSLESS+ was requested. There is no
|
|
// lossless m4a tier, so retry via the openAPI v2 manifest
|
|
// before settling for the downgrade.
|
|
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",
|
|
}
|
|
}
|