mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
Refactor: comprehensive cleanup and modularization
- Extracted common JSON parsing helpers into internal/jsonutil - Removed duplicated helper functions from provider packages - Removed dead code in internal/app/app.go and downloader.go - Replaced deprecated strings.Title with jsonutil.TitleCase - Added graceful shutdown with signal handling in main.go - Split monolithic cmd/rip/main.go into args.go, helpers.go, lastfm.go, search.go
This commit is contained in:
211
cmd/rip/args.go
Normal file
211
cmd/rip/args.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
type smokeOptions struct {
|
||||
qualitySet bool
|
||||
quality int
|
||||
ignoreDB bool
|
||||
}
|
||||
|
||||
type globalOptions struct {
|
||||
configPath string
|
||||
folder string
|
||||
noDB bool
|
||||
qualitySet bool
|
||||
quality int
|
||||
codecSet bool
|
||||
codec string
|
||||
noProgress bool
|
||||
noSSLVerify bool
|
||||
verbose bool
|
||||
command string
|
||||
commandArgs []string
|
||||
}
|
||||
|
||||
func parseGlobalArgs(args []string) (globalOptions, error) {
|
||||
opts := globalOptions{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") {
|
||||
opts.command = arg
|
||||
if i+1 < len(args) {
|
||||
opts.commandArgs = append([]string(nil), args[i+1:]...)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case arg == "-ndb" || arg == "--no-db":
|
||||
opts.noDB = true
|
||||
case arg == "--no-progress":
|
||||
opts.noProgress = true
|
||||
case arg == "--no-ssl-verify":
|
||||
opts.noSSLVerify = true
|
||||
case arg == "-v" || arg == "--verbose":
|
||||
opts.verbose = true
|
||||
case arg == "-f" || arg == "--folder":
|
||||
if i+1 >= len(args) {
|
||||
return globalOptions{}, fmt.Errorf("%s requires a value", arg)
|
||||
}
|
||||
opts.folder = strings.TrimSpace(args[i+1])
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--folder="):
|
||||
opts.folder = strings.TrimSpace(strings.TrimPrefix(arg, "--folder="))
|
||||
case arg == "--config-path":
|
||||
if i+1 >= len(args) {
|
||||
return globalOptions{}, fmt.Errorf("--config-path requires a value")
|
||||
}
|
||||
opts.configPath = strings.TrimSpace(args[i+1])
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--config-path="):
|
||||
opts.configPath = strings.TrimSpace(strings.TrimPrefix(arg, "--config-path="))
|
||||
case arg == "-q" || arg == "--quality":
|
||||
if i+1 >= len(args) {
|
||||
return globalOptions{}, fmt.Errorf("%s requires a value", arg)
|
||||
}
|
||||
q, err := strconv.Atoi(args[i+1])
|
||||
if err != nil || q < 0 || q > 4 {
|
||||
return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", args[i+1])
|
||||
}
|
||||
opts.qualitySet = true
|
||||
opts.quality = q
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--quality="):
|
||||
qRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--quality="))
|
||||
q, err := strconv.Atoi(qRaw)
|
||||
if err != nil || q < 0 || q > 4 {
|
||||
return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", qRaw)
|
||||
}
|
||||
opts.qualitySet = true
|
||||
opts.quality = q
|
||||
case arg == "-c" || arg == "--codec":
|
||||
if i+1 >= len(args) {
|
||||
return globalOptions{}, fmt.Errorf("%s requires a value", arg)
|
||||
}
|
||||
codec, err := normalizeCodec(args[i+1])
|
||||
if err != nil {
|
||||
return globalOptions{}, err
|
||||
}
|
||||
opts.codecSet = true
|
||||
opts.codec = codec
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--codec="):
|
||||
codecRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--codec="))
|
||||
codec, err := normalizeCodec(codecRaw)
|
||||
if err != nil {
|
||||
return globalOptions{}, err
|
||||
}
|
||||
opts.codecSet = true
|
||||
opts.codec = codec
|
||||
default:
|
||||
return globalOptions{}, fmt.Errorf("unknown global option %q", arg)
|
||||
}
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func normalizeCodec(raw string) (string, error) {
|
||||
codec := strings.ToUpper(strings.TrimSpace(raw))
|
||||
switch codec {
|
||||
case "ALAC", "FLAC", "MP3", "AAC", "VORBIS":
|
||||
return codec, nil
|
||||
case "OGG":
|
||||
return "VORBIS", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported codec %q (expected ALAC, FLAC, OGG, MP3, AAC)", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
|
||||
if opts.folder != "" {
|
||||
cfg.Session.Downloads.Folder = opts.folder
|
||||
}
|
||||
if opts.noDB {
|
||||
cfg.Session.Database.DownloadsEnabled = false
|
||||
}
|
||||
if opts.qualitySet {
|
||||
cfg.Session.Qobuz.Quality = opts.quality
|
||||
cfg.Session.Tidal.Quality = opts.quality
|
||||
cfg.Session.Deezer.Quality = opts.quality
|
||||
cfg.Session.Soundcloud.Quality = opts.quality
|
||||
}
|
||||
if opts.codecSet {
|
||||
cfg.Session.Conversion.Enabled = true
|
||||
cfg.Session.Conversion.Codec = opts.codec
|
||||
}
|
||||
if opts.noProgress {
|
||||
cfg.Session.CLI.ProgressBars = false
|
||||
}
|
||||
if opts.noSSLVerify {
|
||||
cfg.Session.Downloads.VerifySSL = false
|
||||
}
|
||||
}
|
||||
|
||||
func errorWithActionableHint(err error, opts globalOptions) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
if opts.noSSLVerify {
|
||||
return msg
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") || strings.Contains(lower, "ssl") {
|
||||
return msg + " (hint: try again with --no-ssl-verify)"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
234
cmd/rip/helpers.go
Normal file
234
cmd/rip/helpers.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/app"
|
||||
"streamrip-go/internal/urlparse"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type resolvedLastFMTrack struct {
|
||||
Source string
|
||||
ID string
|
||||
Query string
|
||||
}
|
||||
|
||||
var (
|
||||
lastFMTitleTagsRe = regexp.MustCompile(`<a\b[^>]*\btitle=(?:"([^"]+)"|'([^']+)')`)
|
||||
lastFMDataTrackArtistRe = regexp.MustCompile(`data-track-name=(?:"([^"]+)"|'([^']+)')[^>]*data-artist-name=(?:"([^"]+)"|'([^']+)')`)
|
||||
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
|
||||
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1[^>]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)</h1>`)
|
||||
lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`)
|
||||
lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`)
|
||||
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 && parsed.Kind != urlparse.KindSoundcloud {
|
||||
fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind)
|
||||
return false
|
||||
}
|
||||
if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "soundcloud" {
|
||||
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")
|
||||
}
|
||||
430
cmd/rip/lastfm.go
Normal file
430
cmd/rip/lastfm.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/app"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
)
|
||||
|
||||
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 !isValidLastFMPlaylistURL(opts.PlaylistURL) {
|
||||
return lastFMOptions{}, fmt.Errorf("playlist url must be a last.fm 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 isValidLastFMPlaylistURL(raw string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if s != "http" && s != "https" {
|
||||
return false
|
||||
}
|
||||
h := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(u.Host), "www."))
|
||||
if h != "last.fm" && !strings.HasSuffix(h, ".last.fm") {
|
||||
return false
|
||||
}
|
||||
p := strings.ToLower(strings.TrimSpace(u.Path))
|
||||
return strings.Contains(p, "/playlists/")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if !isValidLastFMPlaylistURL(playlistURL) {
|
||||
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 fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL)
|
||||
}
|
||||
title, total, err := extractLastFMPlaylistInfo(page1)
|
||||
if err != nil {
|
||||
return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL)
|
||||
}
|
||||
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 fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) {
|
||||
client := netutil.NewHTTPClient(30*time.Second, verifySSL)
|
||||
all := make([]lastFMTrack, 0, 200)
|
||||
title := ""
|
||||
|
||||
for page := 1; page <= 50; page++ {
|
||||
body, err := fetchLastFMPlaylistMirrorPage(ctx, client, playlistURL, page)
|
||||
if err != nil {
|
||||
if page == 1 {
|
||||
return "", nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
pageTitle, tracks := extractLastFMTracksFromMirrorMarkdown(body)
|
||||
if title == "" && strings.TrimSpace(pageTitle) != "" {
|
||||
title = pageTitle
|
||||
}
|
||||
if len(tracks) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, tracks...)
|
||||
if !strings.Contains(strings.ToLower(body), "show more") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return "", nil, fmt.Errorf("could not parse playlist tracks from last.fm")
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "Last.fm Playlist"
|
||||
}
|
||||
return title, all, nil
|
||||
}
|
||||
|
||||
func fetchLastFMPlaylistMirrorPage(ctx context.Context, client *http.Client, playlistURL string, page int) (string, error) {
|
||||
u, err := url.Parse(playlistURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if page > 1 {
|
||||
q := u.Query()
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
raw := u.String()
|
||||
raw = strings.TrimPrefix(raw, "https://")
|
||||
raw = strings.TrimPrefix(raw, "http://")
|
||||
mirrorURL := "https://r.jina.ai/http://" + raw
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mirrorURL, 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 mirror request failed: status %d", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), 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 {
|
||||
dataPairs := lastFMDataTrackArtistRe.FindAllStringSubmatch(page, -1)
|
||||
if len(dataPairs) > 0 {
|
||||
out := make([]lastFMTrack, 0, len(dataPairs))
|
||||
for _, m := range dataPairs {
|
||||
title := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[1], m[2])))
|
||||
artist := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[3], m[4])))
|
||||
if title == "" || artist == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, lastFMTrack{Title: title, Artist: artist})
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
titles := lastFMTitleTagsRe.FindAllStringSubmatch(page, -1)
|
||||
out := make([]lastFMTrack, 0, len(titles)/2)
|
||||
for i := 0; i+1 < len(titles); i += 2 {
|
||||
titleRaw := strings.TrimSpace(firstNonEmpty(titles[i][1], titles[i][2]))
|
||||
artistRaw := strings.TrimSpace(firstNonEmpty(titles[i+1][1], titles[i+1][2]))
|
||||
if strings.EqualFold(titleRaw, "Play on YouTube") || strings.EqualFold(artistRaw, "Play on YouTube") {
|
||||
continue
|
||||
}
|
||||
title := html.UnescapeString(titleRaw)
|
||||
artist := html.UnescapeString(artistRaw)
|
||||
if title == "" || artist == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, lastFMTrack{Title: title, Artist: artist})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(items ...string) string {
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) != "" {
|
||||
return strings.TrimSpace(item)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractLastFMTracksFromMirrorMarkdown(md string) (string, []lastFMTrack) {
|
||||
lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n")
|
||||
title := ""
|
||||
tracks := make([]lastFMTrack, 0, 100)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if title == "" {
|
||||
if m := lastFMMirrorTitleRe.FindStringSubmatch(line); len(m) >= 2 {
|
||||
title = strings.TrimSpace(html.UnescapeString(m[1]))
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(line, "|") || !strings.Contains(strings.ToLower(line), "play track") {
|
||||
continue
|
||||
}
|
||||
cols := splitMarkdownTableRow(line)
|
||||
if len(cols) < 6 {
|
||||
continue
|
||||
}
|
||||
trackName := markdownLinkText(cols[3])
|
||||
artist := markdownLinkText(cols[4])
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artist) == "" {
|
||||
continue
|
||||
}
|
||||
tracks = append(tracks, lastFMTrack{Title: html.UnescapeString(strings.TrimSpace(trackName)), Artist: html.UnescapeString(strings.TrimSpace(artist))})
|
||||
}
|
||||
return title, tracks
|
||||
}
|
||||
|
||||
func splitMarkdownTableRow(line string) []string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
trimmed = strings.TrimPrefix(trimmed, "|")
|
||||
trimmed = strings.TrimSuffix(trimmed, "|")
|
||||
parts := strings.Split(trimmed, "|")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, strings.TrimSpace(p))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func markdownLinkText(cell string) string {
|
||||
m := lastFMMirrorLinkTextRe.FindStringSubmatch(cell)
|
||||
if len(m) >= 2 {
|
||||
return m[1]
|
||||
}
|
||||
return strings.TrimSpace(cell)
|
||||
}
|
||||
|
||||
func resolveLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOptions, tracks []lastFMTrack) ([]resolvedLastFMTrack, error) {
|
||||
primary, err := mainApp.GetLoggedInProvider(ctx, opts.Source)
|
||||
if err != nil {
|
||||
return nil, 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 nil, fmt.Errorf("%s login error: %w", opts.FallbackSource, err)
|
||||
}
|
||||
}
|
||||
|
||||
found := 0
|
||||
failed := 0
|
||||
resolved := make([]resolvedLastFMTrack, 0, len(tracks))
|
||||
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
|
||||
}
|
||||
resolved = append(resolved, resolvedLastFMTrack{Source: source, ID: id, Query: query})
|
||||
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 resolved, nil
|
||||
}
|
||||
|
||||
func fetchSoundcloudOEmbed(ctx context.Context, verifySSL bool, trackURL string) (map[string]any, error) {
|
||||
parsed, err := url.Parse(trackURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("invalid soundcloud url")
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("format", "json")
|
||||
q.Set("url", trackURL)
|
||||
endpoint := "https://soundcloud.com/oembed?" + q.Encode()
|
||||
|
||||
client := netutil.NewHTTPClient(20*time.Second, verifySSL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "streamrip-go/0.1")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("soundcloud oembed failed: status %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err = json.Unmarshal(body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, 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
|
||||
}
|
||||
1364
cmd/rip/main.go
1364
cmd/rip/main.go
File diff suppressed because it is too large
Load Diff
527
cmd/rip/search.go
Normal file
527
cmd/rip/search.go
Normal file
@@ -0,0 +1,527 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
|
||||
"streamrip-go/internal/jsonutil"
|
||||
)
|
||||
|
||||
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++ {
|
||||
if args[i] == "--" {
|
||||
if i+1 < len(args) {
|
||||
parts = append(parts, args[i+1:]...)
|
||||
}
|
||||
break
|
||||
}
|
||||
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])
|
||||
if outputFile == "" {
|
||||
return searchOptions{}, fmt.Errorf("--output-file requires a non-empty path")
|
||||
}
|
||||
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
|
||||
}
|
||||
if strings.HasPrefix(args[i], "-") {
|
||||
return searchOptions{}, fmt.Errorf("unknown option %q", args[i])
|
||||
}
|
||||
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 {
|
||||
artist := strings.TrimSpace(r.Artist)
|
||||
if artist == "" {
|
||||
artist = "Unknown Artist"
|
||||
}
|
||||
label := fmt.Sprintf("%2d. %s - %s", i+1, artist, 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, jsonutil.TitleCase(source)),
|
||||
Help: "SPACE: select ENTER: download /: filter ESC: cancel",
|
||||
Options: labels,
|
||||
Description: func(value string, index int) string {
|
||||
resultIndex, ok := labelToIndex[value]
|
||||
if !ok || resultIndex < 0 || resultIndex >= len(results) {
|
||||
return ""
|
||||
}
|
||||
return formatSearchDetails(results[resultIndex])
|
||||
},
|
||||
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
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" && dir != "." {
|
||||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
func isAllowedSearchSource(source string) bool {
|
||||
return source == "qobuz" || source == "tidal" || source == "deezer" || source == "soundcloud"
|
||||
}
|
||||
|
||||
func isAllowedMediaType(mediaType string) bool {
|
||||
switch mediaType {
|
||||
case "track", "album", "playlist", "artist", "label", "video":
|
||||
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/deezer/soundcloud]: ")
|
||||
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/video]: ")
|
||||
if err != nil {
|
||||
return "", "", searchOptions{}, err
|
||||
}
|
||||
mediaType = strings.ToLower(mediaType)
|
||||
if !isAllowedMediaType(mediaType) {
|
||||
fmt.Println("Invalid media type.")
|
||||
continue
|
||||
}
|
||||
if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
|
||||
fmt.Println("SoundCloud search supports track and playlist only.")
|
||||
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)
|
||||
seen := map[string]struct{}{}
|
||||
appendUnique := func(r searchResult) {
|
||||
if strings.TrimSpace(r.ID) == "" || strings.TrimSpace(r.Title) == "" {
|
||||
return
|
||||
}
|
||||
key := r.ID
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
results = append(results, r)
|
||||
}
|
||||
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"])
|
||||
appendUnique(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"])
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
|
||||
}
|
||||
case "deezer":
|
||||
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"])
|
||||
}
|
||||
artist := nestedSearchString(itm, "artist", "name")
|
||||
album := nestedSearchString(itm, "album", "title")
|
||||
trackCount := searchInt(itm["nb_tracks"])
|
||||
explicit := searchBool(itm["explicit_lyrics"])
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
|
||||
}
|
||||
case "soundcloud":
|
||||
items, ok := page["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"])
|
||||
artist := nestedSearchString(itm, "artist", "name")
|
||||
trackCount := searchInt(itm["tracks_count"])
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, TrackCount: trackCount})
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func formatSearchDetails(r searchResult) string {
|
||||
lines := []string{"Selected item", ""}
|
||||
lines = append(lines, 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")
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("ID : %s", r.ID))
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user