From 50ca5f564b666d3efa56d482ffaf27aac30bd2be Mon Sep 17 00:00:00 2001 From: Joren Date: Wed, 22 Apr 2026 00:16:59 +0200 Subject: [PATCH] improve search menu preview and artist release details --- cmd/rip/search.go | 192 +++++++++++++++++++++++++++++++++++------ cmd/rip/search_test.go | 87 +++++++++++++++++++ 2 files changed, 253 insertions(+), 26 deletions(-) create mode 100644 cmd/rip/search_test.go diff --git a/cmd/rip/search.go b/cmd/rip/search.go index c27ebf8..f83dab7 100644 --- a/cmd/rip/search.go +++ b/cmd/rip/search.go @@ -19,6 +19,8 @@ type searchResult struct { Title string Artist string Album string + Date string + Releases int TrackCount int Explicit bool } @@ -191,31 +193,40 @@ func promptSearchSelectionMenu(source, mediaType, query string, results []search 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 { - artist := strings.TrimSpace(r.Artist) - if artist == "" { - artist = "Unknown Artist" - } - label := fmt.Sprintf("%2d. %s - %s", i+1, artist, r.Title) + 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 /: filter ESC: cancel", + 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(results[resultIndex]) + return formatSearchDetails(mediaType, results[resultIndex]) }, - PageSize: 15, + PageSize: pageSize, } if err := survey.AskOne(prompt, &selected); err != nil { if strings.Contains(strings.ToLower(err.Error()), "interrupt") { @@ -241,6 +252,22 @@ func promptSearchSelectionMenu(source, mediaType, query string, results []search 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"` @@ -391,7 +418,23 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) [] trackCount = searchInt(itm["track_count"]) } explicit := searchBool(itm["parental_warning"]) - appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit}) + 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) @@ -425,7 +468,23 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) [] trackCount = searchInt(itm["tracks_count"]) } explicit := searchBool(itm["explicit"]) - appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: 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" @@ -451,7 +510,19 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) [] 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}) + 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) @@ -467,32 +538,80 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) [] title := asString(itm["title"]) artist := nestedSearchString(itm, "artist", "name") trackCount := searchInt(itm["tracks_count"]) - appendUnique(searchResult{ID: id, Title: title, Artist: artist, TrackCount: trackCount}) + 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(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)) +func formatSearchDetails(mediaType string, r searchResult) string { + date := strings.TrimSpace(r.Date) + if date == "" { + date = "Unknown" } - if strings.TrimSpace(r.Album) != "" { - lines = append(lines, fmt.Sprintf("Album : %s", r.Album)) + 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") + } } - 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)) + 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 { @@ -505,6 +624,27 @@ func nestedSearchString(v map[string]any, keys ...string) string { 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: diff --git a/cmd/rip/search_test.go b/cmd/rip/search_test.go new file mode 100644 index 0000000..e4d88ba --- /dev/null +++ b/cmd/rip/search_test.go @@ -0,0 +1,87 @@ +package main + +import "testing" + +func TestFormatSearchDetailsAlbum(t *testing.T) { + r := searchResult{ID: "9399700017915", Date: "1994-02-01", TrackCount: 18} + got := formatSearchDetails("album", r) + want := "Date released:\n1994-02-01\n\n18 Tracks\n\nID: 9399700017915" + if got != want { + t.Fatalf("unexpected album details:\n%s", got) + } +} + +func TestFormatSearchDetailsTrackDefaultsDate(t *testing.T) { + r := searchResult{ID: "3083287"} + got := formatSearchDetails("track", r) + want := "Released on:\nUnknown\n\nID: 3083287" + if got != want { + t.Fatalf("unexpected track details:\n%s", got) + } +} + +func TestFormatSearchDetailsArtistWithReleases(t *testing.T) { + r := searchResult{ID: "1863897", Title: "Lost Frequencies", Releases: 23} + got := formatSearchDetails("artist", r) + want := "Lost Frequencies\n\n23 Releases\n\nID: 1863897" + if got != want { + t.Fatalf("unexpected artist details:\n%s", got) + } +} + +func TestNormalizeSearchResultsQobuzExtractsDate(t *testing.T) { + pages := []map[string]any{ + {"albums": map[string]any{"items": []any{ + map[string]any{ + "id": "1", + "title": "Master of Reality", + "release_date_original": "1971-07-21", + "artist": map[string]any{"name": "Black Sabbath"}, + }, + }}}, + } + results := normalizeSearchResults("qobuz", "album", pages) + if len(results) != 1 { + t.Fatalf("len(results)=%d want 1", len(results)) + } + if results[0].Date != "1971-07-21" { + t.Fatalf("date=%q want 1971-07-21", results[0].Date) + } +} + +func TestFormatSearchOptionLabelArtistNoUnknownArtistSuffix(t *testing.T) { + r := searchResult{Title: "Lost Frequencies"} + got := formatSearchOptionLabel(1, "artist", r) + want := "1. Lost Frequencies" + if got != want { + t.Fatalf("label=%q want %q", got, want) + } +} + +func TestFormatSearchOptionLabelTrackWithArtist(t *testing.T) { + r := searchResult{Title: "Dreams", Artist: "Fleetwood Mac"} + got := formatSearchOptionLabel(2, "track", r) + want := "2. Dreams by Fleetwood Mac" + if got != want { + t.Fatalf("label=%q want %q", got, want) + } +} + +func TestNormalizeSearchResultsQobuzArtistExtractsReleases(t *testing.T) { + pages := []map[string]any{ + {"artists": map[string]any{"items": []any{ + map[string]any{ + "id": "1863897", + "name": "Lost Frequencies", + "albums_count": 23, + }, + }}}, + } + results := normalizeSearchResults("qobuz", "artist", pages) + if len(results) != 1 { + t.Fatalf("len(results)=%d want 1", len(results)) + } + if results[0].Releases != 23 { + t.Fatalf("releases=%d want 23", results[0].Releases) + } +}