// 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) }