mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
Six correctness bugs that the existing tests could not see. The suite passed and `go test -race` was clean throughout, because nothing exercised concurrent token refresh, shared-folder artwork, or hostile metadata. main(): extract run() int main() was a single 1196-line function that registered 20 `defer mainApp.Close()` handlers and then called os.Exit 150 times, so none of them ever ran. Close() is what closes the SQLite handle, flushes the progress bars and removes the artwork temp dirs, so every failed rip leaked temp directories. main() is now a wrapper around run() int and the command bodies return exit codes. Ctrl-C now stops a rip ctx was threaded everywhere but never checked: zero ctx.Err()/ctx.Done() in internal/app and internal/download. Cancellation left Rip() iterating the whole remaining queue, and the retry ladder retried on a dead context, so one Ctrl-C cost three doomed attempts per remaining track. Adds checks to the dispatch loops, the worker-spawn loops and the retry ladder, and routes the MarkFailed/MarkDownloaded bookkeeping through a detached context so the records survive cancellation. Convert and TagFLAC now take a ctx and use exec.CommandContext, so a stalled ffmpeg/ffprobe no longer outlives Ctrl-C. Config race Provider token refresh mutated the shared *config.Config and called SaveFile() from inside download workers with no lock, racing the struct fields and issuing concurrent os.WriteFile against one path. Adds a mutex to config.Config plus UpdateTokens(), and routes all five refresh sites through it. Each provider client gets an authMu guarding loggedIn and its login/refresh path; beatport needed it most, since ensureToken is reached from apiRequest. Artwork cover race artwork.Prepare was check-then-write on a path shared by every worker in a playlist rip. Two workers both saw a missing cover.jpg, both created it, and the loser's cleanup deleted the winner's finished file. Adds a per-folder lock and downloads via a unique temp file + rename. The regression test fails against the old code with 15 of 16 workers getting no cover. Path traversal from remote metadata naming.CleanName stripped <>:"/\|?* and control chars but not "." , so a title of ".." survived into filepath.Join and escaped the download root. SoundCloud and Tidal playlist names are user-generated. Now rejects "." and "..", trims trailing dots, handles Windows device names, adds DEL to the invalid set and caps components at 255 bytes without splitting a rune. app.go gains safeJoin as a second line of defence. Credentials no longer world-readable The config holds plaintext provider passwords, Deezer ARL cookies and OAuth tokens and was written 0o644. Now 0o600, with 0o700 directories, and an existing 0o644 file is tightened on write. Also: deletes the 9 duplicated *-rip-smoke dev commands (~300 lines fully subsumed by `rip id`) and the unreachable second dev-help arm; registers image/png so PNG covers are downscaled instead of being silently saved as cover.jpg containing PNG bytes; adds the missing .gitignore, which was keeping a 21MB build artifact one `git add -A` away from the repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
904 lines
25 KiB
Go
904 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()
|
|
cfg.File = def
|
|
cfg.Session = 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
|
|
}
|