From d5b336ca4e11a38d85b3362c7c03e6fd5b3d29e1 Mon Sep 17 00:00:00 2001 From: Joren Date: Thu, 23 Apr 2026 23:53:41 +0200 Subject: [PATCH] fix tidal lossless quality negotiation and atmos format fallback --- internal/provider/tidal/client.go | 47 ++++++++++++----- internal/provider/tidal/client_test.go | 73 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/internal/provider/tidal/client.go b/internal/provider/tidal/client.go index 35e427a..a946d80 100644 --- a/internal/provider/tidal/client.go +++ b/internal/provider/tidal/client.go @@ -37,12 +37,12 @@ var qualityMap = map[int]string{ 4: "HI_RES_LOSSLESS", } -var qualityToFormat = map[int]string{ - 0: "HEAACV1", - 1: "AACLC", - 2: "FLAC", - 3: "FLAC_HIRES", - 4: "FLAC_HIRES", +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"} @@ -54,6 +54,7 @@ type Client struct { http *http.Client limiter *ratelimit.Limiter baseURL string + openAPI string loggedIn bool } @@ -63,6 +64,7 @@ func New(cfg *config.Config) *Client { http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), baseURL: baseURL, + openAPI: openAPIV2, } } @@ -263,6 +265,11 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in } 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 } } @@ -475,19 +482,23 @@ 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) + 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", format) + 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, openAPIV2) + resp, status, err := c.apiRequest(ctx, "trackManifests/"+trackID, params, c.openAPI) if err != nil { return nil, err } @@ -507,9 +518,9 @@ func (c *Client) getDownloadableFromTrackManifestForFormat(ctx context.Context, if uri == "" { return nil, errors.New("tidal trackManifests missing uri") } - formats, _ := attrs["formats"].([]any) + attrFormats, _ := attrs["formats"].([]any) ext := "m4a" - for _, f := range formats { + for _, f := range attrFormats { fv := strings.ToUpper(stringify(f)) if strings.Contains(fv, "FLAC") { ext = "flac" @@ -523,6 +534,18 @@ func (c *Client) getDownloadableFromTrackManifestForFormat(ctx context.Context, return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, 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") diff --git a/internal/provider/tidal/client_test.go b/internal/provider/tidal/client_test.go index 655e706..7433710 100644 --- a/internal/provider/tidal/client_test.go +++ b/internal/provider/tidal/client_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "strconv" "testing" @@ -243,3 +244,75 @@ func TestTrackSupportsAtmosFromTags(t *testing.T) { t.Fatalf("expected atmos support from mediaMetadata tags") } } + +func TestFormatsForQuality(t *testing.T) { + tests := []struct { + name string + q int + atmos bool + wants []string + }{ + {name: "low", q: 0, wants: []string{"HEAACV1"}}, + {name: "high", q: 1, wants: []string{"HEAACV1", "AACLC"}}, + {name: "lossless", q: 2, wants: []string{"HEAACV1", "AACLC", "FLAC"}}, + {name: "hires", q: 4, wants: []string{"HEAACV1", "AACLC", "FLAC", "FLAC_HIRES"}}, + {name: "atmos adds eac3", q: 2, atmos: true, wants: []string{"HEAACV1", "AACLC", "FLAC", "EAC3_JOC"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := formatsForQuality(tc.q, tc.atmos) + if !reflect.DeepEqual(got, tc.wants) { + t.Fatalf("formatsForQuality(%d, atmos=%v) = %#v, want %#v", tc.q, tc.atmos, got, tc.wants) + } + }) + } +} + +func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T) { + var gotFormats string + var ts *httptest.Server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/tracks/42/playbackinfopostpaywall": + manifest := map[string]any{"urls": []string{ts.URL + "/aac.m3u8"}, "codecs": "mp4a.40.2"} + b, _ := json.Marshal(manifest) + _ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)}) + case "/v2/trackManifests/42": + gotFormats = r.URL.Query().Get("formats") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "attributes": map[string]any{ + "uri": ts.URL + "/song.flac", + "formats": []any{"FLAC"}, + }, + }, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + 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" + c.openAPI = ts.URL + "/v2" + + d, err := c.GetDownloadable(context.Background(), "42", 2) + if err != nil { + t.Fatalf("GetDownloadable() err = %v", err) + } + if gotFormats != "HEAACV1,AACLC,FLAC" { + t.Fatalf("formats query = %q, want %q", gotFormats, "HEAACV1,AACLC,FLAC") + } + if d.URL != ts.URL+"/song.flac" { + t.Fatalf("url = %q, want %q", d.URL, ts.URL+"/song.flac") + } + if d.Extension != "flac" { + t.Fatalf("extension = %q, want flac", d.Extension) + } +}