mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
523 lines
15 KiB
Go
523 lines
15 KiB
Go
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", Audio: soundcloudAudioProfile(ext)}, nil
|
|
}
|
|
|
|
func (c *Client) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func soundcloudAudioProfile(ext string) provider.AudioProfile {
|
|
switch strings.ToLower(strings.TrimSpace(ext)) {
|
|
case "mp3":
|
|
return provider.AudioProfile{Container: "MP3", Codec: "MP3", Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"}
|
|
case "flac":
|
|
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}
|
|
case "m4a", "aac":
|
|
return provider.AudioProfile{Container: "M4A", Codec: "AAC", Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"}
|
|
default:
|
|
container := strings.ToUpper(strings.TrimSpace(ext))
|
|
if container == "" {
|
|
container = "M4A"
|
|
}
|
|
return provider.AudioProfile{Container: container, Codec: container, Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|