mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
Make download dedupe source-specific to prevent cross-provider ID collisions. Also correct non-remaster filtering, avoid FLAC tagging on non-FLAC files, and use album IDs for singles folder templating.
1968 lines
52 KiB
Go
1968 lines
52 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AlecAivazis/survey/v2"
|
|
"golang.org/x/term"
|
|
|
|
"streamrip-go/internal/app"
|
|
"streamrip-go/internal/config"
|
|
"streamrip-go/internal/netutil"
|
|
"streamrip-go/internal/provider"
|
|
"streamrip-go/internal/urlparse"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Println("usage: rip <command>")
|
|
fmt.Println("commands: url, file, config, database, id, search, lastfm, qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke, qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke, qobuz-search-smoke, tidal-search-smoke, tidal-metadata-smoke, tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke")
|
|
os.Exit(2)
|
|
}
|
|
|
|
cfg, err := config.Load("")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
switch os.Args[1] {
|
|
case "url":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip url <url...> [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
|
|
rawArgs := make([]string, 0, len(os.Args[2:]))
|
|
ignoreDB := false
|
|
for _, arg := range os.Args[2:] {
|
|
if arg == "--force" || arg == "--ignore-db" {
|
|
ignoreDB = true
|
|
continue
|
|
}
|
|
rawArgs = append(rawArgs, arg)
|
|
}
|
|
mainApp.IgnoreDB = ignoreDB
|
|
|
|
added := 0
|
|
for _, raw := range rawArgs {
|
|
if addURLToQueue(ctx, mainApp, raw) {
|
|
added++
|
|
}
|
|
}
|
|
|
|
if added == 0 {
|
|
fmt.Println("nothing to rip")
|
|
return
|
|
}
|
|
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("url rip complete (%d item(s))\n", added)
|
|
case "file":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip file <path> [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
|
|
ignoreDB := false
|
|
for _, arg := range os.Args[3:] {
|
|
switch arg {
|
|
case "--force", "--ignore-db":
|
|
ignoreDB = true
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
content, err := os.ReadFile(os.Args[2])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "read file error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
idItems, urls, repeated, jsonInput, err := parseFileInput(content)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "file parse error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = ignoreDB
|
|
|
|
added := 0
|
|
if jsonInput {
|
|
fmt.Printf("detected json file. loading %d item(s)\n", len(idItems))
|
|
for _, item := range idItems {
|
|
if err = mainApp.AddByID(ctx, item.Source, item.MediaType, item.ID); err != nil {
|
|
fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", item.Source, item.MediaType, item.ID, err)
|
|
continue
|
|
}
|
|
added++
|
|
}
|
|
} else {
|
|
if repeated > 0 {
|
|
fmt.Printf("found %d repeated url(s)\n", repeated)
|
|
}
|
|
fmt.Printf("detected list of urls. loading %d item(s)\n", len(urls))
|
|
for _, raw := range urls {
|
|
if addURLToQueue(ctx, mainApp, raw) {
|
|
added++
|
|
}
|
|
}
|
|
}
|
|
|
|
if added == 0 {
|
|
fmt.Println("nothing to rip")
|
|
return
|
|
}
|
|
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("file rip complete (%d item(s))\n", added)
|
|
case "config":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip config <open|reset|path> [options]")
|
|
os.Exit(2)
|
|
}
|
|
switch os.Args[2] {
|
|
case "open":
|
|
vim := false
|
|
for _, arg := range os.Args[3:] {
|
|
switch arg {
|
|
case "-v", "--vim":
|
|
vim = true
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
fmt.Printf("opening file at %s\n", cfg.Path)
|
|
if err = openConfigInEditor(cfg.Path, vim); err != nil {
|
|
fmt.Fprintf(os.Stderr, "open config error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
case "reset":
|
|
yes := false
|
|
for _, arg := range os.Args[3:] {
|
|
switch arg {
|
|
case "-y", "--yes":
|
|
yes = true
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
if !yes {
|
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
fmt.Fprintln(os.Stderr, "reset requires --yes in non-interactive mode")
|
|
os.Exit(2)
|
|
}
|
|
ok, askErr := promptYesNo(fmt.Sprintf("Are you sure you want to reset the config file at %s? [y/N]: ", cfg.Path))
|
|
if askErr != nil {
|
|
fmt.Fprintf(os.Stderr, "prompt error: %v\n", askErr)
|
|
os.Exit(1)
|
|
}
|
|
if !ok {
|
|
fmt.Println("reset aborted")
|
|
return
|
|
}
|
|
}
|
|
def := config.DefaultConfigData()
|
|
cfg.File = def
|
|
cfg.Session = def
|
|
if err = cfg.SaveFile(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "reset config error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("reset the config file at %s\n", cfg.Path)
|
|
case "path":
|
|
if len(os.Args) > 3 {
|
|
fmt.Fprintf(os.Stderr, "option error: unexpected argument %q\n", os.Args[3])
|
|
os.Exit(2)
|
|
}
|
|
fmt.Printf("config path: '%s'\n", cfg.Path)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown config command: %s\n", os.Args[2])
|
|
os.Exit(2)
|
|
}
|
|
case "database":
|
|
if len(os.Args) < 4 || os.Args[2] != "browse" {
|
|
fmt.Println("usage: rip database browse <downloads|failed>")
|
|
os.Exit(2)
|
|
}
|
|
table := strings.ToLower(strings.TrimSpace(os.Args[3]))
|
|
switch table {
|
|
case "downloads":
|
|
rows, listErr := listDownloadsRows(cfg.Session.Database.DownloadsPath)
|
|
if listErr != nil {
|
|
fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("downloads database")
|
|
fmt.Println("row id")
|
|
for i, id := range rows {
|
|
fmt.Printf("%02d %s\n", i, id)
|
|
}
|
|
case "failed":
|
|
rows, listErr := listFailedRows(cfg.Session.Database.FailedDownloadsPath)
|
|
if listErr != nil && isNoSuchTableErr(listErr) && cfg.Session.Database.FailedDownloadsPath != cfg.Session.Database.DownloadsPath {
|
|
rows, listErr = listFailedRows(cfg.Session.Database.DownloadsPath)
|
|
}
|
|
if listErr != nil {
|
|
fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("failed downloads database")
|
|
fmt.Println("row source media_type id")
|
|
for i, row := range rows {
|
|
fmt.Printf("%02d %s %s %s\n", i, row.Source, row.MediaType, row.ID)
|
|
}
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "invalid database %q. choose downloads or failed\n", table)
|
|
os.Exit(2)
|
|
}
|
|
case "id":
|
|
if len(os.Args) < 5 {
|
|
fmt.Println("usage: rip id <source> <track|album|playlist|artist|label> <id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
|
|
source := strings.ToLower(strings.TrimSpace(os.Args[2]))
|
|
mediaType := strings.ToLower(strings.TrimSpace(os.Args[3]))
|
|
itemID := strings.TrimSpace(os.Args[4])
|
|
|
|
opts, err := parseSmokeOptions(os.Args[5:], 0, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
switch source {
|
|
case "qobuz":
|
|
if opts.quality < 1 {
|
|
fmt.Fprintf(os.Stderr, "quality error: qobuz quality must be 1-4\n")
|
|
os.Exit(2)
|
|
}
|
|
cfg.Session.Qobuz.Quality = opts.quality
|
|
case "tidal":
|
|
cfg.Session.Tidal.Quality = opts.quality
|
|
}
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
|
|
if err = mainApp.AddByID(ctx, source, mediaType, itemID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID)
|
|
case "search":
|
|
var source, mediaType string
|
|
var sopts searchOptions
|
|
if len(os.Args) < 5 {
|
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
fmt.Println("usage: rip search <qobuz|tidal> <track|album|playlist|artist|label> <query...> [--limit N] [--force|--ignore-db] [--no-download]")
|
|
os.Exit(2)
|
|
}
|
|
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "search prompt error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
} else {
|
|
source = strings.ToLower(strings.TrimSpace(os.Args[2]))
|
|
mediaType = strings.ToLower(strings.TrimSpace(os.Args[3]))
|
|
sopts, err = parseSearchArgs(os.Args[4:], cfg.Session.CLI.MaxSearchResults)
|
|
}
|
|
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "search option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if !isAllowedSearchSource(source) {
|
|
fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source)
|
|
os.Exit(2)
|
|
}
|
|
if !isAllowedMediaType(mediaType) {
|
|
fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType)
|
|
os.Exit(2)
|
|
}
|
|
if sopts.query == "" {
|
|
fmt.Fprintln(os.Stderr, "search query cannot be empty")
|
|
os.Exit(2)
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
|
|
provider, err := mainApp.GetLoggedInProvider(ctx, source)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s login error: %v\n", source, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
pages, err := provider.Search(ctx, mediaType, sopts.query, sopts.limit)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
results := normalizeSearchResults(source, mediaType, pages)
|
|
if len(results) == 0 {
|
|
fmt.Println("no results")
|
|
return
|
|
}
|
|
|
|
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 sopts.outputFile != "" {
|
|
if err = writeSearchResultsToFile(source, mediaType, results, sopts.outputFile); err != nil {
|
|
fmt.Fprintf(os.Stderr, "write results error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("wrote %d results to %s\n", len(results), sopts.outputFile)
|
|
return
|
|
}
|
|
if sopts.first {
|
|
results = results[:1]
|
|
}
|
|
if sopts.noDownload {
|
|
return
|
|
}
|
|
|
|
if sopts.first {
|
|
selection := []int{0}
|
|
mainApp.IgnoreDB = sopts.ignoreDB
|
|
skippedDownloaded := 0
|
|
added := 0
|
|
for _, idx := range selection {
|
|
item := results[idx]
|
|
if !sopts.ignoreDB {
|
|
already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID)
|
|
if checkErr == nil && already {
|
|
skippedDownloaded++
|
|
fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title)
|
|
continue
|
|
}
|
|
}
|
|
if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil {
|
|
fmt.Printf("add failed: id=%s err=%v\n", item.ID, err)
|
|
continue
|
|
}
|
|
added++
|
|
}
|
|
if added == 0 {
|
|
if skippedDownloaded > 0 {
|
|
fmt.Println("selected item was already downloaded (use --force to redownload)")
|
|
} else {
|
|
fmt.Println("nothing selected to download")
|
|
}
|
|
return
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("search download complete (%d item(s))\n", added)
|
|
return
|
|
}
|
|
|
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
fmt.Println("non-interactive input; use `rip id` to download specific results")
|
|
return
|
|
}
|
|
|
|
selection, err := promptSearchSelectionMenu(source, mediaType, sopts.query, results)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "selection error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if len(selection) == 0 {
|
|
fmt.Println("download cancelled")
|
|
return
|
|
}
|
|
|
|
mainApp.IgnoreDB = sopts.ignoreDB
|
|
skippedDownloaded := 0
|
|
added := 0
|
|
for _, idx := range selection {
|
|
item := results[idx]
|
|
if !sopts.ignoreDB {
|
|
already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID)
|
|
if checkErr == nil && already {
|
|
skippedDownloaded++
|
|
fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title)
|
|
continue
|
|
}
|
|
}
|
|
if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil {
|
|
fmt.Printf("add failed: id=%s err=%v\n", item.ID, err)
|
|
continue
|
|
}
|
|
added++
|
|
}
|
|
if added == 0 {
|
|
if skippedDownloaded > 0 {
|
|
fmt.Println("all selected items were already downloaded (use --force to redownload)")
|
|
} else {
|
|
fmt.Println("nothing selected to download")
|
|
}
|
|
return
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("search download complete (%d item(s))\n", added)
|
|
case "lastfm":
|
|
opts, parseErr := parseLastFMArgs(os.Args[2:], cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource)
|
|
if parseErr != nil {
|
|
fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr)
|
|
fmt.Println("usage: rip lastfm [--source SOURCE] [--fallback-source SOURCE] <playlist_url>")
|
|
os.Exit(2)
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
|
|
title, tracks, err := fetchLastFMPlaylist(ctx, cfg.Session.Downloads.VerifySSL, opts.PlaylistURL)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "lastfm parse error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if len(tracks) == 0 {
|
|
fmt.Println("no tracks found in playlist")
|
|
return
|
|
}
|
|
fmt.Printf("lastfm playlist: %s (%d tracks)\n", title, len(tracks))
|
|
|
|
if err = queueLastFMTracks(ctx, mainApp, opts, tracks); err != nil {
|
|
fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if len(mainApp.Pending) == 0 {
|
|
fmt.Println("no lastfm tracks resolved")
|
|
return
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("lastfm rip complete (%d track(s))\n", len(mainApp.Pending))
|
|
case "qobuz-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip qobuz-smoke <track_id> [quality]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 1, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Qobuz.Quality = opts.quality
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
|
|
provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
trackID := os.Args[2]
|
|
meta, err := provider.GetMetadata(ctx, trackID, "track")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "metadata error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
title, _ := meta["title"].(string)
|
|
d, err := provider.GetDownloadable(ctx, trackID, cfg.Session.Qobuz.Quality)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "downloadable error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("qobuz ok: title=%q quality=%d ext=%s\n", title, cfg.Session.Qobuz.Quality, d.Extension)
|
|
fmt.Printf("stream_url=%s\n", d.URL)
|
|
case "qobuz-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip qobuz-rip-smoke <track_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 1, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Qobuz.Quality = opts.quality
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
|
|
trackID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Println("qobuz rip smoke complete")
|
|
case "qobuz-convert-rip-smoke":
|
|
if len(os.Args) < 4 {
|
|
fmt.Println("usage: rip qobuz-convert-rip-smoke <track_id> <codec> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[4:], 1, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Qobuz.Quality = opts.quality
|
|
}
|
|
cfg.Session.Conversion.Enabled = true
|
|
cfg.Session.Conversion.Codec = strings.ToUpper(strings.TrimSpace(os.Args[3]))
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
|
|
trackID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("qobuz convert rip smoke complete (codec=%s)\n", cfg.Session.Conversion.Codec)
|
|
case "qobuz-album-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip qobuz-album-rip-smoke <album_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 1, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Qobuz.Quality = opts.quality
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
|
|
albumID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "qobuz", "album", albumID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Println("qobuz album rip smoke complete")
|
|
case "qobuz-playlist-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip qobuz-playlist-rip-smoke <playlist_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 1, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Qobuz.Quality = opts.quality
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
|
|
playlistID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "qobuz", "playlist", playlistID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("qobuz playlist rip smoke complete")
|
|
case "qobuz-artist-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip qobuz-artist-rip-smoke <artist_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 1, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Qobuz.Quality = opts.quality
|
|
}
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
artistID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "qobuz", "artist", artistID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("qobuz artist rip smoke complete")
|
|
case "qobuz-label-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip qobuz-label-rip-smoke <label_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 1, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Qobuz.Quality = opts.quality
|
|
}
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
labelID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "qobuz", "label", labelID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("qobuz label rip smoke complete")
|
|
case "qobuz-search-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip qobuz-search-smoke <query>")
|
|
os.Exit(2)
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
|
|
provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
query := strings.Join(os.Args[2:], " ")
|
|
pages, err := provider.Search(ctx, "album", query, 10)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
for _, page := range pages {
|
|
albums, ok := page["albums"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
items, ok := albums["items"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, raw := range items {
|
|
item, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := asString(item["id"])
|
|
title := asString(item["title"])
|
|
version := asString(item["version"])
|
|
bitDepth := asString(item["maximum_bit_depth"])
|
|
sr := asString(item["maximum_sampling_rate"])
|
|
if version != "" {
|
|
title = title + " (" + version + ")"
|
|
}
|
|
fmt.Printf("album_id=%s | %s | %sB-%skHz\n", id, title, bitDepth, sr)
|
|
}
|
|
}
|
|
case "tidal-search-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip tidal-search-smoke <query>")
|
|
os.Exit(2)
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
|
|
provider, err := mainApp.GetLoggedInProvider(ctx, "tidal")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
query := strings.Join(os.Args[2:], " ")
|
|
pages, err := provider.Search(ctx, "album", query, 10)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
for _, page := range pages {
|
|
items, ok := page["items"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, raw := range items {
|
|
wrapper, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
item, ok := wrapper["item"].(map[string]any)
|
|
if !ok {
|
|
item = wrapper
|
|
}
|
|
fmt.Printf("album_id=%s | %s\n", asString(item["id"]), asString(item["title"]))
|
|
}
|
|
}
|
|
case "tidal-metadata-smoke":
|
|
if len(os.Args) < 4 {
|
|
fmt.Println("usage: rip tidal-metadata-smoke <track|album|playlist|artist> <id>")
|
|
os.Exit(2)
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
|
|
provider, err := mainApp.GetLoggedInProvider(ctx, "tidal")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
mediaType := os.Args[2]
|
|
itemID := os.Args[3]
|
|
meta, err := provider.GetMetadata(ctx, itemID, mediaType)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "metadata error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
title := asString(meta["title"])
|
|
if title == "" {
|
|
title = asString(meta["name"])
|
|
}
|
|
trackCount := 0
|
|
if tracksMap, ok := meta["tracks"].(map[string]any); ok {
|
|
if items, ok := tracksMap["items"].([]map[string]any); ok {
|
|
trackCount = len(items)
|
|
} else if anyItems, ok := tracksMap["items"].([]any); ok {
|
|
trackCount = len(anyItems)
|
|
}
|
|
}
|
|
fmt.Printf("tidal metadata ok: type=%s id=%s title=%q tracks=%d\n", mediaType, itemID, title, trackCount)
|
|
case "tidal-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip tidal-rip-smoke <track_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 0, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Tidal.Quality = opts.quality
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
|
|
trackID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "tidal", "track", trackID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("tidal rip smoke complete")
|
|
case "tidal-album-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip tidal-album-rip-smoke <album_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 0, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Tidal.Quality = opts.quality
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
|
|
albumID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "tidal", "album", albumID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("tidal album rip smoke complete")
|
|
case "tidal-playlist-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip tidal-playlist-rip-smoke <playlist_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 0, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Tidal.Quality = opts.quality
|
|
}
|
|
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
|
|
playlistID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "tidal", "playlist", playlistID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("tidal playlist rip smoke complete")
|
|
case "tidal-artist-rip-smoke":
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: rip tidal-artist-rip-smoke <artist_id> [quality] [--force|--ignore-db]")
|
|
os.Exit(2)
|
|
}
|
|
opts, err := parseSmokeOptions(os.Args[3:], 0, 4)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
if opts.qualitySet {
|
|
cfg.Session.Tidal.Quality = opts.quality
|
|
}
|
|
mainApp, err := app.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = mainApp.Close() }()
|
|
mainApp.IgnoreDB = opts.ignoreDB
|
|
artistID := os.Args[2]
|
|
if err = mainApp.AddByID(ctx, "tidal", "artist", artistID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Resolve(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if err = mainApp.Rip(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println("tidal artist rip smoke complete")
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
type smokeOptions struct {
|
|
qualitySet bool
|
|
quality int
|
|
ignoreDB bool
|
|
}
|
|
|
|
func parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) {
|
|
opts := smokeOptions{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--force", "--ignore-db":
|
|
opts.ignoreDB = true
|
|
default:
|
|
q, err := parseQuality(arg, minQuality, maxQuality)
|
|
if err != nil {
|
|
return smokeOptions{}, fmt.Errorf("unknown option %q", arg)
|
|
}
|
|
opts.quality = q
|
|
opts.qualitySet = true
|
|
}
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func parseQuality(raw string, min int, max int) (int, error) {
|
|
q, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if q < min || q > max {
|
|
return 0, fmt.Errorf("quality must be %d-%d, got %d", min, max, q)
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
func asString(v any) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case int:
|
|
return strconv.Itoa(t)
|
|
case int64:
|
|
return strconv.FormatInt(t, 10)
|
|
case float64:
|
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
type fileIDItem struct {
|
|
Source string
|
|
MediaType string
|
|
ID string
|
|
}
|
|
|
|
type failedRow struct {
|
|
Source string
|
|
MediaType string
|
|
ID string
|
|
}
|
|
|
|
type lastFMOptions struct {
|
|
Source string
|
|
FallbackSource string
|
|
PlaylistURL string
|
|
}
|
|
|
|
type lastFMTrack struct {
|
|
Title string
|
|
Artist string
|
|
}
|
|
|
|
var (
|
|
lastFMTitleTagsRe = regexp.MustCompile(`<a\s+href="[^"]+"\s+title="([^"]+)"`)
|
|
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
|
|
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1 class="playlisting-playlist-header-title">([^<]+)</h1>`)
|
|
errLastFMInvalidSource = "unsupported source"
|
|
)
|
|
|
|
func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool {
|
|
parsed := urlparse.Parse(raw)
|
|
if parsed == nil {
|
|
fmt.Printf("invalid: %s\n", raw)
|
|
return false
|
|
}
|
|
if parsed.Kind != urlparse.KindGeneric {
|
|
fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind)
|
|
return false
|
|
}
|
|
if parsed.Source != "qobuz" && parsed.Source != "tidal" {
|
|
fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw)
|
|
return false
|
|
}
|
|
if err := mainApp.AddByID(ctx, parsed.Source, parsed.MediaType, parsed.ID); err != nil {
|
|
fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", parsed.Source, parsed.MediaType, parsed.ID, err)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseFileInput(content []byte) ([]fileIDItem, []string, int, bool, error) {
|
|
trimmed := strings.TrimSpace(string(content))
|
|
if trimmed == "" {
|
|
return nil, nil, 0, false, nil
|
|
}
|
|
|
|
var parsed any
|
|
if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
|
|
arr, ok := parsed.([]any)
|
|
if !ok {
|
|
return nil, nil, 0, true, fmt.Errorf("json input must be an array of objects")
|
|
}
|
|
items := make([]fileIDItem, 0, len(arr))
|
|
for i, raw := range arr {
|
|
entry, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, nil, 0, true, fmt.Errorf("json item %d must be an object", i+1)
|
|
}
|
|
source := strings.ToLower(strings.TrimSpace(asString(entry["source"])))
|
|
mediaType := strings.ToLower(strings.TrimSpace(asString(entry["media_type"])))
|
|
if mediaType == "" {
|
|
mediaType = strings.ToLower(strings.TrimSpace(asString(entry["mediaType"])))
|
|
}
|
|
id := strings.TrimSpace(asString(entry["id"]))
|
|
if source == "" || mediaType == "" || id == "" {
|
|
return nil, nil, 0, true, fmt.Errorf("json item %d missing source/media_type/id", i+1)
|
|
}
|
|
items = append(items, fileIDItem{Source: source, MediaType: mediaType, ID: id})
|
|
}
|
|
return items, nil, 0, true, nil
|
|
}
|
|
|
|
parts := strings.Fields(trimmed)
|
|
if len(parts) == 0 {
|
|
return nil, nil, 0, false, nil
|
|
}
|
|
seen := make(map[string]struct{}, len(parts))
|
|
urls := make([]string, 0, len(parts))
|
|
repeated := 0
|
|
for _, raw := range parts {
|
|
if _, ok := seen[raw]; ok {
|
|
repeated++
|
|
continue
|
|
}
|
|
seen[raw] = struct{}{}
|
|
urls = append(urls, raw)
|
|
}
|
|
return nil, urls, repeated, false, nil
|
|
}
|
|
|
|
func promptYesNo(prompt string) (bool, error) {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
fmt.Print(prompt)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
line = strings.ToLower(strings.TrimSpace(line))
|
|
return line == "y" || line == "yes", nil
|
|
}
|
|
|
|
func openConfigInEditor(path string, vim bool) error {
|
|
launch := func(name string, args ...string) error {
|
|
cmd := exec.Command(name, args...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
if vim {
|
|
if p, err := exec.LookPath("nvim"); err == nil {
|
|
return launch(p, path)
|
|
}
|
|
if p, err := exec.LookPath("vim"); err == nil {
|
|
return launch(p, path)
|
|
}
|
|
}
|
|
|
|
if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" {
|
|
parts := strings.Fields(editor)
|
|
if len(parts) > 0 {
|
|
return launch(parts[0], append(parts[1:], path)...)
|
|
}
|
|
}
|
|
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return launch("open", path)
|
|
case "windows":
|
|
return launch("cmd", "/c", "start", "", path)
|
|
default:
|
|
if p, err := exec.LookPath("xdg-open"); err == nil {
|
|
return launch(p, path)
|
|
}
|
|
return fmt.Errorf("could not find an editor (set $EDITOR or install xdg-open)")
|
|
}
|
|
}
|
|
|
|
func listDownloadsRows(path string) ([]string, error) {
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
rows, err := db.Query(`SELECT id FROM downloads ORDER BY rowid`)
|
|
if err != nil {
|
|
if isNoSuchTableErr(err) {
|
|
return []string{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := []string{}
|
|
for rows.Next() {
|
|
var id string
|
|
if err = rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, id)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func listFailedRows(path string) ([]failedRow, error) {
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
rows, err := db.Query(`SELECT source, media_type, id FROM failed_downloads ORDER BY rowid`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := []failedRow{}
|
|
for rows.Next() {
|
|
var r failedRow
|
|
if err = rows.Scan(&r.Source, &r.MediaType, &r.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func isNoSuchTableErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "no such table")
|
|
}
|
|
|
|
func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (lastFMOptions, error) {
|
|
opts := lastFMOptions{Source: strings.ToLower(strings.TrimSpace(defaultSource)), FallbackSource: strings.ToLower(strings.TrimSpace(defaultFallback))}
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-s", "--source":
|
|
if i+1 >= len(args) {
|
|
return lastFMOptions{}, fmt.Errorf("--source requires a value")
|
|
}
|
|
opts.Source = strings.ToLower(strings.TrimSpace(args[i+1]))
|
|
i++
|
|
case "-fs", "--fallback-source":
|
|
if i+1 >= len(args) {
|
|
return lastFMOptions{}, fmt.Errorf("--fallback-source requires a value")
|
|
}
|
|
opts.FallbackSource = strings.ToLower(strings.TrimSpace(args[i+1]))
|
|
i++
|
|
default:
|
|
if strings.HasPrefix(args[i], "-") {
|
|
return lastFMOptions{}, fmt.Errorf("unknown option %q", args[i])
|
|
}
|
|
if opts.PlaylistURL != "" {
|
|
return lastFMOptions{}, fmt.Errorf("unexpected extra argument %q", args[i])
|
|
}
|
|
opts.PlaylistURL = strings.TrimSpace(args[i])
|
|
}
|
|
}
|
|
if opts.Source == "" {
|
|
opts.Source = "qobuz"
|
|
}
|
|
if opts.PlaylistURL == "" {
|
|
return lastFMOptions{}, fmt.Errorf("missing playlist url")
|
|
}
|
|
if !isAllowedSearchSource(opts.Source) {
|
|
return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.Source)
|
|
}
|
|
if opts.FallbackSource != "" && !isAllowedSearchSource(opts.FallbackSource) {
|
|
return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.FallbackSource)
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) {
|
|
parsed, err := url.Parse(playlistURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return "", nil, fmt.Errorf("invalid playlist url")
|
|
}
|
|
client := netutil.NewHTTPClient(30*time.Second, verifySSL)
|
|
|
|
page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
title, total, err := extractLastFMPlaylistInfo(page1)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
tracks := extractLastFMTitleArtistPairs(page1)
|
|
if total <= len(tracks) || total <= 50 {
|
|
if len(tracks) > total && total > 0 {
|
|
tracks = tracks[:total]
|
|
}
|
|
return title, tracks, nil
|
|
}
|
|
|
|
remaining := total - 50
|
|
lastPage := 1 + remaining/50
|
|
if remaining%50 != 0 {
|
|
lastPage++
|
|
}
|
|
for page := 2; page <= lastPage; page++ {
|
|
body, fetchErr := fetchLastFMPlaylistPage(ctx, client, parsed, page)
|
|
if fetchErr != nil {
|
|
return "", nil, fetchErr
|
|
}
|
|
tracks = append(tracks, extractLastFMTitleArtistPairs(body)...)
|
|
}
|
|
if len(tracks) > total {
|
|
tracks = tracks[:total]
|
|
}
|
|
return title, tracks, nil
|
|
}
|
|
|
|
func fetchLastFMPlaylistPage(ctx context.Context, client *http.Client, parsed *url.URL, page int) (string, error) {
|
|
u := *parsed
|
|
if page > 1 {
|
|
q := u.Query()
|
|
q.Set("page", strconv.Itoa(page))
|
|
u.RawQuery = q.Encode()
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("User-Agent", "streamrip-go/0")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("lastfm request failed: status %d", resp.StatusCode)
|
|
}
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func extractLastFMPlaylistInfo(page string) (string, int, error) {
|
|
titleMatch := lastFMPlaylistTitleRe.FindStringSubmatch(page)
|
|
if len(titleMatch) < 2 {
|
|
return "", 0, fmt.Errorf("could not parse playlist title")
|
|
}
|
|
totalMatch := lastFMTotalTracksRe.FindStringSubmatch(page)
|
|
if len(totalMatch) < 2 {
|
|
return "", 0, fmt.Errorf("could not parse total track count")
|
|
}
|
|
total, err := strconv.Atoi(totalMatch[1])
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("invalid total track count")
|
|
}
|
|
return html.UnescapeString(strings.TrimSpace(titleMatch[1])), total, nil
|
|
}
|
|
|
|
func extractLastFMTitleArtistPairs(page string) []lastFMTrack {
|
|
titles := lastFMTitleTagsRe.FindAllStringSubmatch(page, -1)
|
|
out := make([]lastFMTrack, 0, len(titles)/2)
|
|
for i := 0; i+1 < len(titles); i += 2 {
|
|
title := html.UnescapeString(strings.TrimSpace(titles[i][1]))
|
|
artist := html.UnescapeString(strings.TrimSpace(titles[i+1][1]))
|
|
if title == "" || artist == "" {
|
|
continue
|
|
}
|
|
out = append(out, lastFMTrack{Title: title, Artist: artist})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func queueLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOptions, tracks []lastFMTrack) error {
|
|
primary, err := mainApp.GetLoggedInProvider(ctx, opts.Source)
|
|
if err != nil {
|
|
return fmt.Errorf("%s login error: %w", opts.Source, err)
|
|
}
|
|
var fallback provider.Client
|
|
if opts.FallbackSource != "" && opts.FallbackSource != opts.Source {
|
|
fallback, err = mainApp.GetLoggedInProvider(ctx, opts.FallbackSource)
|
|
if err != nil {
|
|
return fmt.Errorf("%s login error: %w", opts.FallbackSource, err)
|
|
}
|
|
}
|
|
|
|
found := 0
|
|
failed := 0
|
|
for i, tr := range tracks {
|
|
query := strings.TrimSpace(tr.Title + " " + tr.Artist)
|
|
id, source, searchErr := searchLastFMTrack(ctx, opts, primary, fallback, query)
|
|
if searchErr != nil {
|
|
failed++
|
|
fmt.Printf("[%d/%d] search failed: %s (%v)\n", i+1, len(tracks), query, searchErr)
|
|
continue
|
|
}
|
|
if id == "" {
|
|
failed++
|
|
fmt.Printf("[%d/%d] no result: %s\n", i+1, len(tracks), query)
|
|
continue
|
|
}
|
|
if err = mainApp.AddByID(ctx, source, "track", id); err != nil {
|
|
failed++
|
|
fmt.Printf("[%d/%d] add failed: %s (%v)\n", i+1, len(tracks), query, err)
|
|
continue
|
|
}
|
|
found++
|
|
fmt.Printf("[%d/%d] found: %s (%s)\n", i+1, len(tracks), query, source)
|
|
}
|
|
fmt.Printf("lastfm resolve complete: %d found, %d failed\n", found, failed)
|
|
return nil
|
|
}
|
|
|
|
func searchLastFMTrack(ctx context.Context, opts lastFMOptions, primary provider.Client, fallback provider.Client, query string) (string, string, error) {
|
|
pages, err := primary.Search(ctx, "track", query, 1)
|
|
if err == nil {
|
|
results := normalizeSearchResults(opts.Source, "track", pages)
|
|
if len(results) > 0 {
|
|
return results[0].ID, opts.Source, nil
|
|
}
|
|
}
|
|
if fallback != nil {
|
|
pages, fbErr := fallback.Search(ctx, "track", query, 1)
|
|
if fbErr != nil {
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("primary=%v fallback=%v", err, fbErr)
|
|
}
|
|
return "", "", fbErr
|
|
}
|
|
results := normalizeSearchResults(opts.FallbackSource, "track", pages)
|
|
if len(results) > 0 {
|
|
return results[0].ID, opts.FallbackSource, nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return "", "", nil
|
|
}
|
|
|
|
type searchResult struct {
|
|
ID string
|
|
Title string
|
|
Artist string
|
|
Album string
|
|
TrackCount int
|
|
Explicit bool
|
|
}
|
|
|
|
type searchOptions struct {
|
|
query string
|
|
limit int
|
|
ignoreDB bool
|
|
noDownload bool
|
|
first bool
|
|
outputFile string
|
|
}
|
|
|
|
func parseSearchArgs(args []string, defaultLimit int) (searchOptions, error) {
|
|
if defaultLimit <= 0 {
|
|
defaultLimit = 20
|
|
}
|
|
limit := defaultLimit
|
|
parts := make([]string, 0, len(args))
|
|
ignoreDB := false
|
|
noDownload := false
|
|
first := false
|
|
outputFile := ""
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--force", "--ignore-db":
|
|
ignoreDB = true
|
|
continue
|
|
case "--no-download":
|
|
noDownload = true
|
|
continue
|
|
case "--first":
|
|
first = true
|
|
continue
|
|
case "--output-file":
|
|
if i+1 >= len(args) {
|
|
return searchOptions{}, fmt.Errorf("--output-file requires a path")
|
|
}
|
|
outputFile = strings.TrimSpace(args[i+1])
|
|
i++
|
|
continue
|
|
case "--num-results":
|
|
if i+1 >= len(args) {
|
|
return searchOptions{}, fmt.Errorf("--num-results requires a value")
|
|
}
|
|
v, err := strconv.Atoi(args[i+1])
|
|
if err != nil || v <= 0 {
|
|
return searchOptions{}, fmt.Errorf("invalid --num-results value %q", args[i+1])
|
|
}
|
|
limit = v
|
|
i++
|
|
continue
|
|
}
|
|
if args[i] == "--limit" {
|
|
if i+1 >= len(args) {
|
|
return searchOptions{}, fmt.Errorf("--limit requires a value")
|
|
}
|
|
v, err := strconv.Atoi(args[i+1])
|
|
if err != nil || v <= 0 {
|
|
return searchOptions{}, fmt.Errorf("invalid --limit value %q", args[i+1])
|
|
}
|
|
limit = v
|
|
i++
|
|
continue
|
|
}
|
|
parts = append(parts, args[i])
|
|
}
|
|
return searchOptions{
|
|
query: strings.TrimSpace(strings.Join(parts, " ")),
|
|
limit: limit,
|
|
ignoreDB: ignoreDB,
|
|
noDownload: noDownload,
|
|
first: first,
|
|
outputFile: outputFile,
|
|
}, nil
|
|
}
|
|
|
|
func promptSearchSelection(results []searchResult) ([]int, error) {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
for {
|
|
fmt.Print("Select results to download (e.g. 1,3-5; a=all; q=cancel): ")
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.EqualFold(line, "q") || strings.EqualFold(line, "quit") {
|
|
return nil, nil
|
|
}
|
|
if strings.EqualFold(line, "a") || strings.EqualFold(line, "all") {
|
|
out := make([]int, 0, len(results))
|
|
for i := range results {
|
|
out = append(out, i)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
selected := map[int]struct{}{}
|
|
chunks := strings.Split(line, ",")
|
|
ok := true
|
|
for _, raw := range chunks {
|
|
part := strings.TrimSpace(raw)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(part, "-") {
|
|
bounds := strings.SplitN(part, "-", 2)
|
|
if len(bounds) != 2 {
|
|
ok = false
|
|
break
|
|
}
|
|
start, err1 := strconv.Atoi(strings.TrimSpace(bounds[0]))
|
|
end, err2 := strconv.Atoi(strings.TrimSpace(bounds[1]))
|
|
if err1 != nil || err2 != nil || start <= 0 || end <= 0 || start > end {
|
|
ok = false
|
|
break
|
|
}
|
|
for i := start; i <= end; i++ {
|
|
if i > len(results) {
|
|
ok = false
|
|
break
|
|
}
|
|
selected[i-1] = struct{}{}
|
|
}
|
|
if !ok {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
idx, err := strconv.Atoi(part)
|
|
if err != nil || idx <= 0 || idx > len(results) {
|
|
ok = false
|
|
break
|
|
}
|
|
selected[idx-1] = struct{}{}
|
|
}
|
|
|
|
if !ok || len(selected) == 0 {
|
|
fmt.Println("Invalid selection, try again.")
|
|
continue
|
|
}
|
|
|
|
out := make([]int, 0, len(selected))
|
|
for idx := range selected {
|
|
out = append(out, idx)
|
|
}
|
|
for i := 1; i < len(out); i++ {
|
|
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
|
out[j], out[j-1] = out[j-1], out[j]
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
func promptSearchSelectionMenu(source, mediaType, query string, results []searchResult) ([]int, error) {
|
|
if len(results) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
labels := make([]string, 0, len(results))
|
|
labelToIndex := map[string]int{}
|
|
for i, r := range results {
|
|
label := fmt.Sprintf("%2d. %s", i+1, r.Title)
|
|
labels = append(labels, label)
|
|
labelToIndex[label] = i
|
|
}
|
|
|
|
selected := []string{}
|
|
prompt := &survey.MultiSelect{
|
|
Message: fmt.Sprintf("Results for %s '%s' from %s", mediaType, query, strings.Title(source)),
|
|
Help: "SPACE: select ENTER: download /: filter ESC: cancel",
|
|
Options: labels,
|
|
Description: func(value string, index int) string {
|
|
if index < 0 || index >= len(results) {
|
|
return ""
|
|
}
|
|
return formatSearchDetails(results[index])
|
|
},
|
|
PageSize: 15,
|
|
}
|
|
if err := survey.AskOne(prompt, &selected); err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "interrupt") {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if len(selected) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]int, 0, len(selected))
|
|
for _, label := range selected {
|
|
if idx, ok := labelToIndex[label]; ok {
|
|
out = append(out, idx)
|
|
}
|
|
}
|
|
for i := 1; i < len(out); i++ {
|
|
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
|
out[j], out[j-1] = out[j-1], out[j]
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func writeSearchResultsToFile(source, mediaType string, results []searchResult, path string) error {
|
|
type outItem struct {
|
|
Source string `json:"source"`
|
|
MediaType string `json:"media_type"`
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
}
|
|
out := make([]outItem, 0, len(results))
|
|
for _, r := range results {
|
|
out = append(out, outItem{Source: source, MediaType: mediaType, ID: r.ID, Title: r.Title})
|
|
}
|
|
b, err := json.MarshalIndent(out, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, b, 0o644)
|
|
}
|
|
|
|
func isAllowedSearchSource(source string) bool {
|
|
return source == "qobuz" || source == "tidal"
|
|
}
|
|
|
|
func isAllowedMediaType(mediaType string) bool {
|
|
switch mediaType {
|
|
case "track", "album", "playlist", "artist", "label":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, error) {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
|
|
read := func(prompt string) (string, error) {
|
|
fmt.Print(prompt)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(line), nil
|
|
}
|
|
|
|
for {
|
|
source, err := read("Source [qobuz/tidal]: ")
|
|
if err != nil {
|
|
return "", "", searchOptions{}, err
|
|
}
|
|
source = strings.ToLower(source)
|
|
if !isAllowedSearchSource(source) {
|
|
fmt.Println("Invalid source.")
|
|
continue
|
|
}
|
|
|
|
mediaType, err := read("Type [track/album/playlist/artist/label]: ")
|
|
if err != nil {
|
|
return "", "", searchOptions{}, err
|
|
}
|
|
mediaType = strings.ToLower(mediaType)
|
|
if !isAllowedMediaType(mediaType) {
|
|
fmt.Println("Invalid media type.")
|
|
continue
|
|
}
|
|
|
|
query, err := read("Query: ")
|
|
if err != nil {
|
|
return "", "", searchOptions{}, err
|
|
}
|
|
if strings.TrimSpace(query) == "" {
|
|
fmt.Println("Query cannot be empty.")
|
|
continue
|
|
}
|
|
|
|
limitRaw, err := read(fmt.Sprintf("Limit [%d]: ", defaultLimit))
|
|
if err != nil {
|
|
return "", "", searchOptions{}, err
|
|
}
|
|
limit := defaultLimit
|
|
if strings.TrimSpace(limitRaw) != "" {
|
|
v, convErr := strconv.Atoi(limitRaw)
|
|
if convErr != nil || v <= 0 {
|
|
fmt.Println("Invalid limit.")
|
|
continue
|
|
}
|
|
limit = v
|
|
}
|
|
|
|
return source, mediaType, searchOptions{query: query, limit: limit}, nil
|
|
}
|
|
}
|
|
|
|
func normalizeSearchResults(source, mediaType string, pages []map[string]any) []searchResult {
|
|
results := make([]searchResult, 0)
|
|
for _, page := range pages {
|
|
switch source {
|
|
case "qobuz":
|
|
key := mediaType + "s"
|
|
bucket, ok := page[key].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
items, ok := bucket["items"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, raw := range items {
|
|
itm, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := asString(itm["id"])
|
|
title := asString(itm["title"])
|
|
if title == "" {
|
|
title = asString(itm["name"])
|
|
}
|
|
if version := asString(itm["version"]); version != "" {
|
|
title += " (" + version + ")"
|
|
}
|
|
artist := nestedSearchString(itm, "artist", "name")
|
|
if artist == "" {
|
|
artist = nestedSearchString(itm, "performer", "name")
|
|
}
|
|
album := nestedSearchString(itm, "album", "title")
|
|
trackCount := searchInt(itm["tracks_count"])
|
|
if trackCount == 0 {
|
|
trackCount = searchInt(itm["track_count"])
|
|
}
|
|
explicit := searchBool(itm["parental_warning"])
|
|
if id != "" && title != "" {
|
|
results = append(results, searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
|
|
}
|
|
}
|
|
case "tidal":
|
|
items, ok := page["items"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, raw := range items {
|
|
itm, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if wrapped, ok := itm["item"].(map[string]any); ok {
|
|
itm = wrapped
|
|
}
|
|
id := asString(itm["id"])
|
|
title := asString(itm["title"])
|
|
if title == "" {
|
|
title = asString(itm["name"])
|
|
}
|
|
artist := nestedSearchString(itm, "artist", "name")
|
|
if artist == "" {
|
|
if artists, ok := itm["artists"].([]any); ok && len(artists) > 0 {
|
|
if a0, ok := artists[0].(map[string]any); ok {
|
|
artist = asString(a0["name"])
|
|
}
|
|
}
|
|
}
|
|
album := nestedSearchString(itm, "album", "title")
|
|
trackCount := searchInt(itm["numberOfTracks"])
|
|
if trackCount == 0 {
|
|
trackCount = searchInt(itm["tracks_count"])
|
|
}
|
|
explicit := searchBool(itm["explicit"])
|
|
if id != "" && title != "" {
|
|
results = append(results, searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
func formatSearchDetails(r searchResult) string {
|
|
lines := []string{fmt.Sprintf("ID: %s", r.ID), fmt.Sprintf("Title: %s", r.Title)}
|
|
if strings.TrimSpace(r.Artist) != "" {
|
|
lines = append(lines, fmt.Sprintf("Artist: %s", r.Artist))
|
|
}
|
|
if strings.TrimSpace(r.Album) != "" {
|
|
lines = append(lines, fmt.Sprintf("Album: %s", r.Album))
|
|
}
|
|
if r.TrackCount > 0 {
|
|
lines = append(lines, fmt.Sprintf("Tracks: %d", r.TrackCount))
|
|
}
|
|
if r.Explicit {
|
|
lines = append(lines, "Explicit: yes")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func nestedSearchString(v map[string]any, keys ...string) string {
|
|
cur := any(v)
|
|
for _, key := range keys {
|
|
m, ok := cur.(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
cur = m[key]
|
|
}
|
|
return asString(cur)
|
|
}
|
|
|
|
func searchInt(v any) int {
|
|
switch t := v.(type) {
|
|
case int:
|
|
return t
|
|
case int64:
|
|
return int(t)
|
|
case float64:
|
|
return int(t)
|
|
case string:
|
|
i, _ := strconv.Atoi(t)
|
|
return i
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func searchBool(v any) bool {
|
|
b, ok := v.(bool)
|
|
return ok && b
|
|
}
|