mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
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>
907 lines
25 KiB
Go
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
|
|
}
|