add optional tidal atmos preference with immersive fallback

This commit is contained in:
2026-04-21 18:31:58 +02:00
parent 6dbf6a222d
commit 0161c01a4c
4 changed files with 214 additions and 1 deletions

View File

@@ -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,

View File

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

View File

@@ -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")
}
}