Files
streamrip-go/internal/provider/soundcloud/client.go
Joren b2688ce949 add CLI parity flags and expand provider support
This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
2026-04-20 00:56:10 +02:00

349 lines
7.9 KiB
Go

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
}