mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
refactor: split the command dispatch out of main.go
Completes the restructuring started when main() became run() int. main.go goes
from 1217 lines to 130: global option handling, config loading, signal setup,
and a dispatch switch that delegates to one function per command.
The command bodies move to cmd_rip.go (url, file, id), cmd_search.go (search,
lastfm), cmd_config.go (config, database) and cmd_smoke.go (the developer smoke
commands), each taking (ctx, cfg, gopts, args) and returning an exit code.
They also stop reading the process global. Previously run() did
os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...)
and the arms then indexed back into os.Args[2:], os.Args[5:] and so on, so the
already-parsed options were round-tripped through os.Args. The arms now take
the parsed gopts.commandArgs directly, which is a uniform index shift of two
(os.Args[5:] -> args[3:], len(os.Args) < 5 -> len(args) < 3).
No behaviour change. Verified by running each command's argument validation and
help path against the built binary, plus the existing suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+936
-800
No files matched your search
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Commands that inspect local state: config and database.
|
||||
|
||||
func cmdConfig(_ context.Context, cfg *config.Config, _ globalOptions, args []string) int {
|
||||
var err error
|
||||
if len(args) < 1 {
|
||||
printCommandHelp(os.Stdout, "config")
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "open":
|
||||
vim := false
|
||||
for _, arg := range args[1:] {
|
||||
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 args[1:] {
|
||||
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(args) > 1 {
|
||||
fmt.Fprintf(os.Stderr, "option error: unexpected argument %q\n", args[1])
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("config path: '%s'\n", cfg.Path)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0])
|
||||
return 2
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdDatabase(_ context.Context, cfg *config.Config, _ globalOptions, args []string) int {
|
||||
if len(args) < 2 || args[0] != "browse" {
|
||||
printCommandHelp(os.Stdout, "database")
|
||||
return 2
|
||||
}
|
||||
table := strings.ToLower(strings.TrimSpace(args[1]))
|
||||
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
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/app"
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
// Commands that queue media and rip it: url, file and id.
|
||||
|
||||
func cmdURL(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
|
||||
if len(args) < 1 {
|
||||
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(args))
|
||||
ignoreDB := false
|
||||
for _, arg := range args {
|
||||
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)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdFile(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
|
||||
if len(args) < 1 {
|
||||
printCommandHelp(os.Stdout, "file")
|
||||
return 2
|
||||
}
|
||||
|
||||
ignoreDB := false
|
||||
for _, arg := range args[1:] {
|
||||
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(args[0])
|
||||
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)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdID(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
|
||||
if len(args) < 3 {
|
||||
printCommandHelp(os.Stdout, "id")
|
||||
return 2
|
||||
}
|
||||
|
||||
source := strings.ToLower(strings.TrimSpace(args[0]))
|
||||
mediaType := strings.ToLower(strings.TrimSpace(args[1]))
|
||||
itemID := strings.TrimSpace(args[2])
|
||||
|
||||
opts, err := parseSmokeOptions(args[3:], 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)
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/app"
|
||||
"streamrip-go/internal/config"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Commands that discover media before ripping it: search and lastfm.
|
||||
|
||||
func cmdSearch(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
|
||||
var err error
|
||||
var source, mediaType string
|
||||
var sopts searchOptions
|
||||
if len(args) < 3 {
|
||||
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(args[0]))
|
||||
mediaType = strings.ToLower(strings.TrimSpace(args[1]))
|
||||
sopts, err = parseSearchArgs(args[2:], 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)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdLastFM(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
|
||||
opts, parseErr := parseLastFMArgs(args, 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))
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/app"
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/provider"
|
||||
)
|
||||
|
||||
// Developer smoke commands, listed by "rip dev-help". These exercise a single
|
||||
// provider call and print the result; they are not part of the supported CLI.
|
||||
|
||||
func cmdSoundcloudSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
|
||||
if len(args) < 1 {
|
||||
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
|
||||
return 2
|
||||
}
|
||||
meta, err := fetchSoundcloudOEmbed(ctx, cfg.Session.Downloads.VerifySSL, strings.TrimSpace(args[0]))
|
||||
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"]))
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdQobuzSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
|
||||
if len(args) < 1 {
|
||||
fmt.Println("usage: rip qobuz-smoke <track_id> [quality]")
|
||||
return 2
|
||||
}
|
||||
opts, err := parseSmokeOptions(args[1:], 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 := args[0]
|
||||
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)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdQobuzConvertRipSmoke(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
|
||||
if len(args) < 2 {
|
||||
fmt.Println("usage: rip qobuz-convert-rip-smoke <track_id> <codec> [quality] [--force|--ignore-db]")
|
||||
return 2
|
||||
}
|
||||
opts, err := parseSmokeOptions(args[2:], 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(args[1]))
|
||||
|
||||
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 := args[0]
|
||||
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)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdQobuzSearchSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
|
||||
if len(args) < 1 {
|
||||
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(args, " ")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdTidalSearchSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
|
||||
if len(args) < 1 {
|
||||
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(args, " ")
|
||||
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"]))
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdTidalMetadataSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
|
||||
if len(args) < 2 {
|
||||
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 := args[0]
|
||||
itemID := args[1]
|
||||
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)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdTidalVideoSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
|
||||
if len(args) < 1 {
|
||||
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(args[0])
|
||||
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)
|
||||
|
||||
return 0
|
||||
}
|
||||
+18
-800
@@ -6,14 +6,9 @@ import (
|
||||
"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"
|
||||
@@ -92,819 +87,42 @@ func run() int {
|
||||
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] {
|
||||
args := gopts.commandArgs
|
||||
|
||||
switch gopts.command {
|
||||
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)
|
||||
return cmdURL(ctx, cfg, gopts, args)
|
||||
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)
|
||||
return cmdFile(ctx, cfg, gopts, args)
|
||||
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
|
||||
}
|
||||
return cmdConfig(ctx, cfg, gopts, args)
|
||||
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
|
||||
}
|
||||
return cmdDatabase(ctx, cfg, gopts, args)
|
||||
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)
|
||||
return cmdID(ctx, cfg, gopts, args)
|
||||
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)
|
||||
return cmdSearch(ctx, cfg, gopts, args)
|
||||
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))
|
||||
return cmdLastFM(ctx, cfg, gopts, args)
|
||||
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"]))
|
||||
return cmdSoundcloudSmoke(ctx, cfg, gopts, args)
|
||||
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)
|
||||
return cmdQobuzSmoke(ctx, cfg, gopts, args)
|
||||
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)
|
||||
return cmdQobuzConvertRipSmoke(ctx, cfg, gopts, args)
|
||||
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)
|
||||
}
|
||||
}
|
||||
return cmdQobuzSearchSmoke(ctx, cfg, gopts, args)
|
||||
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"]))
|
||||
}
|
||||
}
|
||||
return cmdTidalSearchSmoke(ctx, cfg, gopts, args)
|
||||
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)
|
||||
return cmdTidalMetadataSmoke(ctx, cfg, gopts, args)
|
||||
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)
|
||||
return cmdTidalVideoSmoke(ctx, cfg, gopts, args)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", gopts.command)
|
||||
return 2
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
Reference in new issue
Block a user