package soundcloud import ( "context" "encoding/json" "errors" "fmt" "os/exec" "strconv" "strings" "sync" "streamrip-go/internal/config" "streamrip-go/internal/provider" ) var errUnsupportedMediaType = errors.New("unsupported soundcloud media type") type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error) type Client struct { cfg *config.Config loggedIn bool bin string run commandRunner mu sync.Mutex cache map[string]map[string]any } func New(cfg *config.Config) *Client { return &Client{ cfg: cfg, bin: "yt-dlp", run: runCommand, cache: map[string]map[string]any{}, } } func (c *Client) Source() string { return "soundcloud" } func (c *Client) LoggedIn() bool { return c.loggedIn } func (c *Client) Login(context.Context) error { if _, err := exec.LookPath(c.bin); err != nil { return fmt.Errorf("yt-dlp is required for soundcloud downloads/search. install it and ensure it is in $PATH (e.g. pipx install yt-dlp): %w", err) } c.loggedIn = true return nil } func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) { if !c.loggedIn { return nil, errors.New("soundcloud client not logged in") } if mediaType != "track" { return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType) } if limit <= 0 { limit = 20 } target := fmt.Sprintf("scsearch%d:%s", limit, query) b, err := c.run(ctx, c.bin, "-J", "--flat-playlist", "--skip-download", "--no-warnings", target) if err != nil { return nil, err } root, err := parseJSONMap(b) if err != nil { return nil, err } entries := asAnySlice(root["entries"]) if len(entries) == 0 { return []map[string]any{}, nil } items := make([]any, 0, len(entries)) for _, e := range entries { m, ok := e.(map[string]any) if !ok { continue } id := strings.TrimSpace(stringFromAny(m["webpage_url"])) if id == "" { id = strings.TrimSpace(stringFromAny(m["url"])) } if id == "" { continue } artist := strings.TrimSpace(stringFromAny(m["uploader"])) if artist == "" { artist = strings.TrimSpace(stringFromAny(m["channel"])) } item := map[string]any{ "id": id, "title": stringFromAny(m["title"]), "artist": map[string]any{ "name": artist, }, } items = append(items, item) } return []map[string]any{{"items": items}}, nil } func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) { if !c.loggedIn { return nil, errors.New("soundcloud client not logged in") } switch mediaType { case "track": info, err := c.trackInfo(ctx, item) if err != nil { return nil, err } return trackMetadataFromInfo(item, info), nil case "playlist": b, err := c.run(ctx, c.bin, "-J", "--skip-download", "--no-warnings", item) if err != nil { return nil, err } root, err := parseJSONMap(b) if err != nil { return nil, err } tracks := make([]any, 0) for _, raw := range asAnySlice(root["entries"]) { entry, ok := raw.(map[string]any) if !ok { continue } id := strings.TrimSpace(stringFromAny(entry["webpage_url"])) if id == "" { id = strings.TrimSpace(stringFromAny(entry["url"])) } if id == "" { continue } tracks = append(tracks, map[string]any{"id": id}) } name := strings.TrimSpace(stringFromAny(root["title"])) if name == "" { name = "SoundCloud Playlist" } return map[string]any{ "name": name, "tracks": map[string]any{"items": tracks}, }, nil default: return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType) } } func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) { if !c.loggedIn { return nil, errors.New("soundcloud client not logged in") } info, err := c.trackInfo(ctx, item) if err != nil { return nil, err } streamURL := strings.TrimSpace(stringFromAny(info["url"])) if streamURL == "" { return nil, errors.New("yt-dlp output missing url") } ext := strings.TrimSpace(stringFromAny(info["ext"])) if ext == "" { ext = "m4a" } return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "soundcloud"}, nil } func (c *Client) Close() error { return nil } func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, error) { if strings.TrimSpace(item) == "" { return nil, errors.New("empty soundcloud item") } c.mu.Lock() if cached, ok := c.cache[item]; ok { copied := cloneMap(cached) c.mu.Unlock() return copied, nil } c.mu.Unlock() b, err := c.run(ctx, c.bin, "-J", "--no-playlist", "--skip-download", "--no-warnings", item) if err != nil { return nil, err } info, err := parseJSONMap(b) if err != nil { return nil, err } c.mu.Lock() c.cache[item] = cloneMap(info) c.mu.Unlock() return info, nil } func trackMetadataFromInfo(id string, info map[string]any) map[string]any { title := strings.TrimSpace(stringFromAny(info["title"])) if title == "" { title = id } artistName := strings.TrimSpace(stringFromAny(info["artist"])) if artistName == "" { artistName = strings.TrimSpace(stringFromAny(info["uploader"])) } if artistName == "" { artistName = strings.TrimSpace(stringFromAny(info["channel"])) } trackNum := intFromAny(info["track_number"]) if trackNum <= 0 { trackNum = 1 } meta := map[string]any{ "id": id, "title": title, "track_number": trackNum, "artist": map[string]any{"name": artistName}, "performer": map[string]any{"name": artistName}, "album": map[string]any{ "id": strings.TrimSpace(stringFromAny(info["album"])), "title": strings.TrimSpace(stringFromAny(info["album"])), "artist": map[string]any{"name": artistName}, }, "description": strings.TrimSpace(stringFromAny(info["description"])), "genre": strings.TrimSpace(stringFromAny(info["genre"])), "release_date": strings.TrimSpace(firstNonEmpty( stringFromAny(info["release_date"]), stringFromAny(info["upload_date"]), )), } if meta["release_date"] == "" { delete(meta, "release_date") } if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" { meta["image"] = map[string]any{ "small": thumb, "large": thumb, "extralarge": thumb, "original": thumb, } } if album := strings.TrimSpace(stringFromAny(info["album"])); album == "" { meta["album"] = map[string]any{ "id": id, "title": title, "artist": map[string]any{"name": artistName}, } } if durationSec := intFromAny(info["duration"]); durationSec > 0 { meta["duration"] = durationSec } return meta } func parseJSONMap(b []byte) (map[string]any, error) { var out map[string]any if err := json.Unmarshal(b, &out); err != nil { return nil, err } if out == nil { return nil, errors.New("empty json payload") } return out, nil } func cloneMap(in map[string]any) map[string]any { out := make(map[string]any, len(in)) for k, v := range in { out[k] = v } return out } func asAnySlice(v any) []any { items, ok := v.([]any) if !ok { return nil } 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 runCommand(ctx context.Context, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) b, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("command %s failed: %w: %s", name, err, string(b)) } return b, nil }