mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
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.
This commit is contained in:
348
internal/provider/soundcloud/client.go
Normal file
348
internal/provider/soundcloud/client.go
Normal file
@@ -0,0 +1,348 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user