mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
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.
349 lines
7.9 KiB
Go
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
|
|
}
|