Files
streamrip-go/internal/jsonutil/jsonutil.go
T
JorenandClaude Opus 5 b3b6a2eb1d refactor: delete dead code, dedupe helpers, add tests for untested packages
Dead code
  Removed, each verified to have no non-test caller: promptSearchSelection
  (77 lines, superseded by promptSearchSelectionMenu), AddPlaylistByTrackIDs,
  download.NewWithVerifySSL, qobuz.Client.GetTrackMetadata,
  tidal.Client.trackSupportsAtmos (test-only, and the comment above
  GetDownloadable already documented its removal from the live path) and its
  now-orphaned test, yandex.canonicalTrackRequestID (a no-op returning its
  trimmed input; the call site uses strings.TrimSpace directly) and the unused
  defaultEstimatedKbps constant.

Config keys that did nothing
  qobuz.download_booklets and misc.check_for_updates were declared, defaulted
  and documented in config.toml.example but read by no code. The whole
  [youtube] block went too: there is no YouTube provider. go-toml ignores
  unknown keys, verified, so existing user configs still load.

Duplicated helpers
  tidal.stringify and yandex.firstNonEmpty were byte-identical copies of
  jsonutil.StringFromAny and jsonutil.FirstNonEmpty. displayArtistNames
  existed three times, byte-identical, in internal/app, provider/yandex and
  provider/beatport; it now lives in jsonutil. The CLI's normalizeCodec
  delegates to config.NormalizeCodec so the flag and the config file are
  checked by the same rules.

Tests for packages that had none
  internal/jsonutil (0% -> 100%) and internal/netutil (0% -> 78%), the latter
  covering redactURL, which is a security control, and the zero-timeout
  contract that internal/download and the qobuz body copy depend on.
  internal/config rose to 82% with coverage of the new Validate(), the
  0600 permissions, CloneConfigData not sharing slices, and a concurrent
  UpdateTokens test that exercises the mutex under -race.

Test suite is ~85x faster
  Provider tests built clients with the default 60 requests/minute, so the
  rate limiter slept 1s per HTTP call: deezer took 31s, tidal 11s, qobuz 8s.
  Each provider test package now builds its config through a testConfigData
  helper that disables throttling. Whole suite: ~60s -> 0.7s, and 2.8s
  under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 17:05:55 +02:00

148 lines
3.3 KiB
Go

// Package jsonutil provides shared helpers for working with untyped JSON
// values (map[string]any) that come from API responses across all providers.
package jsonutil
import (
"strconv"
"strings"
)
// StringFromAny converts a dynamic JSON value to a string.
// Numeric types are formatted without trailing zeroes.
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 ""
}
}
// IntFromAny converts a dynamic JSON value to an int.
// Handles int, int64, float64, and string types.
func IntFromAny(v any) int {
switch t := v.(type) {
case int:
return t
case int32:
return int(t)
case int64:
return int(t)
case float64:
return int(t)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(t))
return i
default:
return 0
}
}
// FloatFromAny converts a dynamic JSON value to a float64.
func FloatFromAny(v any) float64 {
switch t := v.(type) {
case float64:
return t
case int:
return float64(t)
case int64:
return float64(t)
default:
return 0
}
}
// BoolFromAny converts a dynamic JSON value to a bool.
// Supports bool, string ("true"/"1"/"yes"), and numeric types.
func BoolFromAny(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
l := strings.ToLower(strings.TrimSpace(t))
return l == "1" || l == "true" || l == "yes"
case int:
return t != 0
case int64:
return t != 0
case float64:
return t != 0
default:
return false
}
}
// FirstNonEmpty returns the first string in items that is non-empty after trimming.
func FirstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}
// NestedMap returns the value at m[key] as a map[string]any.
// Returns an empty map if the key is missing or the value is not a map.
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
}
// NestedAny traverses a chain of map keys and returns the final value.
func NestedAny(v map[string]any, keys ...string) any {
cur := any(v)
for _, key := range keys {
m, ok := cur.(map[string]any)
if !ok {
return nil
}
cur = m[key]
}
return cur
}
// NestedString traverses a chain of map keys and returns the final value as a string.
func NestedString(v map[string]any, keys ...string) string {
return StringFromAny(NestedAny(v, keys...))
}
// TitleCase capitalises the first rune of s. This is a simple ASCII replacement
// for the deprecated strings.Title function, suitable for source names like
// "qobuz" → "Qobuz".
func TitleCase(s string) string {
if s == "" {
return s
}
r := []rune(s)
if r[0] >= 'a' && r[0] <= 'z' {
r[0] -= 'a' - 'A'
}
return string(r)
}
// DisplayArtistNames joins artist names the way the providers present them:
// "A", "A & B", or "A, B & C". Previously duplicated byte-for-byte in
// internal/app, provider/yandex and provider/beatport.
func DisplayArtistNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " & " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1]
}
}