package main import ( "context" "errors" "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" ) func main() { gopts, err := parseGlobalArgs(os.Args[1:]) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(2) } if gopts.command == "" { fmt.Println("usage: rip ") fmt.Println("commands: url, file, config, database, id, search, lastfm") fmt.Println("tip: run `rip dev-help` to list developer smoke commands") os.Exit(2) } cfg, err := config.Load(gopts.configPath) if err != nil { if errors.Is(err, config.ErrOutdatedConfig) { resolvedPath, upErr := config.UpgradeOutdated(gopts.configPath) if upErr != nil { fmt.Fprintf(os.Stderr, "config error: %v\n", err) fmt.Fprintf(os.Stderr, "config auto-upgrade failed: %v\n", upErr) os.Exit(1) } fmt.Fprintf(os.Stderr, "config upgraded at %s\n", resolvedPath) cfg, err = config.Load(gopts.configPath) } } if err != nil { fmt.Fprintf(os.Stderr, "config error: %v\n", err) os.Exit(1) } applyGlobalConfigOverrides(cfg, gopts) verbose.SetLevel(gopts.verbose) if gopts.verbose >= 2 { fmt.Fprintln(os.Stderr, "verbose mode enabled (level 2: downloads + http)") } else if gopts.verbose >= 1 { 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] { case "dev-help": fmt.Println("developer smoke commands:") fmt.Println(" soundcloud-smoke") fmt.Println(" qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke") fmt.Println(" qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke") fmt.Println(" qobuz-search-smoke") fmt.Println(" tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke") fmt.Println(" tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke") return case "url": if len(os.Args) < 3 { fmt.Println("usage: rip url [--force|--ignore-db]") 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() }() 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 } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("url rip complete (%d item(s))\n", added) case "file": if len(os.Args) < 3 { fmt.Println("usage: rip file [--force|--ignore-db]") os.Exit(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) os.Exit(2) } } content, err := os.ReadFile(os.Args[2]) if err != nil { fmt.Fprintf(os.Stderr, "read file error: %v\n", err) os.Exit(1) } idItems, urls, repeated, jsonInput, err := parseFileInput(content) if err != nil { fmt.Fprintf(os.Stderr, "file parse error: %v\n", err) 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() }() 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 } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("file rip complete (%d item(s))\n", added) case "config": if len(os.Args) < 3 { fmt.Println("usage: rip config [options]") os.Exit(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) os.Exit(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) os.Exit(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) os.Exit(2) } } if !yes { if !term.IsTerminal(int(os.Stdin.Fd())) { fmt.Fprintln(os.Stderr, "reset requires --yes in non-interactive mode") os.Exit(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) os.Exit(1) } if !ok { fmt.Println("reset aborted") return } } def := config.DefaultConfigData() cfg.File = def cfg.Session = def if err = cfg.SaveFile(); err != nil { fmt.Fprintf(os.Stderr, "reset config error: %v\n", err) os.Exit(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]) os.Exit(2) } fmt.Printf("config path: '%s'\n", cfg.Path) default: fmt.Fprintf(os.Stderr, "unknown config command: %s\n", os.Args[2]) os.Exit(2) } case "database": if len(os.Args) < 4 || os.Args[2] != "browse" { fmt.Println("usage: rip database browse ") os.Exit(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) os.Exit(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) os.Exit(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) os.Exit(2) } case "id": if len(os.Args) < 5 { fmt.Println("usage: rip id [quality] [--force|--ignore-db]") os.Exit(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) os.Exit(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") os.Exit(2) } cfg.Session.Qobuz.Quality = opts.quality case "tidal": cfg.Session.Tidal.Quality = opts.quality case "yandex": cfg.Session.Yandex.Quality = opts.quality } } 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() }() 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) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID) case "search": var source, mediaType string var sopts searchOptions if len(os.Args) < 5 { if !term.IsTerminal(int(os.Stdin.Fd())) { fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") os.Exit(2) } source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) if err != nil { fmt.Fprintf(os.Stderr, "search prompt error: %v\n", err) os.Exit(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) os.Exit(2) } if sopts.first && sopts.outputFile != "" { fmt.Fprintln(os.Stderr, "cannot choose --first and --output-file together") os.Exit(2) } if !isAllowedSearchSource(source) { fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source) os.Exit(2) } if !isAllowedMediaType(mediaType) { fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType) os.Exit(2) } if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" { fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist") os.Exit(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") os.Exit(2) } if sopts.query == "" { fmt.Fprintln(os.Stderr, "search query cannot be empty") 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() }() provider, err := mainApp.GetLoggedInProvider(ctx, source) if err != nil { fmt.Fprintf(os.Stderr, "%s login error: %v\n", source, err) os.Exit(1) } pages, err := provider.Search(ctx, mediaType, sopts.query, sopts.limit) if err != nil { fmt.Fprintf(os.Stderr, "search error: %v\n", err) os.Exit(1) } results := normalizeSearchResults(source, mediaType, pages) if len(results) == 0 { fmt.Println("no results") return } 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) } fmt.Printf("wrote %d results to %s\n", len(results), sopts.outputFile) 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 || 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 } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("search download complete (%d item(s))\n", added) return } if !term.IsTerminal(int(os.Stdin.Fd())) { fmt.Println("non-interactive input; use `rip id` to download specific results") return } selection, err := promptSearchSelectionMenu(source, mediaType, sopts.query, results) if err != nil { fmt.Fprintf(os.Stderr, "selection error: %v\n", err) os.Exit(2) } if len(selection) == 0 { fmt.Println("download cancelled") return } 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 } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts)) os.Exit(1) } fmt.Printf("search download complete (%d item(s))\n", added) 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) fmt.Println("usage: rip lastfm [--source SOURCE] [--fallback-source SOURCE] ") 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() }() 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) os.Exit(1) } if len(tracks) == 0 { fmt.Println("no tracks found in playlist") return } 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) os.Exit(1) } if len(resolvedTracks) == 0 { fmt.Println("no lastfm tracks resolved") return } 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 } fmt.Printf("queued lastfm playlist: %s (%d tracks)\n", title, len(refs)) if len(refs) == 0 { fmt.Println("no lastfm playlists queued") return } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Printf("lastfm rip complete (%d track(s) across 1 playlist)\n", len(resolvedTracks)) case "soundcloud-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip soundcloud-smoke ") os.Exit(2) } meta, err := fetchSoundcloudOEmbed(ctx, cfg.Session.Downloads.VerifySSL, strings.TrimSpace(os.Args[2])) if err != nil { fmt.Fprintf(os.Stderr, "soundcloud smoke error: %v\n", err) os.Exit(1) } fmt.Printf("soundcloud oembed ok: title=%q author=%q provider=%q\n", asString(meta["title"]), asString(meta["author_name"]), asString(meta["provider_name"])) case "qobuz-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip qobuz-smoke [quality]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 1, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(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) os.Exit(1) } defer func() { _ = mainApp.Close() }() provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz") if err != nil { fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err) os.Exit(1) } trackID := os.Args[2] meta, err := provider.GetMetadata(ctx, trackID, "track") if err != nil { fmt.Fprintf(os.Stderr, "metadata error: %v\n", err) os.Exit(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) os.Exit(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) case "qobuz-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip qobuz-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 1, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(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) os.Exit(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) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("qobuz rip smoke complete") case "qobuz-convert-rip-smoke": if len(os.Args) < 4 { fmt.Println("usage: rip qobuz-convert-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[4:], 1, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(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) os.Exit(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) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Printf("qobuz convert rip smoke complete (codec=%s)\n", cfg.Session.Conversion.Codec) case "qobuz-album-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip qobuz-album-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 1, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(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) os.Exit(1) } defer func() { _ = mainApp.Close() }() mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB albumID := os.Args[2] if err = mainApp.AddByID(ctx, "qobuz", "album", albumID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("qobuz album rip smoke complete") case "qobuz-playlist-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip qobuz-playlist-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 1, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(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) os.Exit(1) } defer func() { _ = mainApp.Close() }() mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB playlistID := os.Args[2] if err = mainApp.AddByID(ctx, "qobuz", "playlist", playlistID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("qobuz playlist rip smoke complete") case "qobuz-artist-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip qobuz-artist-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 1, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(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) os.Exit(1) } defer func() { _ = mainApp.Close() }() 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) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("qobuz artist rip smoke complete") case "qobuz-label-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip qobuz-label-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 1, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(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) os.Exit(1) } defer func() { _ = mainApp.Close() }() 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) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("qobuz label rip smoke complete") case "qobuz-search-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip qobuz-search-smoke ") os.Exit(2) } mainApp, err := app.New(cfg) if err != nil { fmt.Fprintf(os.Stderr, "app init error: %v\n", err) os.Exit(1) } defer func() { _ = mainApp.Close() }() provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz") if err != nil { fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err) os.Exit(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) os.Exit(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) } } case "tidal-search-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip tidal-search-smoke ") os.Exit(2) } mainApp, err := app.New(cfg) if err != nil { fmt.Fprintf(os.Stderr, "app init error: %v\n", err) os.Exit(1) } defer func() { _ = mainApp.Close() }() provider, err := mainApp.GetLoggedInProvider(ctx, "tidal") if err != nil { fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) os.Exit(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) os.Exit(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"])) } } case "tidal-metadata-smoke": if len(os.Args) < 4 { fmt.Println("usage: rip tidal-metadata-smoke ") os.Exit(2) } mainApp, err := app.New(cfg) if err != nil { fmt.Fprintf(os.Stderr, "app init error: %v\n", err) os.Exit(1) } defer func() { _ = mainApp.Close() }() provider, err := mainApp.GetLoggedInProvider(ctx, "tidal") if err != nil { fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) os.Exit(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) os.Exit(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) case "tidal-video-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip tidal-video-smoke ") os.Exit(2) } mainApp, err := app.New(cfg) if err != nil { fmt.Fprintf(os.Stderr, "app init error: %v\n", err) os.Exit(1) } defer func() { _ = mainApp.Close() }() providerClient, err := mainApp.GetLoggedInProvider(ctx, "tidal") if err != nil { fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) os.Exit(1) } videoProvider, ok := providerClient.(interface { GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) }) if !ok { fmt.Fprintln(os.Stderr, "tidal provider does not support video downloadable") os.Exit(1) } videoID := strings.TrimSpace(os.Args[2]) meta, err := providerClient.GetMetadata(ctx, videoID, "video") if err != nil { fmt.Fprintf(os.Stderr, "video metadata error: %v\n", err) os.Exit(1) } d, err := videoProvider.GetVideoDownloadable(ctx, videoID) if err != nil { fmt.Fprintf(os.Stderr, "video downloadable error: %v\n", err) os.Exit(1) } title := asString(meta["title"]) if title == "" { title = asString(meta["name"]) } fmt.Printf("tidal video ok: id=%s title=%q ext=%s\n", videoID, title, d.Extension) fmt.Printf("stream_url=%s\n", d.URL) case "tidal-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip tidal-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 0, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(2) } if opts.qualitySet { cfg.Session.Tidal.Quality = opts.quality } 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() }() mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB trackID := os.Args[2] if err = mainApp.AddByID(ctx, "tidal", "track", trackID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("tidal rip smoke complete") case "tidal-album-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip tidal-album-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 0, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(2) } if opts.qualitySet { cfg.Session.Tidal.Quality = opts.quality } 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() }() mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB albumID := os.Args[2] if err = mainApp.AddByID(ctx, "tidal", "album", albumID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("tidal album rip smoke complete") case "tidal-playlist-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip tidal-playlist-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 0, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(2) } if opts.qualitySet { cfg.Session.Tidal.Quality = opts.quality } 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() }() mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB playlistID := os.Args[2] if err = mainApp.AddByID(ctx, "tidal", "playlist", playlistID); err != nil { fmt.Fprintf(os.Stderr, "add error: %v\n", err) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("tidal playlist rip smoke complete") case "tidal-artist-rip-smoke": if len(os.Args) < 3 { fmt.Println("usage: rip tidal-artist-rip-smoke [quality] [--force|--ignore-db]") os.Exit(2) } opts, err := parseSmokeOptions(os.Args[3:], 0, 4) if err != nil { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(2) } if opts.qualitySet { cfg.Session.Tidal.Quality = opts.quality } 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() }() 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) os.Exit(1) } if err = mainApp.Resolve(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) os.Exit(1) } if err = mainApp.Rip(ctx); err != nil { fmt.Fprintf(os.Stderr, "rip error: %v\n", err) os.Exit(1) } fmt.Println("tidal artist rip smoke complete") default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) os.Exit(2) } }