mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
add CLI parity flags and expand provider support
This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
This commit is contained in:
383
cmd/rip/main.go
383
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 <command>")
|
||||
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 <source> <track|album|playlist|artist|label> <id> [quality] [--force|--ignore-db]")
|
||||
fmt.Println("usage: rip id <source> <track|album|playlist|artist|label|video> <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 <qobuz|tidal> <track|album|playlist|artist|label> <query...> [--limit N] [--force|--ignore-db] [--no-download]")
|
||||
fmt.Println("usage: rip search <qobuz|tidal|deezer|soundcloud> <track|album|playlist|artist|label|video> <query...> [--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 <soundcloud_url>")
|
||||
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 <track_id> [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 <video_id>")
|
||||
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 <track_id> [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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
451
internal/provider/deezer/client.go
Normal file
451
internal/provider/deezer/client.go
Normal file
@@ -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
|
||||
}
|
||||
66
internal/provider/deezer/client_test.go
Normal file
66
internal/provider/deezer/client_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
348
internal/provider/soundcloud/client.go
Normal file
348
internal/provider/soundcloud/client.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package soundcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/provider"
|
||||
)
|
||||
|
||||
var errUnsupportedMediaType = errors.New("unsupported soundcloud media type")
|
||||
|
||||
type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
loggedIn bool
|
||||
bin string
|
||||
run commandRunner
|
||||
mu sync.Mutex
|
||||
cache map[string]map[string]any
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
bin: "yt-dlp",
|
||||
run: runCommand,
|
||||
cache: map[string]map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Source() string {
|
||||
return "soundcloud"
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
func (c *Client) Login(context.Context) error {
|
||||
if _, err := exec.LookPath(c.bin); err != nil {
|
||||
return fmt.Errorf("yt-dlp is required for soundcloud downloads/search. install it and ensure it is in $PATH (e.g. pipx install yt-dlp): %w", err)
|
||||
}
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("soundcloud client not logged in")
|
||||
}
|
||||
if mediaType != "track" {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType)
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("scsearch%d:%s", limit, query)
|
||||
b, err := c.run(ctx, c.bin, "-J", "--flat-playlist", "--skip-download", "--no-warnings", target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := parseJSONMap(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := asAnySlice(root["entries"])
|
||||
if len(entries) == 0 {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
items := make([]any, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
m, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(stringFromAny(m["webpage_url"]))
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(stringFromAny(m["url"]))
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
artist := strings.TrimSpace(stringFromAny(m["uploader"]))
|
||||
if artist == "" {
|
||||
artist = strings.TrimSpace(stringFromAny(m["channel"]))
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": id,
|
||||
"title": stringFromAny(m["title"]),
|
||||
"artist": map[string]any{
|
||||
"name": artist,
|
||||
},
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return []map[string]any{{"items": items}}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("soundcloud client not logged in")
|
||||
}
|
||||
|
||||
switch mediaType {
|
||||
case "track":
|
||||
info, err := c.trackInfo(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return trackMetadataFromInfo(item, info), nil
|
||||
case "playlist":
|
||||
b, err := c.run(ctx, c.bin, "-J", "--skip-download", "--no-warnings", item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := parseJSONMap(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks := make([]any, 0)
|
||||
for _, raw := range asAnySlice(root["entries"]) {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(stringFromAny(entry["webpage_url"]))
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(stringFromAny(entry["url"]))
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
tracks = append(tracks, map[string]any{"id": id})
|
||||
}
|
||||
name := strings.TrimSpace(stringFromAny(root["title"]))
|
||||
if name == "" {
|
||||
name = "SoundCloud Playlist"
|
||||
}
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"tracks": map[string]any{"items": tracks},
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("soundcloud client not logged in")
|
||||
}
|
||||
info, err := c.trackInfo(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
streamURL := strings.TrimSpace(stringFromAny(info["url"]))
|
||||
if streamURL == "" {
|
||||
return nil, errors.New("yt-dlp output missing url")
|
||||
}
|
||||
ext := strings.TrimSpace(stringFromAny(info["ext"]))
|
||||
if ext == "" {
|
||||
ext = "m4a"
|
||||
}
|
||||
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "soundcloud"}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, error) {
|
||||
if strings.TrimSpace(item) == "" {
|
||||
return nil, errors.New("empty soundcloud item")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if cached, ok := c.cache[item]; ok {
|
||||
copied := cloneMap(cached)
|
||||
c.mu.Unlock()
|
||||
return copied, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
b, err := c.run(ctx, c.bin, "-J", "--no-playlist", "--skip-download", "--no-warnings", item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := parseJSONMap(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.cache[item] = cloneMap(info)
|
||||
c.mu.Unlock()
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
|
||||
title := strings.TrimSpace(stringFromAny(info["title"]))
|
||||
if title == "" {
|
||||
title = id
|
||||
}
|
||||
artistName := strings.TrimSpace(stringFromAny(info["artist"]))
|
||||
if artistName == "" {
|
||||
artistName = strings.TrimSpace(stringFromAny(info["uploader"]))
|
||||
}
|
||||
if artistName == "" {
|
||||
artistName = strings.TrimSpace(stringFromAny(info["channel"]))
|
||||
}
|
||||
|
||||
trackNum := intFromAny(info["track_number"])
|
||||
if trackNum <= 0 {
|
||||
trackNum = 1
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"id": id,
|
||||
"title": title,
|
||||
"track_number": trackNum,
|
||||
"artist": map[string]any{"name": artistName},
|
||||
"performer": map[string]any{"name": artistName},
|
||||
"album": map[string]any{
|
||||
"id": strings.TrimSpace(stringFromAny(info["album"])),
|
||||
"title": strings.TrimSpace(stringFromAny(info["album"])),
|
||||
"artist": map[string]any{"name": artistName},
|
||||
},
|
||||
"description": strings.TrimSpace(stringFromAny(info["description"])),
|
||||
"genre": strings.TrimSpace(stringFromAny(info["genre"])),
|
||||
"release_date": strings.TrimSpace(firstNonEmpty(
|
||||
stringFromAny(info["release_date"]),
|
||||
stringFromAny(info["upload_date"]),
|
||||
)),
|
||||
}
|
||||
|
||||
if meta["release_date"] == "" {
|
||||
delete(meta, "release_date")
|
||||
}
|
||||
|
||||
if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" {
|
||||
meta["image"] = map[string]any{
|
||||
"small": thumb,
|
||||
"large": thumb,
|
||||
"extralarge": thumb,
|
||||
"original": thumb,
|
||||
}
|
||||
}
|
||||
|
||||
if album := strings.TrimSpace(stringFromAny(info["album"])); album == "" {
|
||||
meta["album"] = map[string]any{
|
||||
"id": id,
|
||||
"title": title,
|
||||
"artist": map[string]any{"name": artistName},
|
||||
}
|
||||
}
|
||||
|
||||
if durationSec := intFromAny(info["duration"]); durationSec > 0 {
|
||||
meta["duration"] = durationSec
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func parseJSONMap(b []byte) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
return nil, errors.New("empty json payload")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asAnySlice(v any) []any {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func stringFromAny(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t
|
||||
case int64:
|
||||
return int(t)
|
||||
case float64:
|
||||
return int(t)
|
||||
case string:
|
||||
i, _ := strconv.Atoi(strings.TrimSpace(t))
|
||||
return i
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(items ...string) string {
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) != "" {
|
||||
return strings.TrimSpace(item)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
b, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("command %s failed: %w: %s", name, err, string(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
106
internal/provider/soundcloud/client_test.go
Normal file
106
internal/provider/soundcloud/client_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user