diff --git a/cmd/rip/main.go b/cmd/rip/main.go index db6258d..8c14a0a 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "html" "io" @@ -38,11 +39,24 @@ func main() { } if gopts.command == "" { fmt.Println("usage: rip ") - fmt.Println("commands: url, file, config, database, id, search, lastfm, soundcloud-smoke, qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke, qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke, qobuz-search-smoke, tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke, tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke") + fmt.Println("commands: url, file, config, database, id, search, lastfm") + fmt.Println("tip: run `rip dev-help` to list developer smoke commands") os.Exit(2) } cfg, err := config.Load(gopts.configPath) + if err != nil { + if errors.Is(err, config.ErrOutdatedConfig) { + resolvedPath, upErr := config.UpgradeOutdated(gopts.configPath) + if upErr != nil { + fmt.Fprintf(os.Stderr, "config error: %v\n", err) + fmt.Fprintf(os.Stderr, "config auto-upgrade failed: %v\n", upErr) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "config upgraded at %s\n", resolvedPath) + cfg, err = config.Load(gopts.configPath) + } + } if err != nil { fmt.Fprintf(os.Stderr, "config error: %v\n", err) os.Exit(1) @@ -57,6 +71,15 @@ func main() { ctx := context.Background() switch os.Args[1] { + case "dev-help": + fmt.Println("developer smoke commands:") + fmt.Println(" soundcloud-smoke") + fmt.Println(" qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke") + fmt.Println(" qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke") + fmt.Println(" qobuz-search-smoke") + fmt.Println(" tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke") + fmt.Println(" tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke") + return case "url": if len(os.Args) < 3 { fmt.Println("usage: rip url [--force|--ignore-db]") @@ -94,11 +117,11 @@ func main() { } if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("url rip complete (%d item(s))\n", added) @@ -167,11 +190,11 @@ func main() { } if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("file rip complete (%d item(s))\n", added) @@ -318,11 +341,11 @@ func main() { os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID) @@ -349,6 +372,10 @@ func main() { fmt.Fprintf(os.Stderr, "search option error: %v\n", err) os.Exit(2) } + if sopts.first && sopts.outputFile != "" { + fmt.Fprintln(os.Stderr, "cannot choose --first and --output-file together") + os.Exit(2) + } if !isAllowedSearchSource(source) { fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source) os.Exit(2) @@ -357,8 +384,8 @@ func main() { fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType) os.Exit(2) } - if source == "soundcloud" && mediaType != "track" { - fmt.Fprintln(os.Stderr, "soundcloud search currently supports media type track only") + if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" { + fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist") os.Exit(2) } if sopts.query == "" { @@ -448,11 +475,11 @@ func main() { return } if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("search download complete (%d item(s))\n", added) @@ -502,11 +529,11 @@ func main() { return } if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("search download complete (%d item(s))\n", added) @@ -546,22 +573,18 @@ func main() { return } - playlistGroups := groupLastFMResolvedTracksBySource(resolvedTracks) - addedPlaylists := 0 - for source, ids := range playlistGroups { - playlistID := fmt.Sprintf("lastfm:%s:%s", source, strings.ToLower(strings.ReplaceAll(title, " ", "_"))) - playlistName := title - if len(playlistGroups) > 1 { - playlistName = fmt.Sprintf("%s (%s)", title, strings.Title(source)) - } - if addErr := mainApp.AddPlaylistByTrackIDs(ctx, source, playlistID, playlistName, ids); addErr != nil { - fmt.Printf("playlist queue failed: source=%s err=%v\n", source, addErr) - continue - } - addedPlaylists++ - fmt.Printf("queued lastfm playlist: %s (%d tracks, %s)\n", playlistName, len(ids), source) + playlistID := fmt.Sprintf("lastfm:%s", strings.ToLower(strings.ReplaceAll(title, " ", "_"))) + refs := make([]app.PlaylistTrackRef, 0, len(resolvedTracks)) + for _, item := range resolvedTracks { + refs = append(refs, app.PlaylistTrackRef{Source: item.Source, ID: item.ID}) } - if addedPlaylists == 0 { + if addErr := mainApp.AddMixedPlaylistByTrackRefs(ctx, playlistID, title, refs); addErr != nil { + fmt.Printf("playlist queue failed: err=%v\n", addErr) + fmt.Println("no lastfm playlists queued") + return + } + fmt.Printf("queued lastfm playlist: %s (%d tracks)\n", title, len(refs)) + if len(refs) == 0 { fmt.Println("no lastfm playlists queued") return } @@ -573,7 +596,7 @@ func main() { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } - fmt.Printf("lastfm rip complete (%d track(s) across %d playlist(s))\n", len(resolvedTracks), addedPlaylists) + fmt.Printf("lastfm rip complete (%d track(s) across 1 playlist)\n", len(resolvedTracks)) case "soundcloud-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip soundcloud-smoke ") @@ -1309,6 +1332,21 @@ func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) { } } +func errorWithActionableHint(err error, opts globalOptions) string { + if err == nil { + return "" + } + msg := err.Error() + if opts.noSSLVerify { + return msg + } + lower := strings.ToLower(msg) + if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") || strings.Contains(lower, "ssl") { + return msg + " (hint: try again with --no-ssl-verify)" + } + return msg +} + func parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) { opts := smokeOptions{} for _, arg := range args { @@ -1654,7 +1692,6 @@ func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) { client := netutil.NewHTTPClient(30*time.Second, verifySSL) all := make([]lastFMTrack, 0, 200) - seen := map[string]struct{}{} title := "" for page := 1; page <= 50; page++ { @@ -1672,17 +1709,8 @@ func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistU if len(tracks) == 0 { break } - newOnPage := 0 - for _, tr := range tracks { - key := strings.ToLower(strings.TrimSpace(tr.Title + "\x00" + tr.Artist)) - if _, dup := seen[key]; dup { - continue - } - seen[key] = struct{}{} - all = append(all, tr) - newOnPage++ - } - if newOnPage == 0 || !strings.Contains(body, "Show more") { + all = append(all, tracks...) + if !strings.Contains(body, "Show more") { break } } @@ -1874,19 +1902,6 @@ func resolveLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOpti return resolved, nil } -func groupLastFMResolvedTracksBySource(resolved []resolvedLastFMTrack) map[string][]string { - out := map[string][]string{} - for _, item := range resolved { - source := strings.TrimSpace(item.Source) - id := strings.TrimSpace(item.ID) - if source == "" || id == "" { - continue - } - out[source] = append(out[source], id) - } - return out -} - func fetchSoundcloudOEmbed(ctx context.Context, verifySSL bool, trackURL string) (map[string]any, error) { parsed, err := url.Parse(trackURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { @@ -2229,8 +2244,8 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e fmt.Println("Invalid media type.") continue } - if source == "soundcloud" && mediaType != "track" { - fmt.Println("SoundCloud search supports track only.") + if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" { + fmt.Println("SoundCloud search supports track and playlist only.") continue } @@ -2379,8 +2394,9 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) [] id := asString(itm["id"]) title := asString(itm["title"]) artist := nestedSearchString(itm, "artist", "name") + trackCount := searchInt(itm["tracks_count"]) if id != "" && title != "" { - results = append(results, searchResult{ID: id, Title: title, Artist: artist}) + results = append(results, searchResult{ID: id, Title: title, Artist: artist, TrackCount: trackCount}) } } } diff --git a/cmd/rip/main_test.go b/cmd/rip/main_test.go index cbaca24..24e61e4 100644 --- a/cmd/rip/main_test.go +++ b/cmd/rip/main_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "errors" + "testing" +) func TestParseFileInputJSONItems(t *testing.T) { content := []byte(`[ @@ -167,26 +170,6 @@ func TestNormalizeCodecRejectsUnknown(t *testing.T) { } } -func TestGroupLastFMResolvedTracksBySourcePreservesOrderAndDuplicates(t *testing.T) { - resolved := []resolvedLastFMTrack{ - {Source: "tidal", ID: "1"}, - {Source: "tidal", ID: "1"}, - {Source: "qobuz", ID: "2"}, - {Source: "tidal", ID: "3"}, - {Source: "", ID: "4"}, - } - groups := groupLastFMResolvedTracksBySource(resolved) - if len(groups["tidal"]) != 3 { - t.Fatalf("tidal ids len = %d, want 3", len(groups["tidal"])) - } - if len(groups["qobuz"]) != 1 { - t.Fatalf("qobuz ids len = %d, want 1", len(groups["qobuz"])) - } - if groups["tidal"][0] != "1" || groups["tidal"][1] != "1" || groups["tidal"][2] != "3" { - t.Fatalf("unexpected tidal ordering: %+v", groups["tidal"]) - } -} - func TestExtractLastFMTracksFromMirrorMarkdown(t *testing.T) { md := `Title: My Playlist | user playlists | Last.fm | Play | Image | Loved | Name | Artist name | Buy | Options | Duration | @@ -204,3 +187,29 @@ func TestExtractLastFMTracksFromMirrorMarkdown(t *testing.T) { t.Fatalf("unexpected first track: %+v", tracks[0]) } } + +func TestParseSearchArgsAllowsFirstAndOutputFileButCallerCanReject(t *testing.T) { + opts, err := parseSearchArgs([]string{"q", "--first", "--output-file", "/tmp/out.json"}, 20) + if err != nil { + t.Fatalf("parseSearchArgs() error = %v", err) + } + if !opts.first || opts.outputFile == "" { + t.Fatalf("expected first=true and output file set, got %+v", opts) + } +} + +func TestErrorWithActionableHintForSSL(t *testing.T) { + err := errors.New("x509: certificate signed by unknown authority") + msg := errorWithActionableHint(err, globalOptions{}) + if msg == err.Error() { + t.Fatalf("expected ssl hint in message") + } +} + +func TestErrorWithActionableHintNoHintWhenDisabled(t *testing.T) { + err := errors.New("tls handshake failure") + msg := errorWithActionableHint(err, globalOptions{noSSLVerify: true}) + if msg != err.Error() { + t.Fatalf("unexpected hint when noSSLVerify set") + } +} diff --git a/internal/app/app.go b/internal/app/app.go index a19ecee..1b447a8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -36,6 +36,11 @@ type Main struct { Media []media.Media } +type PlaylistTrackRef struct { + Source string + ID string +} + type ripTrackOptions struct { albumFolder string albumEmbedCover string @@ -219,6 +224,37 @@ func (m *Main) AddPlaylistByTrackIDs(ctx context.Context, source, playlistID, pl return nil } +func (m *Main) AddMixedPlaylistByTrackRefs(ctx context.Context, playlistID, playlistName string, refs []PlaylistTrackRef) error { + if strings.TrimSpace(playlistName) == "" { + playlistName = playlistID + } + valid := make([]PlaylistTrackRef, 0, len(refs)) + for _, ref := range refs { + source := strings.TrimSpace(ref.Source) + id := strings.TrimSpace(ref.ID) + if source == "" || id == "" { + continue + } + valid = append(valid, PlaylistTrackRef{Source: source, ID: id}) + } + if len(valid) == 0 { + return fmt.Errorf("playlist %q has no track refs", playlistName) + } + + pending := media.PendingFunc{ + ResolveFn: func(context.Context) (media.Media, error) { + return media.MediaFunc{RipFn: func(ctx context.Context) error { + if m.Config.Session.CLI.TextOutput { + m.logf("Downloading playlist: %s\n", playlistName) + } + return m.ripPlaylistMixed(ctx, playlistID, playlistName, valid) + }}, nil + }, + } + m.Pending = append(m.Pending, pending) + return nil +} + func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any) error { name := titleFromMetadata(meta, id) if n := stringFromAny(meta["name"]); n != "" { @@ -679,6 +715,56 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl return nil } +func (m *Main) ripPlaylistMixed(ctx context.Context, playlistID, name string, refs []PlaylistTrackRef) error { + folder := filepath.Join(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{ + RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, + TruncateTo: m.Config.Session.Filepaths.TruncateTo, + })) + + total := len(refs) + m.logf("Playlist: %s (%d tracks)\n", name, total) + failures := 0 + + providerCache := map[string]provider.Client{} + getProvider := func(source string) (provider.Client, error) { + if p, ok := providerCache[source]; ok { + return p, nil + } + p, err := m.GetLoggedInProvider(ctx, source) + if err != nil { + return nil, err + } + providerCache[source] = p + return p, nil + } + + for i, ref := range refs { + p, err := getProvider(ref.Source) + if err != nil { + failures++ + m.logf("track failed: id=%s source=%s reason=%v\n", ref.ID, ref.Source, err) + continue + } + opts := ripTrackOptions{ + albumFolder: folder, + index: i + 1, + total: total, + forPlaylist: true, + playlistName: name, + playlistPos: i + 1, + } + if err = m.ripTrack(ctx, p, ref.Source, ref.ID, "", opts); err != nil { + failures++ + m.logf("track failed: id=%s source=%s reason=%v\n", ref.ID, ref.Source, err) + } + } + + if failures > 0 { + m.logf("Playlist done with %d failed track(s)\n", failures) + } + return nil +} + func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error { alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id) if err == nil && alreadyDownloaded { diff --git a/internal/config/config.go b/internal/config/config.go index 60d2fa8..148f002 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -171,7 +171,7 @@ func Load(path string) (*Config, error) { return nil, err } - var data ConfigData + data := DefaultConfigData() if err = toml.Unmarshal(raw, &data); err != nil { return nil, err } @@ -184,6 +184,27 @@ func Load(path string) (*Config, error) { return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil } +func UpgradeOutdated(path string) (string, error) { + resolvedPath, err := resolvePath(path) + if err != nil { + return "", err + } + raw, err := os.ReadFile(resolvedPath) + if err != nil { + return "", err + } + data := DefaultConfigData() + if err = toml.Unmarshal(raw, &data); err != nil { + return "", err + } + applyRuntimeDefaults(&data) + data.Misc.Version = CurrentConfigVersion + if err = saveConfigData(resolvedPath, data); err != nil { + return "", err + } + return resolvedPath, nil +} + func (c *Config) SaveFile() error { return saveConfigData(c.Path, c.File) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c5df28e..9f260a2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -53,6 +53,37 @@ func TestLoadOutdatedConfig(t *testing.T) { } } +func TestUpgradeOutdatedConfig(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.toml") + + data := DefaultConfigData() + data.Misc.Version = "1.0.0" + data.Downloads.Folder = filepath.Join(tmpDir, "Music") + if err := saveConfigData(path, data); err != nil { + t.Fatalf("saveConfigData() error = %v", err) + } + + resolved, err := UpgradeOutdated(path) + if err != nil { + t.Fatalf("UpgradeOutdated() error = %v", err) + } + if resolved != path { + t.Fatalf("resolved path = %q, want %q", resolved, path) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() after upgrade error = %v", err) + } + if cfg.File.Misc.Version != CurrentConfigVersion { + t.Fatalf("version = %q, want %q", cfg.File.Misc.Version, CurrentConfigVersion) + } + if cfg.File.Downloads.Folder != data.Downloads.Folder { + t.Fatalf("downloads folder changed unexpectedly") + } +} + func TestSessionCloneDoesNotAliasSlices(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") diff --git a/internal/provider/soundcloud/client.go b/internal/provider/soundcloud/client.go index 15a6a58..f908487 100644 --- a/internal/provider/soundcloud/client.go +++ b/internal/provider/soundcloud/client.go @@ -5,10 +5,15 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" + "net/url" "os/exec" + "regexp" "strconv" "strings" "sync" + "time" "streamrip-go/internal/config" "streamrip-go/internal/provider" @@ -16,6 +21,8 @@ import ( 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 { @@ -23,6 +30,7 @@ type Client struct { loggedIn bool bin string run commandRunner + http *http.Client mu sync.Mutex cache map[string]map[string]any } @@ -32,6 +40,7 @@ func New(cfg *config.Config) *Client { cfg: cfg, bin: "yt-dlp", run: runCommand, + http: &http.Client{Timeout: 20 * time.Second}, cache: map[string]map[string]any{}, } } @@ -56,12 +65,19 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) 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 } + 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) @@ -82,10 +98,7 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) if !ok { continue } - id := strings.TrimSpace(stringFromAny(m["webpage_url"])) - if id == "" { - id = strings.TrimSpace(stringFromAny(m["url"])) - } + id := canonicalSoundcloudURL(m) if id == "" { continue } @@ -100,11 +113,85 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) "name": artist, }, } + 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"]))) + 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 pid := strings.TrimSpace(stringFromAny(info["id"])); pid != "" { + item["source_playlist_id"] = pid + } + if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" { + item["image"] = map[string]any{"small": thumb, "large": thumb, "extralarge": thumb, "original": 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") @@ -118,37 +205,56 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s } 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) + root, err := c.playlistInfo(ctx, item) if err != nil { return nil, err } tracks := make([]any, 0) - for _, raw := range asAnySlice(root["entries"]) { + for i, 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"])) - } + id := canonicalSoundcloudURL(entry) if id == "" { continue } - tracks = append(tracks, map[string]any{"id": id}) + 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 != "" { + track["artist"] = map[string]any{"name": artist} + } + track["track_number"] = i + 1 + tracks = append(tracks, track) } name := strings.TrimSpace(stringFromAny(root["title"])) if name == "" { name = "SoundCloud Playlist" } - return map[string]any{ - "name": name, - "tracks": map[string]any{"items": tracks}, - }, nil + 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"] = map[string]any{"small": thumb, "large": thumb, "extralarge": thumb, "original": 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) } @@ -164,7 +270,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov } streamURL := strings.TrimSpace(stringFromAny(info["url"])) if streamURL == "" { - return nil, errors.New("yt-dlp output missing url") + return nil, errors.New("yt-dlp output missing url (track may be unavailable or region-restricted)") } ext := strings.TrimSpace(stringFromAny(info["ext"])) if ext == "" { @@ -198,18 +304,31 @@ func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, er 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) title := strings.TrimSpace(stringFromAny(info["title"])) if title == "" { - title = id + title = canonicalID } artistName := strings.TrimSpace(stringFromAny(info["artist"])) if artistName == "" { @@ -225,7 +344,7 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any { } meta := map[string]any{ - "id": id, + "id": canonicalID, "title": title, "track_number": trackNum, "artist": map[string]any{"name": artistName}, @@ -237,11 +356,20 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any { }, "description": strings.TrimSpace(stringFromAny(info["description"])), "genre": strings.TrimSpace(stringFromAny(info["genre"])), + "isrc": strings.TrimSpace(stringFromAny(info["isrc"])), + "label": strings.TrimSpace(stringFromAny(info["label"])), "release_date": strings.TrimSpace(firstNonEmpty( stringFromAny(info["release_date"]), stringFromAny(info["upload_date"]), )), } + if trackID := strings.TrimSpace(stringFromAny(info["id"])); trackID != "" { + meta["source_track_id"] = trackID + } + + if age := intFromAny(info["age_limit"]); age >= 18 { + meta["explicit"] = true + } if meta["release_date"] == "" { delete(meta, "release_date") @@ -271,6 +399,32 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any { 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 { diff --git a/internal/provider/soundcloud/client_test.go b/internal/provider/soundcloud/client_test.go index 4219742..4ebd4fb 100644 --- a/internal/provider/soundcloud/client_test.go +++ b/internal/provider/soundcloud/client_test.go @@ -3,6 +3,8 @@ package soundcloud import ( "context" "fmt" + "net/http" + "net/http/httptest" "strings" "testing" @@ -28,6 +30,9 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) { if stringFromAny(meta["title"]) != "Lean On" { t.Fatalf("title = %q, want Lean On", stringFromAny(meta["title"])) } + if stringFromAny(meta["id"]) != "https://soundcloud.com/a/b" { + t.Fatalf("id = %q, want canonical soundcloud url", stringFromAny(meta["id"])) + } d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0) if err != nil { @@ -92,6 +97,44 @@ func TestSearchTrack(t *testing.T) { } } +func TestSearchPlaylist(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/search/sets" { + _, _ = w.Write([]byte(`x`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + c.http = ts.Client() + origBase := soundcloudSearchBaseURL + soundcloudSearchBaseURL = ts.URL + defer func() { soundcloudSearchBaseURL = origBase }() + c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + if strings.Contains(joined, "https://soundcloud.com/a/sets/road-trip") { + return []byte(`{"title":"Road Trip","uploader":"User","entries":[{"webpage_url":"https://soundcloud.com/a/t1"}]}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + } + + pages, err := c.Search(context.Background(), "playlist", "road trip", 5) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if len(pages) != 1 { + t.Fatalf("pages len = %d, want 1", len(pages)) + } + items := asAnySlice(pages[0]["items"]) + if len(items) != 1 { + t.Fatalf("items len = %d, want 1", len(items)) + } +} + func TestLoginShowsYtDlpHint(t *testing.T) { cfgData := config.DefaultConfigData() c := New(&config.Config{File: cfgData, Session: cfgData}) @@ -104,3 +147,33 @@ func TestLoginShowsYtDlpHint(t *testing.T) { t.Fatalf("expected yt-dlp hint in error, got: %v", err) } } + +func TestTrackMetadataIncludesExplicitAndISRC(t *testing.T) { + meta := trackMetadataFromInfo("https://soundcloud.com/a/b", map[string]any{ + "title": "T", + "uploader": "U", + "isrc": "US123", + "id": "9876", + "webpage_url": "https://soundcloud.com/a/b?si=abc", + "age_limit": float64(18), + "thumbnail": "https://img", + "upload_date": "20240101", + }) + if stringFromAny(meta["isrc"]) != "US123" { + t.Fatalf("isrc = %q, want US123", stringFromAny(meta["isrc"])) + } + explicit, _ := meta["explicit"].(bool) + if !explicit { + t.Fatalf("expected explicit=true") + } + if stringFromAny(meta["source_track_id"]) != "9876" { + t.Fatalf("source_track_id = %q, want 9876", stringFromAny(meta["source_track_id"])) + } +} + +func TestCanonicalSoundcloudURL(t *testing.T) { + got := canonicalSoundcloudURL(map[string]any{"webpage_url": "https://soundcloud.com/a/b/?si=x#frag"}) + if got != "https://soundcloud.com/a/b" { + t.Fatalf("canonical url = %q, want %q", got, "https://soundcloud.com/a/b") + } +} diff --git a/internal/urlparse/parse.go b/internal/urlparse/parse.go index 8f41b3b..a47ee81 100644 --- a/internal/urlparse/parse.go +++ b/internal/urlparse/parse.go @@ -49,7 +49,7 @@ func Parse(raw string) *ParsedURL { return parseTidal(raw, parts) case isDeezerHost(host): return parseDeezer(raw, parts) - case host == "soundcloud.com": + case isSoundcloudHost(host): return parseSoundcloud(raw, parts) default: return nil @@ -129,7 +129,7 @@ func parseDeezer(raw string, parts []string) *ParsedURL { } func parseSoundcloud(raw string, parts []string) *ParsedURL { - if len(parts) < 2 { + if len(parts) < 1 { return nil } @@ -172,6 +172,10 @@ func isDeezerHost(host string) bool { return host == "deezer.com" } +func isSoundcloudHost(host string) bool { + return host == "soundcloud.com" || strings.HasSuffix(host, ".soundcloud.com") || host == "on.soundcloud.com" +} + func isSupportedMedia(mediaType string) bool { switch mediaType { case "album", "track", "playlist", "artist", "label", "video": diff --git a/internal/urlparse/parse_test.go b/internal/urlparse/parse_test.go index 3fbde36..c625236 100644 --- a/internal/urlparse/parse_test.go +++ b/internal/urlparse/parse_test.go @@ -105,6 +105,8 @@ func TestSoundcloudURL(t *testing.T) { inputs := []string{ "https://soundcloud.com/artist-name/track-name", "https://soundcloud.com/artist-name/sets/playlist-name", + "https://m.soundcloud.com/artist-name/track-name", + "https://on.soundcloud.com/abcdef", } for _, input := range inputs { result := Parse(input)