add CLI parity flags and expand provider support

This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
This commit is contained in:
2026-04-20 00:56:10 +02:00
parent 4da5114a70
commit b2688ce949
15 changed files with 1746 additions and 57 deletions

View File

@@ -18,7 +18,9 @@ import (
"streamrip-go/internal/download"
"streamrip-go/internal/naming"
"streamrip-go/internal/provider"
deezerprovider "streamrip-go/internal/provider/deezer"
qobuzprovider "streamrip-go/internal/provider/qobuz"
soundcloudprovider "streamrip-go/internal/provider/soundcloud"
tidalprovider "streamrip-go/internal/provider/tidal"
"streamrip-go/internal/store"
)
@@ -66,6 +68,10 @@ type trackTagger interface {
TagFLAC(path string, meta tag.Metadata, coverPath string) error
}
type videoDownloadableProvider interface {
GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error)
}
func New(cfg *config.Config) (*Main, error) {
var db store.Database
if cfg.Session.Database.DownloadsEnabled || cfg.Session.Database.FailedDownloadsEnabled {
@@ -79,8 +85,10 @@ func New(cfg *config.Config) (*Main, error) {
}
providers := map[string]provider.Client{
"qobuz": qobuzprovider.New(cfg),
"tidal": tidalprovider.New(cfg),
"qobuz": qobuzprovider.New(cfg),
"tidal": tidalprovider.New(cfg),
"deezer": deezerprovider.New(cfg),
"soundcloud": soundcloudprovider.New(cfg),
}
return &Main{
@@ -156,8 +164,10 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error
return m.ripCollection(ctx, p, source, "Artist", id, meta)
case "label":
return m.ripCollection(ctx, p, source, "Label", id, meta)
case "video":
return m.ripVideo(ctx, p, source, id, meta)
default:
return nil
return fmt.Errorf("unsupported media type %q", mediaType)
}
}}, nil
},
@@ -205,6 +215,37 @@ func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kin
return nil
}
func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID string, meta map[string]any) error {
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, videoID)
if err == nil && alreadyDownloaded && !m.IgnoreDB {
m.logf("skip (already downloaded) id=%s\n", videoID)
return nil
}
vp, ok := p.(videoDownloadableProvider)
if !ok {
return fmt.Errorf("provider %q does not support video downloads", source)
}
d, err := vp.GetVideoDownloadable(ctx, videoID)
if err != nil {
_ = m.Store.MarkFailed(ctx, source, "video", videoID)
return fmt.Errorf("id=%s get_video_downloadable: %w", videoID, err)
}
title := titleFromMetadata(meta, videoID)
outPath := m.videoOutputPath(source, videoID, title, d.Extension)
if err = m.DL.FileVideo(ctx, d.URL, outPath); err != nil {
_ = m.Store.MarkFailed(ctx, source, "video", videoID)
return fmt.Errorf("id=%s title=%q video download: %w", videoID, title, err)
}
if err = m.Store.MarkDownloaded(ctx, source, videoID); err != nil {
return err
}
return nil
}
func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum {
trackCount := intFromAny(meta["tracks_count"])
if trackCount == 0 {
@@ -357,16 +398,21 @@ func extractAlbumIDs(meta map[string]any) []string {
}
func (m *Main) Resolve(ctx context.Context) error {
pendingCount := len(m.Pending)
resolved := make([]media.Media, 0, len(m.Pending))
for _, item := range m.Pending {
med, err := item.Resolve(ctx)
if err != nil {
m.logf("resolve failed: %v\n", err)
continue
}
resolved = append(resolved, med)
}
m.Media = append(m.Media, resolved...)
m.Pending = m.Pending[:0]
if pendingCount > 0 && len(resolved) == 0 {
return fmt.Errorf("resolve failed for all %d pending item(s)", pendingCount)
}
return nil
}
@@ -830,6 +876,24 @@ func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[stri
return filepath.Join(base, fileName+"."+ext)
}
func (m *Main) videoOutputPath(source, id, title, ext string) string {
if strings.TrimSpace(ext) == "" {
ext = "mp4"
}
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, strings.Title(source))
}
fileName := naming.CleanName(title, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
})
if fileName == "" {
fileName = id
}
return filepath.Join(base, fileName+"."+ext)
}
func titleFromMetadata(meta map[string]any, fallback string) string {
if title, ok := meta["title"].(string); ok {
title = strings.TrimSpace(title)

View File

@@ -51,22 +51,35 @@ type fakePlaylistProvider struct {
url string
}
type fakeVideoProvider struct {
url string
}
type fakeFailProvider struct{}
func (f *fakeAlbumProvider) Source() string { return "qobuz" }
func (f *fakePlaylistProvider) Source() string { return "qobuz" }
func (f *fakeVideoProvider) Source() string { return "tidal" }
func (f *fakeAlbumProvider) Login(context.Context) error { return nil }
func (f *fakePlaylistProvider) Login(context.Context) error {
return nil
}
func (f *fakeAlbumProvider) LoggedIn() bool { return true }
func (f *fakePlaylistProvider) LoggedIn() bool { return true }
func (f *fakeAlbumProvider) Close() error { return nil }
func (f *fakePlaylistProvider) Close() error { return nil }
func (f *fakeVideoProvider) Login(context.Context) error { return nil }
func (f *fakeAlbumProvider) LoggedIn() bool { return true }
func (f *fakePlaylistProvider) LoggedIn() bool { return true }
func (f *fakeVideoProvider) LoggedIn() bool { return true }
func (f *fakeAlbumProvider) Close() error { return nil }
func (f *fakePlaylistProvider) Close() error { return nil }
func (f *fakeVideoProvider) Close() error { return nil }
func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakePlaylistProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeVideoProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "album" {
return map[string]any{
@@ -133,6 +146,12 @@ func (f *fakePlaylistProvider) GetMetadata(_ context.Context, id string, mediaTy
},
}, nil
}
func (f *fakeVideoProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "video" {
return map[string]any{"title": "Live Clip"}, nil
}
return nil, nil
}
func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
@@ -142,6 +161,25 @@ func (f *fakeAlbumProvider) GetDownloadable(context.Context, string, int) (*prov
func (f *fakePlaylistProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
func (f *fakeVideoProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return nil, nil
}
func (f *fakeVideoProvider) GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "mp4", Source: "tidal"}, nil
}
func (f *fakeFailProvider) Source() string { return "qobuz" }
func (f *fakeFailProvider) Login(context.Context) error { return nil }
func (f *fakeFailProvider) LoggedIn() bool { return true }
func (f *fakeFailProvider) Close() error { return nil }
func (f *fakeFailProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeFailProvider) GetMetadata(context.Context, string, string) (map[string]any, error) {
return nil, os.ErrNotExist
}
func (f *fakeFailProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return nil, os.ErrNotExist
}
func TestTrackRipPipeline(t *testing.T) {
tmp := t.TempDir()
@@ -248,6 +286,88 @@ func TestAlbumRipPipeline(t *testing.T) {
}
}
func TestVideoRipPipeline(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("video-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
cfg := &config.Config{File: d, Session: d}
sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite"))
if err != nil {
t.Fatalf("NewSQLite() error = %v", err)
}
defer func() { _ = sqlite.Close() }()
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{
"tidal": &fakeVideoProvider{url: ts.URL},
},
Store: sqlite,
DL: download.New(),
Tagger: noopTagger{},
Pending: nil,
Media: nil,
}
ctx := context.Background()
if err = m.AddByID(ctx, "tidal", "video", "v1"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err = m.Resolve(ctx); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err = m.Rip(ctx); err != nil {
t.Fatalf("Rip() error = %v", err)
}
if _, err = os.Stat(filepath.Join(tmp, "Live Clip.mp4")); err != nil {
t.Fatalf("expected downloaded video file: %v", err)
}
ok, err := sqlite.IsDownloaded(ctx, "tidal", "v1")
if err != nil {
t.Fatalf("IsDownloaded() error = %v", err)
}
if !ok {
t.Fatalf("expected video marked downloaded")
}
}
func TestResolveAllFailedReturnsError(t *testing.T) {
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
cfg := &config.Config{File: d, Session: d}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{
"qobuz": &fakeFailProvider{},
},
Store: store.NewDummy(),
DL: download.New(),
Tagger: noopTagger{},
Pending: nil,
Media: nil,
}
ctx := context.Background()
if err := m.AddByID(ctx, "qobuz", "track", "x"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err := m.Resolve(ctx); err == nil {
t.Fatalf("expected Resolve() to return error when all items fail")
}
}
func TestPlaylistRipPipeline(t *testing.T) {
tmp := t.TempDir()