mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
add optional tidal atmos preference with immersive fallback
This commit is contained in:
@@ -44,6 +44,8 @@ var qualityToFormat = map[int]string{
|
||||
4: "FLAC_HIRES",
|
||||
}
|
||||
|
||||
var atmosAudioQualities = []string{"HI_RES_LOSSLESS", "HI_RES", "LOSSLESS", "HIGH"}
|
||||
|
||||
var ErrMissingTidalToken = errors.New("missing tidal access_token")
|
||||
|
||||
type Client struct {
|
||||
@@ -241,6 +243,14 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
|
||||
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")
|
||||
@@ -259,6 +269,111 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
|
||||
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
|
||||
}
|
||||
@@ -360,6 +475,10 @@ func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[
|
||||
|
||||
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)
|
||||
@@ -390,10 +509,14 @@ func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID s
|
||||
formats, _ := attrs["formats"].([]any)
|
||||
ext := "m4a"
|
||||
for _, f := range formats {
|
||||
if strings.Contains(strings.ToUpper(stringify(f)), "FLAC") {
|
||||
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
|
||||
@@ -488,6 +611,8 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
|
||||
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"}
|
||||
}
|
||||
|
||||
@@ -160,3 +160,86 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
|
||||
t.Fatalf("albums len = %d, want 102", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) {
|
||||
var calls []string
|
||||
allImmersive := true
|
||||
var ts *httptest.Server
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/tracks/42":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "audioModes": []any{"DOLBY_ATMOS", "STEREO"}, "mediaMetadata": map[string]any{"tags": []any{"LOSSLESS", "DOLBY_ATMOS"}}})
|
||||
case "/v1/tracks/42/playbackinfopostpaywall":
|
||||
if r.URL.Query().Get("immersiveaudio") != "true" {
|
||||
allImmersive = false
|
||||
}
|
||||
aq := r.URL.Query().Get("audioquality")
|
||||
calls = append(calls, aq)
|
||||
manifest := map[string]any{"urls": []string{ts.URL + "/stereo.m3u8"}, "codecs": "flac"}
|
||||
if aq == "HI_RES" {
|
||||
manifest = map[string]any{"urls": []string{ts.URL + "/atmos.m3u8"}, "codecs": "ec-3", "audioMode": "DOLBY_ATMOS"}
|
||||
}
|
||||
b, _ := json.Marshal(manifest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b), "audioMode": manifest["audioMode"]})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
cfgData.Tidal.PreferAtmos = true
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.loggedIn = true
|
||||
c.baseURL = ts.URL + "/v1"
|
||||
|
||||
d, err := c.GetDownloadable(context.Background(), "42", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDownloadable() err = %v", err)
|
||||
}
|
||||
if d.URL != ts.URL+"/atmos.m3u8" {
|
||||
t.Fatalf("url = %q, want %q", d.URL, ts.URL+"/atmos.m3u8")
|
||||
}
|
||||
if d.Extension != "mka" {
|
||||
t.Fatalf("extension = %q, want mka", d.Extension)
|
||||
}
|
||||
if len(calls) < 2 || calls[0] != "HI_RES_LOSSLESS" || calls[1] != "HI_RES" {
|
||||
t.Fatalf("unexpected audioquality call order: %+v", calls)
|
||||
}
|
||||
if !allImmersive {
|
||||
t.Fatalf("expected immersiveaudio=true on Atmos probing calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackLooksAtmosFromManifest(t *testing.T) {
|
||||
manifest := map[string]any{"urls": []string{"https://cdn.example/stream.m3u8"}, "codecs": "ec-3"}
|
||||
b, _ := json.Marshal(manifest)
|
||||
resp := map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)}
|
||||
if !playbackLooksAtmos(resp) {
|
||||
t.Fatalf("expected atmos detection from manifest codec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackSupportsAtmosFromTags(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/tracks/42" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "audioModes": []any{"STEREO"}, "mediaMetadata": map[string]any{"tags": []any{"LOSSLESS", "DOLBY_ATMOS"}}})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.loggedIn = true
|
||||
c.baseURL = ts.URL + "/v1"
|
||||
|
||||
if !c.trackSupportsAtmos(context.Background(), "42") {
|
||||
t.Fatalf("expected atmos support from mediaMetadata tags")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user