diff --git a/cmd/rip/cmd_config.go b/cmd/rip/cmd_config.go new file mode 100644 index 0000000..915ca4c --- /dev/null +++ b/cmd/rip/cmd_config.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "streamrip-go/internal/config" + + "golang.org/x/term" +) + +// Commands that inspect local state: config and database. + +func cmdConfig(_ context.Context, cfg *config.Config, _ globalOptions, args []string) int { + var err error + if len(args) < 1 { + printCommandHelp(os.Stdout, "config") + return 2 + } + switch args[0] { + case "open": + vim := false + for _, arg := range args[1:] { + switch arg { + case "-v", "--vim": + vim = true + default: + fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) + return 2 + } + } + fmt.Printf("opening file at %s\n", cfg.Path) + if err = openConfigInEditor(cfg.Path, vim); err != nil { + fmt.Fprintf(os.Stderr, "open config error: %v\n", err) + return 1 + } + case "reset": + yes := false + for _, arg := range args[1:] { + switch arg { + case "-y", "--yes": + yes = true + default: + fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) + return 2 + } + } + if !yes { + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprintln(os.Stderr, "reset requires --yes in non-interactive mode") + return 2 + } + ok, askErr := promptYesNo(fmt.Sprintf("Are you sure you want to reset the config file at %s? [y/N]: ", cfg.Path)) + if askErr != nil { + fmt.Fprintf(os.Stderr, "prompt error: %v\n", askErr) + return 1 + } + if !ok { + fmt.Println("reset aborted") + return 0 + } + } + def := config.DefaultConfigData() + // Separate copies: assigning the same value to both halves made + // File.Qobuz.Secrets and Session.Qobuz.Secrets share one backing + // array. + cfg.File = config.CloneConfigData(def) + cfg.Session = config.CloneConfigData(def) + if err = cfg.SaveFile(); err != nil { + fmt.Fprintf(os.Stderr, "reset config error: %v\n", err) + return 1 + } + fmt.Printf("reset the config file at %s\n", cfg.Path) + case "path": + if len(args) > 1 { + fmt.Fprintf(os.Stderr, "option error: unexpected argument %q\n", args[1]) + return 2 + } + fmt.Printf("config path: '%s'\n", cfg.Path) + default: + fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0]) + return 2 + } + + return 0 +} + +func cmdDatabase(_ context.Context, cfg *config.Config, _ globalOptions, args []string) int { + if len(args) < 2 || args[0] != "browse" { + printCommandHelp(os.Stdout, "database") + return 2 + } + table := strings.ToLower(strings.TrimSpace(args[1])) + switch table { + case "downloads": + rows, listErr := listDownloadsRows(cfg.Session.Database.DownloadsPath) + if listErr != nil { + fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr) + return 1 + } + fmt.Println("downloads database") + fmt.Println("row id") + for i, id := range rows { + fmt.Printf("%02d %s\n", i, id) + } + case "failed": + rows, listErr := listFailedRows(cfg.Session.Database.FailedDownloadsPath) + if listErr != nil && isNoSuchTableErr(listErr) && cfg.Session.Database.FailedDownloadsPath != cfg.Session.Database.DownloadsPath { + rows, listErr = listFailedRows(cfg.Session.Database.DownloadsPath) + } + if listErr != nil { + fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr) + return 1 + } + fmt.Println("failed downloads database") + fmt.Println("row source media_type id") + for i, row := range rows { + fmt.Printf("%02d %s %s %s\n", i, row.Source, row.MediaType, row.ID) + } + default: + fmt.Fprintf(os.Stderr, "invalid database %q. choose downloads or failed\n", table) + return 2 + } + + return 0 +} diff --git a/cmd/rip/cmd_rip.go b/cmd/rip/cmd_rip.go new file mode 100644 index 0000000..39284a8 --- /dev/null +++ b/cmd/rip/cmd_rip.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "streamrip-go/internal/app" + "streamrip-go/internal/config" +) + +// Commands that queue media and rip it: url, file and id. + +func cmdURL(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int { + if len(args) < 1 { + printCommandHelp(os.Stdout, "url") + return 2 + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + + rawArgs := make([]string, 0, len(args)) + ignoreDB := false + for _, arg := range args { + if arg == "--force" || arg == "--ignore-db" { + ignoreDB = true + continue + } + rawArgs = append(rawArgs, arg) + } + mainApp.IgnoreDB = ignoreDB || gopts.noDB + + added := 0 + for _, raw := range rawArgs { + if addURLToQueue(ctx, mainApp, raw) { + added++ + } + } + + if added == 0 { + fmt.Println("nothing to rip") + return 0 + } + + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + fmt.Printf("url rip complete (%d item(s))\n", added) + + return 0 +} + +func cmdFile(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int { + if len(args) < 1 { + printCommandHelp(os.Stdout, "file") + return 2 + } + + ignoreDB := false + for _, arg := range args[1:] { + switch arg { + case "--force", "--ignore-db": + ignoreDB = true + default: + fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) + return 2 + } + } + + content, err := os.ReadFile(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "read file error: %v\n", err) + return 1 + } + + idItems, urls, repeated, jsonInput, err := parseFileInput(content) + if err != nil { + fmt.Fprintf(os.Stderr, "file parse error: %v\n", err) + return 2 + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = ignoreDB || gopts.noDB + + added := 0 + if jsonInput { + fmt.Printf("detected json file. loading %d item(s)\n", len(idItems)) + for _, item := range idItems { + if err = mainApp.AddByID(ctx, item.Source, item.MediaType, item.ID); err != nil { + fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", item.Source, item.MediaType, item.ID, err) + continue + } + added++ + } + } else { + if repeated > 0 { + fmt.Printf("found %d repeated url(s)\n", repeated) + } + fmt.Printf("detected list of urls. loading %d item(s)\n", len(urls)) + for _, raw := range urls { + if addURLToQueue(ctx, mainApp, raw) { + added++ + } + } + } + + if added == 0 { + fmt.Println("nothing to rip") + return 0 + } + + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + fmt.Printf("file rip complete (%d item(s))\n", added) + + return 0 +} + +func cmdID(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int { + if len(args) < 3 { + printCommandHelp(os.Stdout, "id") + return 2 + } + + source := strings.ToLower(strings.TrimSpace(args[0])) + mediaType := strings.ToLower(strings.TrimSpace(args[1])) + itemID := strings.TrimSpace(args[2]) + + opts, err := parseSmokeOptions(args[3:], 0, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + return 2 + } + if opts.qualitySet { + switch source { + case "qobuz": + if opts.quality < 1 { + fmt.Fprintf(os.Stderr, "quality error: qobuz quality must be 1-4\n") + return 2 + } + cfg.Session.Qobuz.Quality = opts.quality + case "tidal": + cfg.Session.Tidal.Quality = opts.quality + case "yandex": + cfg.Session.Yandex.Quality = opts.quality + case "beatport": + if opts.quality < 1 { + fmt.Fprintf(os.Stderr, "quality error: beatport quality must be 1-4\n") + return 2 + } + cfg.Session.Beatport.Quality = opts.quality + } + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + 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) + return 1 + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID) + + return 0 +} diff --git a/cmd/rip/cmd_search.go b/cmd/rip/cmd_search.go new file mode 100644 index 0000000..68aa046 --- /dev/null +++ b/cmd/rip/cmd_search.go @@ -0,0 +1,281 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "streamrip-go/internal/app" + "streamrip-go/internal/config" + + "golang.org/x/term" +) + +// Commands that discover media before ripping it: search and lastfm. + +func cmdSearch(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int { + var err error + var source, mediaType string + var sopts searchOptions + if len(args) < 3 { + if !term.IsTerminal(int(os.Stdin.Fd())) { + printCommandHelp(os.Stdout, "search") + return 2 + } + source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) + if err != nil { + fmt.Fprintf(os.Stderr, "search prompt error: %v\n", err) + return 2 + } + } else { + source = strings.ToLower(strings.TrimSpace(args[0])) + mediaType = strings.ToLower(strings.TrimSpace(args[1])) + sopts, err = parseSearchArgs(args[2:], cfg.Session.CLI.MaxSearchResults) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "search option error: %v\n", err) + return 2 + } + if sopts.first && sopts.outputFile != "" { + fmt.Fprintln(os.Stderr, "cannot choose --first and --output-file together") + return 2 + } + if !isAllowedSearchSource(source) { + fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source) + return 2 + } + if !isAllowedMediaType(mediaType) { + fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType) + return 2 + } + if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" { + fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist") + return 2 + } + if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" { + fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist") + return 2 + } + if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" { + fmt.Fprintln(os.Stderr, "beatport search currently supports media types track, album, and label") + return 2 + } + if sopts.query == "" { + fmt.Fprintln(os.Stderr, "search query cannot be empty") + return 2 + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, source) + if err != nil { + fmt.Fprintf(os.Stderr, "%s login error: %v\n", source, err) + return 1 + } + + pages, err := provider.Search(ctx, mediaType, sopts.query, sopts.limit) + if err != nil { + fmt.Fprintf(os.Stderr, "search error: %v\n", err) + return 1 + } + + results := normalizeSearchResults(source, mediaType, pages) + if len(results) == 0 { + fmt.Println("no results") + return 0 + } + 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) + return 1 + } + fmt.Printf("wrote %d results to %s\n", len(results), sopts.outputFile) + return 0 + } + 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 0 + } + if !sopts.first { + fmt.Printf("results: %d\n", len(results)) + } + + if sopts.first { + selection := []int{0} + mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB + skippedDownloaded := 0 + added := 0 + for _, idx := range selection { + item := results[idx] + if !sopts.ignoreDB { + already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID) + if checkErr == nil && already { + skippedDownloaded++ + fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title) + continue + } + } + if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil { + fmt.Printf("add failed: id=%s err=%v\n", item.ID, err) + continue + } + added++ + } + if added == 0 { + if skippedDownloaded > 0 { + fmt.Println("selected item was already downloaded (use --force to redownload)") + } else { + fmt.Println("nothing selected to download") + } + return 0 + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + fmt.Printf("search download complete (%d item(s))\n", added) + return 0 + } + + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Println("non-interactive input; use `rip id` to download specific results") + return 0 + } + + selection, err := promptSearchSelectionMenu(source, mediaType, sopts.query, results) + if err != nil { + fmt.Fprintf(os.Stderr, "selection error: %v\n", err) + return 2 + } + if len(selection) == 0 { + fmt.Println("download cancelled") + return 0 + } + + mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB + skippedDownloaded := 0 + added := 0 + for _, idx := range selection { + item := results[idx] + if !sopts.ignoreDB { + already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID) + if checkErr == nil && already { + skippedDownloaded++ + fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title) + continue + } + } + if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil { + fmt.Printf("add failed: id=%s err=%v\n", item.ID, err) + continue + } + added++ + } + if added == 0 { + if skippedDownloaded > 0 { + fmt.Println("all selected items were already downloaded (use --force to redownload)") + } else { + fmt.Println("nothing selected to download") + } + return 0 + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) + return 1 + } + fmt.Printf("search download complete (%d item(s))\n", added) + + return 0 +} + +func cmdLastFM(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int { + opts, parseErr := parseLastFMArgs(args, cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource) + if parseErr != nil { + fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr) + printCommandHelp(os.Stdout, "lastfm") + return 2 + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = gopts.noDB + + title, tracks, err := fetchLastFMPlaylist(ctx, cfg.Session.Downloads.VerifySSL, opts.PlaylistURL) + if err != nil { + fmt.Fprintf(os.Stderr, "lastfm parse error: %v\n", err) + return 1 + } + if len(tracks) == 0 { + fmt.Println("no tracks found in playlist") + return 0 + } + fmt.Printf("lastfm playlist: %s (%d tracks)\n", title, len(tracks)) + + resolvedTracks, err := resolveLastFMTracks(ctx, mainApp, opts, tracks) + if err != nil { + fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err) + return 1 + } + if len(resolvedTracks) == 0 { + fmt.Println("no lastfm tracks resolved") + return 0 + } + + playlistID := fmt.Sprintf("lastfm:%s", strings.ToLower(strings.ReplaceAll(title, " ", "_"))) + refs := make([]app.PlaylistTrackRef, 0, len(resolvedTracks)) + for _, item := range resolvedTracks { + refs = append(refs, app.PlaylistTrackRef{Source: item.Source, ID: item.ID}) + } + if addErr := mainApp.AddMixedPlaylistByTrackRefs(ctx, playlistID, title, refs); addErr != nil { + fmt.Printf("playlist queue failed: err=%v\n", addErr) + fmt.Println("no lastfm playlists queued") + return 0 + } + fmt.Printf("queued lastfm playlist: %s (%d tracks)\n", title, len(refs)) + if len(refs) == 0 { + fmt.Println("no lastfm playlists queued") + return 0 + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + return 1 + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + return 1 + } + fmt.Printf("lastfm rip complete (%d track(s) across 1 playlist)\n", len(resolvedTracks)) + + return 0 +} diff --git a/cmd/rip/cmd_smoke.go b/cmd/rip/cmd_smoke.go new file mode 100644 index 0000000..bb3bd1d --- /dev/null +++ b/cmd/rip/cmd_smoke.go @@ -0,0 +1,309 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "streamrip-go/internal/app" + "streamrip-go/internal/config" + "streamrip-go/internal/provider" +) + +// Developer smoke commands, listed by "rip dev-help". These exercise a single +// provider call and print the result; they are not part of the supported CLI. + +func cmdSoundcloudSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int { + if len(args) < 1 { + fmt.Println("usage: rip soundcloud-smoke ") + return 2 + } + meta, err := fetchSoundcloudOEmbed(ctx, cfg.Session.Downloads.VerifySSL, strings.TrimSpace(args[0])) + if err != nil { + fmt.Fprintf(os.Stderr, "soundcloud smoke error: %v\n", err) + return 1 + } + fmt.Printf("soundcloud oembed ok: title=%q author=%q provider=%q\n", asString(meta["title"]), asString(meta["author_name"]), asString(meta["provider_name"])) + + return 0 +} + +func cmdQobuzSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int { + if len(args) < 1 { + fmt.Println("usage: rip qobuz-smoke [quality]") + return 2 + } + opts, err := parseSmokeOptions(args[1:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + return 2 + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz") + if err != nil { + fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err) + return 1 + } + + trackID := args[0] + meta, err := provider.GetMetadata(ctx, trackID, "track") + if err != nil { + fmt.Fprintf(os.Stderr, "metadata error: %v\n", err) + return 1 + } + + title, _ := meta["title"].(string) + d, err := provider.GetDownloadable(ctx, trackID, cfg.Session.Qobuz.Quality) + if err != nil { + fmt.Fprintf(os.Stderr, "downloadable error: %v\n", err) + return 1 + } + + fmt.Printf("qobuz ok: title=%q quality=%d ext=%s\n", title, cfg.Session.Qobuz.Quality, d.Extension) + fmt.Printf("stream_url=%s\n", d.URL) + + return 0 +} + +func cmdQobuzConvertRipSmoke(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int { + if len(args) < 2 { + fmt.Println("usage: rip qobuz-convert-rip-smoke [quality] [--force|--ignore-db]") + return 2 + } + opts, err := parseSmokeOptions(args[2:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + return 2 + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + cfg.Session.Conversion.Enabled = true + cfg.Session.Conversion.Codec = strings.ToUpper(strings.TrimSpace(args[1])) + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB + + trackID := args[0] + if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + return 1 + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + return 1 + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + return 1 + } + fmt.Printf("qobuz convert rip smoke complete (codec=%s)\n", cfg.Session.Conversion.Codec) + + return 0 +} + +func cmdQobuzSearchSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int { + if len(args) < 1 { + fmt.Println("usage: rip qobuz-search-smoke ") + return 2 + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz") + if err != nil { + fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err) + return 1 + } + + query := strings.Join(args, " ") + pages, err := provider.Search(ctx, "album", query, 10) + if err != nil { + fmt.Fprintf(os.Stderr, "search error: %v\n", err) + return 1 + } + for _, page := range pages { + albums, ok := page["albums"].(map[string]any) + if !ok { + continue + } + items, ok := albums["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + continue + } + id := asString(item["id"]) + title := asString(item["title"]) + version := asString(item["version"]) + bitDepth := asString(item["maximum_bit_depth"]) + sr := asString(item["maximum_sampling_rate"]) + if version != "" { + title = title + " (" + version + ")" + } + fmt.Printf("album_id=%s | %s | %sB-%skHz\n", id, title, bitDepth, sr) + } + } + + return 0 +} + +func cmdTidalSearchSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int { + if len(args) < 1 { + fmt.Println("usage: rip tidal-search-smoke ") + return 2 + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, "tidal") + if err != nil { + fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) + return 1 + } + + query := strings.Join(args, " ") + pages, err := provider.Search(ctx, "album", query, 10) + if err != nil { + fmt.Fprintf(os.Stderr, "search error: %v\n", err) + return 1 + } + for _, page := range pages { + items, ok := page["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + wrapper, ok := raw.(map[string]any) + if !ok { + continue + } + item, ok := wrapper["item"].(map[string]any) + if !ok { + item = wrapper + } + fmt.Printf("album_id=%s | %s\n", asString(item["id"]), asString(item["title"])) + } + } + + return 0 +} + +func cmdTidalMetadataSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int { + if len(args) < 2 { + fmt.Println("usage: rip tidal-metadata-smoke ") + return 2 + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, "tidal") + if err != nil { + fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) + return 1 + } + + mediaType := args[0] + itemID := args[1] + meta, err := provider.GetMetadata(ctx, itemID, mediaType) + if err != nil { + fmt.Fprintf(os.Stderr, "metadata error: %v\n", err) + return 1 + } + title := asString(meta["title"]) + if title == "" { + title = asString(meta["name"]) + } + trackCount := 0 + if tracksMap, ok := meta["tracks"].(map[string]any); ok { + if items, ok := tracksMap["items"].([]map[string]any); ok { + trackCount = len(items) + } else if anyItems, ok := tracksMap["items"].([]any); ok { + trackCount = len(anyItems) + } + } + fmt.Printf("tidal metadata ok: type=%s id=%s title=%q tracks=%d\n", mediaType, itemID, title, trackCount) + + return 0 +} + +func cmdTidalVideoSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int { + if len(args) < 1 { + fmt.Println("usage: rip tidal-video-smoke ") + return 2 + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + return 1 + } + defer func() { _ = mainApp.Close() }() + + providerClient, err := mainApp.GetLoggedInProvider(ctx, "tidal") + if err != nil { + fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) + return 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") + return 1 + } + + videoID := strings.TrimSpace(args[0]) + meta, err := providerClient.GetMetadata(ctx, videoID, "video") + if err != nil { + fmt.Fprintf(os.Stderr, "video metadata error: %v\n", err) + return 1 + } + d, err := videoProvider.GetVideoDownloadable(ctx, videoID) + if err != nil { + fmt.Fprintf(os.Stderr, "video downloadable error: %v\n", err) + return 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) + + return 0 +} diff --git a/cmd/rip/main.go b/cmd/rip/main.go index 0f21222..be2600a 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -6,14 +6,9 @@ import ( "fmt" "os" "os/signal" - "strings" "syscall" - "golang.org/x/term" - - "streamrip-go/internal/app" "streamrip-go/internal/config" - "streamrip-go/internal/provider" "streamrip-go/internal/verbose" _ "modernc.org/sqlite" @@ -92,819 +87,42 @@ func run() int { fmt.Fprintln(os.Stderr, "verbose mode enabled (level 1: downloads)") } - os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - switch os.Args[1] { + args := gopts.commandArgs + + switch gopts.command { case "url": - if len(os.Args) < 3 { - printCommandHelp(os.Stdout, "url") - return 2 - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - - rawArgs := make([]string, 0, len(os.Args[2:])) - ignoreDB := false - for _, arg := range os.Args[2:] { - if arg == "--force" || arg == "--ignore-db" { - ignoreDB = true - continue - } - rawArgs = append(rawArgs, arg) - } - mainApp.IgnoreDB = ignoreDB || gopts.noDB - - added := 0 - for _, raw := range rawArgs { - if addURLToQueue(ctx, mainApp, raw) { - added++ - } - } - - if added == 0 { - fmt.Println("nothing to rip") - return 0 - } - - if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - fmt.Printf("url rip complete (%d item(s))\n", added) + return cmdURL(ctx, cfg, gopts, args) case "file": - if len(os.Args) < 3 { - printCommandHelp(os.Stdout, "file") - return 2 - } - - ignoreDB := false - for _, arg := range os.Args[3:] { - switch arg { - case "--force", "--ignore-db": - ignoreDB = true - default: - fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) - return 2 - } - } - - content, err := os.ReadFile(os.Args[2]) - if err != nil { - fmt.Fprintf(os.Stderr, "read file error: %v\n", err) - return 1 - } - - idItems, urls, repeated, jsonInput, err := parseFileInput(content) - if err != nil { - fmt.Fprintf(os.Stderr, "file parse error: %v\n", err) - return 2 - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = ignoreDB || gopts.noDB - - added := 0 - if jsonInput { - fmt.Printf("detected json file. loading %d item(s)\n", len(idItems)) - for _, item := range idItems { - if err = mainApp.AddByID(ctx, item.Source, item.MediaType, item.ID); err != nil { - fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", item.Source, item.MediaType, item.ID, err) - continue - } - added++ - } - } else { - if repeated > 0 { - fmt.Printf("found %d repeated url(s)\n", repeated) - } - fmt.Printf("detected list of urls. loading %d item(s)\n", len(urls)) - for _, raw := range urls { - if addURLToQueue(ctx, mainApp, raw) { - added++ - } - } - } - - if added == 0 { - fmt.Println("nothing to rip") - return 0 - } - - if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - fmt.Printf("file rip complete (%d item(s))\n", added) + return cmdFile(ctx, cfg, gopts, args) case "config": - if len(os.Args) < 3 { - printCommandHelp(os.Stdout, "config") - return 2 - } - switch os.Args[2] { - case "open": - vim := false - for _, arg := range os.Args[3:] { - switch arg { - case "-v", "--vim": - vim = true - default: - fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) - return 2 - } - } - fmt.Printf("opening file at %s\n", cfg.Path) - if err = openConfigInEditor(cfg.Path, vim); err != nil { - fmt.Fprintf(os.Stderr, "open config error: %v\n", err) - return 1 - } - case "reset": - yes := false - for _, arg := range os.Args[3:] { - switch arg { - case "-y", "--yes": - yes = true - default: - fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) - return 2 - } - } - if !yes { - if !term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Fprintln(os.Stderr, "reset requires --yes in non-interactive mode") - return 2 - } - ok, askErr := promptYesNo(fmt.Sprintf("Are you sure you want to reset the config file at %s? [y/N]: ", cfg.Path)) - if askErr != nil { - fmt.Fprintf(os.Stderr, "prompt error: %v\n", askErr) - return 1 - } - if !ok { - fmt.Println("reset aborted") - return 0 - } - } - def := config.DefaultConfigData() - // Separate copies: assigning the same value to both halves made - // File.Qobuz.Secrets and Session.Qobuz.Secrets share one backing - // array. - cfg.File = config.CloneConfigData(def) - cfg.Session = config.CloneConfigData(def) - if err = cfg.SaveFile(); err != nil { - fmt.Fprintf(os.Stderr, "reset config error: %v\n", err) - return 1 - } - fmt.Printf("reset the config file at %s\n", cfg.Path) - case "path": - if len(os.Args) > 3 { - fmt.Fprintf(os.Stderr, "option error: unexpected argument %q\n", os.Args[3]) - return 2 - } - fmt.Printf("config path: '%s'\n", cfg.Path) - default: - fmt.Fprintf(os.Stderr, "unknown config command: %s\n", os.Args[2]) - return 2 - } + return cmdConfig(ctx, cfg, gopts, args) case "database": - if len(os.Args) < 4 || os.Args[2] != "browse" { - printCommandHelp(os.Stdout, "database") - return 2 - } - table := strings.ToLower(strings.TrimSpace(os.Args[3])) - switch table { - case "downloads": - rows, listErr := listDownloadsRows(cfg.Session.Database.DownloadsPath) - if listErr != nil { - fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr) - return 1 - } - fmt.Println("downloads database") - fmt.Println("row id") - for i, id := range rows { - fmt.Printf("%02d %s\n", i, id) - } - case "failed": - rows, listErr := listFailedRows(cfg.Session.Database.FailedDownloadsPath) - if listErr != nil && isNoSuchTableErr(listErr) && cfg.Session.Database.FailedDownloadsPath != cfg.Session.Database.DownloadsPath { - rows, listErr = listFailedRows(cfg.Session.Database.DownloadsPath) - } - if listErr != nil { - fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr) - return 1 - } - fmt.Println("failed downloads database") - fmt.Println("row source media_type id") - for i, row := range rows { - fmt.Printf("%02d %s %s %s\n", i, row.Source, row.MediaType, row.ID) - } - default: - fmt.Fprintf(os.Stderr, "invalid database %q. choose downloads or failed\n", table) - return 2 - } + return cmdDatabase(ctx, cfg, gopts, args) case "id": - if len(os.Args) < 5 { - printCommandHelp(os.Stdout, "id") - return 2 - } - - source := strings.ToLower(strings.TrimSpace(os.Args[2])) - mediaType := strings.ToLower(strings.TrimSpace(os.Args[3])) - itemID := strings.TrimSpace(os.Args[4]) - - opts, err := parseSmokeOptions(os.Args[5:], 0, 4) - if err != nil { - fmt.Fprintf(os.Stderr, "option error: %v\n", err) - return 2 - } - if opts.qualitySet { - switch source { - case "qobuz": - if opts.quality < 1 { - fmt.Fprintf(os.Stderr, "quality error: qobuz quality must be 1-4\n") - return 2 - } - cfg.Session.Qobuz.Quality = opts.quality - case "tidal": - cfg.Session.Tidal.Quality = opts.quality - case "yandex": - cfg.Session.Yandex.Quality = opts.quality - case "beatport": - if opts.quality < 1 { - fmt.Fprintf(os.Stderr, "quality error: beatport quality must be 1-4\n") - return 2 - } - cfg.Session.Beatport.Quality = opts.quality - } - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - 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) - return 1 - } - if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID) + return cmdID(ctx, cfg, gopts, args) case "search": - var source, mediaType string - var sopts searchOptions - if len(os.Args) < 5 { - if !term.IsTerminal(int(os.Stdin.Fd())) { - printCommandHelp(os.Stdout, "search") - return 2 - } - source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) - if err != nil { - fmt.Fprintf(os.Stderr, "search prompt error: %v\n", err) - return 2 - } - } else { - source = strings.ToLower(strings.TrimSpace(os.Args[2])) - mediaType = strings.ToLower(strings.TrimSpace(os.Args[3])) - sopts, err = parseSearchArgs(os.Args[4:], cfg.Session.CLI.MaxSearchResults) - } - - if err != nil { - fmt.Fprintf(os.Stderr, "search option error: %v\n", err) - return 2 - } - if sopts.first && sopts.outputFile != "" { - fmt.Fprintln(os.Stderr, "cannot choose --first and --output-file together") - return 2 - } - if !isAllowedSearchSource(source) { - fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source) - return 2 - } - if !isAllowedMediaType(mediaType) { - fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType) - return 2 - } - if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" { - fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist") - return 2 - } - if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" { - fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist") - return 2 - } - if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" { - fmt.Fprintln(os.Stderr, "beatport search currently supports media types track, album, and label") - return 2 - } - if sopts.query == "" { - fmt.Fprintln(os.Stderr, "search query cannot be empty") - return 2 - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - - provider, err := mainApp.GetLoggedInProvider(ctx, source) - if err != nil { - fmt.Fprintf(os.Stderr, "%s login error: %v\n", source, err) - return 1 - } - - pages, err := provider.Search(ctx, mediaType, sopts.query, sopts.limit) - if err != nil { - fmt.Fprintf(os.Stderr, "search error: %v\n", err) - return 1 - } - - results := normalizeSearchResults(source, mediaType, pages) - if len(results) == 0 { - fmt.Println("no results") - return 0 - } - 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) - return 1 - } - fmt.Printf("wrote %d results to %s\n", len(results), sopts.outputFile) - return 0 - } - 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 0 - } - if !sopts.first { - fmt.Printf("results: %d\n", len(results)) - } - - if sopts.first { - selection := []int{0} - mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB - skippedDownloaded := 0 - added := 0 - for _, idx := range selection { - item := results[idx] - if !sopts.ignoreDB { - already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID) - if checkErr == nil && already { - skippedDownloaded++ - fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title) - continue - } - } - if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil { - fmt.Printf("add failed: id=%s err=%v\n", item.ID, err) - continue - } - added++ - } - if added == 0 { - if skippedDownloaded > 0 { - fmt.Println("selected item was already downloaded (use --force to redownload)") - } else { - fmt.Println("nothing selected to download") - } - return 0 - } - if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - fmt.Printf("search download complete (%d item(s))\n", added) - return 0 - } - - if !term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Println("non-interactive input; use `rip id` to download specific results") - return 0 - } - - selection, err := promptSearchSelectionMenu(source, mediaType, sopts.query, results) - if err != nil { - fmt.Fprintf(os.Stderr, "selection error: %v\n", err) - return 2 - } - if len(selection) == 0 { - fmt.Println("download cancelled") - return 0 - } - - mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB - skippedDownloaded := 0 - added := 0 - for _, idx := range selection { - item := results[idx] - if !sopts.ignoreDB { - already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID) - if checkErr == nil && already { - skippedDownloaded++ - fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title) - continue - } - } - if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil { - fmt.Printf("add failed: id=%s err=%v\n", item.ID, err) - continue - } - added++ - } - if added == 0 { - if skippedDownloaded > 0 { - fmt.Println("all selected items were already downloaded (use --force to redownload)") - } else { - fmt.Println("nothing selected to download") - } - return 0 - } - if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) - return 1 - } - fmt.Printf("search download complete (%d item(s))\n", added) + return cmdSearch(ctx, cfg, gopts, args) case "lastfm": - opts, parseErr := parseLastFMArgs(os.Args[2:], cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource) - if parseErr != nil { - fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr) - printCommandHelp(os.Stdout, "lastfm") - return 2 - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = gopts.noDB - - title, tracks, err := fetchLastFMPlaylist(ctx, cfg.Session.Downloads.VerifySSL, opts.PlaylistURL) - if err != nil { - fmt.Fprintf(os.Stderr, "lastfm parse error: %v\n", err) - return 1 - } - if len(tracks) == 0 { - fmt.Println("no tracks found in playlist") - return 0 - } - fmt.Printf("lastfm playlist: %s (%d tracks)\n", title, len(tracks)) - - resolvedTracks, err := resolveLastFMTracks(ctx, mainApp, opts, tracks) - if err != nil { - fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err) - return 1 - } - if len(resolvedTracks) == 0 { - fmt.Println("no lastfm tracks resolved") - return 0 - } - - playlistID := fmt.Sprintf("lastfm:%s", strings.ToLower(strings.ReplaceAll(title, " ", "_"))) - refs := make([]app.PlaylistTrackRef, 0, len(resolvedTracks)) - for _, item := range resolvedTracks { - refs = append(refs, app.PlaylistTrackRef{Source: item.Source, ID: item.ID}) - } - if addErr := mainApp.AddMixedPlaylistByTrackRefs(ctx, playlistID, title, refs); addErr != nil { - fmt.Printf("playlist queue failed: err=%v\n", addErr) - fmt.Println("no lastfm playlists queued") - return 0 - } - fmt.Printf("queued lastfm playlist: %s (%d tracks)\n", title, len(refs)) - if len(refs) == 0 { - fmt.Println("no lastfm playlists queued") - return 0 - } - if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) - return 1 - } - if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %v\n", err) - return 1 - } - fmt.Printf("lastfm rip complete (%d track(s) across 1 playlist)\n", len(resolvedTracks)) + return cmdLastFM(ctx, cfg, gopts, args) case "soundcloud-smoke": - if len(os.Args) < 3 { - fmt.Println("usage: rip soundcloud-smoke ") - return 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) - return 1 - } - fmt.Printf("soundcloud oembed ok: title=%q author=%q provider=%q\n", asString(meta["title"]), asString(meta["author_name"]), asString(meta["provider_name"])) + return cmdSoundcloudSmoke(ctx, cfg, gopts, args) case "qobuz-smoke": - if len(os.Args) < 3 { - fmt.Println("usage: rip qobuz-smoke [quality]") - return 2 - } - opts, err := parseSmokeOptions(os.Args[3:], 1, 4) - if err != nil { - fmt.Fprintf(os.Stderr, "option error: %v\n", err) - return 2 - } - if opts.qualitySet { - cfg.Session.Qobuz.Quality = opts.quality - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - - provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz") - if err != nil { - fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err) - return 1 - } - - trackID := os.Args[2] - meta, err := provider.GetMetadata(ctx, trackID, "track") - if err != nil { - fmt.Fprintf(os.Stderr, "metadata error: %v\n", err) - return 1 - } - - title, _ := meta["title"].(string) - d, err := provider.GetDownloadable(ctx, trackID, cfg.Session.Qobuz.Quality) - if err != nil { - fmt.Fprintf(os.Stderr, "downloadable error: %v\n", err) - return 1 - } - - fmt.Printf("qobuz ok: title=%q quality=%d ext=%s\n", title, cfg.Session.Qobuz.Quality, d.Extension) - fmt.Printf("stream_url=%s\n", d.URL) + return cmdQobuzSmoke(ctx, cfg, gopts, args) case "qobuz-convert-rip-smoke": - if len(os.Args) < 4 { - fmt.Println("usage: rip qobuz-convert-rip-smoke [quality] [--force|--ignore-db]") - return 2 - } - opts, err := parseSmokeOptions(os.Args[4:], 1, 4) - if err != nil { - fmt.Fprintf(os.Stderr, "option error: %v\n", err) - return 2 - } - if opts.qualitySet { - cfg.Session.Qobuz.Quality = opts.quality - } - cfg.Session.Conversion.Enabled = true - cfg.Session.Conversion.Codec = strings.ToUpper(strings.TrimSpace(os.Args[3])) - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB - - trackID := os.Args[2] - if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil { - fmt.Fprintf(os.Stderr, "add error: %v\n", err) - return 1 - } - if err = mainApp.Resolve(ctx); err != nil { - fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) - return 1 - } - if err = mainApp.Rip(ctx); err != nil { - fmt.Fprintf(os.Stderr, "rip error: %v\n", err) - return 1 - } - fmt.Printf("qobuz convert rip smoke complete (codec=%s)\n", cfg.Session.Conversion.Codec) + return cmdQobuzConvertRipSmoke(ctx, cfg, gopts, args) case "qobuz-search-smoke": - if len(os.Args) < 3 { - fmt.Println("usage: rip qobuz-search-smoke ") - return 2 - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - - provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz") - if err != nil { - fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err) - return 1 - } - - query := strings.Join(os.Args[2:], " ") - pages, err := provider.Search(ctx, "album", query, 10) - if err != nil { - fmt.Fprintf(os.Stderr, "search error: %v\n", err) - return 1 - } - for _, page := range pages { - albums, ok := page["albums"].(map[string]any) - if !ok { - continue - } - items, ok := albums["items"].([]any) - if !ok { - continue - } - for _, raw := range items { - item, ok := raw.(map[string]any) - if !ok { - continue - } - id := asString(item["id"]) - title := asString(item["title"]) - version := asString(item["version"]) - bitDepth := asString(item["maximum_bit_depth"]) - sr := asString(item["maximum_sampling_rate"]) - if version != "" { - title = title + " (" + version + ")" - } - fmt.Printf("album_id=%s | %s | %sB-%skHz\n", id, title, bitDepth, sr) - } - } + return cmdQobuzSearchSmoke(ctx, cfg, gopts, args) case "tidal-search-smoke": - if len(os.Args) < 3 { - fmt.Println("usage: rip tidal-search-smoke ") - return 2 - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - - provider, err := mainApp.GetLoggedInProvider(ctx, "tidal") - if err != nil { - fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) - return 1 - } - - query := strings.Join(os.Args[2:], " ") - pages, err := provider.Search(ctx, "album", query, 10) - if err != nil { - fmt.Fprintf(os.Stderr, "search error: %v\n", err) - return 1 - } - for _, page := range pages { - items, ok := page["items"].([]any) - if !ok { - continue - } - for _, raw := range items { - wrapper, ok := raw.(map[string]any) - if !ok { - continue - } - item, ok := wrapper["item"].(map[string]any) - if !ok { - item = wrapper - } - fmt.Printf("album_id=%s | %s\n", asString(item["id"]), asString(item["title"])) - } - } + return cmdTidalSearchSmoke(ctx, cfg, gopts, args) case "tidal-metadata-smoke": - if len(os.Args) < 4 { - fmt.Println("usage: rip tidal-metadata-smoke ") - return 2 - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - - provider, err := mainApp.GetLoggedInProvider(ctx, "tidal") - if err != nil { - fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) - return 1 - } - - mediaType := os.Args[2] - itemID := os.Args[3] - meta, err := provider.GetMetadata(ctx, itemID, mediaType) - if err != nil { - fmt.Fprintf(os.Stderr, "metadata error: %v\n", err) - return 1 - } - title := asString(meta["title"]) - if title == "" { - title = asString(meta["name"]) - } - trackCount := 0 - if tracksMap, ok := meta["tracks"].(map[string]any); ok { - if items, ok := tracksMap["items"].([]map[string]any); ok { - trackCount = len(items) - } else if anyItems, ok := tracksMap["items"].([]any); ok { - trackCount = len(anyItems) - } - } - fmt.Printf("tidal metadata ok: type=%s id=%s title=%q tracks=%d\n", mediaType, itemID, title, trackCount) + return cmdTidalMetadataSmoke(ctx, cfg, gopts, args) case "tidal-video-smoke": - if len(os.Args) < 3 { - fmt.Println("usage: rip tidal-video-smoke ") - return 2 - } - - mainApp, err := app.New(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "app init error: %v\n", err) - return 1 - } - defer func() { _ = mainApp.Close() }() - - providerClient, err := mainApp.GetLoggedInProvider(ctx, "tidal") - if err != nil { - fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) - return 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") - return 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) - return 1 - } - d, err := videoProvider.GetVideoDownloadable(ctx, videoID) - if err != nil { - fmt.Fprintf(os.Stderr, "video downloadable error: %v\n", err) - return 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) + return cmdTidalVideoSmoke(ctx, cfg, gopts, args) default: - fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + fmt.Fprintf(os.Stderr, "unknown command: %s\n", gopts.command) return 2 } - - return 0 }