Files
streamrip-go/cmd/rip/main.go
Joren b2688ce949 add CLI parity flags and expand provider support
This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
2026-04-20 00:56:10 +02:00

2275 lines
61 KiB
Go

package main
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"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 <command>")
fmt.Println("commands: url, file, config, database, id, search, lastfm, soundcloud-smoke, qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke, qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke, qobuz-search-smoke, tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke, tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke")
os.Exit(2)
}
cfg, err := config.Load(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 "url":
if len(os.Args) < 3 {
fmt.Println("usage: rip url <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: %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("url rip complete (%d item(s))\n", added)
case "file":
if len(os.Args) < 3 {
fmt.Println("usage: rip file <path> [--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: %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("file rip complete (%d item(s))\n", added)
case "config":
if len(os.Args) < 3 {
fmt.Println("usage: rip config <open|reset|path> [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 <downloads|failed>")
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 <source> <track|album|playlist|artist|label|video> <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: %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("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 <qobuz|tidal|deezer|soundcloud> <track|album|playlist|artist|label|video> <query...> [--limit N] [--force|--ignore-db] [--no-download]")
os.Exit(2)
}
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
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 !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" {
fmt.Fprintln(os.Stderr, "soundcloud search currently supports media type track only")
os.Exit(2)
}
if sopts.query == "" {
fmt.Fprintln(os.Stderr, "search query cannot be empty")
os.Exit(2)
}
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: %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("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: %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("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] <playlist_url>")
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))
if err = queueLastFMTracks(ctx, mainApp, opts, tracks); err != nil {
fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err)
os.Exit(1)
}
if len(mainApp.Pending) == 0 {
fmt.Println("no lastfm tracks resolved")
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))\n", len(mainApp.Pending))
case "soundcloud-smoke":
if len(os.Args) < 3 {
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
os.Exit(2)
}
meta, err := fetchSoundcloudOEmbed(ctx, cfg.Session.Downloads.VerifySSL, strings.TrimSpace(os.Args[2]))
if err != nil {
fmt.Fprintf(os.Stderr, "soundcloud smoke error: %v\n", err)
os.Exit(1)
}
fmt.Printf("soundcloud oembed ok: title=%q author=%q provider=%q\n", asString(meta["title"]), asString(meta["author_name"]), asString(meta["provider_name"]))
case "qobuz-smoke":
if len(os.Args) < 3 {
fmt.Println("usage: rip qobuz-smoke <track_id> [quality]")
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 <track_id> [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 <track_id> <codec> [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 <album_id> [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 <playlist_id> [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 <artist_id> [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 <label_id> [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 <query>")
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 <query>")
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 <track|album|playlist|artist> <id>")
os.Exit(2)
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
os.Exit(1)
}
defer func() { _ = mainApp.Close() }()
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 <video_id>")
os.Exit(2)
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
os.Exit(1)
}
defer func() { _ = mainApp.Close() }()
providerClient, err := mainApp.GetLoggedInProvider(ctx, "tidal")
if err != nil {
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
os.Exit(1)
}
videoProvider, ok := providerClient.(interface {
GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error)
})
if !ok {
fmt.Fprintln(os.Stderr, "tidal provider does not support video downloadable")
os.Exit(1)
}
videoID := strings.TrimSpace(os.Args[2])
meta, err := providerClient.GetMetadata(ctx, videoID, "video")
if err != nil {
fmt.Fprintf(os.Stderr, "video metadata error: %v\n", err)
os.Exit(1)
}
d, err := videoProvider.GetVideoDownloadable(ctx, videoID)
if err != nil {
fmt.Fprintf(os.Stderr, "video downloadable error: %v\n", err)
os.Exit(1)
}
title := asString(meta["title"])
if title == "" {
title = asString(meta["name"])
}
fmt.Printf("tidal video ok: id=%s title=%q ext=%s\n", videoID, title, d.Extension)
fmt.Printf("stream_url=%s\n", d.URL)
case "tidal-rip-smoke":
if len(os.Args) < 3 {
fmt.Println("usage: rip tidal-rip-smoke <track_id> [quality] [--force|--ignore-db]")
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 <album_id> [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 <playlist_id> [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 <artist_id> [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 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
}
var (
lastFMTitleTagsRe = regexp.MustCompile(`<a\s+href="[^"]+"\s+title="([^"]+)"`)
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1 class="playlisting-playlist-header-title">([^<]+)</h1>`)
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 !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 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")
}
client := netutil.NewHTTPClient(30*time.Second, verifySSL)
page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1)
if err != nil {
return "", nil, err
}
title, total, err := extractLastFMPlaylistInfo(page1)
if err != nil {
return "", nil, err
}
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 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 queueLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOptions, tracks []lastFMTrack) error {
primary, err := mainApp.GetLoggedInProvider(ctx, opts.Source)
if err != nil {
return 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 fmt.Errorf("%s login error: %w", opts.FallbackSource, err)
}
}
found := 0
failed := 0
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
}
if err = mainApp.AddByID(ctx, source, "track", id); err != nil {
failed++
fmt.Printf("[%d/%d] add failed: %s (%v)\n", i+1, len(tracks), query, err)
continue
}
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 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++ {
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])
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
}
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
}
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" {
fmt.Println("SoundCloud search supports track 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")
if id != "" && title != "" {
results = append(results, searchResult{ID: id, Title: title, Artist: artist})
}
}
}
}
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
}