Files
streamrip-go/cmd/rip/search.go

668 lines
16 KiB
Go

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
Date string
Releases int
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
}
prevTemplate := survey.MultiSelectQuestionTemplate
survey.MultiSelectQuestionTemplate = streamripLikeSearchTemplate
defer func() {
survey.MultiSelectQuestionTemplate = prevTemplate
}()
labels := make([]string, 0, len(results))
labelToIndex := map[string]int{}
for i, r := range results {
label := formatSearchOptionLabel(i+1, mediaType, r)
labels = append(labels, label)
labelToIndex[label] = i
}
pageSize := len(labels)
if pageSize < 15 {
pageSize = 15
}
if pageSize > 30 {
pageSize = 30
}
selected := []string{}
prompt := &survey.MultiSelect{
Message: fmt.Sprintf("Results for %s '%s' from %s", mediaType, query, jsonutil.TitleCase(source)),
Help: "SPACE - select, ENTER - download, ESC - exit",
Options: labels,
Description: func(value string, index int) string {
resultIndex, ok := labelToIndex[value]
if !ok || resultIndex < 0 || resultIndex >= len(results) {
return ""
}
return formatSearchDetails(mediaType, results[resultIndex])
},
PageSize: pageSize,
}
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
}
const streamripLikeSearchTemplate = `
{{ color "default+hb"}}{{ .Message }}{{color "reset"}}
{{- if .Help }}
{{ .Help }}
{{- end }}
{{- range $ix, $option := .PageEntries }}
{{ if eq $.SelectedIndex $ix }}>{{ else }} {{ end }} [{{ if index $.Checked $option.Index }}x{{ else }} {{ end }}] {{ $option.Value }}
{{- end }}
preview
{{- if gt (len .PageEntries) 0 }}
{{ $current := index .PageEntries .SelectedIndex }}
{{ $.GetDescription $current }}
{{- end }}
`
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"])
date := firstNonEmpty(
asString(itm["release_date_original"]),
asString(itm["release_date"]),
asString(itm["releaseDate"]),
asString(itm["streamStartDate"]),
nestedSearchString(itm, "album", "release_date_original"),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = firstPositiveInt(
searchInt(itm["albums_count"]),
searchInt(itm["albumsCount"]),
nestedSearchInt(itm, "albums", "total"),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, 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"])
date := firstNonEmpty(
asString(itm["releaseDate"]),
asString(itm["streamStartDate"]),
asString(itm["release_date"]),
nestedSearchString(itm, "album", "releaseDate"),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = firstPositiveInt(
searchInt(itm["numberOfAlbums"]),
searchInt(itm["albums_count"]),
searchInt(itm["albumsCount"]),
nestedSearchInt(itm, "albums", "total"),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, 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"])
date := firstNonEmpty(
asString(itm["release_date"]),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = firstPositiveInt(
searchInt(itm["nb_album"]),
searchInt(itm["albums_count"]),
searchInt(itm["numberOfAlbums"]),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, 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"])
date := firstNonEmpty(
asString(itm["release_date"]),
asString(itm["display_date"]),
)
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Date: date, TrackCount: trackCount})
}
}
}
return results
}
func formatSearchDetails(mediaType string, r searchResult) string {
date := strings.TrimSpace(r.Date)
if date == "" {
date = "Unknown"
}
lines := []string{}
switch mediaType {
case "album":
lines = append(lines, "Date released:", date)
if r.TrackCount > 0 {
lines = append(lines, "", fmt.Sprintf("%d Tracks", r.TrackCount))
}
case "artist":
if strings.TrimSpace(r.Title) != "" {
lines = append(lines, r.Title)
}
if r.Releases > 0 {
lines = append(lines, "", fmt.Sprintf("%d Releases", r.Releases))
}
case "track":
lines = append(lines, "Released on:", date)
case "playlist":
if r.TrackCount > 0 {
lines = append(lines, fmt.Sprintf("%d Tracks", r.TrackCount))
}
default:
if strings.TrimSpace(r.Title) != "" {
lines = append(lines, r.Title)
}
if strings.TrimSpace(r.Artist) != "" {
lines = append(lines, strings.TrimSpace(r.Artist))
}
if strings.TrimSpace(r.Album) != "" {
lines = append(lines, strings.TrimSpace(r.Album))
}
if r.TrackCount > 0 {
lines = append(lines, fmt.Sprintf("%d Tracks", r.TrackCount))
}
if r.Explicit {
lines = append(lines, "Explicit")
}
}
lines = append(lines, "", "ID: "+r.ID)
return strings.Join(lines, "\n")
}
func formatSearchOptionLabel(index int, mediaType string, r searchResult) string {
title := strings.TrimSpace(r.Title)
if title == "" {
title = "Unknown"
}
artist := strings.TrimSpace(r.Artist)
switch mediaType {
case "artist", "label":
return fmt.Sprintf("%d. %s", index, title)
default:
if artist == "" {
return fmt.Sprintf("%d. %s", index, title)
}
return fmt.Sprintf("%d. %s by %s", index, title, artist)
}
}
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 nestedSearchInt(v map[string]any, keys ...string) int {
cur := any(v)
for _, key := range keys {
m, ok := cur.(map[string]any)
if !ok {
return 0
}
cur = m[key]
}
return searchInt(cur)
}
func firstPositiveInt(values ...int) int {
for _, v := range values {
if v > 0 {
return v
}
}
return 0
}
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
}