From 0161c01a4c921b354b311ab13b86a5765c8598c0 Mon Sep 17 00:00:00 2001 From: Joren Date: Tue, 21 Apr 2026 18:31:58 +0200 Subject: [PATCH] add optional tidal atmos preference with immersive fallback --- config.toml.example | 3 + internal/config/config.go | 2 + internal/provider/tidal/client.go | 127 ++++++++++++++++++++++++- internal/provider/tidal/client_test.go | 83 ++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) diff --git a/config.toml.example b/config.toml.example index 92f7750..be395c6 100644 --- a/config.toml.example +++ b/config.toml.example @@ -37,6 +37,9 @@ secrets = [] [tidal] # 0: AAC 256, 1: AAC 320, 2: FLAC 16/44.1, 3: FLAC hi-res when available quality = 3 +# Prefer Dolby Atmos/immersive stream variants when available +# Disabled by default because stereo FLAC is usually preferred +prefer_atmos = false # Download videos included in supported Tidal media download_videos = true diff --git a/internal/config/config.go b/internal/config/config.go index 4144497..23a8235 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,6 +60,7 @@ type QobuzConfig struct { type TidalConfig struct { Quality int `toml:"quality"` DownloadVideos bool `toml:"download_videos"` + PreferAtmos bool `toml:"prefer_atmos"` UserID string `toml:"user_id"` CountryCode string `toml:"country_code"` AccessToken string `toml:"access_token"` @@ -233,6 +234,7 @@ func DefaultConfigData() ConfigData { Tidal: TidalConfig{ Quality: 3, DownloadVideos: true, + PreferAtmos: false, }, Deezer: DeezerConfig{ Quality: 2, diff --git a/internal/provider/tidal/client.go b/internal/provider/tidal/client.go index 42368b8..4fca37b 100644 --- a/internal/provider/tidal/client.go +++ b/internal/provider/tidal/client.go @@ -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"} } diff --git a/internal/provider/tidal/client_test.go b/internal/provider/tidal/client_test.go index 23c062f..655e706 100644 --- a/internal/provider/tidal/client_test.go +++ b/internal/provider/tidal/client_test.go @@ -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") + } +}