From 63e1f20e045204b5825d2541c637b9a04626d9ab Mon Sep 17 00:00:00 2001 From: Joren Date: Sat, 25 Apr 2026 01:24:11 +0200 Subject: [PATCH] fix tidal lyrics fetch host and synced lyric tagging --- internal/provider/tidal/client.go | 62 +++++++++++++++------ internal/provider/tidal/client_test.go | 76 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/internal/provider/tidal/client.go b/internal/provider/tidal/client.go index 3aae6f1..baa4f5b 100644 --- a/internal/provider/tidal/client.go +++ b/internal/provider/tidal/client.go @@ -22,11 +22,12 @@ import ( ) const ( - baseURL = "https://api.tidalhifi.com/v1" - openAPIV2 = "https://openapi.tidal.com/v2" - authURL = "https://auth.tidal.com/v1/oauth2" - clientID = "fX2JxdmntZWK0ixT" - clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" + baseURL = "https://api.tidalhifi.com/v1" + lyricsAPIv1 = "https://api.tidal.com/v1" + openAPIV2 = "https://openapi.tidal.com/v2" + authURL = "https://auth.tidal.com/v1/oauth2" + clientID = "fX2JxdmntZWK0ixT" + clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" ) var qualityMap = map[int]string{ @@ -50,21 +51,23 @@ var atmosAudioQualities = []string{"HI_RES_LOSSLESS", "HI_RES", "LOSSLESS", "HIG var ErrMissingTidalToken = errors.New("missing tidal access_token") type Client struct { - cfg *config.Config - http *http.Client - limiter *ratelimit.Limiter - baseURL string - openAPI string - loggedIn bool + cfg *config.Config + http *http.Client + limiter *ratelimit.Limiter + baseURL string + lyricsAPI string + openAPI string + loggedIn bool } func New(cfg *config.Config) *Client { return &Client{ - cfg: cfg, - http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), - limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), - baseURL: baseURL, - openAPI: openAPIV2, + cfg: cfg, + http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), + limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), + baseURL: baseURL, + lyricsAPI: lyricsAPIv1, + openAPI: openAPIV2, } } @@ -206,11 +209,38 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s if album, ok := resp["album"].(map[string]any); ok { enrichTidalImage(album) } + if lyrics, lrc := c.fetchTrackLyrics(ctx, item); lyrics != "" || lrc != "" { + if lyrics != "" { + resp["lyrics"] = lyrics + } + if lrc != "" { + resp["lyrics_synced"] = lrc + } + } } return resp, nil } +func (c *Client) fetchTrackLyrics(ctx context.Context, trackID string) (string, string) { + params := url.Values{} + params.Set("deviceType", "PHONE") + params.Set("locale", "en_US") + params.Set("platform", "ANDROID") + + resp, status, err := c.apiRequest(ctx, "tracks/"+url.PathEscape(strings.TrimSpace(trackID))+"/lyrics", params, c.lyricsAPI) + if err != nil { + return "", "" + } + if status != http.StatusOK { + return "", "" + } + + lyrics := strings.TrimSpace(stringify(resp["lyrics"])) + lrc := strings.TrimSpace(stringify(resp["subtitles"])) + return lyrics, lrc +} + func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, 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 c109086..9a86892 100644 --- a/internal/provider/tidal/client_test.go +++ b/internal/provider/tidal/client_test.go @@ -162,6 +162,82 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) { } } +func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) { + 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, "title": "Song", "album": map[string]any{"id": 10, "title": "Album"}}) + case "/v1/tracks/42/lyrics": + q := r.URL.Query() + if q.Get("deviceType") != "PHONE" || q.Get("locale") != "en_US" || q.Get("platform") != "ANDROID" || q.Get("countryCode") != "MY" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "bad query"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "lyrics": "plain lyrics line", + "subtitles": "[00:00.00]plain lyrics line", + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + cfgData.Tidal.AccessToken = "token" + cfgData.Tidal.CountryCode = "MY" + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + c.baseURL = ts.URL + "/v1" + c.lyricsAPI = ts.URL + "/v1" + + meta, err := c.GetMetadata(context.Background(), "42", "track") + if err != nil { + t.Fatalf("GetMetadata() err = %v", err) + } + if got := stringify(meta["lyrics"]); got != "plain lyrics line" { + t.Fatalf("lyrics = %q, want plain lyrics line", got) + } + if got := stringify(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" { + t.Fatalf("lyrics_synced = %q, want synced lrc", got) + } +} + +func TestGetMetadataTrackIgnoresLyricsEndpointFailure(t *testing.T) { + 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, "title": "Song"}) + case "/v1/tracks/42/lyrics": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "not found"}) + 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.lyricsAPI = ts.URL + "/v1" + + meta, err := c.GetMetadata(context.Background(), "42", "track") + if err != nil { + t.Fatalf("GetMetadata() err = %v", err) + } + if _, ok := meta["lyrics"]; ok { + t.Fatalf("did not expect lyrics when endpoint fails") + } + if _, ok := meta["lyrics_synced"]; ok { + t.Fatalf("did not expect lyrics_synced when endpoint fails") + } +} + func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) { var calls []string allImmersive := true