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

@@ -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 == "" {