Refactor: comprehensive cleanup and modularization

- Extracted common JSON parsing helpers into internal/jsonutil
- Removed duplicated helper functions from provider packages
- Removed dead code in internal/app/app.go and downloader.go
- Replaced deprecated strings.Title with jsonutil.TitleCase
- Added graceful shutdown with signal handling in main.go
- Split monolithic cmd/rip/main.go into args.go, helpers.go, lastfm.go, search.go
This commit is contained in:
2026-04-21 23:38:41 +02:00
parent d65dc182f8
commit 6bc4b3b319
15 changed files with 1763 additions and 1853 deletions

View File

@@ -17,6 +17,7 @@ import (
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
@@ -152,7 +153,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
return nil, err
}
enrichTrack(resp)
if lyr, lyrErr := c.fetchLyricsFromPipe(ctx, strings.TrimSpace(stringFromAny(resp["id"]))); lyrErr == nil {
if lyr, lyrErr := c.fetchLyricsFromPipe(ctx, strings.TrimSpace(jsonutil.StringFromAny(resp["id"]))); lyrErr == nil {
if strings.TrimSpace(lyr.Text) != "" {
resp["lyrics"] = lyr.Text
}
@@ -205,7 +206,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
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 != "" {
if n := strings.TrimSpace(jsonutil.StringFromAny(artistMeta["name"])); n != "" {
name = n
}
}
@@ -246,7 +247,7 @@ func (c *Client) getArtistAlbums(ctx context.Context, artistID string) (map[stri
data, _ := resp["data"].([]any)
all = append(all, data...)
if total < 0 {
total = intFromAny(resp["total"])
total = jsonutil.IntFromAny(resp["total"])
}
if len(data) < pageSize {
break
@@ -288,7 +289,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if err != nil {
return nil, err
}
trackToken := strings.TrimSpace(stringFromAny(meta["track_token"]))
trackToken := strings.TrimSpace(jsonutil.StringFromAny(meta["track_token"]))
if trackToken == "" {
trackToken, err = c.getTrackToken(ctx, item)
if err != nil {
@@ -303,7 +304,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if ext == "" {
ext = "mp3"
}
trackID := strings.TrimSpace(stringFromAny(meta["id"]))
trackID := strings.TrimSpace(jsonutil.StringFromAny(meta["id"]))
if trackID == "" {
trackID = strings.TrimSpace(item)
}
@@ -344,9 +345,9 @@ func (c *Client) apiGet(ctx context.Context, path string, params url.Values) (ma
return nil, fmt.Errorf("deezer api failed: status=%d body=%s", resp.StatusCode, string(body))
}
if errObj, ok := out["error"].(map[string]any); ok {
msg := strings.TrimSpace(stringFromAny(errObj["message"]))
msg := strings.TrimSpace(jsonutil.StringFromAny(errObj["message"]))
if msg == "" {
msg = strings.TrimSpace(stringFromAny(errObj["type"]))
msg = strings.TrimSpace(jsonutil.StringFromAny(errObj["type"]))
}
if msg == "" {
msg = "unknown deezer error"
@@ -394,17 +395,17 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error {
return err
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
return fmt.Errorf("deezer getUserData error: %s", stringFromAny(errObj["message"]))
return fmt.Errorf("deezer getUserData error: %s", jsonutil.StringFromAny(errObj["message"]))
}
results, _ := out["results"].(map[string]any)
if len(results) == 0 {
return errors.New("deezer getUserData returned empty results")
}
c.sid = firstNonEmpty(c.sid, sidFromCookies(c.http, webGWLight))
c.sid = jsonutil.FirstNonEmpty(c.sid, sidFromCookies(c.http, webGWLight))
c.license = findStringByKey(results, "license_token")
c.userID = findStringByKey(results, "USER_ID")
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
if c.sid == "" {
if sid, sidErr := c.bootstrapSID(ctx); sidErr == nil {
c.sid = sid
@@ -460,7 +461,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
if err != nil {
return err
}
c.sid = firstNonEmpty(c.sid, sid)
c.sid = jsonutil.FirstNonEmpty(c.sid, sid)
encryptedPassword, err := encryptPassword(mobileToken, password)
if err != nil {
@@ -515,22 +516,22 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
return err
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"]))
if msg == "" {
msg = "unknown mobile_userAuth error"
}
return errors.New(msg)
}
results := nestedMap(out, "results")
results := jsonutil.NestedMap(out, "results")
if len(results) == 0 {
return errors.New("mobile_userAuth returned empty results")
}
c.arl = firstNonEmpty(c.arl, findStringByKey(results, "ARL"))
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
c.userID = firstNonEmpty(c.userID, findStringByKey(results, "USER_ID"))
c.arl = jsonutil.FirstNonEmpty(c.arl, findStringByKey(results, "ARL"))
c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
c.license = jsonutil.FirstNonEmpty(c.license, findStringByKey(results, "license_token"))
c.userID = jsonutil.FirstNonEmpty(c.userID, findStringByKey(results, "USER_ID"))
if c.arl == "" {
return errors.New("mobile_userAuth missing arl")
@@ -558,7 +559,7 @@ func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, err
if err != nil {
return "", err
}
token := strings.TrimSpace(stringFromAny(resp["track_token"]))
token := strings.TrimSpace(jsonutil.StringFromAny(resp["track_token"]))
if token == "" {
return "", errors.New("deezer track metadata missing track_token")
}
@@ -613,8 +614,8 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri
msg := ""
typ := ""
if em, ok := errs[0].(map[string]any); ok {
msg = strings.TrimSpace(stringFromAny(em["message"]))
typ = strings.TrimSpace(stringFromAny(em["type"]))
msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"]))
typ = strings.TrimSpace(jsonutil.StringFromAny(em["type"]))
}
if strings.EqualFold(typ, "JwtTokenExpiredError") || strings.Contains(strings.ToLower(msg), "not valid anymore") || strings.Contains(strings.ToLower(msg), "jwt") && strings.Contains(strings.ToLower(msg), "expired") {
return nil, errDeezerJWTExpired
@@ -624,8 +625,8 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri
}
return nil, errors.New(msg)
}
lyrics := nestedMap(nestedMap(nestedMap(out, "data"), "track"), "lyrics")
text := strings.TrimSpace(stringFromAny(lyrics["text"]))
lyrics := jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "lyrics")
text := strings.TrimSpace(jsonutil.StringFromAny(lyrics["text"]))
synced := buildSyncedLRC(lyrics["synchronizedLines"])
if text != "" || synced != "" {
return &lyricsResult{Text: text, SyncedLRC: synced}, nil
@@ -637,9 +638,9 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri
if !ok {
continue
}
line := strings.TrimSpace(stringFromAny(m["line"]))
line := strings.TrimSpace(jsonutil.StringFromAny(m["line"]))
if line == "" {
line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
line = strings.TrimSpace(jsonutil.StringFromAny(m["lineTranslated"]))
}
if line != "" {
parts = append(parts, line)
@@ -682,14 +683,14 @@ func buildSyncedLRC(v any) string {
if !ok {
continue
}
line := strings.TrimSpace(stringFromAny(m["line"]))
line := strings.TrimSpace(jsonutil.StringFromAny(m["line"]))
if line == "" {
line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
line = strings.TrimSpace(jsonutil.StringFromAny(m["lineTranslated"]))
}
if line == "" {
continue
}
ms := intFromAny(m["milliseconds"])
ms := jsonutil.IntFromAny(m["milliseconds"])
out = append(out, fmt.Sprintf("[%02d:%05.2f]%s", ms/60000, float64(ms%60000)/1000.0, line))
}
return strings.Join(out, "\n")
@@ -741,13 +742,13 @@ func (c *Client) mobileAuth(ctx context.Context) (string, error) {
return "", err
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"]))
if msg == "" {
msg = "mobile_auth returned an error"
}
return "", errors.New(msg)
}
token := findStringByKey(nestedMap(out, "results"), "TOKEN")
token := findStringByKey(jsonutil.NestedMap(out, "results"), "TOKEN")
if token == "" {
return "", errors.New("mobile_auth returned empty token")
}
@@ -788,13 +789,13 @@ func (c *Client) apiCheckToken(ctx context.Context, authToken string) (string, e
return "", err
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"]))
if msg == "" {
msg = "api_checkToken returned an error"
}
return "", errors.New(msg)
}
sid := strings.TrimSpace(stringFromAny(out["results"]))
sid := strings.TrimSpace(jsonutil.StringFromAny(out["results"]))
if sid == "" {
return "", errors.New("api_checkToken returned empty sid")
}
@@ -852,13 +853,13 @@ func (c *Client) mobileUserAutolog(ctx context.Context) error {
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
continue
}
results := nestedMap(out, "results")
results := jsonutil.NestedMap(out, "results")
if len(results) == 0 {
continue
}
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
c.license = jsonutil.FirstNonEmpty(c.license, findStringByKey(results, "license_token"))
if c.jwt != "" || c.license != "" {
return nil
}
@@ -895,16 +896,16 @@ func (c *Client) refreshJWT(ctx context.Context) error {
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"]))
msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.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(jsonutil.StringFromAny(out["jwt"])); jwt != "" {
c.jwt = jwt
}
if rt := strings.TrimSpace(stringFromAny(out["refresh_token"])); rt != "" {
if rt := strings.TrimSpace(jsonutil.StringFromAny(out["refresh_token"])); rt != "" {
c.refresh = rt
}
if c.jwt == "" {
@@ -951,7 +952,7 @@ func (c *Client) refreshLicenseFromPipe(ctx context.Context) error {
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"]))
msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"]))
}
if msg == "" {
msg = "pipe response returned graphql error"
@@ -1228,7 +1229,7 @@ func findStringByKey(v any, wantedKey string) string {
case map[string]any:
for k, value := range x {
if strings.ToLower(k) == w {
if s := stringFromAny(value); strings.TrimSpace(s) != "" {
if s := jsonutil.StringFromAny(value); strings.TrimSpace(s) != "" {
return s
}
}
@@ -1246,17 +1247,9 @@ func findStringByKey(v any, wantedKey string) string {
return ""
}
func nestedMap(m map[string]any, key string) map[string]any {
v, ok := m[key].(map[string]any)
if !ok {
return map[string]any{}
}
return v
}
func enrichTrack(track map[string]any) {
if artist, ok := track["artist"].(map[string]any); ok {
track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])}
track["performer"] = map[string]any{"name": jsonutil.StringFromAny(artist["name"]), "id": jsonutil.StringFromAny(artist["id"])}
}
if album, ok := track["album"].(map[string]any); ok {
enrichAlbumImage(album)
@@ -1271,7 +1264,7 @@ func enrichTrack(track map[string]any) {
track["media_number"] = d
}
}
if boolFromAny(track["explicit_lyrics"]) {
if jsonutil.BoolFromAny(track["explicit_lyrics"]) {
track["explicit"] = true
}
}
@@ -1280,11 +1273,11 @@ func enrichAlbumImage(meta map[string]any) {
if _, ok := meta["image"].(map[string]any); ok {
return
}
cover := firstNonEmpty(
stringFromAny(meta["cover_xl"]),
stringFromAny(meta["cover_big"]),
stringFromAny(meta["cover_medium"]),
stringFromAny(meta["cover_small"]),
cover := jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(meta["cover_xl"]),
jsonutil.StringFromAny(meta["cover_big"]),
jsonutil.StringFromAny(meta["cover_medium"]),
jsonutil.StringFromAny(meta["cover_small"]),
)
if cover == "" {
return
@@ -1296,48 +1289,3 @@ func enrichAlbumImage(meta map[string]any) {
"original": cover,
}
}
func stringFromAny(v any) string {
switch t := v.(type) {
case string:
return t
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
}
}
func firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}
func intFromAny(v any) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(t))
return i
default:
return 0
}
}
func boolFromAny(v any) bool {
b, ok := v.(bool)
return ok && b
}

View File

@@ -9,6 +9,8 @@ import (
"strings"
"testing"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/config"
)
@@ -89,7 +91,7 @@ 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" {
if got := strings.TrimSpace(jsonutil.StringFromAny(meta["name"])); got != "Lost Frequencies" {
t.Fatalf("artist name = %q, want Lost Frequencies", got)
}
if callCount != 2 {
@@ -220,11 +222,11 @@ func TestGetMetadataAddsLyricsFromPipe(t *testing.T) {
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if !strings.Contains(stringFromAny(meta["lyrics"]), "Go shawty") {
t.Fatalf("expected lyrics text, got %q", stringFromAny(meta["lyrics"]))
if !strings.Contains(jsonutil.StringFromAny(meta["lyrics"]), "Go shawty") {
t.Fatalf("expected lyrics text, got %q", jsonutil.StringFromAny(meta["lyrics"]))
}
if !strings.Contains(stringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
t.Fatalf("expected synced lyrics, got %q", stringFromAny(meta["lyrics_synced"]))
if !strings.Contains(jsonutil.StringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
t.Fatalf("expected synced lyrics, got %q", jsonutil.StringFromAny(meta["lyrics_synced"]))
}
}
@@ -243,7 +245,7 @@ func TestLoginWithCredentials(t *testing.T) {
case "mobile_userAuth":
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
if strings.TrimSpace(stringFromAny(payload["mail"])) == "" || strings.TrimSpace(stringFromAny(payload["password"])) == "" {
if strings.TrimSpace(jsonutil.StringFromAny(payload["mail"])) == "" || strings.TrimSpace(jsonutil.StringFromAny(payload["password"])) == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "missing creds"}})
return