From 654bed17e295d864dc19ffe5ea9756dc926a1577 Mon Sep 17 00:00:00 2001 From: Joren Date: Tue, 21 Apr 2026 12:19:23 +0200 Subject: [PATCH] harden deezer auth errors and mixed playlist preflight --- internal/app/app.go | 14 ++++ internal/app/app_test.go | 10 +++ internal/provider/deezer/client.go | 51 +++++++++++++- internal/provider/deezer/client_test.go | 89 +++++++++++++++++++------ 4 files changed, 142 insertions(+), 22 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index f61cbb9..14145f2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -728,6 +728,20 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl } func (m *Main) ripPlaylistMixed(ctx context.Context, playlistID, name string, refs []PlaylistTrackRef) error { + requiredSources := map[string]struct{}{} + for _, ref := range refs { + s := strings.TrimSpace(ref.Source) + if s == "" { + continue + } + requiredSources[s] = struct{}{} + } + for source := range requiredSources { + if err := m.requireSourceDownloadAuth(source); err != nil { + return err + } + } + folder := filepath.Join(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{ RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, TruncateTo: m.Config.Session.Filepaths.TruncateTo, diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 052963c..728611b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -535,6 +535,16 @@ func TestRipAlbumRequiresDeezerARL(t *testing.T) { } } +func TestRipPlaylistMixedRequiresDeezerAuth(t *testing.T) { + d := config.DefaultConfigData() + m := &Main{Config: &config.Config{File: d, Session: d}} + + err := m.ripPlaylistMixed(context.Background(), "mix1", "Mix", []PlaylistTrackRef{{Source: "deezer", ID: "1"}}) + if err == nil || !strings.Contains(err.Error(), "deezer") { + t.Fatalf("expected deezer auth error, got %v", err) + } +} + func TestApplyQobuzArtistFiltersRepeats(t *testing.T) { albums := []collectionAlbum{ {ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false}, diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go index e5b7067..a4c0aa3 100644 --- a/internal/provider/deezer/client.go +++ b/internal/provider/deezer/client.go @@ -203,6 +203,12 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s resp["tracks"] = map[string]any{"items": items} return resp, nil case "artist": + name := strings.TrimSpace(item) + if artistMeta, artistErr := c.apiGet(ctx, "/artist/"+item, nil); artistErr == nil { + if n := strings.TrimSpace(stringFromAny(artistMeta["name"])); n != "" { + name = n + } + } resp, err := c.getArtistAlbums(ctx, item) if err != nil { return nil, err @@ -218,7 +224,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s albums = append(albums, itm) } } - return map[string]any{"name": "", "albums": map[string]any{"items": albums}}, nil + return map[string]any{"name": name, "albums": map[string]any{"items": albums}}, nil default: return nil, fmt.Errorf("unsupported deezer media type: %s", mediaType) } @@ -727,10 +733,20 @@ func (c *Client) mobileAuth(ctx context.Context) (string, error) { if err != nil { return "", err } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("mobile_auth failed: status=%d body=%s", resp.StatusCode, string(raw)) + } out := map[string]any{} if err = json.Unmarshal(raw, &out); err != nil { return "", err } + if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { + msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"])) + if msg == "" { + msg = "mobile_auth returned an error" + } + return "", errors.New(msg) + } token := findStringByKey(nestedMap(out, "results"), "TOKEN") if token == "" { return "", errors.New("mobile_auth returned empty token") @@ -764,10 +780,20 @@ func (c *Client) apiCheckToken(ctx context.Context, authToken string) (string, e if err != nil { return "", err } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("api_checkToken failed: status=%d body=%s", resp.StatusCode, string(raw)) + } out := map[string]any{} if err = json.Unmarshal(raw, &out); err != nil { return "", err } + if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { + msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"])) + if msg == "" { + msg = "api_checkToken returned an error" + } + return "", errors.New(msg) + } sid := strings.TrimSpace(stringFromAny(out["results"])) if sid == "" { return "", errors.New("api_checkToken returned empty sid") @@ -861,10 +887,20 @@ func (c *Client) refreshJWT(ctx context.Context) error { } defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("jwt refresh failed: status=%d body=%s", resp.StatusCode, string(raw)) + } out := map[string]any{} if json.Unmarshal(raw, &out) != nil { return errors.New("invalid jwt refresh response") } + if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 { + msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"])) + if msg == "" { + msg = "jwt refresh returned an error" + } + return errors.New(msg) + } if jwt := strings.TrimSpace(stringFromAny(out["jwt"])); jwt != "" { c.jwt = jwt } @@ -905,10 +941,23 @@ func (c *Client) refreshLicenseFromPipe(ctx context.Context) error { } defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("pipe license refresh failed: status=%d body=%s", resp.StatusCode, string(raw)) + } out := map[string]any{} if json.Unmarshal(raw, &out) != nil { return errors.New("invalid pipe response") } + if errs, ok := out["errors"].([]any); ok && len(errs) > 0 { + msg := "" + if em, ok := errs[0].(map[string]any); ok { + msg = strings.TrimSpace(stringFromAny(em["message"])) + } + if msg == "" { + msg = "pipe response returned graphql error" + } + return errors.New(msg) + } token := findStringByKey(out, "token") if token == "" { return errors.New("pipe response missing license token") diff --git a/internal/provider/deezer/client_test.go b/internal/provider/deezer/client_test.go index cbae29f..067fd6f 100644 --- a/internal/provider/deezer/client_test.go +++ b/internal/provider/deezer/client_test.go @@ -42,29 +42,32 @@ func TestSearchTrack(t *testing.T) { func TestGetMetadataArtistPaginatesAlbums(t *testing.T) { callCount := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/artist/9/albums" { - w.WriteHeader(http.StatusNotFound) - return - } - callCount++ - index := r.URL.Query().Get("index") - limit := r.URL.Query().Get("limit") - if limit != "100" { - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "bad limit"}}) - return - } - switch index { - case "0": - items := make([]any, 0, 100) - for i := 0; i < 100; i++ { - items = append(items, map[string]any{"id": i + 1, "title": "Album"}) + switch r.URL.Path { + case "/artist/9": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 9, "name": "Lost Frequencies"}) + case "/artist/9/albums": + callCount++ + index := r.URL.Query().Get("index") + limit := r.URL.Query().Get("limit") + if limit != "100" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "bad limit"}}) + return + } + switch index { + case "0": + items := make([]any, 0, 100) + for i := 0; i < 100; i++ { + items = append(items, map[string]any{"id": i + 1, "title": "Album"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 101}) + case "100": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 101, "title": "Album 101"}}, "total": 101}) + default: + w.WriteHeader(http.StatusBadRequest) } - _ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 101}) - case "100": - _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 101, "title": "Album 101"}}, "total": 101}) default: - w.WriteHeader(http.StatusBadRequest) + w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() @@ -86,6 +89,9 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) { if len(items) != 101 { t.Fatalf("albums len = %d, want 101", len(items)) } + if got := strings.TrimSpace(stringFromAny(meta["name"])); got != "Lost Frequencies" { + t.Fatalf("artist name = %q, want Lost Frequencies", got) + } if callCount != 2 { t.Fatalf("call count = %d, want 2", callCount) } @@ -333,3 +339,44 @@ func TestLoginWithRefreshToken(t *testing.T) { t.Fatalf("session refresh token = %q", c.cfg.Session.Deezer.RefreshToken) } } + +func TestRefreshJWTHTTPError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "bad refresh"}}) + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.refresh = "refresh-token" + + origAuth := authURL + authURL = ts.URL + defer func() { authURL = origAuth }() + + err := c.refreshJWT(context.Background()) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "status=401") { + t.Fatalf("expected http status error, got %v", err) + } +} + +func TestRefreshLicenseFromPipeGraphQLError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"errors": []any{map[string]any{"message": "token expired"}}}) + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.jwt = "jwt-token" + + origPipe := pipeURL + pipeURL = ts.URL + defer func() { pipeURL = origPipe }() + + err := c.refreshLicenseFromPipe(context.Background()) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "token expired") { + t.Fatalf("expected graphql error, got %v", err) + } +}