mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
- 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
528 lines
13 KiB
Go
528 lines
13 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
|
|
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
|
|
}
|