Files
streamrip-go/cmd/rip/main.go
T
JorenandClaude Opus 5 201aa5daa7 fix: database migration, config validation and provider client defects
Completes the correctness pass.

Download database migration no longer destroys dedupe state
  The pre-source-column migration carried every legacy row over with
  source='' and then dropped downloads_legacy, while IsDownloaded matched
  source exactly. The result was that every previously recorded download
  looked new, the user silently re-downloaded their whole library, and the
  original rows were gone. Now the file is copied to <path>.bak-<unixtime>
  first, downloads_legacy is kept, and IsDownloaded falls back to the
  sourceless rows (over-matching across sources costs a skipped re-download,
  which is the cheaper error). Schema state is tracked in PRAGMA user_version
  so later migrations can be ordered explicitly. Databases already migrated by
  the old code are detected on open.

  Also enables WAL and a 10s busy_timeout and caps the pool at one connection,
  so a concurrent reader no longer gets SQLITE_BUSY with no retry, and builds
  the DSN through url.URL so a path containing '?' is not parsed as query
  parameters.

--quality now applies to every source
  applyGlobalConfigOverrides set only qobuz, tidal, deezer and soundcloud, so
  `rip -q 4 url <beatport-url>` was a silent no-op even though
  qualityForSource reads Yandex and Beatport. All six are set, with the
  per-source floor applied and reported rather than silently ignored.

config: validation and a fixed reset
  Load checked nothing but misc.version, so a negative max_connections or a
  bogus conversion codec was accepted and only surfaced mid-rip. Adds
  Validate() covering per-source quality ranges, max_connections,
  requests_per_minute and truncate_to, and normalizes the conversion codec
  from the file through the same path as --codec. `config reset` assigned one
  value to both Config.File and Config.Session, so their Secrets slices shared
  a backing array; it now takes separate copies.

  Tests cover the shipped defaults and config.toml.example, which is how the
  over-strict artwork max-width bound in the first draft was caught: -1 is the
  documented "do not downscale" sentinel.

soundcloud: use the shared HTTP client
  It was the only provider constructing its own http.Client, so it silently
  ignored downloads.verify_ssl and max_connections and bypassed the transport
  that redacts credentials from -vv logs. It also had no rate limiter at all.

qobuz: mobile fallback could not download a long track
  http.Client.Timeout bounds the whole request including the body read, so the
  30s API client aborted any track that took longer than 30s to stream. The
  body copy now uses a no-timeout client, as internal/download already does.

qobuz: HKDF no longer fails silently
  hexDecodeOrNil discarded its error, so a non-hex app secret keyed HKDF on
  nil, derived a wrong KEK and produced silently corrupt audio instead of an
  error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 16:58:59 +02:00

907 lines
25 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"golang.org/x/term"
"streamrip-go/internal/app"
"streamrip-go/internal/config"
"streamrip-go/internal/provider"
"streamrip-go/internal/verbose"
_ "modernc.org/sqlite"
)
func main() {
os.Exit(run())
}
// run executes the CLI and returns the process exit code. It exists so that
// deferred cleanup (SQLite handles, progress bars, artwork temp dirs) always
// runs; calling os.Exit directly from the command bodies skipped every defer.
func run() int {
gopts, err := parseGlobalArgs(os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
return 2
}
if gopts.help {
printMainHelp(os.Stdout)
return 0
}
if gopts.command == "" {
printMainHelp(os.Stdout)
return 2
}
if gopts.command == "help" {
if len(gopts.commandArgs) == 0 || isHelpArg(gopts.commandArgs[0]) {
printMainHelp(os.Stdout)
return 0
}
if printCommandHelp(os.Stdout, gopts.commandArgs[0]) {
return 0
}
fmt.Fprintf(os.Stderr, "unknown help topic: %s\n", gopts.commandArgs[0])
return 2
}
if gopts.command == "dev-help" {
printDeveloperHelp(os.Stdout)
return 0
}
if commandWantsHelp(gopts.commandArgs) {
if printCommandHelp(os.Stdout, gopts.command) {
return 0
}
fmt.Fprintf(os.Stderr, "unknown command: %s\n", gopts.command)
return 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)
return 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)
return 1
}
applyGlobalConfigOverrides(cfg, gopts)
verbose.SetLevel(gopts.verbose)
if gopts.verbose >= 2 {
fmt.Fprintln(os.Stderr, "verbose mode enabled (level 2: downloads + http)")
} else if gopts.verbose >= 1 {
fmt.Fprintln(os.Stderr, "verbose mode enabled (level 1: downloads)")
}
os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
switch os.Args[1] {
case "url":
if len(os.Args) < 3 {
printCommandHelp(os.Stdout, "url")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
rawArgs := make([]string, 0, len(os.Args[2:]))
ignoreDB := false
for _, arg := range os.Args[2:] {
if arg == "--force" || arg == "--ignore-db" {
ignoreDB = true
continue
}
rawArgs = append(rawArgs, arg)
}
mainApp.IgnoreDB = ignoreDB || gopts.noDB
added := 0
for _, raw := range rawArgs {
if addURLToQueue(ctx, mainApp, raw) {
added++
}
}
if added == 0 {
fmt.Println("nothing to rip")
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("url rip complete (%d item(s))\n", added)
case "file":
if len(os.Args) < 3 {
printCommandHelp(os.Stdout, "file")
return 2
}
ignoreDB := false
for _, arg := range os.Args[3:] {
switch arg {
case "--force", "--ignore-db":
ignoreDB = true
default:
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
return 2
}
}
content, err := os.ReadFile(os.Args[2])
if err != nil {
fmt.Fprintf(os.Stderr, "read file error: %v\n", err)
return 1
}
idItems, urls, repeated, jsonInput, err := parseFileInput(content)
if err != nil {
fmt.Fprintf(os.Stderr, "file parse error: %v\n", err)
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = ignoreDB || gopts.noDB
added := 0
if jsonInput {
fmt.Printf("detected json file. loading %d item(s)\n", len(idItems))
for _, item := range idItems {
if err = mainApp.AddByID(ctx, item.Source, item.MediaType, item.ID); err != nil {
fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", item.Source, item.MediaType, item.ID, err)
continue
}
added++
}
} else {
if repeated > 0 {
fmt.Printf("found %d repeated url(s)\n", repeated)
}
fmt.Printf("detected list of urls. loading %d item(s)\n", len(urls))
for _, raw := range urls {
if addURLToQueue(ctx, mainApp, raw) {
added++
}
}
}
if added == 0 {
fmt.Println("nothing to rip")
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("file rip complete (%d item(s))\n", added)
case "config":
if len(os.Args) < 3 {
printCommandHelp(os.Stdout, "config")
return 2
}
switch os.Args[2] {
case "open":
vim := false
for _, arg := range os.Args[3:] {
switch arg {
case "-v", "--vim":
vim = true
default:
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
return 2
}
}
fmt.Printf("opening file at %s\n", cfg.Path)
if err = openConfigInEditor(cfg.Path, vim); err != nil {
fmt.Fprintf(os.Stderr, "open config error: %v\n", err)
return 1
}
case "reset":
yes := false
for _, arg := range os.Args[3:] {
switch arg {
case "-y", "--yes":
yes = true
default:
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
return 2
}
}
if !yes {
if !term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprintln(os.Stderr, "reset requires --yes in non-interactive mode")
return 2
}
ok, askErr := promptYesNo(fmt.Sprintf("Are you sure you want to reset the config file at %s? [y/N]: ", cfg.Path))
if askErr != nil {
fmt.Fprintf(os.Stderr, "prompt error: %v\n", askErr)
return 1
}
if !ok {
fmt.Println("reset aborted")
return 0
}
}
def := config.DefaultConfigData()
// Separate copies: assigning the same value to both halves made
// File.Qobuz.Secrets and Session.Qobuz.Secrets share one backing
// array.
cfg.File = config.CloneConfigData(def)
cfg.Session = config.CloneConfigData(def)
if err = cfg.SaveFile(); err != nil {
fmt.Fprintf(os.Stderr, "reset config error: %v\n", err)
return 1
}
fmt.Printf("reset the config file at %s\n", cfg.Path)
case "path":
if len(os.Args) > 3 {
fmt.Fprintf(os.Stderr, "option error: unexpected argument %q\n", os.Args[3])
return 2
}
fmt.Printf("config path: '%s'\n", cfg.Path)
default:
fmt.Fprintf(os.Stderr, "unknown config command: %s\n", os.Args[2])
return 2
}
case "database":
if len(os.Args) < 4 || os.Args[2] != "browse" {
printCommandHelp(os.Stdout, "database")
return 2
}
table := strings.ToLower(strings.TrimSpace(os.Args[3]))
switch table {
case "downloads":
rows, listErr := listDownloadsRows(cfg.Session.Database.DownloadsPath)
if listErr != nil {
fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr)
return 1
}
fmt.Println("downloads database")
fmt.Println("row id")
for i, id := range rows {
fmt.Printf("%02d %s\n", i, id)
}
case "failed":
rows, listErr := listFailedRows(cfg.Session.Database.FailedDownloadsPath)
if listErr != nil && isNoSuchTableErr(listErr) && cfg.Session.Database.FailedDownloadsPath != cfg.Session.Database.DownloadsPath {
rows, listErr = listFailedRows(cfg.Session.Database.DownloadsPath)
}
if listErr != nil {
fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr)
return 1
}
fmt.Println("failed downloads database")
fmt.Println("row source media_type id")
for i, row := range rows {
fmt.Printf("%02d %s %s %s\n", i, row.Source, row.MediaType, row.ID)
}
default:
fmt.Fprintf(os.Stderr, "invalid database %q. choose downloads or failed\n", table)
return 2
}
case "id":
if len(os.Args) < 5 {
printCommandHelp(os.Stdout, "id")
return 2
}
source := strings.ToLower(strings.TrimSpace(os.Args[2]))
mediaType := strings.ToLower(strings.TrimSpace(os.Args[3]))
itemID := strings.TrimSpace(os.Args[4])
opts, err := parseSmokeOptions(os.Args[5:], 0, 4)
if err != nil {
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
return 2
}
if opts.qualitySet {
switch source {
case "qobuz":
if opts.quality < 1 {
fmt.Fprintf(os.Stderr, "quality error: qobuz quality must be 1-4\n")
return 2
}
cfg.Session.Qobuz.Quality = opts.quality
case "tidal":
cfg.Session.Tidal.Quality = opts.quality
case "yandex":
cfg.Session.Yandex.Quality = opts.quality
case "beatport":
if opts.quality < 1 {
fmt.Fprintf(os.Stderr, "quality error: beatport quality must be 1-4\n")
return 2
}
cfg.Session.Beatport.Quality = opts.quality
}
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
if err = mainApp.AddByID(ctx, source, mediaType, itemID); err != nil {
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
return 1
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID)
case "search":
var source, mediaType string
var sopts searchOptions
if len(os.Args) < 5 {
if !term.IsTerminal(int(os.Stdin.Fd())) {
printCommandHelp(os.Stdout, "search")
return 2
}
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
if err != nil {
fmt.Fprintf(os.Stderr, "search prompt error: %v\n", err)
return 2
}
} else {
source = strings.ToLower(strings.TrimSpace(os.Args[2]))
mediaType = strings.ToLower(strings.TrimSpace(os.Args[3]))
sopts, err = parseSearchArgs(os.Args[4:], cfg.Session.CLI.MaxSearchResults)
}
if err != nil {
fmt.Fprintf(os.Stderr, "search option error: %v\n", err)
return 2
}
if sopts.first && sopts.outputFile != "" {
fmt.Fprintln(os.Stderr, "cannot choose --first and --output-file together")
return 2
}
if !isAllowedSearchSource(source) {
fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source)
return 2
}
if !isAllowedMediaType(mediaType) {
fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType)
return 2
}
if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist")
return 2
}
if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" {
fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist")
return 2
}
if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" {
fmt.Fprintln(os.Stderr, "beatport search currently supports media types track, album, and label")
return 2
}
if sopts.query == "" {
fmt.Fprintln(os.Stderr, "search query cannot be empty")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, source)
if err != nil {
fmt.Fprintf(os.Stderr, "%s login error: %v\n", source, err)
return 1
}
pages, err := provider.Search(ctx, mediaType, sopts.query, sopts.limit)
if err != nil {
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
return 1
}
results := normalizeSearchResults(source, mediaType, pages)
if len(results) == 0 {
fmt.Println("no results")
return 0
}
if sopts.outputFile != "" {
fmt.Printf("results: %d\n", len(results))
for i, result := range results {
fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title)
}
if err = writeSearchResultsToFile(source, mediaType, results, sopts.outputFile); err != nil {
fmt.Fprintf(os.Stderr, "write results error: %v\n", err)
return 1
}
fmt.Printf("wrote %d results to %s\n", len(results), sopts.outputFile)
return 0
}
if sopts.first {
fmt.Printf("results: %d\n", len(results))
fmt.Printf(" 1. id=%s | %s\n", results[0].ID, results[0].Title)
results = results[:1]
}
if sopts.noDownload {
fmt.Printf("results: %d\n", len(results))
for i, result := range results {
fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title)
}
return 0
}
if !sopts.first {
fmt.Printf("results: %d\n", len(results))
}
if sopts.first {
selection := []int{0}
mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB
skippedDownloaded := 0
added := 0
for _, idx := range selection {
item := results[idx]
if !sopts.ignoreDB {
already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID)
if checkErr == nil && already {
skippedDownloaded++
fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title)
continue
}
}
if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil {
fmt.Printf("add failed: id=%s err=%v\n", item.ID, err)
continue
}
added++
}
if added == 0 {
if skippedDownloaded > 0 {
fmt.Println("selected item was already downloaded (use --force to redownload)")
} else {
fmt.Println("nothing selected to download")
}
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("search download complete (%d item(s))\n", added)
return 0
}
if !term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Println("non-interactive input; use `rip id` to download specific results")
return 0
}
selection, err := promptSearchSelectionMenu(source, mediaType, sopts.query, results)
if err != nil {
fmt.Fprintf(os.Stderr, "selection error: %v\n", err)
return 2
}
if len(selection) == 0 {
fmt.Println("download cancelled")
return 0
}
mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB
skippedDownloaded := 0
added := 0
for _, idx := range selection {
item := results[idx]
if !sopts.ignoreDB {
already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID)
if checkErr == nil && already {
skippedDownloaded++
fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title)
continue
}
}
if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil {
fmt.Printf("add failed: id=%s err=%v\n", item.ID, err)
continue
}
added++
}
if added == 0 {
if skippedDownloaded > 0 {
fmt.Println("all selected items were already downloaded (use --force to redownload)")
} else {
fmt.Println("nothing selected to download")
}
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("search download complete (%d item(s))\n", added)
case "lastfm":
opts, parseErr := parseLastFMArgs(os.Args[2:], cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource)
if parseErr != nil {
fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr)
printCommandHelp(os.Stdout, "lastfm")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = gopts.noDB
title, tracks, err := fetchLastFMPlaylist(ctx, cfg.Session.Downloads.VerifySSL, opts.PlaylistURL)
if err != nil {
fmt.Fprintf(os.Stderr, "lastfm parse error: %v\n", err)
return 1
}
if len(tracks) == 0 {
fmt.Println("no tracks found in playlist")
return 0
}
fmt.Printf("lastfm playlist: %s (%d tracks)\n", title, len(tracks))
resolvedTracks, err := resolveLastFMTracks(ctx, mainApp, opts, tracks)
if err != nil {
fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err)
return 1
}
if len(resolvedTracks) == 0 {
fmt.Println("no lastfm tracks resolved")
return 0
}
playlistID := fmt.Sprintf("lastfm:%s", strings.ToLower(strings.ReplaceAll(title, " ", "_")))
refs := make([]app.PlaylistTrackRef, 0, len(resolvedTracks))
for _, item := range resolvedTracks {
refs = append(refs, app.PlaylistTrackRef{Source: item.Source, ID: item.ID})
}
if addErr := mainApp.AddMixedPlaylistByTrackRefs(ctx, playlistID, title, refs); addErr != nil {
fmt.Printf("playlist queue failed: err=%v\n", addErr)
fmt.Println("no lastfm playlists queued")
return 0
}
fmt.Printf("queued lastfm playlist: %s (%d tracks)\n", title, len(refs))
if len(refs) == 0 {
fmt.Println("no lastfm playlists queued")
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
return 1
}
fmt.Printf("lastfm rip complete (%d track(s) across 1 playlist)\n", len(resolvedTracks))
case "soundcloud-smoke":
if len(os.Args) < 3 {
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
return 2
}
meta, err := fetchSoundcloudOEmbed(ctx, cfg.Session.Downloads.VerifySSL, strings.TrimSpace(os.Args[2]))
if err != nil {
fmt.Fprintf(os.Stderr, "soundcloud smoke error: %v\n", err)
return 1
}
fmt.Printf("soundcloud oembed ok: title=%q author=%q provider=%q\n", asString(meta["title"]), asString(meta["author_name"]), asString(meta["provider_name"]))
case "qobuz-smoke":
if len(os.Args) < 3 {
fmt.Println("usage: rip qobuz-smoke <track_id> [quality]")
return 2
}
opts, err := parseSmokeOptions(os.Args[3:], 1, 4)
if err != nil {
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
return 2
}
if opts.qualitySet {
cfg.Session.Qobuz.Quality = opts.quality
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz")
if err != nil {
fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err)
return 1
}
trackID := os.Args[2]
meta, err := provider.GetMetadata(ctx, trackID, "track")
if err != nil {
fmt.Fprintf(os.Stderr, "metadata error: %v\n", err)
return 1
}
title, _ := meta["title"].(string)
d, err := provider.GetDownloadable(ctx, trackID, cfg.Session.Qobuz.Quality)
if err != nil {
fmt.Fprintf(os.Stderr, "downloadable error: %v\n", err)
return 1
}
fmt.Printf("qobuz ok: title=%q quality=%d ext=%s\n", title, cfg.Session.Qobuz.Quality, d.Extension)
fmt.Printf("stream_url=%s\n", d.URL)
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]")
return 2
}
opts, err := parseSmokeOptions(os.Args[4:], 1, 4)
if err != nil {
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
return 2
}
if opts.qualitySet {
cfg.Session.Qobuz.Quality = opts.quality
}
cfg.Session.Conversion.Enabled = true
cfg.Session.Conversion.Codec = strings.ToUpper(strings.TrimSpace(os.Args[3]))
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
trackID := os.Args[2]
if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil {
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
return 1
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
return 1
}
fmt.Printf("qobuz convert rip smoke complete (codec=%s)\n", cfg.Session.Conversion.Codec)
case "qobuz-search-smoke":
if len(os.Args) < 3 {
fmt.Println("usage: rip qobuz-search-smoke <query>")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz")
if err != nil {
fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err)
return 1
}
query := strings.Join(os.Args[2:], " ")
pages, err := provider.Search(ctx, "album", query, 10)
if err != nil {
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
return 1
}
for _, page := range pages {
albums, ok := page["albums"].(map[string]any)
if !ok {
continue
}
items, ok := albums["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
id := asString(item["id"])
title := asString(item["title"])
version := asString(item["version"])
bitDepth := asString(item["maximum_bit_depth"])
sr := asString(item["maximum_sampling_rate"])
if version != "" {
title = title + " (" + version + ")"
}
fmt.Printf("album_id=%s | %s | %sB-%skHz\n", id, title, bitDepth, sr)
}
}
case "tidal-search-smoke":
if len(os.Args) < 3 {
fmt.Println("usage: rip tidal-search-smoke <query>")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, "tidal")
if err != nil {
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
return 1
}
query := strings.Join(os.Args[2:], " ")
pages, err := provider.Search(ctx, "album", query, 10)
if err != nil {
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
return 1
}
for _, page := range pages {
items, ok := page["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
wrapper, ok := raw.(map[string]any)
if !ok {
continue
}
item, ok := wrapper["item"].(map[string]any)
if !ok {
item = wrapper
}
fmt.Printf("album_id=%s | %s\n", asString(item["id"]), asString(item["title"]))
}
}
case "tidal-metadata-smoke":
if len(os.Args) < 4 {
fmt.Println("usage: rip tidal-metadata-smoke <track|album|playlist|artist> <id>")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, "tidal")
if err != nil {
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
return 1
}
mediaType := os.Args[2]
itemID := os.Args[3]
meta, err := provider.GetMetadata(ctx, itemID, mediaType)
if err != nil {
fmt.Fprintf(os.Stderr, "metadata error: %v\n", err)
return 1
}
title := asString(meta["title"])
if title == "" {
title = asString(meta["name"])
}
trackCount := 0
if tracksMap, ok := meta["tracks"].(map[string]any); ok {
if items, ok := tracksMap["items"].([]map[string]any); ok {
trackCount = len(items)
} else if anyItems, ok := tracksMap["items"].([]any); ok {
trackCount = len(anyItems)
}
}
fmt.Printf("tidal metadata ok: type=%s id=%s title=%q tracks=%d\n", mediaType, itemID, title, trackCount)
case "tidal-video-smoke":
if len(os.Args) < 3 {
fmt.Println("usage: rip tidal-video-smoke <video_id>")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
providerClient, err := mainApp.GetLoggedInProvider(ctx, "tidal")
if err != nil {
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
return 1
}
videoProvider, ok := providerClient.(interface {
GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error)
})
if !ok {
fmt.Fprintln(os.Stderr, "tidal provider does not support video downloadable")
return 1
}
videoID := strings.TrimSpace(os.Args[2])
meta, err := providerClient.GetMetadata(ctx, videoID, "video")
if err != nil {
fmt.Fprintf(os.Stderr, "video metadata error: %v\n", err)
return 1
}
d, err := videoProvider.GetVideoDownloadable(ctx, videoID)
if err != nil {
fmt.Fprintf(os.Stderr, "video downloadable error: %v\n", err)
return 1
}
title := asString(meta["title"])
if title == "" {
title = asString(meta["name"])
}
fmt.Printf("tidal video ok: id=%s title=%q ext=%s\n", videoID, title, d.Extension)
fmt.Printf("stream_url=%s\n", d.URL)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
return 2
}
return 0
}