fix tidal lossless quality negotiation and atmos format fallback

This commit is contained in:
2026-04-23 23:53:41 +02:00
parent c1e89f5876
commit d5b336ca4e
2 changed files with 108 additions and 12 deletions

View File

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

View File

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