From b2688ce949ee057c591aa101f16974f1257eddfd Mon Sep 17 00:00:00 2001 From: Joren Date: Mon, 20 Apr 2026 00:56:10 +0200 Subject: [PATCH] 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. --- cmd/rip/main.go | 383 +++++++++++++++-- cmd/rip/main_test.go | 51 +++ internal/app/app.go | 70 ++- internal/app/app_test.go | 128 +++++- internal/artwork/artwork.go | 7 +- internal/download/downloader.go | 26 +- internal/download/downloader_test.go | 7 + internal/provider/deezer/client.go | 451 ++++++++++++++++++++ internal/provider/deezer/client_test.go | 66 +++ internal/provider/soundcloud/client.go | 348 +++++++++++++++ internal/provider/soundcloud/client_test.go | 106 +++++ internal/provider/tidal/client.go | 99 +++++ internal/provider/tidal/client_test.go | 48 +++ internal/urlparse/parse.go | 2 +- internal/urlparse/parse_test.go | 11 + 15 files changed, 1746 insertions(+), 57 deletions(-) create mode 100644 internal/provider/deezer/client.go create mode 100644 internal/provider/deezer/client_test.go create mode 100644 internal/provider/soundcloud/client.go create mode 100644 internal/provider/soundcloud/client_test.go diff --git a/cmd/rip/main.go b/cmd/rip/main.go index 89ce527..f715bea 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -31,17 +31,28 @@ import ( ) func main() { - if len(os.Args) < 2 { + gopts, err := parseGlobalArgs(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if gopts.command == "" { fmt.Println("usage: rip ") - fmt.Println("commands: url, file, config, database, id, search, lastfm, 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-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke") + 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") os.Exit(2) } - cfg, err := config.Load("") + cfg, err := config.Load(gopts.configPath) if err != nil { fmt.Fprintf(os.Stderr, "config error: %v\n", err) os.Exit(1) } + applyGlobalConfigOverrides(cfg, gopts) + if gopts.verbose { + fmt.Fprintln(os.Stderr, "verbose mode enabled") + } + + os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...) ctx := context.Background() @@ -68,7 +79,7 @@ func main() { } rawArgs = append(rawArgs, arg) } - mainApp.IgnoreDB = ignoreDB + mainApp.IgnoreDB = ignoreDB || gopts.noDB added := 0 for _, raw := range rawArgs { @@ -126,7 +137,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = ignoreDB + mainApp.IgnoreDB = ignoreDB || gopts.noDB added := 0 if jsonInput { @@ -268,7 +279,7 @@ func main() { } case "id": if len(os.Args) < 5 { - fmt.Println("usage: rip id [quality] [--force|--ignore-db]") + fmt.Println("usage: rip id [quality] [--force|--ignore-db]") os.Exit(2) } @@ -300,7 +311,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB if err = mainApp.AddByID(ctx, source, mediaType, itemID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) @@ -320,7 +331,7 @@ func main() { var sopts searchOptions if len(os.Args) < 5 { if !term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") + fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") os.Exit(2) } source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) @@ -346,6 +357,10 @@ 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") + os.Exit(2) + } if sopts.query == "" { fmt.Fprintln(os.Stderr, "search query cannot be empty") os.Exit(2) @@ -375,12 +390,11 @@ func main() { fmt.Println("no results") return } - - fmt.Printf("results: %d\n", len(results)) - for i, result := range results { - fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title) - } if sopts.outputFile != "" { + fmt.Printf("results: %d\n", len(results)) + for i, result := range results { + fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title) + } if err = writeSearchResultsToFile(source, mediaType, results, sopts.outputFile); err != nil { fmt.Fprintf(os.Stderr, "write results error: %v\n", err) os.Exit(1) @@ -389,15 +403,24 @@ func main() { return } if sopts.first { + fmt.Printf("results: %d\n", len(results)) + fmt.Printf(" 1. id=%s | %s\n", results[0].ID, results[0].Title) results = results[:1] } if sopts.noDownload { + fmt.Printf("results: %d\n", len(results)) + for i, result := range results { + fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title) + } return } + if !sopts.first { + fmt.Printf("results: %d\n", len(results)) + } if sopts.first { selection := []int{0} - mainApp.IgnoreDB = sopts.ignoreDB + mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB skippedDownloaded := 0 added := 0 for _, idx := range selection { @@ -451,7 +474,7 @@ func main() { return } - mainApp.IgnoreDB = sopts.ignoreDB + mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB skippedDownloaded := 0 added := 0 for _, idx := range selection { @@ -530,6 +553,17 @@ func main() { os.Exit(1) } fmt.Printf("lastfm rip complete (%d track(s))\n", len(mainApp.Pending)) + case "soundcloud-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip soundcloud-smoke ") + os.Exit(2) + } + meta, err := fetchSoundcloudOEmbed(ctx, cfg.Session.Downloads.VerifySSL, strings.TrimSpace(os.Args[2])) + if err != nil { + fmt.Fprintf(os.Stderr, "soundcloud smoke error: %v\n", err) + os.Exit(1) + } + fmt.Printf("soundcloud oembed ok: title=%q author=%q provider=%q\n", asString(meta["title"]), asString(meta["author_name"]), asString(meta["provider_name"])) case "qobuz-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip qobuz-smoke [quality]") @@ -593,7 +627,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB trackID := os.Args[2] if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil { @@ -632,7 +666,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB trackID := os.Args[2] if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil { @@ -668,7 +702,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB albumID := os.Args[2] if err = mainApp.AddByID(ctx, "qobuz", "album", albumID); err != nil { @@ -705,7 +739,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB playlistID := os.Args[2] if err = mainApp.AddByID(ctx, "qobuz", "playlist", playlistID); err != nil { @@ -740,7 +774,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB artistID := os.Args[2] if err = mainApp.AddByID(ctx, "qobuz", "artist", artistID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) @@ -774,7 +808,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB labelID := os.Args[2] if err = mainApp.AddByID(ctx, "qobuz", "label", labelID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) @@ -920,6 +954,49 @@ func main() { } } fmt.Printf("tidal metadata ok: type=%s id=%s title=%q tracks=%d\n", mediaType, itemID, title, trackCount) + case "tidal-video-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip tidal-video-smoke ") + os.Exit(2) + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + + providerClient, err := mainApp.GetLoggedInProvider(ctx, "tidal") + if err != nil { + fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) + os.Exit(1) + } + videoProvider, ok := providerClient.(interface { + GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) + }) + if !ok { + fmt.Fprintln(os.Stderr, "tidal provider does not support video downloadable") + os.Exit(1) + } + + videoID := strings.TrimSpace(os.Args[2]) + meta, err := providerClient.GetMetadata(ctx, videoID, "video") + if err != nil { + fmt.Fprintf(os.Stderr, "video metadata error: %v\n", err) + os.Exit(1) + } + d, err := videoProvider.GetVideoDownloadable(ctx, videoID) + if err != nil { + fmt.Fprintf(os.Stderr, "video downloadable error: %v\n", err) + os.Exit(1) + } + title := asString(meta["title"]) + if title == "" { + title = asString(meta["name"]) + } + fmt.Printf("tidal video ok: id=%s title=%q ext=%s\n", videoID, title, d.Extension) + fmt.Printf("stream_url=%s\n", d.URL) case "tidal-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip tidal-rip-smoke [quality] [--force|--ignore-db]") @@ -940,7 +1017,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB trackID := os.Args[2] if err = mainApp.AddByID(ctx, "tidal", "track", trackID); err != nil { @@ -976,7 +1053,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB albumID := os.Args[2] if err = mainApp.AddByID(ctx, "tidal", "album", albumID); err != nil { @@ -1012,7 +1089,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB playlistID := os.Args[2] if err = mainApp.AddByID(ctx, "tidal", "playlist", playlistID); err != nil { @@ -1047,7 +1124,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB artistID := os.Args[2] if err = mainApp.AddByID(ctx, "tidal", "artist", artistID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) @@ -1074,6 +1151,143 @@ type smokeOptions struct { ignoreDB bool } +type globalOptions struct { + configPath string + folder string + noDB bool + qualitySet bool + quality int + codecSet bool + codec string + noProgress bool + noSSLVerify bool + verbose bool + command string + commandArgs []string +} + +func parseGlobalArgs(args []string) (globalOptions, error) { + opts := globalOptions{} + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "" { + continue + } + if !strings.HasPrefix(arg, "-") { + opts.command = arg + if i+1 < len(args) { + opts.commandArgs = append([]string(nil), args[i+1:]...) + } + return opts, nil + } + + switch { + case arg == "-ndb" || arg == "--no-db": + opts.noDB = true + case arg == "--no-progress": + opts.noProgress = true + case arg == "--no-ssl-verify": + opts.noSSLVerify = true + case arg == "-v" || arg == "--verbose": + opts.verbose = true + case arg == "-f" || arg == "--folder": + if i+1 >= len(args) { + return globalOptions{}, fmt.Errorf("%s requires a value", arg) + } + opts.folder = strings.TrimSpace(args[i+1]) + i++ + case strings.HasPrefix(arg, "--folder="): + opts.folder = strings.TrimSpace(strings.TrimPrefix(arg, "--folder=")) + case arg == "--config-path": + if i+1 >= len(args) { + return globalOptions{}, fmt.Errorf("--config-path requires a value") + } + opts.configPath = strings.TrimSpace(args[i+1]) + i++ + case strings.HasPrefix(arg, "--config-path="): + opts.configPath = strings.TrimSpace(strings.TrimPrefix(arg, "--config-path=")) + case arg == "-q" || arg == "--quality": + if i+1 >= len(args) { + return globalOptions{}, fmt.Errorf("%s requires a value", arg) + } + q, err := strconv.Atoi(args[i+1]) + if err != nil || q < 0 || q > 4 { + return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", args[i+1]) + } + opts.qualitySet = true + opts.quality = q + i++ + case strings.HasPrefix(arg, "--quality="): + qRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--quality=")) + q, err := strconv.Atoi(qRaw) + if err != nil || q < 0 || q > 4 { + return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", qRaw) + } + opts.qualitySet = true + opts.quality = q + case arg == "-c" || arg == "--codec": + if i+1 >= len(args) { + return globalOptions{}, fmt.Errorf("%s requires a value", arg) + } + codec, err := normalizeCodec(args[i+1]) + if err != nil { + return globalOptions{}, err + } + opts.codecSet = true + opts.codec = codec + i++ + case strings.HasPrefix(arg, "--codec="): + codecRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--codec=")) + codec, err := normalizeCodec(codecRaw) + if err != nil { + return globalOptions{}, err + } + opts.codecSet = true + opts.codec = codec + default: + return globalOptions{}, fmt.Errorf("unknown global option %q", arg) + } + } + return opts, nil +} + +func normalizeCodec(raw string) (string, error) { + codec := strings.ToUpper(strings.TrimSpace(raw)) + switch codec { + case "ALAC", "FLAC", "MP3", "AAC", "VORBIS": + return codec, nil + case "OGG": + return "VORBIS", nil + default: + return "", fmt.Errorf("unsupported codec %q (expected ALAC, FLAC, OGG, MP3, AAC)", raw) + } +} + +func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) { + if opts.folder != "" { + cfg.Session.Downloads.Folder = opts.folder + } + if opts.noDB { + cfg.Session.Database.DownloadsEnabled = false + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + cfg.Session.Tidal.Quality = opts.quality + cfg.Session.Deezer.Quality = opts.quality + cfg.Session.Soundcloud.Quality = opts.quality + } + if opts.codecSet { + cfg.Session.Conversion.Enabled = true + cfg.Session.Conversion.Codec = opts.codec + } + if opts.noProgress { + cfg.Session.CLI.ProgressBars = false + } + if opts.noSSLVerify { + cfg.Session.Downloads.VerifySSL = false + } +} + func parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) { opts := smokeOptions{} for _, arg := range args { @@ -1154,11 +1368,11 @@ func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool { fmt.Printf("invalid: %s\n", raw) return false } - if parsed.Kind != urlparse.KindGeneric { + if parsed.Kind != urlparse.KindGeneric && parsed.Kind != urlparse.KindSoundcloud { fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind) return false } - if parsed.Source != "qobuz" && parsed.Source != "tidal" { + if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "soundcloud" { fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw) return false } @@ -1506,6 +1720,43 @@ func queueLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOption return nil } +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 == "" { + return nil, fmt.Errorf("invalid soundcloud url") + } + + q := url.Values{} + q.Set("format", "json") + q.Set("url", trackURL) + endpoint := "https://soundcloud.com/oembed?" + q.Encode() + + client := netutil.NewHTTPClient(20*time.Second, verifySSL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "streamrip-go/0.1") + + resp, err := client.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 oembed failed: status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + out := map[string]any{} + if err = json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + func searchLastFMTrack(ctx context.Context, opts lastFMOptions, primary provider.Client, fallback provider.Client, query string) (string, string, error) { pages, err := primary.Search(ctx, "track", query, 1) if err == nil { @@ -1701,7 +1952,11 @@ func promptSearchSelectionMenu(source, mediaType, query string, results []search labels := make([]string, 0, len(results)) labelToIndex := map[string]int{} for i, r := range results { - label := fmt.Sprintf("%2d. %s", i+1, r.Title) + artist := strings.TrimSpace(r.Artist) + if artist == "" { + artist = "Unknown Artist" + } + label := fmt.Sprintf("%2d. %s - %s", i+1, artist, r.Title) labels = append(labels, label) labelToIndex[label] = i } @@ -1712,10 +1967,11 @@ func promptSearchSelectionMenu(source, mediaType, query string, results []search Help: "SPACE: select ENTER: download /: filter ESC: cancel", Options: labels, Description: func(value string, index int) string { - if index < 0 || index >= len(results) { + resultIndex, ok := labelToIndex[value] + if !ok || resultIndex < 0 || resultIndex >= len(results) { return "" } - return formatSearchDetails(results[index]) + return formatSearchDetails(results[resultIndex]) }, PageSize: 15, } @@ -1762,12 +2018,12 @@ func writeSearchResultsToFile(source, mediaType string, results []searchResult, } func isAllowedSearchSource(source string) bool { - return source == "qobuz" || source == "tidal" + return source == "qobuz" || source == "tidal" || source == "deezer" || source == "soundcloud" } func isAllowedMediaType(mediaType string) bool { switch mediaType { - case "track", "album", "playlist", "artist", "label": + case "track", "album", "playlist", "artist", "label", "video": return true default: return false @@ -1787,7 +2043,7 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e } for { - source, err := read("Source [qobuz/tidal]: ") + source, err := read("Source [qobuz/tidal/deezer/soundcloud]: ") if err != nil { return "", "", searchOptions{}, err } @@ -1797,7 +2053,7 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e continue } - mediaType, err := read("Type [track/album/playlist/artist/label]: ") + mediaType, err := read("Type [track/album/playlist/artist/label/video]: ") if err != nil { return "", "", searchOptions{}, err } @@ -1806,6 +2062,10 @@ 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.") + continue + } query, err := read("Query: ") if err != nil { @@ -1911,25 +2171,72 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) [] results = append(results, searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit}) } } + case "deezer": + key := mediaType + "s" + bucket, ok := page[key].(map[string]any) + if !ok { + continue + } + items, ok := bucket["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + id := asString(itm["id"]) + title := asString(itm["title"]) + if title == "" { + title = asString(itm["name"]) + } + artist := nestedSearchString(itm, "artist", "name") + album := nestedSearchString(itm, "album", "title") + trackCount := searchInt(itm["nb_tracks"]) + explicit := searchBool(itm["explicit_lyrics"]) + if id != "" && title != "" { + results = append(results, searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit}) + } + } + case "soundcloud": + items, ok := page["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + id := asString(itm["id"]) + title := asString(itm["title"]) + artist := nestedSearchString(itm, "artist", "name") + if id != "" && title != "" { + results = append(results, searchResult{ID: id, Title: title, Artist: artist}) + } + } } } return results } func formatSearchDetails(r searchResult) string { - lines := []string{fmt.Sprintf("ID: %s", r.ID), fmt.Sprintf("Title: %s", r.Title)} + lines := []string{"Selected item", ""} + lines = append(lines, fmt.Sprintf("Title : %s", r.Title)) if strings.TrimSpace(r.Artist) != "" { - lines = append(lines, fmt.Sprintf("Artist: %s", r.Artist)) + lines = append(lines, fmt.Sprintf("Artist : %s", r.Artist)) } if strings.TrimSpace(r.Album) != "" { - lines = append(lines, fmt.Sprintf("Album: %s", r.Album)) + lines = append(lines, fmt.Sprintf("Album : %s", r.Album)) } if r.TrackCount > 0 { - lines = append(lines, fmt.Sprintf("Tracks: %d", r.TrackCount)) + lines = append(lines, fmt.Sprintf("Tracks : %d", r.TrackCount)) } if r.Explicit { lines = append(lines, "Explicit: yes") } + lines = append(lines, fmt.Sprintf("ID : %s", r.ID)) return strings.Join(lines, "\n") } diff --git a/cmd/rip/main_test.go b/cmd/rip/main_test.go index 78a47d5..bd4f395 100644 --- a/cmd/rip/main_test.go +++ b/cmd/rip/main_test.go @@ -115,3 +115,54 @@ func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) { t.Fatalf("unexpected first pair: %+v", pairs[0]) } } + +func TestParseGlobalArgsNoDBBeforeCommand(t *testing.T) { + opts, err := parseGlobalArgs([]string{"-ndb", "url", "https://play.qobuz.com/album/0004228000522"}) + if err != nil { + t.Fatalf("parseGlobalArgs() error = %v", err) + } + if !opts.noDB { + t.Fatalf("expected noDB true") + } + if opts.command != "url" { + t.Fatalf("command = %q, want %q", opts.command, "url") + } + if len(opts.commandArgs) != 1 { + t.Fatalf("command args len = %d, want 1", len(opts.commandArgs)) + } +} + +func TestParseGlobalArgsAllOfficialFlags(t *testing.T) { + opts, err := parseGlobalArgs([]string{ + "--config-path", "/tmp/custom.toml", + "-f", "/tmp/music", + "--no-db", + "-q", "3", + "-c", "ogg", + "--no-progress", + "--no-ssl-verify", + "-v", + "search", "tidal", "track", "dreams", + }) + if err != nil { + t.Fatalf("parseGlobalArgs() error = %v", err) + } + if opts.configPath != "/tmp/custom.toml" || opts.folder != "/tmp/music" { + t.Fatalf("unexpected path/folder opts: %+v", opts) + } + if !opts.noDB || !opts.qualitySet || opts.quality != 3 || !opts.codecSet || opts.codec != "VORBIS" { + t.Fatalf("unexpected quality/codec/db opts: %+v", opts) + } + if !opts.noProgress || !opts.noSSLVerify || !opts.verbose { + t.Fatalf("unexpected boolean opts: %+v", opts) + } + if opts.command != "search" { + t.Fatalf("command = %q, want search", opts.command) + } +} + +func TestNormalizeCodecRejectsUnknown(t *testing.T) { + if _, err := normalizeCodec("wav"); err == nil { + t.Fatalf("expected error for unsupported codec") + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 4109623..5c4ebaa 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,7 +18,9 @@ import ( "streamrip-go/internal/download" "streamrip-go/internal/naming" "streamrip-go/internal/provider" + deezerprovider "streamrip-go/internal/provider/deezer" qobuzprovider "streamrip-go/internal/provider/qobuz" + soundcloudprovider "streamrip-go/internal/provider/soundcloud" tidalprovider "streamrip-go/internal/provider/tidal" "streamrip-go/internal/store" ) @@ -66,6 +68,10 @@ type trackTagger interface { TagFLAC(path string, meta tag.Metadata, coverPath string) error } +type videoDownloadableProvider interface { + GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) +} + func New(cfg *config.Config) (*Main, error) { var db store.Database if cfg.Session.Database.DownloadsEnabled || cfg.Session.Database.FailedDownloadsEnabled { @@ -79,8 +85,10 @@ func New(cfg *config.Config) (*Main, error) { } providers := map[string]provider.Client{ - "qobuz": qobuzprovider.New(cfg), - "tidal": tidalprovider.New(cfg), + "qobuz": qobuzprovider.New(cfg), + "tidal": tidalprovider.New(cfg), + "deezer": deezerprovider.New(cfg), + "soundcloud": soundcloudprovider.New(cfg), } return &Main{ @@ -156,8 +164,10 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error return m.ripCollection(ctx, p, source, "Artist", id, meta) case "label": return m.ripCollection(ctx, p, source, "Label", id, meta) + case "video": + return m.ripVideo(ctx, p, source, id, meta) default: - return nil + return fmt.Errorf("unsupported media type %q", mediaType) } }}, nil }, @@ -205,6 +215,37 @@ func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kin return nil } +func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID string, meta map[string]any) error { + alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, videoID) + if err == nil && alreadyDownloaded && !m.IgnoreDB { + m.logf("skip (already downloaded) id=%s\n", videoID) + return nil + } + + vp, ok := p.(videoDownloadableProvider) + if !ok { + return fmt.Errorf("provider %q does not support video downloads", source) + } + + d, err := vp.GetVideoDownloadable(ctx, videoID) + if err != nil { + _ = m.Store.MarkFailed(ctx, source, "video", videoID) + return fmt.Errorf("id=%s get_video_downloadable: %w", videoID, err) + } + + title := titleFromMetadata(meta, videoID) + outPath := m.videoOutputPath(source, videoID, title, d.Extension) + if err = m.DL.FileVideo(ctx, d.URL, outPath); err != nil { + _ = m.Store.MarkFailed(ctx, source, "video", videoID) + return fmt.Errorf("id=%s title=%q video download: %w", videoID, title, err) + } + + if err = m.Store.MarkDownloaded(ctx, source, videoID); err != nil { + return err + } + return nil +} + func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum { trackCount := intFromAny(meta["tracks_count"]) if trackCount == 0 { @@ -357,16 +398,21 @@ func extractAlbumIDs(meta map[string]any) []string { } func (m *Main) Resolve(ctx context.Context) error { + pendingCount := len(m.Pending) resolved := make([]media.Media, 0, len(m.Pending)) for _, item := range m.Pending { med, err := item.Resolve(ctx) if err != nil { + m.logf("resolve failed: %v\n", err) continue } resolved = append(resolved, med) } m.Media = append(m.Media, resolved...) m.Pending = m.Pending[:0] + if pendingCount > 0 && len(resolved) == 0 { + return fmt.Errorf("resolve failed for all %d pending item(s)", pendingCount) + } return nil } @@ -830,6 +876,24 @@ func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[stri return filepath.Join(base, fileName+"."+ext) } +func (m *Main) videoOutputPath(source, id, title, ext string) string { + if strings.TrimSpace(ext) == "" { + ext = "mp4" + } + base := m.Config.Session.Downloads.Folder + if m.Config.Session.Downloads.SourceSubdirectories { + base = filepath.Join(base, strings.Title(source)) + } + fileName := naming.CleanName(title, naming.Config{ + RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, + TruncateTo: m.Config.Session.Filepaths.TruncateTo, + }) + if fileName == "" { + fileName = id + } + return filepath.Join(base, fileName+"."+ext) +} + func titleFromMetadata(meta map[string]any, fallback string) string { if title, ok := meta["title"].(string); ok { title = strings.TrimSpace(title) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 1168a76..e926ebb 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -51,22 +51,35 @@ type fakePlaylistProvider struct { url string } +type fakeVideoProvider struct { + url string +} + +type fakeFailProvider struct{} + func (f *fakeAlbumProvider) Source() string { return "qobuz" } func (f *fakePlaylistProvider) Source() string { return "qobuz" } +func (f *fakeVideoProvider) Source() string { return "tidal" } func (f *fakeAlbumProvider) Login(context.Context) error { return nil } func (f *fakePlaylistProvider) Login(context.Context) error { return nil } -func (f *fakeAlbumProvider) LoggedIn() bool { return true } -func (f *fakePlaylistProvider) LoggedIn() bool { return true } -func (f *fakeAlbumProvider) Close() error { return nil } -func (f *fakePlaylistProvider) Close() error { return nil } +func (f *fakeVideoProvider) Login(context.Context) error { return nil } +func (f *fakeAlbumProvider) LoggedIn() bool { return true } +func (f *fakePlaylistProvider) LoggedIn() bool { return true } +func (f *fakeVideoProvider) LoggedIn() bool { return true } +func (f *fakeAlbumProvider) Close() error { return nil } +func (f *fakePlaylistProvider) Close() error { return nil } +func (f *fakeVideoProvider) Close() error { return nil } func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } func (f *fakePlaylistProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } +func (f *fakeVideoProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { + return nil, nil +} func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { if mediaType == "album" { return map[string]any{ @@ -133,6 +146,12 @@ func (f *fakePlaylistProvider) GetMetadata(_ context.Context, id string, mediaTy }, }, nil } +func (f *fakeVideoProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { + if mediaType == "video" { + return map[string]any{"title": "Live Clip"}, nil + } + return nil, nil +} func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil } @@ -142,6 +161,25 @@ func (f *fakeAlbumProvider) GetDownloadable(context.Context, string, int) (*prov func (f *fakePlaylistProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil } +func (f *fakeVideoProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { + return nil, nil +} +func (f *fakeVideoProvider) GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) { + return &provider.Downloadable{URL: f.url, Extension: "mp4", Source: "tidal"}, nil +} +func (f *fakeFailProvider) Source() string { return "qobuz" } +func (f *fakeFailProvider) Login(context.Context) error { return nil } +func (f *fakeFailProvider) LoggedIn() bool { return true } +func (f *fakeFailProvider) Close() error { return nil } +func (f *fakeFailProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { + return nil, nil +} +func (f *fakeFailProvider) GetMetadata(context.Context, string, string) (map[string]any, error) { + return nil, os.ErrNotExist +} +func (f *fakeFailProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { + return nil, os.ErrNotExist +} func TestTrackRipPipeline(t *testing.T) { tmp := t.TempDir() @@ -248,6 +286,88 @@ func TestAlbumRipPipeline(t *testing.T) { } } +func TestVideoRipPipeline(t *testing.T) { + tmp := t.TempDir() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("video-bytes")) + })) + defer ts.Close() + + d := config.DefaultConfigData() + d.Downloads.Folder = tmp + d.Downloads.SourceSubdirectories = false + cfg := &config.Config{File: d, Session: d} + + sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) + if err != nil { + t.Fatalf("NewSQLite() error = %v", err) + } + defer func() { _ = sqlite.Close() }() + + m := &Main{ + Config: cfg, + Providers: map[string]provider.Client{ + "tidal": &fakeVideoProvider{url: ts.URL}, + }, + Store: sqlite, + DL: download.New(), + Tagger: noopTagger{}, + Pending: nil, + Media: nil, + } + + ctx := context.Background() + if err = m.AddByID(ctx, "tidal", "video", "v1"); err != nil { + t.Fatalf("AddByID() error = %v", err) + } + if err = m.Resolve(ctx); err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if err = m.Rip(ctx); err != nil { + t.Fatalf("Rip() error = %v", err) + } + + if _, err = os.Stat(filepath.Join(tmp, "Live Clip.mp4")); err != nil { + t.Fatalf("expected downloaded video file: %v", err) + } + + ok, err := sqlite.IsDownloaded(ctx, "tidal", "v1") + if err != nil { + t.Fatalf("IsDownloaded() error = %v", err) + } + if !ok { + t.Fatalf("expected video marked downloaded") + } +} + +func TestResolveAllFailedReturnsError(t *testing.T) { + tmp := t.TempDir() + d := config.DefaultConfigData() + d.Downloads.Folder = tmp + cfg := &config.Config{File: d, Session: d} + + m := &Main{ + Config: cfg, + Providers: map[string]provider.Client{ + "qobuz": &fakeFailProvider{}, + }, + Store: store.NewDummy(), + DL: download.New(), + Tagger: noopTagger{}, + Pending: nil, + Media: nil, + } + + ctx := context.Background() + if err := m.AddByID(ctx, "qobuz", "track", "x"); err != nil { + t.Fatalf("AddByID() error = %v", err) + } + if err := m.Resolve(ctx); err == nil { + t.Fatalf("expected Resolve() to return error when all items fail") + } +} + func TestPlaylistRipPipeline(t *testing.T) { tmp := t.TempDir() diff --git a/internal/artwork/artwork.go b/internal/artwork/artwork.go index 4499408..47683cc 100644 --- a/internal/artwork/artwork.go +++ b/internal/artwork/artwork.go @@ -65,7 +65,7 @@ func Prepare(ctx context.Context, dl Downloader, folder string, albumMeta map[st } if cfg.Embed && embedURL != "" { - embedDir := filepath.Join(folder, "__artwork") + embedDir := sessionEmbedDir(folder) if err := os.MkdirAll(embedDir, 0o755); err == nil { registerTempDir(embedDir) embedPath := filepath.Join(embedDir, embedFilename(embedURL)) @@ -134,6 +134,11 @@ func embedFilename(url string) string { return fmt.Sprintf("cover%x.jpg", s[:8]) } +func sessionEmbedDir(folder string) string { + key := sha1.Sum([]byte(folder)) + return filepath.Join(os.TempDir(), "streamrip-go-artwork", fmt.Sprintf("%x", key[:8])) +} + func stringAny(v any) string { s, _ := v.(string) return s diff --git a/internal/download/downloader.go b/internal/download/downloader.go index 402f410..1e878c3 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strings" "sync/atomic" - "time" "github.com/vbauerster/mpb/v8" "github.com/vbauerster/mpb/v8/decor" @@ -38,7 +37,7 @@ func NewWithVerifySSL(verifySSL bool) *Downloader { func NewWithOptions(verifySSL bool, showProgress bool) *Downloader { forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true") interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb")) - d := &Downloader{http: netutil.NewHTTPClient(2*time.Minute, verifySSL), showProgress: interactive} + d := &Downloader{http: netutil.NewHTTPClient(0, verifySSL), showProgress: interactive} if interactive { d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr)) } @@ -46,14 +45,18 @@ func NewWithOptions(verifySSL bool, showProgress bool) *Downloader { } func (d *Downloader) File(ctx context.Context, sourceURL, outputPath string) error { - return d.file(ctx, sourceURL, outputPath, true) + return d.file(ctx, sourceURL, outputPath, true, false) } func (d *Downloader) FileNoProgress(ctx context.Context, sourceURL, outputPath string) error { - return d.file(ctx, sourceURL, outputPath, false) + return d.file(ctx, sourceURL, outputPath, false, false) } -func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool) error { +func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string) error { + return d.file(ctx, sourceURL, outputPath, true, true) +} + +func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error { if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return err } @@ -77,7 +80,7 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all peek, _ := reader.Peek(1024) if isManifestResponse(resp.Header.Get("Content-Type"), peek) { _ = resp.Body.Close() - return d.streamManifestWithFFmpeg(ctx, sourceURL, outputPath) + return d.streamManifestWithFFmpeg(ctx, sourceURL, outputPath, includeVideo) } out, err := os.Create(outputPath) @@ -162,7 +165,7 @@ func shortenName(name string, max int) string { return string(r[:max-3]) + "..." } -func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string) error { +func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string, includeVideo bool) error { if _, err := exec.LookPath("ffmpeg"); err != nil { return fmt.Errorf("ffmpeg not found for manifest stream: %w", err) } @@ -171,10 +174,13 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou "-y", "-protocol_whitelist", "file,http,https,tcp,tls,crypto,data", "-i", sourceURL, - "-map", "0:a:0", - "-c", "copy", - outputPath, } + if includeVideo { + args = append(args, "-map", "0") + } else { + args = append(args, "-map", "0:a:0") + } + args = append(args, "-c", "copy", outputPath) cmd := exec.CommandContext(ctx, "ffmpeg", args...) output, err := cmd.CombinedOutput() diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go index 531f523..f45bf7d 100644 --- a/internal/download/downloader_test.go +++ b/internal/download/downloader_test.go @@ -9,6 +9,13 @@ import ( "testing" ) +func TestDownloaderHasNoClientTimeout(t *testing.T) { + d := NewWithOptions(true, false) + if d.http.Timeout != 0 { + t.Fatalf("http timeout = %v, want 0 (no global timeout)", d.http.Timeout) + } +} + func TestDownloaderFile(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("abc123")) diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go new file mode 100644 index 0000000..ba702e3 --- /dev/null +++ b/internal/provider/deezer/client.go @@ -0,0 +1,451 @@ +package deezer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "strconv" + "strings" + "time" + + "streamrip-go/internal/config" + "streamrip-go/internal/netutil" + "streamrip-go/internal/provider" + "streamrip-go/internal/ratelimit" +) + +var baseURL = "https://api.deezer.com" + +type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error) + +type Client struct { + cfg *config.Config + http *http.Client + limiter *ratelimit.Limiter + loggedIn bool + bin string + run commandRunner +} + +func New(cfg *config.Config) *Client { + return &Client{ + cfg: cfg, + http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), + limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), + bin: "yt-dlp", + run: runCommand, + } +} + +func (c *Client) Source() string { + return "deezer" +} + +func (c *Client) Login(context.Context) error { + c.loggedIn = true + return nil +} + +func (c *Client) LoggedIn() bool { + return c.loggedIn +} + +func (c *Client) Close() error { + 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("deezer client not logged in") + } + if limit <= 0 { + limit = 25 + } + + pathType := mediaType + if mediaType == "playlist" { + pathType = "playlist" + } + params := url.Values{} + params.Set("q", query) + params.Set("limit", strconv.Itoa(limit)) + + resp, err := c.apiGet(ctx, "/search/"+pathType, params) + if err != nil { + return nil, err + } + data, _ := resp["data"].([]any) + if len(data) == 0 { + return []map[string]any{}, nil + } + + bucket := map[string]any{"items": data} + return []map[string]any{{mediaType + "s": bucket}}, nil +} + +func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) { + if !c.loggedIn { + return nil, errors.New("deezer client not logged in") + } + + switch mediaType { + case "track": + resp, err := c.apiGet(ctx, "/track/"+item, nil) + if err != nil { + return nil, err + } + enrichTrack(resp) + return resp, nil + case "album": + resp, err := c.apiGet(ctx, "/album/"+item, nil) + if err != nil { + return nil, err + } + items := make([]any, 0) + if tracks, ok := resp["tracks"].(map[string]any); ok { + if data, ok := tracks["data"].([]any); ok { + for _, raw := range data { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + enrichTrack(itm) + items = append(items, itm) + } + } + } + resp["tracks"] = map[string]any{"items": items} + enrichAlbumImage(resp) + return resp, nil + case "playlist": + resp, err := c.apiGet(ctx, "/playlist/"+item, nil) + if err != nil { + return nil, err + } + items := make([]any, 0) + if tracks, ok := resp["tracks"].(map[string]any); ok { + if data, ok := tracks["data"].([]any); ok { + for _, raw := range data { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + enrichTrack(itm) + items = append(items, itm) + } + } + } + resp["tracks"] = map[string]any{"items": items} + return resp, nil + case "artist": + resp, err := c.apiGet(ctx, "/artist/"+item+"/albums", nil) + if err != nil { + return nil, err + } + albums := make([]any, 0) + if data, ok := resp["data"].([]any); ok { + for _, raw := range data { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + enrichAlbumImage(itm) + albums = append(albums, itm) + } + } + return map[string]any{"name": "", "albums": map[string]any{"items": albums}}, nil + default: + return nil, fmt.Errorf("unsupported deezer media type: %s", mediaType) + } +} + +func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) { + meta, err := c.GetMetadata(ctx, item, "track") + if err != nil { + return nil, err + } + if c.shouldTryYtDlp() { + d, dlErr := c.getDownloadableViaYtDlp(ctx, item, meta) + if dlErr == nil { + return d, nil + } + if !c.cfg.Session.Deezer.LowerQualityIfNotAvailable { + return nil, dlErr + } + } + preview := strings.TrimSpace(stringFromAny(meta["preview"])) + if preview == "" { + return nil, errors.New("deezer track missing preview url") + } + return &provider.Downloadable{URL: preview, Extension: "mp3", Source: "deezer"}, nil +} + +func (c *Client) shouldTryYtDlp() bool { + if c.cfg == nil { + return false + } + if c.cfg.Session.Deezer.UseDeezloader { + return true + } + return strings.TrimSpace(c.cfg.Session.Deezer.ARL) != "" +} + +func (c *Client) getDownloadableViaYtDlp(ctx context.Context, trackID string, meta map[string]any) (*provider.Downloadable, error) { + if _, err := exec.LookPath(c.bin); err != nil { + return nil, fmt.Errorf("yt-dlp not found for deezer full-quality mode: %w", err) + } + + target := strings.TrimSpace(stringFromAny(meta["link"])) + if target == "" { + target = "https://www.deezer.com/track/" + trackID + } + args := []string{"-J", "--no-playlist", "--skip-download", "--no-warnings"} + if arl := strings.TrimSpace(c.cfg.Session.Deezer.ARL); arl != "" { + args = append(args, "--add-header", "Cookie: arl="+arl) + } + args = append(args, target) + b, err := c.run(ctx, c.bin, args...) + if err != nil { + return nil, err + } + info := map[string]any{} + if err = json.Unmarshal(b, &info); err != nil { + return nil, err + } + f := selectDeezerFormat(info, c.cfg.Session.Deezer.Quality) + if f.url == "" { + return nil, errors.New("yt-dlp output missing downloadable format url") + } + ext := f.ext + if ext == "" { + ext = "mp3" + } + return &provider.Downloadable{URL: f.url, Extension: ext, Source: "deezer"}, nil +} + +type deezerFormat struct { + url string + ext string + abr int +} + +func selectDeezerFormat(info map[string]any, quality int) deezerFormat { + formats, _ := info["formats"].([]any) + selected := deezerFormat{} + + pick := func(candidate deezerFormat, better func(cur, next deezerFormat) bool) { + if candidate.url == "" { + return + } + if selected.url == "" || better(selected, candidate) { + selected = candidate + } + } + + for _, raw := range formats { + m, ok := raw.(map[string]any) + if !ok { + continue + } + if strings.TrimSpace(stringFromAny(m["vcodec"])) != "none" { + continue + } + cand := deezerFormat{ + url: strings.TrimSpace(stringFromAny(m["url"])), + ext: strings.TrimSpace(stringFromAny(m["ext"])), + abr: intFromAny(m["abr"]), + } + if quality >= 2 { + pick(cand, func(cur, next deezerFormat) bool { + curFlac := strings.EqualFold(cur.ext, "flac") + nextFlac := strings.EqualFold(next.ext, "flac") + if curFlac != nextFlac { + return nextFlac + } + return next.abr > cur.abr + }) + continue + } + if quality == 1 { + pick(cand, func(cur, next deezerFormat) bool { + curScore := abrScore(cur.abr, 320) + nextScore := abrScore(next.abr, 320) + if curScore == nextScore { + return next.abr > cur.abr + } + return nextScore > curScore + }) + continue + } + pick(cand, func(cur, next deezerFormat) bool { + curScore := abrScore(cur.abr, 128) + nextScore := abrScore(next.abr, 128) + if curScore == nextScore { + if cur.abr == 0 { + return next.abr > 0 + } + if next.abr == 0 { + return false + } + return next.abr < cur.abr + } + return nextScore > curScore + }) + } + + if selected.url != "" { + return selected + } + + rootURL := strings.TrimSpace(stringFromAny(info["url"])) + if rootURL == "" { + return deezerFormat{} + } + return deezerFormat{url: rootURL, ext: strings.TrimSpace(stringFromAny(info["ext"])), abr: intFromAny(info["abr"])} +} + +func abrScore(abr int, target int) int { + if abr <= 0 { + return -1 + } + if abr > target { + return target - (abr-target)*2 + } + return abr +} + +func (c *Client) apiGet(ctx context.Context, path string, params url.Values) (map[string]any, error) { + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + + u := strings.TrimSuffix(baseURL, "/") + "/" + strings.TrimPrefix(path, "/") + if len(params) > 0 { + u += "?" + params.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "streamrip-go/0.1") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + out := map[string]any{} + if len(body) > 0 { + if err = json.Unmarshal(body, &out); err != nil { + return nil, err + } + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("deezer api failed: status=%d body=%s", resp.StatusCode, string(body)) + } + if e := stringFromAny(out["error"]); e != "" { + return nil, fmt.Errorf("deezer api error: %s", e) + } + return out, nil +} + +func enrichTrack(track map[string]any) { + if artist, ok := track["artist"].(map[string]any); ok { + track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])} + } + if album, ok := track["album"].(map[string]any); ok { + enrichAlbumImage(album) + } + if _, ok := track["track_number"]; !ok { + if p := track["track_position"]; p != nil { + track["track_number"] = p + } + } + if _, ok := track["media_number"]; !ok { + if d := track["disk_number"]; d != nil { + track["media_number"] = d + } + } + if v := stringFromAny(track["explicit_lyrics"]); v == "true" { + track["explicit"] = true + } +} + +func enrichAlbumImage(meta map[string]any) { + if _, ok := meta["image"].(map[string]any); ok { + return + } + cover := firstNonEmpty( + stringFromAny(meta["cover_xl"]), + stringFromAny(meta["cover_big"]), + stringFromAny(meta["cover_medium"]), + stringFromAny(meta["cover_small"]), + ) + if cover == "" { + return + } + meta["image"] = map[string]any{ + "small": cover, + "large": cover, + "extralarge": cover, + "original": cover, + } +} + +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 firstNonEmpty(items ...string) string { + for _, item := range items { + if strings.TrimSpace(item) != "" { + return strings.TrimSpace(item) + } + } + 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 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 +} diff --git a/internal/provider/deezer/client_test.go b/internal/provider/deezer/client_test.go new file mode 100644 index 0000000..dc12a88 --- /dev/null +++ b/internal/provider/deezer/client_test.go @@ -0,0 +1,66 @@ +package deezer + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "streamrip-go/internal/config" +) + +func TestSearchTrack(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/search/track": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 1, "title": "Dreams", "artist": map[string]any{"name": "Fleetwood Mac"}}}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + + orig := baseURL + baseURL = ts.URL + defer func() { baseURL = orig }() + + pages, err := c.Search(context.Background(), "track", "dreams", 5) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if len(pages) != 1 { + t.Fatalf("pages len = %d, want 1", len(pages)) + } +} + +func TestGetDownloadableUsesPreview(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/track/42": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "X", "preview": "https://cdn.example/p.mp3"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + orig := baseURL + baseURL = ts.URL + defer func() { baseURL = orig }() + + d, err := c.GetDownloadable(context.Background(), "42", 0) + if err != nil { + t.Fatalf("GetDownloadable() error = %v", err) + } + if d.URL != "https://cdn.example/p.mp3" || d.Extension != "mp3" { + t.Fatalf("unexpected downloadable: %+v", d) + } +} diff --git a/internal/provider/soundcloud/client.go b/internal/provider/soundcloud/client.go new file mode 100644 index 0000000..15a6a58 --- /dev/null +++ b/internal/provider/soundcloud/client.go @@ -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 +} diff --git a/internal/provider/soundcloud/client_test.go b/internal/provider/soundcloud/client_test.go new file mode 100644 index 0000000..4219742 --- /dev/null +++ b/internal/provider/soundcloud/client_test.go @@ -0,0 +1,106 @@ +package soundcloud + +import ( + "context" + "fmt" + "strings" + "testing" + + "streamrip-go/internal/config" +) + +func TestGetTrackMetadataAndDownloadable(t *testing.T) { + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + if strings.Contains(joined, "--no-playlist") { + return []byte(`{"title":"Lean On","uploader":"Major Lazer","url":"https://cdn.example/audio.m4a","ext":"m4a","thumbnail":"https://img.example/cover.jpg"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + } + + meta, err := c.GetMetadata(context.Background(), "https://soundcloud.com/a/b", "track") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + if stringFromAny(meta["title"]) != "Lean On" { + t.Fatalf("title = %q, want Lean On", stringFromAny(meta["title"])) + } + + d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0) + if err != nil { + t.Fatalf("GetDownloadable() error = %v", err) + } + if d.URL != "https://cdn.example/audio.m4a" || d.Extension != "m4a" { + t.Fatalf("unexpected downloadable: %+v", d) + } +} + +func TestGetPlaylistMetadata(t *testing.T) { + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + if strings.Contains(joined, "--skip-download") && !strings.Contains(joined, "--no-playlist") { + return []byte(`{"title":"Road Trip","entries":[{"webpage_url":"https://soundcloud.com/a/t1"},{"url":"https://soundcloud.com/a/t2"}]}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + } + + meta, err := c.GetMetadata(context.Background(), "https://soundcloud.com/a/sets/road-trip", "playlist") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + if stringFromAny(meta["name"]) != "Road Trip" { + t.Fatalf("name = %q, want Road Trip", stringFromAny(meta["name"])) + } + tracksMap, ok := meta["tracks"].(map[string]any) + if !ok { + t.Fatalf("tracks missing") + } + items := asAnySlice(tracksMap["items"]) + if len(items) != 2 { + t.Fatalf("playlist items len = %d, want 2", len(items)) + } +} + +func TestSearchTrack(t *testing.T) { + cfgData := config.DefaultConfigData() + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.loggedIn = true + c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + if strings.Contains(joined, "scsearch2:lean on") { + return []byte(`{"entries":[{"title":"Lean On","uploader":"Major Lazer","webpage_url":"https://soundcloud.com/a/b"}]}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + } + + pages, err := c.Search(context.Background(), "track", "lean on", 2) + 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}) + c.bin = "definitely-not-a-real-yt-dlp-bin" + err := c.Login(context.Background()) + if err == nil { + t.Fatalf("expected login error") + } + if !strings.Contains(strings.ToLower(err.Error()), "yt-dlp is required") { + t.Fatalf("expected yt-dlp hint in error, got: %v", err) + } +} diff --git a/internal/provider/tidal/client.go b/internal/provider/tidal/client.go index c3e3fa5..b2a1192 100644 --- a/internal/provider/tidal/client.go +++ b/internal/provider/tidal/client.go @@ -381,6 +381,70 @@ func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID s return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, nil } +func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) { + if !c.loggedIn { + return nil, errors.New("tidal client not logged in") + } + + params := url.Values{} + params.Set("videoquality", "HIGH") + params.Set("playbackmode", "STREAM") + params.Set("assetpresentation", "FULL") + + resp, status, err := c.apiRequest(ctx, "videos/"+videoID+"/playbackinfopostpaywall", params, c.baseURL) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("tidal video playbackinfo failed: status=%d", status) + } + + manifestB64 := stringify(resp["manifest"]) + if manifestB64 == "" { + return nil, errors.New("tidal video manifest missing") + } + b, err := base64.StdEncoding.DecodeString(manifestB64) + if err != nil { + return nil, fmt.Errorf("decode video manifest: %w", err) + } + manifest := map[string]any{} + if err = json.Unmarshal(b, &manifest); err != nil { + return nil, fmt.Errorf("parse video manifest json: %w", err) + } + urls, ok := manifest["urls"].([]any) + if !ok || len(urls) == 0 { + return nil, errors.New("tidal video manifest urls missing") + } + masterURL := stringify(urls[0]) + if masterURL == "" { + return nil, errors.New("tidal video master url missing") + } + + if err = c.limiter.Wait(ctx); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "streamrip-go/0.1") + respHTTP, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = respHTTP.Body.Close() }() + if respHTTP.StatusCode < 200 || respHTTP.StatusCode >= 300 { + return nil, fmt.Errorf("tidal video playlist fetch failed: status=%d", respHTTP.StatusCode) + } + body, err := io.ReadAll(respHTTP.Body) + if err != nil { + return nil, err + } + + streamURL := bestHLSVariantURL(masterURL, string(body)) + return &provider.Downloadable{URL: streamURL, Extension: "mp4", Source: "tidal"}, nil +} + func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadable { manifestB64 := stringify(resp["manifest"]) if manifestB64 == "" { @@ -410,6 +474,41 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal"} } +func bestHLSVariantURL(masterURL, playlist string) string { + lines := strings.Split(strings.ReplaceAll(playlist, "\r\n", "\n"), "\n") + best := strings.TrimSpace(masterURL) + for i := 0; i < len(lines)-1; i++ { + line := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(line, "#EXT-X-STREAM-INF:") { + continue + } + if strings.Contains(strings.ToLower(line), "codecs=\"jpeg") { + continue + } + next := strings.TrimSpace(lines[i+1]) + if next == "" || strings.HasPrefix(next, "#") { + continue + } + best = resolvePlaylistURL(masterURL, next) + } + return best +} + +func resolvePlaylistURL(baseRaw, refRaw string) string { + if strings.HasPrefix(refRaw, "http://") || strings.HasPrefix(refRaw, "https://") { + return refRaw + } + baseURL, err := url.Parse(baseRaw) + if err != nil { + return refRaw + } + refURL, err := url.Parse(refRaw) + if err != nil { + return refRaw + } + return baseURL.ResolveReference(refURL).String() +} + func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) { if err := c.limiter.Wait(ctx); err != nil { return nil, 0, err diff --git a/internal/provider/tidal/client_test.go b/internal/provider/tidal/client_test.go index 796034e..6a0ea1f 100644 --- a/internal/provider/tidal/client_test.go +++ b/internal/provider/tidal/client_test.go @@ -2,6 +2,7 @@ package tidal import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -50,3 +51,50 @@ func TestSearch(t *testing.T) { t.Fatalf("pages = %d", len(pages)) } } + +func TestGetVideoDownloadable(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/sessions": + _ = json.NewEncoder(w).Encode(map[string]any{"countryCode": "US", "userId": 123}) + case "/v1/videos/42/playbackinfopostpaywall": + manifest := map[string]any{"urls": []string{server.URL + "/master.m3u8"}} + b, _ := json.Marshal(manifest) + _ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)}) + case "/master.m3u8": + _, _ = w.Write([]byte("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,CODECS=\"avc1.42E01E,mp4a.40.2\",RESOLUTION=640x360\nlow/stream.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000,CODECS=\"avc1.4D401F,mp4a.40.2\",RESOLUTION=1280x720\nhi/stream.m3u8\n")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + cfgData := config.DefaultConfigData() + cfgData.Tidal.AccessToken = "token" + cfgData.Tidal.CountryCode = "US" + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.baseURL = server.URL + "/v1" + + if err := c.Login(context.Background()); err != nil { + t.Fatalf("login err = %v", err) + } + d, err := c.GetVideoDownloadable(context.Background(), "42") + if err != nil { + t.Fatalf("GetVideoDownloadable() err = %v", err) + } + if d.Extension != "mp4" { + t.Fatalf("extension = %q, want mp4", d.Extension) + } + if d.URL != server.URL+"/hi/stream.m3u8" { + t.Fatalf("url = %q, want %q", d.URL, server.URL+"/hi/stream.m3u8") + } +} + +func TestBestHLSVariantURLFallsBackToMaster(t *testing.T) { + master := "https://example.com/master.m3u8" + got := bestHLSVariantURL(master, "#EXTM3U\n#comment") + if got != master { + t.Fatalf("url = %q, want %q", got, master) + } +} diff --git a/internal/urlparse/parse.go b/internal/urlparse/parse.go index 04ce42f..8f41b3b 100644 --- a/internal/urlparse/parse.go +++ b/internal/urlparse/parse.go @@ -174,7 +174,7 @@ func isDeezerHost(host string) bool { func isSupportedMedia(mediaType string) bool { switch mediaType { - case "album", "track", "playlist", "artist", "label": + case "album", "track", "playlist", "artist", "label", "video": return true default: return false diff --git a/internal/urlparse/parse_test.go b/internal/urlparse/parse_test.go index bce8f8e..3fbde36 100644 --- a/internal/urlparse/parse_test.go +++ b/internal/urlparse/parse_test.go @@ -38,6 +38,17 @@ func TestTidalTrackURL(t *testing.T) { } } +func TestTidalVideoURL(t *testing.T) { + url := "https://tidal.com/browse/video/59727844" + result := Parse(url) + if result == nil { + t.Fatalf("expected parsed url") + } + if result.Source != "tidal" || result.MediaType != "video" || result.ID != "59727844" { + t.Fatalf("unexpected parse result: %+v", result) + } +} + func TestDeezerTrackURL(t *testing.T) { url := "https://www.deezer.com/track/4195713" result := Parse(url)