Files
streamrip-go/internal/provider/soundcloud/client.go
Joren 26c9d50fac implement native Deezer download/decrypt pipeline
Replace Deezer yt-dlp usage with native ARL session + media.get_url resolution, add BF_CBC_STRIPE decryption in downloader, and wire cipher-aware Deezer downloads through the main rip pipeline. Includes validation hardening and metadata/source-id improvements used by tagging flows.
2026-04-21 00:48:07 +02:00

571 lines
15 KiB
Go

package soundcloud
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"streamrip-go/internal/config"
"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(stringFromAny(m["uploader"]))
if artist == "" {
artist = strings.TrimSpace(stringFromAny(m["channel"]))
}
artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(m["uploader_id"]), stringFromAny(m["channel_id"])))
item := map[string]any{
"id": id,
"title": stringFromAny(m["title"]),
"artist": map[string]any{
"name": artist,
},
}
if artistID != "" {
item["artist"] = map[string]any{"name": artist, "id": artistID}
}
if trackID := strings.TrimSpace(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(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"])))
trackCount := 0
if entries := asAnySlice(info["entries"]); len(entries) > 0 {
trackCount = len(entries)
}
canonical := 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(stringFromAny(info["id"])); pid != "" {
item["source_playlist_id"] = pid
}
if thumb := strings.TrimSpace(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(stringFromAny(entry["id"])); trackID != "" {
track["source_track_id"] = trackID
}
if title := strings.TrimSpace(stringFromAny(entry["title"])); title != "" {
track["title"] = title
}
if artist := strings.TrimSpace(firstNonEmpty(stringFromAny(entry["uploader"]), stringFromAny(entry["channel"]))); artist != "" {
artistMap := map[string]any{"name": artist}
if artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(entry["uploader_id"]), stringFromAny(entry["channel_id"]))); artistID != "" {
artistMap["id"] = artistID
}
track["artist"] = artistMap
}
track["track_number"] = i + 1
tracks = append(tracks, track)
}
name := strings.TrimSpace(stringFromAny(root["title"]))
if name == "" {
name = "SoundCloud Playlist"
}
meta := map[string]any{
"id": firstNonEmpty(canonicalSoundcloudURL(root), item),
"name": name,
"description": strings.TrimSpace(stringFromAny(root["description"])),
"tracks": map[string]any{"items": tracks},
}
if pid := strings.TrimSpace(stringFromAny(root["id"])); pid != "" {
meta["source_playlist_id"] = pid
}
if artist := strings.TrimSpace(firstNonEmpty(stringFromAny(root["uploader"]), stringFromAny(root["channel"]))); artist != "" {
meta["artist"] = map[string]any{"name": artist}
}
if thumb := strings.TrimSpace(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(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"]))
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 := firstNonEmpty(canonicalSoundcloudURL(info), id)
publisher := nestedMap(info, "publisher_metadata")
title := strings.TrimSpace(stringFromAny(info["title"]))
if title == "" {
title = canonicalID
}
albumTitle := strings.TrimSpace(stringFromAny(publisher["album_title"]))
if albumTitle == "" {
albumTitle = strings.TrimSpace(stringFromAny(info["album"]))
}
if albumTitle == "" {
albumTitle = title
}
artistName := strings.TrimSpace(stringFromAny(info["artist"]))
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(publisher["artist"]))
}
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(info["uploader"]))
}
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(info["channel"]))
}
artistID := strings.TrimSpace(firstNonEmpty(
stringFromAny(info["uploader_id"]),
stringFromAny(info["channel_id"]),
stringFromAny(nestedMap(info, "user")["id"]),
))
trackNum := 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": firstNonEmpty(strings.TrimSpace(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"]),
)),
}
if trackID := strings.TrimSpace(stringFromAny(info["id"])); trackID != "" {
meta["source_track_id"] = trackID
}
if boolFromAny(publisher["explicit"]) || intFromAny(info["age_limit"]) >= 18 {
meta["explicit"] = true
}
if meta["release_date"] == "" {
delete(meta, "release_date")
}
if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" {
meta["image"] = soundcloudImageMap(thumb)
}
if strings.TrimSpace(stringFromAny(info["album"])) == "" && strings.TrimSpace(stringFromAny(publisher["album_title"])) == "" {
meta["album"] = map[string]any{
"id": canonicalID,
"title": title,
"artist": map[string]any{"name": artistName, "id": artistID},
}
}
if durationSec := 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(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" {
continue
}
u.Scheme = "https"
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 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 == "" {
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
}