harden deezer auth errors and mixed playlist preflight

This commit is contained in:
2026-04-21 12:19:23 +02:00
parent 1246a24749
commit 654bed17e2
4 changed files with 142 additions and 22 deletions

View File

@@ -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 { 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{ folder := filepath.Join(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
TruncateTo: m.Config.Session.Filepaths.TruncateTo, TruncateTo: m.Config.Session.Filepaths.TruncateTo,

View File

@@ -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) { func TestApplyQobuzArtistFiltersRepeats(t *testing.T) {
albums := []collectionAlbum{ albums := []collectionAlbum{
{ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false}, {ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false},

View File

@@ -203,6 +203,12 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
resp["tracks"] = map[string]any{"items": items} resp["tracks"] = map[string]any{"items": items}
return resp, nil return resp, nil
case "artist": 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) resp, err := c.getArtistAlbums(ctx, item)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -218,7 +224,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
albums = append(albums, itm) 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: default:
return nil, fmt.Errorf("unsupported deezer media type: %s", mediaType) 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 { if err != nil {
return "", err 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{} out := map[string]any{}
if err = json.Unmarshal(raw, &out); err != nil { if err = json.Unmarshal(raw, &out); err != nil {
return "", err 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") token := findStringByKey(nestedMap(out, "results"), "TOKEN")
if token == "" { if token == "" {
return "", errors.New("mobile_auth returned empty 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 { if err != nil {
return "", err 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{} out := map[string]any{}
if err = json.Unmarshal(raw, &out); err != nil { if err = json.Unmarshal(raw, &out); err != nil {
return "", err 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"])) sid := strings.TrimSpace(stringFromAny(out["results"]))
if sid == "" { if sid == "" {
return "", errors.New("api_checkToken returned empty 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() }() defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body) 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{} out := map[string]any{}
if json.Unmarshal(raw, &out) != nil { if json.Unmarshal(raw, &out) != nil {
return errors.New("invalid jwt refresh response") 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 != "" { if jwt := strings.TrimSpace(stringFromAny(out["jwt"])); jwt != "" {
c.jwt = jwt c.jwt = jwt
} }
@@ -905,10 +941,23 @@ func (c *Client) refreshLicenseFromPipe(ctx context.Context) error {
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body) 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{} out := map[string]any{}
if json.Unmarshal(raw, &out) != nil { if json.Unmarshal(raw, &out) != nil {
return errors.New("invalid pipe response") 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") token := findStringByKey(out, "token")
if token == "" { if token == "" {
return errors.New("pipe response missing license token") return errors.New("pipe response missing license token")

View File

@@ -42,10 +42,10 @@ func TestSearchTrack(t *testing.T) {
func TestGetMetadataArtistPaginatesAlbums(t *testing.T) { func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
callCount := 0 callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/artist/9/albums" { switch r.URL.Path {
w.WriteHeader(http.StatusNotFound) case "/artist/9":
return _ = json.NewEncoder(w).Encode(map[string]any{"id": 9, "name": "Lost Frequencies"})
} case "/artist/9/albums":
callCount++ callCount++
index := r.URL.Query().Get("index") index := r.URL.Query().Get("index")
limit := r.URL.Query().Get("limit") limit := r.URL.Query().Get("limit")
@@ -66,6 +66,9 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
default: default:
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
} }
default:
w.WriteHeader(http.StatusNotFound)
}
})) }))
defer ts.Close() defer ts.Close()
@@ -86,6 +89,9 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
if len(items) != 101 { if len(items) != 101 {
t.Fatalf("albums len = %d, want 101", len(items)) 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 { if callCount != 2 {
t.Fatalf("call count = %d, want 2", callCount) 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) 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)
}
}