package soundcloud import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os/exec" "regexp" "strings" "sync" "time" "streamrip-go/internal/config" "streamrip-go/internal/jsonutil" "streamrip-go/internal/provider" ) var errUnsupportedMediaType = errors.New("unsupported soundcloud media type") var soundcloudSearchBaseURL = "https://soundcloud.com" type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error) type Client struct { cfg *config.Config loggedIn bool bin string run commandRunner http *http.Client mu sync.Mutex cache map[string]map[string]any } func New(cfg *config.Config) *Client { return &Client{ cfg: cfg, bin: "yt-dlp", run: runCommand, http: &http.Client{Timeout: 20 * time.Second}, 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 limit <= 0 { limit = 20 } if mediaType == "track" { return c.searchTracks(ctx, query, limit) } if mediaType == "playlist" { return c.searchPlaylists(ctx, query, limit) } return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType) } func (c *Client) searchTracks(ctx context.Context, query string, limit int) ([]map[string]any, error) { 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 := canonicalSoundcloudURL(m) if id == "" { continue } artist := strings.TrimSpace(jsonutil.StringFromAny(m["uploader"])) if artist == "" { artist = strings.TrimSpace(jsonutil.StringFromAny(m["channel"])) } artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(m["uploader_id"]), jsonutil.StringFromAny(m["channel_id"]))) item := map[string]any{ "id": id, "title": jsonutil.StringFromAny(m["title"]), "artist": map[string]any{ "name": artist, }, } if artistID != "" { item["artist"] = map[string]any{"name": artist, "id": artistID} } if trackID := strings.TrimSpace(jsonutil.StringFromAny(m["id"])); trackID != "" { item["source_track_id"] = trackID } items = append(items, item) } return []map[string]any{{"items": items}}, nil } func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) ([]map[string]any, error) { searchURL := strings.TrimSuffix(soundcloudSearchBaseURL, "/") + "/search/sets?q=" + url.QueryEscape(query) req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0") resp, err := c.http.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("soundcloud playlist search failed: status=%d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } re := regexp.MustCompile(`/(?:[A-Za-z0-9._-]+)/sets/(?:[A-Za-z0-9._%~-]+)`) paths := re.FindAllString(string(body), -1) if len(paths) == 0 { return []map[string]any{}, nil } seen := map[string]struct{}{} items := make([]any, 0, limit) for _, path := range paths { if _, ok := seen[path]; ok { continue } seen[path] = struct{}{} playlistURL := "https://soundcloud.com" + path info, infoErr := c.playlistInfo(ctx, playlistURL) if infoErr != nil { continue } title := strings.TrimSpace(jsonutil.StringFromAny(info["title"])) if title == "" { title = strings.Trim(strings.ReplaceAll(path, "/", " "), " ") } 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 := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), playlistURL) item := map[string]any{ "id": canonical, "title": title, "tracks_count": trackCount, "artist": map[string]any{"name": artist}, } if artistID != "" { item["artist"] = map[string]any{"name": artist, "id": artistID} } if pid := strings.TrimSpace(jsonutil.StringFromAny(info["id"])); pid != "" { item["source_playlist_id"] = pid } if thumb := strings.TrimSpace(jsonutil.StringFromAny(info["thumbnail"])); thumb != "" { item["image"] = soundcloudImageMap(thumb) } items = append(items, item) if len(items) >= limit { break } } if len(items) == 0 { return []map[string]any{}, nil } 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": root, err := c.playlistInfo(ctx, item) if err != nil { return nil, err } tracks := make([]any, 0) for i, raw := range asAnySlice(root["entries"]) { entry, ok := raw.(map[string]any) if !ok { continue } id := canonicalSoundcloudURL(entry) if id == "" { continue } track := map[string]any{"id": id} if trackID := strings.TrimSpace(jsonutil.StringFromAny(entry["id"])); trackID != "" { track["source_track_id"] = trackID } if title := strings.TrimSpace(jsonutil.StringFromAny(entry["title"])); title != "" { track["title"] = title } 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(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(entry["uploader_id"]), jsonutil.StringFromAny(entry["channel_id"]))); artistID != "" { artistMap["id"] = artistID } track["artist"] = artistMap } track["track_number"] = i + 1 tracks = append(tracks, track) } name := strings.TrimSpace(jsonutil.StringFromAny(root["title"])) if name == "" { name = "SoundCloud Playlist" } meta := map[string]any{ "id": jsonutil.FirstNonEmpty(canonicalSoundcloudURL(root), item), "name": name, "description": strings.TrimSpace(jsonutil.StringFromAny(root["description"])), "tracks": map[string]any{"items": tracks}, } if pid := strings.TrimSpace(jsonutil.StringFromAny(root["id"])); pid != "" { meta["source_playlist_id"] = pid } 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(jsonutil.StringFromAny(root["thumbnail"])); thumb != "" { meta["image"] = soundcloudImageMap(thumb) } if entries := asAnySlice(root["entries"]); len(entries) > 0 { meta["tracks_count"] = len(entries) } return meta, 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(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(jsonutil.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 } canonical := canonicalSoundcloudURL(info) c.mu.Lock() c.cache[item] = cloneMap(info) if canonical != "" { c.cache[canonical] = cloneMap(info) } c.mu.Unlock() return info, nil } func (c *Client) playlistInfo(ctx context.Context, item string) (map[string]any, error) { b, err := c.run(ctx, c.bin, "-J", "--flat-playlist", "--skip-download", "--no-warnings", item) if err != nil { return nil, err } return parseJSONMap(b) } func trackMetadataFromInfo(id string, info map[string]any) map[string]any { 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(jsonutil.StringFromAny(publisher["album_title"])) if albumTitle == "" { albumTitle = strings.TrimSpace(jsonutil.StringFromAny(info["album"])) } if albumTitle == "" { albumTitle = title } artistName := strings.TrimSpace(jsonutil.StringFromAny(info["artist"])) if artistName == "" { artistName = strings.TrimSpace(jsonutil.StringFromAny(publisher["artist"])) } if artistName == "" { artistName = strings.TrimSpace(jsonutil.StringFromAny(info["uploader"])) } if artistName == "" { artistName = strings.TrimSpace(jsonutil.StringFromAny(info["channel"])) } artistID := strings.TrimSpace(jsonutil.FirstNonEmpty( jsonutil.StringFromAny(info["uploader_id"]), jsonutil.StringFromAny(info["channel_id"]), jsonutil.StringFromAny(jsonutil.NestedMap(info, "user")["id"]), )) trackNum := jsonutil.IntFromAny(info["track_number"]) if trackNum <= 0 { trackNum = 1 } meta := map[string]any{ "id": canonicalID, "title": title, "track_number": trackNum, "artist": map[string]any{"name": artistName, "id": artistID}, "performer": map[string]any{"name": artistName, "id": artistID}, "album": map[string]any{ "id": jsonutil.FirstNonEmpty(strings.TrimSpace(jsonutil.StringFromAny(info["album"])), canonicalID), "title": albumTitle, "artist": map[string]any{"name": artistName, "id": artistID}, }, "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(jsonutil.StringFromAny(info["id"])); trackID != "" { meta["source_track_id"] = trackID } if jsonutil.BoolFromAny(publisher["explicit"]) || jsonutil.IntFromAny(info["age_limit"]) >= 18 { meta["explicit"] = true } if meta["release_date"] == "" { delete(meta, "release_date") } if thumb := strings.TrimSpace(jsonutil.StringFromAny(info["thumbnail"])); thumb != "" { meta["image"] = soundcloudImageMap(thumb) } if strings.TrimSpace(jsonutil.StringFromAny(info["album"])) == "" && strings.TrimSpace(jsonutil.StringFromAny(publisher["album_title"])) == "" { meta["album"] = map[string]any{ "id": canonicalID, "title": title, "artist": map[string]any{"name": artistName, "id": artistID}, } } if durationSec := jsonutil.IntFromAny(info["duration"]); durationSec > 0 { meta["duration"] = durationSec } return meta } func canonicalSoundcloudURL(info map[string]any) string { for _, key := range []string{"webpage_url", "original_url", "url"} { raw := strings.TrimSpace(jsonutil.StringFromAny(info[key])) if raw == "" { continue } u, err := url.Parse(raw) if err != nil { continue } host := strings.ToLower(strings.TrimPrefix(u.Host, "www.")) if host != "soundcloud.com" && !strings.HasSuffix(host, ".soundcloud.com") { continue } u.Scheme = "https" u.Host = "soundcloud.com" u.RawQuery = "" u.Fragment = "" u.Path = strings.TrimSuffix(u.Path, "/") if strings.TrimSpace(u.Path) == "" { continue } return u.String() } return "" } 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 soundcloudImageMap(raw string) map[string]any { base := strings.TrimSpace(raw) if base == "" { return map[string]any{} } large := strings.Replace(base, "-large.", "-t500x500.", 1) if large == base { large = strings.Replace(base, "large", "t500x500", 1) } return map[string]any{ "small": base, "large": large, "extralarge": large, "original": large, } } 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 }