package main import ( "bufio" "context" "database/sql" "encoding/json" "errors" "fmt" "html" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "runtime" "strconv" "strings" "time" "github.com/AlecAivazis/survey/v2" "golang.org/x/term" "streamrip-go/internal/app" "streamrip-go/internal/config" "streamrip-go/internal/netutil" "streamrip-go/internal/provider" "streamrip-go/internal/urlparse" _ "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) if gopts.verbose { fmt.Fprintln(os.Stderr, "verbose mode enabled") } os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...) ctx := context.Background() 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 } } 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 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() }() 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) } } type smokeOptions struct { qualitySet bool quality int 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 errorWithActionableHint(err error, opts globalOptions) string { if err == nil { return "" } msg := err.Error() if opts.noSSLVerify { return msg } lower := strings.ToLower(msg) if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") || strings.Contains(lower, "ssl") { return msg + " (hint: try again with --no-ssl-verify)" } return msg } func parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) { opts := smokeOptions{} for _, arg := range args { switch arg { case "--force", "--ignore-db": opts.ignoreDB = true default: q, err := parseQuality(arg, minQuality, maxQuality) if err != nil { return smokeOptions{}, fmt.Errorf("unknown option %q", arg) } opts.quality = q opts.qualitySet = true } } return opts, nil } func parseQuality(raw string, min int, max int) (int, error) { q, err := strconv.Atoi(raw) if err != nil { return 0, err } if q < min || q > max { return 0, fmt.Errorf("quality must be %d-%d, got %d", min, max, q) } return q, nil } func asString(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 "" } } type fileIDItem struct { Source string MediaType string ID string } type failedRow struct { Source string MediaType string ID string } type lastFMOptions struct { Source string FallbackSource string PlaylistURL string } type lastFMTrack struct { Title string Artist string } type resolvedLastFMTrack struct { Source string ID string Query string } var ( lastFMTitleTagsRe = regexp.MustCompile(`([^<]+)`) lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`) lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`) errLastFMInvalidSource = "unsupported source" ) func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool { parsed := urlparse.Parse(raw) if parsed == nil { fmt.Printf("invalid: %s\n", raw) return false } 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" && parsed.Source != "deezer" && parsed.Source != "soundcloud" { fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw) return false } if err := mainApp.AddByID(ctx, parsed.Source, parsed.MediaType, parsed.ID); err != nil { fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", parsed.Source, parsed.MediaType, parsed.ID, err) return false } return true } func parseFileInput(content []byte) ([]fileIDItem, []string, int, bool, error) { trimmed := strings.TrimSpace(string(content)) if trimmed == "" { return nil, nil, 0, false, nil } var parsed any if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil { arr, ok := parsed.([]any) if !ok { return nil, nil, 0, true, fmt.Errorf("json input must be an array of objects") } items := make([]fileIDItem, 0, len(arr)) for i, raw := range arr { entry, ok := raw.(map[string]any) if !ok { return nil, nil, 0, true, fmt.Errorf("json item %d must be an object", i+1) } source := strings.ToLower(strings.TrimSpace(asString(entry["source"]))) mediaType := strings.ToLower(strings.TrimSpace(asString(entry["media_type"]))) if mediaType == "" { mediaType = strings.ToLower(strings.TrimSpace(asString(entry["mediaType"]))) } id := strings.TrimSpace(asString(entry["id"])) if source == "" || mediaType == "" || id == "" { return nil, nil, 0, true, fmt.Errorf("json item %d missing source/media_type/id", i+1) } items = append(items, fileIDItem{Source: source, MediaType: mediaType, ID: id}) } return items, nil, 0, true, nil } parts := strings.Fields(trimmed) if len(parts) == 0 { return nil, nil, 0, false, nil } seen := make(map[string]struct{}, len(parts)) urls := make([]string, 0, len(parts)) repeated := 0 for _, raw := range parts { if _, ok := seen[raw]; ok { repeated++ continue } seen[raw] = struct{}{} urls = append(urls, raw) } return nil, urls, repeated, false, nil } func promptYesNo(prompt string) (bool, error) { reader := bufio.NewReader(os.Stdin) fmt.Print(prompt) line, err := reader.ReadString('\n') if err != nil { return false, err } line = strings.ToLower(strings.TrimSpace(line)) return line == "y" || line == "yes", nil } func openConfigInEditor(path string, vim bool) error { launch := func(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } if vim { if p, err := exec.LookPath("nvim"); err == nil { return launch(p, path) } if p, err := exec.LookPath("vim"); err == nil { return launch(p, path) } } if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" { parts := strings.Fields(editor) if len(parts) > 0 { return launch(parts[0], append(parts[1:], path)...) } } switch runtime.GOOS { case "darwin": return launch("open", path) case "windows": return launch("cmd", "/c", "start", "", path) default: if p, err := exec.LookPath("xdg-open"); err == nil { return launch(p, path) } return fmt.Errorf("could not find an editor (set $EDITOR or install xdg-open)") } } func listDownloadsRows(path string) ([]string, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, err } defer func() { _ = db.Close() }() rows, err := db.Query(`SELECT id FROM downloads ORDER BY rowid`) if err != nil { if isNoSuchTableErr(err) { return []string{}, nil } return nil, err } defer func() { _ = rows.Close() }() out := []string{} for rows.Next() { var id string if err = rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } func listFailedRows(path string) ([]failedRow, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, err } defer func() { _ = db.Close() }() rows, err := db.Query(`SELECT source, media_type, id FROM failed_downloads ORDER BY rowid`) if err != nil { return nil, err } defer func() { _ = rows.Close() }() out := []failedRow{} for rows.Next() { var r failedRow if err = rows.Scan(&r.Source, &r.MediaType, &r.ID); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func isNoSuchTableErr(err error) bool { if err == nil { return false } return strings.Contains(strings.ToLower(err.Error()), "no such table") } func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (lastFMOptions, error) { opts := lastFMOptions{Source: strings.ToLower(strings.TrimSpace(defaultSource)), FallbackSource: strings.ToLower(strings.TrimSpace(defaultFallback))} for i := 0; i < len(args); i++ { switch args[i] { case "-s", "--source": if i+1 >= len(args) { return lastFMOptions{}, fmt.Errorf("--source requires a value") } opts.Source = strings.ToLower(strings.TrimSpace(args[i+1])) i++ case "-fs", "--fallback-source": if i+1 >= len(args) { return lastFMOptions{}, fmt.Errorf("--fallback-source requires a value") } opts.FallbackSource = strings.ToLower(strings.TrimSpace(args[i+1])) i++ default: if strings.HasPrefix(args[i], "-") { return lastFMOptions{}, fmt.Errorf("unknown option %q", args[i]) } if opts.PlaylistURL != "" { return lastFMOptions{}, fmt.Errorf("unexpected extra argument %q", args[i]) } opts.PlaylistURL = strings.TrimSpace(args[i]) } } if opts.Source == "" { opts.Source = "qobuz" } if opts.PlaylistURL == "" { return lastFMOptions{}, fmt.Errorf("missing playlist url") } if !isValidLastFMPlaylistURL(opts.PlaylistURL) { return lastFMOptions{}, fmt.Errorf("playlist url must be a last.fm url") } if !isAllowedSearchSource(opts.Source) { return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.Source) } if opts.FallbackSource != "" && !isAllowedSearchSource(opts.FallbackSource) { return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.FallbackSource) } return opts, nil } func isValidLastFMPlaylistURL(raw string) bool { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u == nil || u.Host == "" { return false } s := strings.ToLower(strings.TrimSpace(u.Scheme)) if s != "http" && s != "https" { return false } h := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(u.Host), "www.")) return h == "last.fm" || strings.HasSuffix(h, ".last.fm") } func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) { parsed, err := url.Parse(playlistURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", nil, fmt.Errorf("invalid playlist url") } if !isValidLastFMPlaylistURL(playlistURL) { return "", nil, fmt.Errorf("invalid playlist url") } client := netutil.NewHTTPClient(30*time.Second, verifySSL) page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1) if err != nil { return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL) } title, total, err := extractLastFMPlaylistInfo(page1) if err != nil { return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL) } tracks := extractLastFMTitleArtistPairs(page1) if total <= len(tracks) || total <= 50 { if len(tracks) > total && total > 0 { tracks = tracks[:total] } return title, tracks, nil } remaining := total - 50 lastPage := 1 + remaining/50 if remaining%50 != 0 { lastPage++ } for page := 2; page <= lastPage; page++ { body, fetchErr := fetchLastFMPlaylistPage(ctx, client, parsed, page) if fetchErr != nil { return "", nil, fetchErr } tracks = append(tracks, extractLastFMTitleArtistPairs(body)...) } if len(tracks) > total { tracks = tracks[:total] } return title, tracks, nil } func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) { client := netutil.NewHTTPClient(30*time.Second, verifySSL) all := make([]lastFMTrack, 0, 200) title := "" for page := 1; page <= 50; page++ { body, err := fetchLastFMPlaylistMirrorPage(ctx, client, playlistURL, page) if err != nil { if page == 1 { return "", nil, err } break } pageTitle, tracks := extractLastFMTracksFromMirrorMarkdown(body) if title == "" && strings.TrimSpace(pageTitle) != "" { title = pageTitle } if len(tracks) == 0 { break } all = append(all, tracks...) if !strings.Contains(strings.ToLower(body), "show more") { break } } if len(all) == 0 { return "", nil, fmt.Errorf("could not parse playlist tracks from last.fm") } if strings.TrimSpace(title) == "" { title = "Last.fm Playlist" } return title, all, nil } func fetchLastFMPlaylistMirrorPage(ctx context.Context, client *http.Client, playlistURL string, page int) (string, error) { u, err := url.Parse(playlistURL) if err != nil { return "", err } if page > 1 { q := u.Query() q.Set("page", strconv.Itoa(page)) u.RawQuery = q.Encode() } raw := u.String() raw = strings.TrimPrefix(raw, "https://") raw = strings.TrimPrefix(raw, "http://") mirrorURL := "https://r.jina.ai/http://" + raw req, err := http.NewRequestWithContext(ctx, http.MethodGet, mirrorURL, nil) if err != nil { return "", err } req.Header.Set("User-Agent", "streamrip-go/0") resp, err := client.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("lastfm mirror request failed: status %d", resp.StatusCode) } b, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(b), nil } func fetchLastFMPlaylistPage(ctx context.Context, client *http.Client, parsed *url.URL, page int) (string, error) { u := *parsed if page > 1 { q := u.Query() q.Set("page", strconv.Itoa(page)) u.RawQuery = q.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return "", err } req.Header.Set("User-Agent", "streamrip-go/0") resp, err := client.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("lastfm request failed: status %d", resp.StatusCode) } b, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(b), nil } func extractLastFMPlaylistInfo(page string) (string, int, error) { titleMatch := lastFMPlaylistTitleRe.FindStringSubmatch(page) if len(titleMatch) < 2 { return "", 0, fmt.Errorf("could not parse playlist title") } totalMatch := lastFMTotalTracksRe.FindStringSubmatch(page) if len(totalMatch) < 2 { return "", 0, fmt.Errorf("could not parse total track count") } total, err := strconv.Atoi(totalMatch[1]) if err != nil { return "", 0, fmt.Errorf("invalid total track count") } return html.UnescapeString(strings.TrimSpace(titleMatch[1])), total, nil } func extractLastFMTitleArtistPairs(page string) []lastFMTrack { titles := lastFMTitleTagsRe.FindAllStringSubmatch(page, -1) out := make([]lastFMTrack, 0, len(titles)/2) for i := 0; i+1 < len(titles); i += 2 { title := html.UnescapeString(strings.TrimSpace(titles[i][1])) artist := html.UnescapeString(strings.TrimSpace(titles[i+1][1])) if title == "" || artist == "" { continue } out = append(out, lastFMTrack{Title: title, Artist: artist}) } return out } func extractLastFMTracksFromMirrorMarkdown(md string) (string, []lastFMTrack) { lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n") title := "" tracks := make([]lastFMTrack, 0, 100) for _, line := range lines { line = strings.TrimSpace(line) if title == "" { if m := lastFMMirrorTitleRe.FindStringSubmatch(line); len(m) >= 2 { title = strings.TrimSpace(html.UnescapeString(m[1])) } } if !strings.HasPrefix(line, "|") || !strings.Contains(strings.ToLower(line), "play track") { continue } cols := splitMarkdownTableRow(line) if len(cols) < 6 { continue } trackName := markdownLinkText(cols[3]) artist := markdownLinkText(cols[4]) if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artist) == "" { continue } tracks = append(tracks, lastFMTrack{Title: html.UnescapeString(strings.TrimSpace(trackName)), Artist: html.UnescapeString(strings.TrimSpace(artist))}) } return title, tracks } func splitMarkdownTableRow(line string) []string { trimmed := strings.TrimSpace(line) trimmed = strings.TrimPrefix(trimmed, "|") trimmed = strings.TrimSuffix(trimmed, "|") parts := strings.Split(trimmed, "|") out := make([]string, 0, len(parts)) for _, p := range parts { out = append(out, strings.TrimSpace(p)) } return out } func markdownLinkText(cell string) string { m := lastFMMirrorLinkTextRe.FindStringSubmatch(cell) if len(m) >= 2 { return m[1] } return strings.TrimSpace(cell) } func resolveLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOptions, tracks []lastFMTrack) ([]resolvedLastFMTrack, error) { primary, err := mainApp.GetLoggedInProvider(ctx, opts.Source) if err != nil { return nil, fmt.Errorf("%s login error: %w", opts.Source, err) } var fallback provider.Client if opts.FallbackSource != "" && opts.FallbackSource != opts.Source { fallback, err = mainApp.GetLoggedInProvider(ctx, opts.FallbackSource) if err != nil { return nil, fmt.Errorf("%s login error: %w", opts.FallbackSource, err) } } found := 0 failed := 0 resolved := make([]resolvedLastFMTrack, 0, len(tracks)) for i, tr := range tracks { query := strings.TrimSpace(tr.Title + " " + tr.Artist) id, source, searchErr := searchLastFMTrack(ctx, opts, primary, fallback, query) if searchErr != nil { failed++ fmt.Printf("[%d/%d] search failed: %s (%v)\n", i+1, len(tracks), query, searchErr) continue } if id == "" { failed++ fmt.Printf("[%d/%d] no result: %s\n", i+1, len(tracks), query) continue } resolved = append(resolved, resolvedLastFMTrack{Source: source, ID: id, Query: query}) found++ fmt.Printf("[%d/%d] found: %s (%s)\n", i+1, len(tracks), query, source) } fmt.Printf("lastfm resolve complete: %d found, %d failed\n", found, failed) return resolved, 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 { results := normalizeSearchResults(opts.Source, "track", pages) if len(results) > 0 { return results[0].ID, opts.Source, nil } } if fallback != nil { pages, fbErr := fallback.Search(ctx, "track", query, 1) if fbErr != nil { if err != nil { return "", "", fmt.Errorf("primary=%v fallback=%v", err, fbErr) } return "", "", fbErr } results := normalizeSearchResults(opts.FallbackSource, "track", pages) if len(results) > 0 { return results[0].ID, opts.FallbackSource, nil } } if err != nil { return "", "", err } return "", "", nil } type searchResult struct { ID string Title string Artist string Album string TrackCount int Explicit bool } type searchOptions struct { query string limit int ignoreDB bool noDownload bool first bool outputFile string } func parseSearchArgs(args []string, defaultLimit int) (searchOptions, error) { if defaultLimit <= 0 { defaultLimit = 20 } limit := defaultLimit parts := make([]string, 0, len(args)) ignoreDB := false noDownload := false first := false outputFile := "" for i := 0; i < len(args); i++ { if args[i] == "--" { if i+1 < len(args) { parts = append(parts, args[i+1:]...) } break } switch args[i] { case "--force", "--ignore-db": ignoreDB = true continue case "--no-download": noDownload = true continue case "--first": first = true continue case "--output-file": if i+1 >= len(args) { return searchOptions{}, fmt.Errorf("--output-file requires a path") } outputFile = strings.TrimSpace(args[i+1]) if outputFile == "" { return searchOptions{}, fmt.Errorf("--output-file requires a non-empty path") } i++ continue case "--num-results": if i+1 >= len(args) { return searchOptions{}, fmt.Errorf("--num-results requires a value") } v, err := strconv.Atoi(args[i+1]) if err != nil || v <= 0 { return searchOptions{}, fmt.Errorf("invalid --num-results value %q", args[i+1]) } limit = v i++ continue } if args[i] == "--limit" { if i+1 >= len(args) { return searchOptions{}, fmt.Errorf("--limit requires a value") } v, err := strconv.Atoi(args[i+1]) if err != nil || v <= 0 { return searchOptions{}, fmt.Errorf("invalid --limit value %q", args[i+1]) } limit = v i++ continue } if strings.HasPrefix(args[i], "-") { return searchOptions{}, fmt.Errorf("unknown option %q", args[i]) } parts = append(parts, args[i]) } return searchOptions{ query: strings.TrimSpace(strings.Join(parts, " ")), limit: limit, ignoreDB: ignoreDB, noDownload: noDownload, first: first, outputFile: outputFile, }, nil } func promptSearchSelection(results []searchResult) ([]int, error) { reader := bufio.NewReader(os.Stdin) for { fmt.Print("Select results to download (e.g. 1,3-5; a=all; q=cancel): ") line, err := reader.ReadString('\n') if err != nil { return nil, err } line = strings.TrimSpace(line) if line == "" || strings.EqualFold(line, "q") || strings.EqualFold(line, "quit") { return nil, nil } if strings.EqualFold(line, "a") || strings.EqualFold(line, "all") { out := make([]int, 0, len(results)) for i := range results { out = append(out, i) } return out, nil } selected := map[int]struct{}{} chunks := strings.Split(line, ",") ok := true for _, raw := range chunks { part := strings.TrimSpace(raw) if part == "" { continue } if strings.Contains(part, "-") { bounds := strings.SplitN(part, "-", 2) if len(bounds) != 2 { ok = false break } start, err1 := strconv.Atoi(strings.TrimSpace(bounds[0])) end, err2 := strconv.Atoi(strings.TrimSpace(bounds[1])) if err1 != nil || err2 != nil || start <= 0 || end <= 0 || start > end { ok = false break } for i := start; i <= end; i++ { if i > len(results) { ok = false break } selected[i-1] = struct{}{} } if !ok { break } continue } idx, err := strconv.Atoi(part) if err != nil || idx <= 0 || idx > len(results) { ok = false break } selected[idx-1] = struct{}{} } if !ok || len(selected) == 0 { fmt.Println("Invalid selection, try again.") continue } out := make([]int, 0, len(selected)) for idx := range selected { out = append(out, idx) } for i := 1; i < len(out); i++ { for j := i; j > 0 && out[j] < out[j-1]; j-- { out[j], out[j-1] = out[j-1], out[j] } } return out, nil } } func promptSearchSelectionMenu(source, mediaType, query string, results []searchResult) ([]int, error) { if len(results) == 0 { return nil, nil } labels := make([]string, 0, len(results)) labelToIndex := map[string]int{} for i, r := range results { 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 } selected := []string{} prompt := &survey.MultiSelect{ Message: fmt.Sprintf("Results for %s '%s' from %s", mediaType, query, strings.Title(source)), Help: "SPACE: select ENTER: download /: filter ESC: cancel", Options: labels, Description: func(value string, index int) string { resultIndex, ok := labelToIndex[value] if !ok || resultIndex < 0 || resultIndex >= len(results) { return "" } return formatSearchDetails(results[resultIndex]) }, PageSize: 15, } if err := survey.AskOne(prompt, &selected); err != nil { if strings.Contains(strings.ToLower(err.Error()), "interrupt") { return nil, nil } return nil, err } if len(selected) == 0 { return nil, nil } out := make([]int, 0, len(selected)) for _, label := range selected { if idx, ok := labelToIndex[label]; ok { out = append(out, idx) } } for i := 1; i < len(out); i++ { for j := i; j > 0 && out[j] < out[j-1]; j-- { out[j], out[j-1] = out[j-1], out[j] } } return out, nil } func writeSearchResultsToFile(source, mediaType string, results []searchResult, path string) error { type outItem struct { Source string `json:"source"` MediaType string `json:"media_type"` ID string `json:"id"` Title string `json:"title"` } out := make([]outItem, 0, len(results)) for _, r := range results { out = append(out, outItem{Source: source, MediaType: mediaType, ID: r.ID, Title: r.Title}) } b, err := json.MarshalIndent(out, "", " ") if err != nil { return err } dir := filepath.Dir(path) if dir != "" && dir != "." { if err = os.MkdirAll(dir, 0o755); err != nil { return err } } return os.WriteFile(path, b, 0o644) } func isAllowedSearchSource(source string) bool { return source == "qobuz" || source == "tidal" || source == "deezer" || source == "soundcloud" } func isAllowedMediaType(mediaType string) bool { switch mediaType { case "track", "album", "playlist", "artist", "label", "video": return true default: return false } } func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, error) { reader := bufio.NewReader(os.Stdin) read := func(prompt string) (string, error) { fmt.Print(prompt) line, err := reader.ReadString('\n') if err != nil { return "", err } return strings.TrimSpace(line), nil } for { source, err := read("Source [qobuz/tidal/deezer/soundcloud]: ") if err != nil { return "", "", searchOptions{}, err } source = strings.ToLower(source) if !isAllowedSearchSource(source) { fmt.Println("Invalid source.") continue } mediaType, err := read("Type [track/album/playlist/artist/label/video]: ") if err != nil { return "", "", searchOptions{}, err } mediaType = strings.ToLower(mediaType) if !isAllowedMediaType(mediaType) { fmt.Println("Invalid media type.") continue } if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" { fmt.Println("SoundCloud search supports track and playlist only.") continue } query, err := read("Query: ") if err != nil { return "", "", searchOptions{}, err } if strings.TrimSpace(query) == "" { fmt.Println("Query cannot be empty.") continue } limitRaw, err := read(fmt.Sprintf("Limit [%d]: ", defaultLimit)) if err != nil { return "", "", searchOptions{}, err } limit := defaultLimit if strings.TrimSpace(limitRaw) != "" { v, convErr := strconv.Atoi(limitRaw) if convErr != nil || v <= 0 { fmt.Println("Invalid limit.") continue } limit = v } return source, mediaType, searchOptions{query: query, limit: limit}, nil } } func normalizeSearchResults(source, mediaType string, pages []map[string]any) []searchResult { results := make([]searchResult, 0) for _, page := range pages { switch source { case "qobuz": 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"]) } if version := asString(itm["version"]); version != "" { title += " (" + version + ")" } artist := nestedSearchString(itm, "artist", "name") if artist == "" { artist = nestedSearchString(itm, "performer", "name") } album := nestedSearchString(itm, "album", "title") trackCount := searchInt(itm["tracks_count"]) if trackCount == 0 { trackCount = searchInt(itm["track_count"]) } explicit := searchBool(itm["parental_warning"]) if id != "" && title != "" { results = append(results, searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit}) } } case "tidal": items, ok := page["items"].([]any) if !ok { continue } for _, raw := range items { itm, ok := raw.(map[string]any) if !ok { continue } if wrapped, ok := itm["item"].(map[string]any); ok { itm = wrapped } id := asString(itm["id"]) title := asString(itm["title"]) if title == "" { title = asString(itm["name"]) } artist := nestedSearchString(itm, "artist", "name") if artist == "" { if artists, ok := itm["artists"].([]any); ok && len(artists) > 0 { if a0, ok := artists[0].(map[string]any); ok { artist = asString(a0["name"]) } } } album := nestedSearchString(itm, "album", "title") trackCount := searchInt(itm["numberOfTracks"]) if trackCount == 0 { trackCount = searchInt(itm["tracks_count"]) } explicit := searchBool(itm["explicit"]) if id != "" && title != "" { 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") trackCount := searchInt(itm["tracks_count"]) if id != "" && title != "" { results = append(results, searchResult{ID: id, Title: title, Artist: artist, TrackCount: trackCount}) } } } } return results } func formatSearchDetails(r searchResult) string { 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)) } if strings.TrimSpace(r.Album) != "" { lines = append(lines, fmt.Sprintf("Album : %s", r.Album)) } if r.TrackCount > 0 { 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") } func nestedSearchString(v map[string]any, keys ...string) string { cur := any(v) for _, key := range keys { m, ok := cur.(map[string]any) if !ok { return "" } cur = m[key] } return asString(cur) } func searchInt(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(t) return i default: return 0 } } func searchBool(v any) bool { b, ok := v.(bool) return ok && b }