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

@@ -18,6 +18,7 @@ import (
"streamrip-go/internal/config"
"streamrip-go/internal/domain/media"
"streamrip-go/internal/download"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/naming"
"streamrip-go/internal/provider"
deezerprovider "streamrip-go/internal/provider/deezer"
@@ -259,7 +260,7 @@ func (m *Main) AddMixedPlaylistByTrackRefs(ctx context.Context, playlistID, play
func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any) error {
name := titleFromMetadata(meta, id)
if n := stringFromAny(meta["name"]); n != "" {
if n := jsonutil.StringFromAny(meta["name"]); n != "" {
name = n
}
@@ -327,18 +328,18 @@ func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID
}
func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum {
trackCount := intFromAny(meta["tracks_count"])
trackCount := jsonutil.IntFromAny(meta["tracks_count"])
if trackCount == 0 {
trackCount = intFromAny(meta["numberOfTracks"])
trackCount = jsonutil.IntFromAny(meta["numberOfTracks"])
}
return collectionAlbum{
ID: id,
Meta: meta,
Title: titleFromMetadata(meta, id),
AlbumArtist: nestedString(meta, "artist", "name"),
BitDepth: intFromAny(meta["maximum_bit_depth"]),
Sampling: floatFromAny(meta["maximum_sampling_rate"]),
Explicit: boolFromAny(meta["parental_warning"]),
AlbumArtist: jsonutil.NestedString(meta, "artist", "name"),
BitDepth: jsonutil.IntFromAny(meta["maximum_bit_depth"]),
Sampling: jsonutil.FloatFromAny(meta["maximum_sampling_rate"]),
Explicit: jsonutil.BoolFromAny(meta["parental_warning"]),
TrackCount: trackCount,
}
}
@@ -459,10 +460,10 @@ func extractAlbumIDs(meta map[string]any) []string {
if !ok {
continue
}
id := stringFromAny(itm["id"])
id := jsonutil.StringFromAny(itm["id"])
if id == "" {
if nested, ok := itm["album"].(map[string]any); ok {
id = stringFromAny(nested["id"])
id = jsonutil.StringFromAny(nested["id"])
}
}
if id == "" {
@@ -517,23 +518,23 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
}
albumTitle := titleFromMetadata(albumMeta, albumID)
albumArtist := nestedString(albumMeta, "artist", "name")
albumArtist := jsonutil.NestedString(albumMeta, "artist", "name")
if albumArtist == "" {
albumArtist = "Unknown"
}
releaseDate := stringFromAny(albumMeta["release_date_original"])
releaseDate := jsonutil.StringFromAny(albumMeta["release_date_original"])
if releaseDate == "" {
releaseDate = stringFromAny(albumMeta["release_date"])
releaseDate = jsonutil.StringFromAny(albumMeta["release_date"])
}
if releaseDate == "" {
releaseDate = stringFromAny(albumMeta["releaseDate"])
releaseDate = jsonutil.StringFromAny(albumMeta["releaseDate"])
}
if releaseDate == "" {
releaseDate = stringFromAny(albumMeta["streamStartDate"])
releaseDate = jsonutil.StringFromAny(albumMeta["streamStartDate"])
}
year := naming.YearFromDate(releaseDate)
bitDepth := intFromAny(albumMeta["maximum_bit_depth"])
sampling := stringFromAny(albumMeta["maximum_sampling_rate"])
bitDepth := jsonutil.IntFromAny(albumMeta["maximum_bit_depth"])
sampling := jsonutil.StringFromAny(albumMeta["maximum_sampling_rate"])
if bitDepth == 0 || sampling == "" {
fallbackBitDepth, fallbackSampling := m.qualityProfileForSource(source)
if bitDepth == 0 {
@@ -564,7 +565,7 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
if !ok {
continue
}
id := stringFromAny(itm["id"])
id := jsonutil.StringFromAny(itm["id"])
if id != "" {
trackIDs = append(trackIDs, id)
}
@@ -573,9 +574,9 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
folder := m.albumFolderPath(source, albumID, albumTitle, albumArtist, year, bitDepth, sampling)
artRes, _ := artwork.Prepare(ctx, m.DL, folder, albumMeta, m.Config.Session.Artwork, false)
total := len(trackIDs)
discTotal := intFromAny(albumMeta["media_count"])
discTotal := jsonutil.IntFromAny(albumMeta["media_count"])
if discTotal == 0 {
discTotal = intFromAny(albumMeta["numberOfVolumes"])
discTotal = jsonutil.IntFromAny(albumMeta["numberOfVolumes"])
}
m.logf("Album: %s (%d tracks)\n", albumTitle, total)
failures := 0
@@ -631,12 +632,12 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
}
name := titleFromMetadata(playlistMeta, playlistID)
if n := stringFromAny(playlistMeta["name"]); n != "" {
if n := jsonutil.StringFromAny(playlistMeta["name"]); n != "" {
name = n
}
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, strings.Title(source))
base = filepath.Join(base, jsonutil.TitleCase(source))
}
folder := filepath.Join(base, naming.CleanName(name, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
@@ -665,9 +666,9 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
if !ok {
continue
}
id := stringFromAny(itm["id"])
id := jsonutil.StringFromAny(itm["id"])
if id == "" {
id = stringFromAny(itm["track_id"])
id = jsonutil.StringFromAny(itm["track_id"])
}
if id != "" {
ids = append(ids, id)
@@ -806,11 +807,9 @@ func (m *Main) requireSourceDownloadAuth(source string) error {
}
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
if err == nil && alreadyDownloaded {
if m.IgnoreDB {
alreadyDownloaded = false
} else {
if !m.IgnoreDB {
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
if err == nil && alreadyDownloaded {
if opts.total > 0 {
m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id)
} else {
@@ -820,19 +819,6 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
}
}
if m.IgnoreDB {
alreadyDownloaded = false
}
if alreadyDownloaded {
if opts.total > 0 {
m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id)
} else {
m.logf("skip (already downloaded) id=%s\n", id)
}
return nil
}
meta, err := p.GetMetadata(ctx, id, "track")
if err != nil {
_ = m.Store.MarkFailed(ctx, source, "track", id)
@@ -970,7 +956,7 @@ func (m *Main) qualityProfileForSource(source string) (int, string) {
func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year string, bitDepth int, samplingRate string) string {
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, strings.Title(source))
base = filepath.Join(base, jsonutil.TitleCase(source))
}
vals := map[string]string{
@@ -995,34 +981,34 @@ func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year st
func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[string]any, albumFolder string, albumDiscTotal int) string {
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, strings.Title(source))
base = filepath.Join(base, jsonutil.TitleCase(source))
}
if albumFolder == "" && m.Config.Session.Filepaths.AddSinglesToFolder {
albumTitle := nestedString(trackMeta, "album", "title")
albumID := nestedString(trackMeta, "album", "id")
albumTitle := jsonutil.NestedString(trackMeta, "album", "title")
albumID := jsonutil.NestedString(trackMeta, "album", "id")
if albumID == "" {
albumID = id
}
albumArtist := nestedString(trackMeta, "album", "artist", "name")
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
if albumArtist == "" {
albumArtist = nestedString(trackMeta, "performer", "name")
albumArtist = jsonutil.NestedString(trackMeta, "performer", "name")
}
albumYear := naming.YearFromDate(stringFromAny(trackMeta["release_date_original"]))
albumYear := naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date_original"]))
if albumYear == "Unknown" {
albumYear = naming.YearFromDate(stringFromAny(trackMeta["release_date"]))
albumYear = naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date"]))
}
albumFolder = m.albumFolderPath(source, albumID, albumTitle, albumArtist, albumYear, intFromAny(trackMeta["maximum_bit_depth"]), stringFromAny(trackMeta["maximum_sampling_rate"]))
albumFolder = m.albumFolderPath(source, albumID, albumTitle, albumArtist, albumYear, jsonutil.IntFromAny(trackMeta["maximum_bit_depth"]), jsonutil.StringFromAny(trackMeta["maximum_sampling_rate"]))
}
if albumFolder != "" {
base = albumFolder
if m.Config.Session.Downloads.DiscSubdirectories && albumDiscTotal > 1 {
discNumber := intFromAny(trackMeta["media_number"])
discNumber := jsonutil.IntFromAny(trackMeta["media_number"])
if discNumber == 0 {
discNumber = intFromAny(trackMeta["volumeNumber"])
discNumber = jsonutil.IntFromAny(trackMeta["volumeNumber"])
}
if discNumber == 0 {
discNumber = intFromAny(trackMeta["disk_number"])
discNumber = jsonutil.IntFromAny(trackMeta["disk_number"])
}
if discNumber == 0 {
discNumber = 1
@@ -1033,19 +1019,19 @@ func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[stri
}
}
trackNumber := intFromAny(trackMeta["track_number"])
trackNumber := jsonutil.IntFromAny(trackMeta["track_number"])
if trackNumber == 0 {
trackNumber = intFromAny(trackMeta["trackNumber"])
trackNumber = jsonutil.IntFromAny(trackMeta["trackNumber"])
}
explicit := ""
if boolFromAny(trackMeta["parental_warning"]) || boolFromAny(trackMeta["explicit"]) {
if jsonutil.BoolFromAny(trackMeta["parental_warning"]) || jsonutil.BoolFromAny(trackMeta["explicit"]) {
explicit = " (Explicit)"
}
artist := nestedString(trackMeta, "performer", "name")
artist := jsonutil.NestedString(trackMeta, "performer", "name")
if artist == "" {
artist = nestedString(trackMeta, "artist", "name")
artist = jsonutil.NestedString(trackMeta, "artist", "name")
}
albumArtist := nestedString(trackMeta, "album", "artist", "name")
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
if albumArtist == "" {
albumArtist = artist
}
@@ -1073,7 +1059,7 @@ func (m *Main) videoOutputPath(source, id, title, ext string) string {
}
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, strings.Title(source))
base = filepath.Join(base, jsonutil.TitleCase(source))
}
fileName := naming.CleanName(title, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
@@ -1088,7 +1074,7 @@ func (m *Main) videoOutputPath(source, id, title, ext string) string {
func titleFromMetadata(meta map[string]any, fallback string) string {
if title, ok := meta["title"].(string); ok {
title = strings.TrimSpace(title)
version := strings.TrimSpace(stringFromAny(meta["version"]))
version := strings.TrimSpace(jsonutil.StringFromAny(meta["version"]))
if version != "" {
return title + " (" + version + ")"
}
@@ -1099,70 +1085,8 @@ func titleFromMetadata(meta map[string]any, fallback string) string {
return fallback
}
func nestedString(v map[string]any, keys ...string) string {
return stringFromAny(nestedAny(v, keys...))
}
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
}
func stringFromAny(v any) string {
switch t := v.(type) {
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case int64:
return strconv.FormatInt(t, 10)
case int:
return strconv.Itoa(t)
default:
return ""
}
}
func intFromAny(v any) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
default:
return 0
}
}
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
}
}
func boolFromAny(v any) bool {
b, _ := v.(bool)
return b
}
func replaygainGainFromAny(v any) string {
s := strings.TrimSpace(stringFromAny(v))
s := strings.TrimSpace(jsonutil.StringFromAny(v))
if s == "" {
return ""
}
@@ -1183,7 +1107,7 @@ func replaygainGainFromAny(v any) string {
}
func replaygainPeakFromAny(v any) string {
return strings.TrimSpace(stringFromAny(v))
return strings.TrimSpace(jsonutil.StringFromAny(v))
}
func trackMetaAlbum(trackMeta map[string]any) map[string]any {
@@ -1195,53 +1119,53 @@ func trackMetaAlbum(trackMeta map[string]any) map[string]any {
}
func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, opts ripTrackOptions) tag.Metadata {
artist := nestedString(trackMeta, "performer", "name")
artist := jsonutil.NestedString(trackMeta, "performer", "name")
if artist == "" {
artist = nestedString(trackMeta, "artist", "name")
artist = jsonutil.NestedString(trackMeta, "artist", "name")
}
albumArtist := nestedString(trackMeta, "album", "artist", "name")
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
if albumArtist == "" {
albumArtist = artist
}
if strings.TrimSpace(opts.albumArtist) != "" {
albumArtist = strings.TrimSpace(opts.albumArtist)
}
trackNumber := intFromAny(trackMeta["track_number"])
trackNumber := jsonutil.IntFromAny(trackMeta["track_number"])
if trackNumber == 0 {
trackNumber = intFromAny(trackMeta["trackNumber"])
trackNumber = jsonutil.IntFromAny(trackMeta["trackNumber"])
}
discNumber := intFromAny(trackMeta["media_number"])
discNumber := jsonutil.IntFromAny(trackMeta["media_number"])
if discNumber == 0 {
discNumber = intFromAny(trackMeta["volumeNumber"])
discNumber = jsonutil.IntFromAny(trackMeta["volumeNumber"])
}
if discNumber == 0 {
discNumber = intFromAny(trackMeta["disk_number"])
discNumber = jsonutil.IntFromAny(trackMeta["disk_number"])
}
date := stringFromAny(trackMeta["release_date_original"])
date := jsonutil.StringFromAny(trackMeta["release_date_original"])
if date == "" {
date = stringFromAny(trackMeta["release_date"])
date = jsonutil.StringFromAny(trackMeta["release_date"])
}
if date == "" {
date = stringFromAny(trackMeta["streamStartDate"])
date = jsonutil.StringFromAny(trackMeta["streamStartDate"])
}
album := nestedString(trackMeta, "album", "title")
album := jsonutil.NestedString(trackMeta, "album", "title")
if album == "" {
album = stringFromAny(trackMeta["title"])
album = jsonutil.StringFromAny(trackMeta["title"])
}
trackTotal := intFromAny(trackMeta["tracks_count"])
trackTotal := jsonutil.IntFromAny(trackMeta["tracks_count"])
if trackTotal == 0 {
trackTotal = intFromAny(trackMeta["numberOfTracks"])
trackTotal = jsonutil.IntFromAny(trackMeta["numberOfTracks"])
}
if trackTotal == 0 {
trackTotal = intFromAny(trackMeta["track_total"])
trackTotal = jsonutil.IntFromAny(trackMeta["track_total"])
}
if opts.forPlaylist && opts.total > 0 {
trackTotal = opts.total
}
discTotal := intFromAny(trackMeta["media_count"])
discTotal := jsonutil.IntFromAny(trackMeta["media_count"])
if discTotal == 0 {
discTotal = intFromAny(trackMeta["numberOfVolumes"])
discTotal = jsonutil.IntFromAny(trackMeta["numberOfVolumes"])
}
if discTotal == 0 && opts.albumDiscTotal > 0 {
discTotal = opts.albumDiscTotal
@@ -1253,15 +1177,15 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
discNumber = 1
}
genre := nestedString(trackMeta, "genre", "name")
genre := jsonutil.NestedString(trackMeta, "genre", "name")
if genre == "" {
genre = stringFromAny(trackMeta["genre"])
genre = jsonutil.StringFromAny(trackMeta["genre"])
}
comment := stringFromAny(trackMeta["comment"])
description := stringFromAny(trackMeta["description"])
lyrics := stringFromAny(trackMeta["lyrics"])
if lrc := stringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
comment := jsonutil.StringFromAny(trackMeta["comment"])
description := jsonutil.StringFromAny(trackMeta["description"])
lyrics := jsonutil.StringFromAny(trackMeta["lyrics"])
if lrc := jsonutil.StringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
lyrics = lrc
}
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
@@ -1273,7 +1197,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
}
albumGain := replaygainGainFromAny(trackMeta["replaygain_album_gain"])
if albumGain == "" {
albumGain = replaygainGainFromAny(nestedAny(trackMeta, "album", "replaygain_album_gain"))
albumGain = replaygainGainFromAny(jsonutil.NestedAny(trackMeta, "album", "replaygain_album_gain"))
}
trackPeak := replaygainPeakFromAny(trackMeta["replaygain_track_peak"])
if trackPeak == "" {
@@ -1281,22 +1205,22 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
}
albumPeak := replaygainPeakFromAny(trackMeta["replaygain_album_peak"])
if albumPeak == "" {
albumPeak = replaygainPeakFromAny(nestedAny(trackMeta, "album", "replaygain_album_peak"))
albumPeak = replaygainPeakFromAny(jsonutil.NestedAny(trackMeta, "album", "replaygain_album_peak"))
}
sourceAlbumID := nestedString(trackMeta, "album", "id")
sourceAlbumID := jsonutil.NestedString(trackMeta, "album", "id")
if sourceAlbumID == "" {
sourceAlbumID = stringFromAny(trackMeta["source_album_id"])
sourceAlbumID = jsonutil.StringFromAny(trackMeta["source_album_id"])
}
sourceArtistID := nestedString(trackMeta, "artist", "id")
sourceArtistID := jsonutil.NestedString(trackMeta, "artist", "id")
if sourceArtistID == "" {
sourceArtistID = nestedString(trackMeta, "performer", "id")
sourceArtistID = jsonutil.NestedString(trackMeta, "performer", "id")
}
if sourceArtistID == "" {
sourceArtistID = stringFromAny(trackMeta["source_artist_id"])
sourceArtistID = jsonutil.StringFromAny(trackMeta["source_artist_id"])
}
sourceTrackID := trackID
if v := stringFromAny(trackMeta["source_track_id"]); v != "" {
if v := jsonutil.StringFromAny(trackMeta["source_track_id"]); v != "" {
sourceTrackID = v
}
@@ -1314,8 +1238,8 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
Comment: comment,
Description: description,
Lyrics: lyrics,
Copyright: stringFromAny(trackMeta["copyright"]),
ISRC: stringFromAny(trackMeta["isrc"]),
Copyright: jsonutil.StringFromAny(trackMeta["copyright"]),
ISRC: jsonutil.StringFromAny(trackMeta["isrc"]),
ReplaygainTrackGain: trackGain,
ReplaygainAlbumGain: albumGain,
ReplaygainTrackPeak: trackPeak,

View File

@@ -553,31 +553,6 @@ const deezerBFChunkSize = 2048
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
func decryptDeezerBFCBCStripe(in []byte, trackID string) ([]byte, error) {
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
return nil, err
}
out := make([]byte, len(in))
for i := 0; i*deezerBFChunkSize < len(in); i++ {
start := i * deezerBFChunkSize
end := start + deezerBFChunkSize
if end > len(in) {
end = len(in)
}
chunk := in[start:end]
if i%3 == 0 && len(chunk) == deezerBFChunkSize {
dec := make([]byte, len(chunk))
mode := cipher.NewCBCDecrypter(block, deezerBFIV)
mode.CryptBlocks(dec, chunk)
copy(out[start:end], dec)
} else {
copy(out[start:end], chunk)
}
}
return out, nil
}
func deriveDeezerBlowfishKey(trackID string) []byte {
sum := md5.Sum([]byte(trackID))
md5Hex := fmt.Sprintf("%x", sum)
@@ -588,20 +563,3 @@ func deriveDeezerBlowfishKey(trackID string) []byte {
}
return key
}
func normalizeDeezerTrackID(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if _, err := strconv.Atoi(trimmed); err == nil {
return trimmed
}
parts := strings.Split(strings.Trim(trimmed, "/"), "/")
for i := len(parts) - 1; i >= 0; i-- {
if _, err := strconv.Atoi(parts[i]); err == nil {
return parts[i]
}
}
return trimmed
}

View File

@@ -66,36 +66,15 @@ func TestManifestDetection(t *testing.T) {
}
}
func TestNormalizeDeezerTrackID(t *testing.T) {
if got := normalizeDeezerTrackID("https://www.deezer.com/track/3135556"); got != "3135556" {
t.Fatalf("normalize track id = %q, want 3135556", got)
}
}
func TestDecryptDeezerBFCBCStripe(t *testing.T) {
func TestDeezerBlowfishKeyDerivation(t *testing.T) {
trackID := "3135556"
plain := make([]byte, deezerBFChunkSize*2)
for i := range plain {
plain[i] = byte(i % 251)
}
enc := make([]byte, len(plain))
copy(enc, plain)
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
t.Fatalf("cipher error: %v", err)
}
cbc := cipher.NewCBCEncrypter(block, deezerBFIV)
cbc.CryptBlocks(enc[:deezerBFChunkSize], enc[:deezerBFChunkSize])
dec, err := decryptDeezerBFCBCStripe(enc, trackID)
if err != nil {
t.Fatalf("decrypt error: %v", err)
}
if len(dec) != len(plain) || string(dec) != string(plain) {
t.Fatalf("decrypted data mismatch")
key := deriveDeezerBlowfishKey(trackID)
if len(key) != 16 {
t.Fatalf("blowfish key len = %d, want 16", len(key))
}
}
func TestFileDeezerEncrypted(t *testing.T) {
trackID := "3135556"
plain := make([]byte, deezerBFChunkSize+777)

View File

@@ -0,0 +1,131 @@
// 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)
}

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

View File

@@ -18,6 +18,7 @@ import (
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
@@ -694,7 +695,7 @@ func (c *Client) fetchAppIDAndSecrets(ctx context.Context) (string, []string, er
tzNames := make([]string, 0, len(ordered))
for _, o := range ordered {
tzNames = append(tzNames, strings.Title(o.timezone))
tzNames = append(tzNames, jsonutil.TitleCase(o.timezone))
}
infoRe := regexp.MustCompile(fmt.Sprintf(infoExtrasTemplate, strings.Join(tzNames, "|")))
idxInfo := infoRe.SubexpIndex("info")

View File

@@ -10,12 +10,12 @@ import (
"net/url"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/provider"
)
@@ -102,14 +102,14 @@ func (c *Client) searchTracks(ctx context.Context, query string, limit int) ([]m
if id == "" {
continue
}
artist := strings.TrimSpace(stringFromAny(m["uploader"]))
artist := strings.TrimSpace(jsonutil.StringFromAny(m["uploader"]))
if artist == "" {
artist = strings.TrimSpace(stringFromAny(m["channel"]))
artist = strings.TrimSpace(jsonutil.StringFromAny(m["channel"]))
}
artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(m["uploader_id"]), stringFromAny(m["channel_id"])))
artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(m["uploader_id"]), jsonutil.StringFromAny(m["channel_id"])))
item := map[string]any{
"id": id,
"title": stringFromAny(m["title"]),
"title": jsonutil.StringFromAny(m["title"]),
"artist": map[string]any{
"name": artist,
},
@@ -117,7 +117,7 @@ func (c *Client) searchTracks(ctx context.Context, query string, limit int) ([]m
if artistID != "" {
item["artist"] = map[string]any{"name": artist, "id": artistID}
}
if trackID := strings.TrimSpace(stringFromAny(m["id"])); trackID != "" {
if trackID := strings.TrimSpace(jsonutil.StringFromAny(m["id"])); trackID != "" {
item["source_track_id"] = trackID
}
items = append(items, item)
@@ -163,17 +163,17 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
if infoErr != nil {
continue
}
title := strings.TrimSpace(stringFromAny(info["title"]))
title := strings.TrimSpace(jsonutil.StringFromAny(info["title"]))
if title == "" {
title = strings.Trim(strings.ReplaceAll(path, "/", " "), " ")
}
artist := strings.TrimSpace(firstNonEmpty(stringFromAny(info["uploader"]), stringFromAny(info["channel"])))
artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(info["uploader_id"]), stringFromAny(info["channel_id"])))
artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["uploader"]), jsonutil.StringFromAny(info["channel"])))
artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["uploader_id"]), jsonutil.StringFromAny(info["channel_id"])))
trackCount := 0
if entries := asAnySlice(info["entries"]); len(entries) > 0 {
trackCount = len(entries)
}
canonical := firstNonEmpty(canonicalSoundcloudURL(info), playlistURL)
canonical := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), playlistURL)
item := map[string]any{
"id": canonical,
"title": title,
@@ -183,10 +183,10 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
if artistID != "" {
item["artist"] = map[string]any{"name": artist, "id": artistID}
}
if pid := strings.TrimSpace(stringFromAny(info["id"])); pid != "" {
if pid := strings.TrimSpace(jsonutil.StringFromAny(info["id"])); pid != "" {
item["source_playlist_id"] = pid
}
if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" {
if thumb := strings.TrimSpace(jsonutil.StringFromAny(info["thumbnail"])); thumb != "" {
item["image"] = soundcloudImageMap(thumb)
}
items = append(items, item)
@@ -228,15 +228,15 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
continue
}
track := map[string]any{"id": id}
if trackID := strings.TrimSpace(stringFromAny(entry["id"])); trackID != "" {
if trackID := strings.TrimSpace(jsonutil.StringFromAny(entry["id"])); trackID != "" {
track["source_track_id"] = trackID
}
if title := strings.TrimSpace(stringFromAny(entry["title"])); title != "" {
if title := strings.TrimSpace(jsonutil.StringFromAny(entry["title"])); title != "" {
track["title"] = title
}
if artist := strings.TrimSpace(firstNonEmpty(stringFromAny(entry["uploader"]), stringFromAny(entry["channel"]))); artist != "" {
if artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(entry["uploader"]), jsonutil.StringFromAny(entry["channel"]))); artist != "" {
artistMap := map[string]any{"name": artist}
if artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(entry["uploader_id"]), stringFromAny(entry["channel_id"]))); artistID != "" {
if artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(entry["uploader_id"]), jsonutil.StringFromAny(entry["channel_id"]))); artistID != "" {
artistMap["id"] = artistID
}
track["artist"] = artistMap
@@ -244,23 +244,23 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
track["track_number"] = i + 1
tracks = append(tracks, track)
}
name := strings.TrimSpace(stringFromAny(root["title"]))
name := strings.TrimSpace(jsonutil.StringFromAny(root["title"]))
if name == "" {
name = "SoundCloud Playlist"
}
meta := map[string]any{
"id": firstNonEmpty(canonicalSoundcloudURL(root), item),
"id": jsonutil.FirstNonEmpty(canonicalSoundcloudURL(root), item),
"name": name,
"description": strings.TrimSpace(stringFromAny(root["description"])),
"description": strings.TrimSpace(jsonutil.StringFromAny(root["description"])),
"tracks": map[string]any{"items": tracks},
}
if pid := strings.TrimSpace(stringFromAny(root["id"])); pid != "" {
if pid := strings.TrimSpace(jsonutil.StringFromAny(root["id"])); pid != "" {
meta["source_playlist_id"] = pid
}
if artist := strings.TrimSpace(firstNonEmpty(stringFromAny(root["uploader"]), stringFromAny(root["channel"]))); artist != "" {
if artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(root["uploader"]), jsonutil.StringFromAny(root["channel"]))); artist != "" {
meta["artist"] = map[string]any{"name": artist}
}
if thumb := strings.TrimSpace(stringFromAny(root["thumbnail"])); thumb != "" {
if thumb := strings.TrimSpace(jsonutil.StringFromAny(root["thumbnail"])); thumb != "" {
meta["image"] = soundcloudImageMap(thumb)
}
if entries := asAnySlice(root["entries"]); len(entries) > 0 {
@@ -280,11 +280,11 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if err != nil {
return nil, err
}
streamURL := strings.TrimSpace(stringFromAny(info["url"]))
streamURL := strings.TrimSpace(jsonutil.StringFromAny(info["url"]))
if streamURL == "" {
return nil, errors.New("yt-dlp output missing url (track may be unavailable or region-restricted)")
}
ext := strings.TrimSpace(stringFromAny(info["ext"]))
ext := strings.TrimSpace(jsonutil.StringFromAny(info["ext"]))
if ext == "" {
ext = "m4a"
}
@@ -337,36 +337,36 @@ func (c *Client) playlistInfo(ctx context.Context, item string) (map[string]any,
}
func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
canonicalID := firstNonEmpty(canonicalSoundcloudURL(info), id)
publisher := nestedMap(info, "publisher_metadata")
title := strings.TrimSpace(stringFromAny(info["title"]))
canonicalID := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), id)
publisher := jsonutil.NestedMap(info, "publisher_metadata")
title := strings.TrimSpace(jsonutil.StringFromAny(info["title"]))
if title == "" {
title = canonicalID
}
albumTitle := strings.TrimSpace(stringFromAny(publisher["album_title"]))
albumTitle := strings.TrimSpace(jsonutil.StringFromAny(publisher["album_title"]))
if albumTitle == "" {
albumTitle = strings.TrimSpace(stringFromAny(info["album"]))
albumTitle = strings.TrimSpace(jsonutil.StringFromAny(info["album"]))
}
if albumTitle == "" {
albumTitle = title
}
artistName := strings.TrimSpace(stringFromAny(info["artist"]))
artistName := strings.TrimSpace(jsonutil.StringFromAny(info["artist"]))
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(publisher["artist"]))
artistName = strings.TrimSpace(jsonutil.StringFromAny(publisher["artist"]))
}
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(info["uploader"]))
artistName = strings.TrimSpace(jsonutil.StringFromAny(info["uploader"]))
}
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(info["channel"]))
artistName = strings.TrimSpace(jsonutil.StringFromAny(info["channel"]))
}
artistID := strings.TrimSpace(firstNonEmpty(
stringFromAny(info["uploader_id"]),
stringFromAny(info["channel_id"]),
stringFromAny(nestedMap(info, "user")["id"]),
artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(info["uploader_id"]),
jsonutil.StringFromAny(info["channel_id"]),
jsonutil.StringFromAny(jsonutil.NestedMap(info, "user")["id"]),
))
trackNum := intFromAny(info["track_number"])
trackNum := jsonutil.IntFromAny(info["track_number"])
if trackNum <= 0 {
trackNum = 1
}
@@ -378,26 +378,26 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
"artist": map[string]any{"name": artistName, "id": artistID},
"performer": map[string]any{"name": artistName, "id": artistID},
"album": map[string]any{
"id": firstNonEmpty(strings.TrimSpace(stringFromAny(info["album"])), canonicalID),
"id": jsonutil.FirstNonEmpty(strings.TrimSpace(jsonutil.StringFromAny(info["album"])), canonicalID),
"title": albumTitle,
"artist": map[string]any{"name": artistName, "id": artistID},
},
"description": strings.TrimSpace(stringFromAny(info["description"])),
"genre": strings.TrimSpace(stringFromAny(info["genre"])),
"isrc": strings.TrimSpace(stringFromAny(info["isrc"])),
"label": strings.TrimSpace(firstNonEmpty(stringFromAny(info["label"]), stringFromAny(info["label_name"]))),
"copyright": strings.TrimSpace(stringFromAny(publisher["p_line"])),
"release_date": strings.TrimSpace(firstNonEmpty(
stringFromAny(info["created_at"]),
stringFromAny(info["release_date"]),
stringFromAny(info["upload_date"]),
"description": strings.TrimSpace(jsonutil.StringFromAny(info["description"])),
"genre": strings.TrimSpace(jsonutil.StringFromAny(info["genre"])),
"isrc": strings.TrimSpace(jsonutil.StringFromAny(info["isrc"])),
"label": strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["label"]), jsonutil.StringFromAny(info["label_name"]))),
"copyright": strings.TrimSpace(jsonutil.StringFromAny(publisher["p_line"])),
"release_date": strings.TrimSpace(jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(info["created_at"]),
jsonutil.StringFromAny(info["release_date"]),
jsonutil.StringFromAny(info["upload_date"]),
)),
}
if trackID := strings.TrimSpace(stringFromAny(info["id"])); trackID != "" {
if trackID := strings.TrimSpace(jsonutil.StringFromAny(info["id"])); trackID != "" {
meta["source_track_id"] = trackID
}
if boolFromAny(publisher["explicit"]) || intFromAny(info["age_limit"]) >= 18 {
if jsonutil.BoolFromAny(publisher["explicit"]) || jsonutil.IntFromAny(info["age_limit"]) >= 18 {
meta["explicit"] = true
}
@@ -405,11 +405,11 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
delete(meta, "release_date")
}
if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" {
if thumb := strings.TrimSpace(jsonutil.StringFromAny(info["thumbnail"])); thumb != "" {
meta["image"] = soundcloudImageMap(thumb)
}
if strings.TrimSpace(stringFromAny(info["album"])) == "" && strings.TrimSpace(stringFromAny(publisher["album_title"])) == "" {
if strings.TrimSpace(jsonutil.StringFromAny(info["album"])) == "" && strings.TrimSpace(jsonutil.StringFromAny(publisher["album_title"])) == "" {
meta["album"] = map[string]any{
"id": canonicalID,
"title": title,
@@ -417,7 +417,7 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
}
}
if durationSec := intFromAny(info["duration"]); durationSec > 0 {
if durationSec := jsonutil.IntFromAny(info["duration"]); durationSec > 0 {
meta["duration"] = durationSec
}
@@ -426,7 +426,7 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
func canonicalSoundcloudURL(info map[string]any) string {
for _, key := range []string{"webpage_url", "original_url", "url"} {
raw := strings.TrimSpace(stringFromAny(info[key]))
raw := strings.TrimSpace(jsonutil.StringFromAny(info[key]))
if raw == "" {
continue
}
@@ -478,72 +478,6 @@ func asAnySlice(v any) []any {
return items
}
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 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 firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
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 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
}
}
func soundcloudImageMap(raw string) map[string]any {
base := strings.TrimSpace(raw)
if base == "" {

View File

@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/config"
)
@@ -27,11 +29,11 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) {
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if stringFromAny(meta["title"]) != "Lean On" {
t.Fatalf("title = %q, want Lean On", stringFromAny(meta["title"]))
if jsonutil.StringFromAny(meta["title"]) != "Lean On" {
t.Fatalf("title = %q, want Lean On", jsonutil.StringFromAny(meta["title"]))
}
if stringFromAny(meta["id"]) != "https://soundcloud.com/a/b" {
t.Fatalf("id = %q, want canonical soundcloud url", stringFromAny(meta["id"]))
if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/b" {
t.Fatalf("id = %q, want canonical soundcloud url", jsonutil.StringFromAny(meta["id"]))
}
d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0)
@@ -59,8 +61,8 @@ func TestGetPlaylistMetadata(t *testing.T) {
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if stringFromAny(meta["name"]) != "Road Trip" {
t.Fatalf("name = %q, want Road Trip", stringFromAny(meta["name"]))
if jsonutil.StringFromAny(meta["name"]) != "Road Trip" {
t.Fatalf("name = %q, want Road Trip", jsonutil.StringFromAny(meta["name"]))
}
tracksMap, ok := meta["tracks"].(map[string]any)
if !ok {
@@ -70,8 +72,8 @@ func TestGetPlaylistMetadata(t *testing.T) {
if len(items) != 2 {
t.Fatalf("playlist items len = %d, want 2", len(items))
}
if stringFromAny(meta["id"]) != "https://soundcloud.com/a/sets/road-trip" {
t.Fatalf("playlist id not canonical: %q", stringFromAny(meta["id"]))
if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/sets/road-trip" {
t.Fatalf("playlist id not canonical: %q", jsonutil.StringFromAny(meta["id"]))
}
}
@@ -102,8 +104,8 @@ func TestSearchTrack(t *testing.T) {
if !ok {
t.Fatalf("expected first item map")
}
if stringFromAny(item0["id"]) != "https://soundcloud.com/a/b" {
t.Fatalf("track search id not canonical: %q", stringFromAny(item0["id"]))
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/b" {
t.Fatalf("track search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
@@ -147,8 +149,8 @@ func TestSearchPlaylist(t *testing.T) {
if !ok {
t.Fatalf("expected first item map")
}
if stringFromAny(item0["id"]) != "https://soundcloud.com/a/sets/road-trip" {
t.Fatalf("playlist search id not canonical: %q", stringFromAny(item0["id"]))
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/sets/road-trip" {
t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
@@ -192,8 +194,8 @@ func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
if !ok {
t.Fatalf("expected first item map")
}
if stringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
t.Fatalf("playlist search id not canonical: %q", stringFromAny(item0["id"]))
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
@@ -221,18 +223,18 @@ func TestTrackMetadataIncludesExplicitAndISRC(t *testing.T) {
"thumbnail": "https://img",
"upload_date": "20240101",
})
if stringFromAny(meta["isrc"]) != "US123" {
t.Fatalf("isrc = %q, want US123", stringFromAny(meta["isrc"]))
if jsonutil.StringFromAny(meta["isrc"]) != "US123" {
t.Fatalf("isrc = %q, want US123", jsonutil.StringFromAny(meta["isrc"]))
}
explicit, _ := meta["explicit"].(bool)
if !explicit {
t.Fatalf("expected explicit=true")
}
if stringFromAny(meta["source_track_id"]) != "9876" {
t.Fatalf("source_track_id = %q, want 9876", stringFromAny(meta["source_track_id"]))
if jsonutil.StringFromAny(meta["source_track_id"]) != "9876" {
t.Fatalf("source_track_id = %q, want 9876", jsonutil.StringFromAny(meta["source_track_id"]))
}
if stringFromAny(nestedMap(meta, "album")["title"]) != "T" {
t.Fatalf("album title mismatch: %#v", nestedMap(meta, "album"))
if jsonutil.StringFromAny(jsonutil.NestedMap(meta, "album")["title"]) != "T" {
t.Fatalf("album title mismatch: %#v", jsonutil.NestedMap(meta, "album"))
}
}

View File

@@ -15,6 +15,7 @@ import (
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
@@ -149,7 +150,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
}
newRefresh := stringify(resp["refresh_token"])
expiresIn := int64(intFromAny(resp["expires_in"]))
expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
if expiresIn <= 0 {
expiresIn = 7 * 24 * 3600
}
@@ -773,19 +774,3 @@ func tidalImageMap(cover string) map[string]any {
"original": base + "/1280x1280.jpg",
}
}
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(t)
return i
default:
return 0
}
}