mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
improve CLI error semantics and soundcloud canonicalization
Auto-upgrade outdated configs on startup, add actionable SSL verification hints in rip error paths, and harden SoundCloud search/metadata with canonical URL handling and richer source IDs.
This commit is contained in:
130
cmd/rip/main.go
130
cmd/rip/main.go
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
@@ -38,11 +39,24 @@ func main() {
|
||||
}
|
||||
if gopts.command == "" {
|
||||
fmt.Println("usage: rip <command>")
|
||||
fmt.Println("commands: url, file, config, database, id, search, lastfm, soundcloud-smoke, 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-video-smoke, tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke")
|
||||
fmt.Println("commands: url, file, config, database, id, search, lastfm")
|
||||
fmt.Println("tip: run `rip dev-help` to list developer smoke commands")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(gopts.configPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrOutdatedConfig) {
|
||||
resolvedPath, upErr := config.UpgradeOutdated(gopts.configPath)
|
||||
if upErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "config auto-upgrade failed: %v\n", upErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "config upgraded at %s\n", resolvedPath)
|
||||
cfg, err = config.Load(gopts.configPath)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -57,6 +71,15 @@ func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
switch os.Args[1] {
|
||||
case "dev-help":
|
||||
fmt.Println("developer smoke commands:")
|
||||
fmt.Println(" soundcloud-smoke")
|
||||
fmt.Println(" qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke")
|
||||
fmt.Println(" qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke")
|
||||
fmt.Println(" qobuz-search-smoke")
|
||||
fmt.Println(" tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke")
|
||||
fmt.Println(" tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke")
|
||||
return
|
||||
case "url":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("usage: rip url <url...> [--force|--ignore-db]")
|
||||
@@ -94,11 +117,11 @@ func main() {
|
||||
}
|
||||
|
||||
if err = mainApp.Resolve(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = mainApp.Rip(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("url rip complete (%d item(s))\n", added)
|
||||
@@ -167,11 +190,11 @@ func main() {
|
||||
}
|
||||
|
||||
if err = mainApp.Resolve(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = mainApp.Rip(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("file rip complete (%d item(s))\n", added)
|
||||
@@ -318,11 +341,11 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = mainApp.Resolve(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = mainApp.Rip(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID)
|
||||
@@ -349,6 +372,10 @@ func main() {
|
||||
fmt.Fprintf(os.Stderr, "search option error: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if sopts.first && sopts.outputFile != "" {
|
||||
fmt.Fprintln(os.Stderr, "cannot choose --first and --output-file together")
|
||||
os.Exit(2)
|
||||
}
|
||||
if !isAllowedSearchSource(source) {
|
||||
fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source)
|
||||
os.Exit(2)
|
||||
@@ -357,8 +384,8 @@ func main() {
|
||||
fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType)
|
||||
os.Exit(2)
|
||||
}
|
||||
if source == "soundcloud" && mediaType != "track" {
|
||||
fmt.Fprintln(os.Stderr, "soundcloud search currently supports media type track only")
|
||||
if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
|
||||
fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist")
|
||||
os.Exit(2)
|
||||
}
|
||||
if sopts.query == "" {
|
||||
@@ -448,11 +475,11 @@ func main() {
|
||||
return
|
||||
}
|
||||
if err = mainApp.Resolve(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = mainApp.Rip(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("search download complete (%d item(s))\n", added)
|
||||
@@ -502,11 +529,11 @@ func main() {
|
||||
return
|
||||
}
|
||||
if err = mainApp.Resolve(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = mainApp.Rip(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("search download complete (%d item(s))\n", added)
|
||||
@@ -546,22 +573,18 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
playlistGroups := groupLastFMResolvedTracksBySource(resolvedTracks)
|
||||
addedPlaylists := 0
|
||||
for source, ids := range playlistGroups {
|
||||
playlistID := fmt.Sprintf("lastfm:%s:%s", source, strings.ToLower(strings.ReplaceAll(title, " ", "_")))
|
||||
playlistName := title
|
||||
if len(playlistGroups) > 1 {
|
||||
playlistName = fmt.Sprintf("%s (%s)", title, strings.Title(source))
|
||||
}
|
||||
if addErr := mainApp.AddPlaylistByTrackIDs(ctx, source, playlistID, playlistName, ids); addErr != nil {
|
||||
fmt.Printf("playlist queue failed: source=%s err=%v\n", source, addErr)
|
||||
continue
|
||||
}
|
||||
addedPlaylists++
|
||||
fmt.Printf("queued lastfm playlist: %s (%d tracks, %s)\n", playlistName, len(ids), source)
|
||||
playlistID := fmt.Sprintf("lastfm:%s", strings.ToLower(strings.ReplaceAll(title, " ", "_")))
|
||||
refs := make([]app.PlaylistTrackRef, 0, len(resolvedTracks))
|
||||
for _, item := range resolvedTracks {
|
||||
refs = append(refs, app.PlaylistTrackRef{Source: item.Source, ID: item.ID})
|
||||
}
|
||||
if addedPlaylists == 0 {
|
||||
if addErr := mainApp.AddMixedPlaylistByTrackRefs(ctx, playlistID, title, refs); addErr != nil {
|
||||
fmt.Printf("playlist queue failed: err=%v\n", addErr)
|
||||
fmt.Println("no lastfm playlists queued")
|
||||
return
|
||||
}
|
||||
fmt.Printf("queued lastfm playlist: %s (%d tracks)\n", title, len(refs))
|
||||
if len(refs) == 0 {
|
||||
fmt.Println("no lastfm playlists queued")
|
||||
return
|
||||
}
|
||||
@@ -573,7 +596,7 @@ func main() {
|
||||
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("lastfm rip complete (%d track(s) across %d playlist(s))\n", len(resolvedTracks), addedPlaylists)
|
||||
fmt.Printf("lastfm rip complete (%d track(s) across 1 playlist)\n", len(resolvedTracks))
|
||||
case "soundcloud-smoke":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
|
||||
@@ -1309,6 +1332,21 @@ func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1654,7 +1692,6 @@ func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string
|
||||
func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) {
|
||||
client := netutil.NewHTTPClient(30*time.Second, verifySSL)
|
||||
all := make([]lastFMTrack, 0, 200)
|
||||
seen := map[string]struct{}{}
|
||||
title := ""
|
||||
|
||||
for page := 1; page <= 50; page++ {
|
||||
@@ -1672,17 +1709,8 @@ func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistU
|
||||
if len(tracks) == 0 {
|
||||
break
|
||||
}
|
||||
newOnPage := 0
|
||||
for _, tr := range tracks {
|
||||
key := strings.ToLower(strings.TrimSpace(tr.Title + "\x00" + tr.Artist))
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
all = append(all, tr)
|
||||
newOnPage++
|
||||
}
|
||||
if newOnPage == 0 || !strings.Contains(body, "Show more") {
|
||||
all = append(all, tracks...)
|
||||
if !strings.Contains(body, "Show more") {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1874,19 +1902,6 @@ func resolveLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOpti
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func groupLastFMResolvedTracksBySource(resolved []resolvedLastFMTrack) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, item := range resolved {
|
||||
source := strings.TrimSpace(item.Source)
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if source == "" || id == "" {
|
||||
continue
|
||||
}
|
||||
out[source] = append(out[source], id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
@@ -2229,8 +2244,8 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e
|
||||
fmt.Println("Invalid media type.")
|
||||
continue
|
||||
}
|
||||
if source == "soundcloud" && mediaType != "track" {
|
||||
fmt.Println("SoundCloud search supports track only.")
|
||||
if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
|
||||
fmt.Println("SoundCloud search supports track and playlist only.")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2379,8 +2394,9 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
|
||||
id := asString(itm["id"])
|
||||
title := asString(itm["title"])
|
||||
artist := nestedSearchString(itm, "artist", "name")
|
||||
trackCount := searchInt(itm["tracks_count"])
|
||||
if id != "" && title != "" {
|
||||
results = append(results, searchResult{ID: id, Title: title, Artist: artist})
|
||||
results = append(results, searchResult{ID: id, Title: title, Artist: artist, TrackCount: trackCount})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user