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)