Files
streamrip-go/internal/app/app_test.go
T
JorenandClaude Opus 5 b5d4d5688d refactor: consolidate the duplicated download and worker-pool code
internal/download
  The ~40-line progress-bar decorator stack was copy-pasted into three download
  paths and had started to drift; it is now newDownloadBar, which returns a nil
  bar when progress is off (so callers use it unconditionally) plus a finish
  func to defer. The three hand-rolled read/write loops that differed only in
  whether they touched a bar become copyWithProgress.

  FileDeezerEncrypted and FileYandexEncrypted were ~90% identical: mkdir, GET,
  status check, create output, bar, cleanup-on-failure, Content-Length check,
  fsync. That scaffolding is now streamEncrypted, and each function supplies
  only its decryption step. Also switches the io.EOF/io.ErrUnexpectedEOF
  comparisons to errors.Is.

internal/app
  Three copies of "coerce a JSON array that may be []any or []map[string]any"
  become anySlice, which also reports whether the value was an array at all so
  callers can still distinguish missing from empty.

  The worker-pool dispatch existed three times and the copies disagreed: two
  counted failures under a mutex and logged inline with the track id, while the
  third collected errors in a channel and logged them all after wg.Wait()
  without the id. runTrackWorkers unifies them on the inline form, which
  reports failures as they happen and names the track, and keeps the mutex held
  across the log so failure lines cannot interleave.

Tests
  The progress-bar paths had no coverage at all, because progress is disabled
  when not attached to a terminal; four new tests force it on via the existing
  STREAMRIP_GO_FORCE_PROGRESS escape hatch and cover the determinate bar, the
  unknown-length spinner, Deezer decryption with a bar attached, and the
  failure path still removing the partial file. internal/download coverage
  53% -> 63%.

  Three cancellation tests pin the Ctrl-C fix. Verified as genuine regression
  tests by reverting the guards: without them the rip walks all 40 tracks of a
  cancelled album, logs a failure for each, and Rip() returns nil.

Adds store.SQLite.IsFailed, the read complement to MarkFailed, used by the
bookkeeping test to confirm failure records still land under a cancelled
context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 17:23:24 +02:00

1141 lines
34 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"streamrip-go/internal/audio/tag"
"streamrip-go/internal/config"
"streamrip-go/internal/download"
"streamrip-go/internal/provider"
"streamrip-go/internal/store"
)
type noopTagger struct{}
func (n noopTagger) TagFLAC(context.Context, string, tag.Metadata, string) error { return nil }
type failingTagger struct {
err error
}
func (f failingTagger) TagFLAC(context.Context, string, tag.Metadata, string) error { return f.err }
type fakeProvider struct {
url string
}
func (f *fakeProvider) Source() string { return "qobuz" }
func (f *fakeProvider) Login(context.Context) error { return nil }
func (f *fakeProvider) LoggedIn() bool { return true }
func (f *fakeProvider) Close() error { return nil }
func (f *fakeProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeProvider) GetMetadata(context.Context, string, string) (map[string]any, error) {
return map[string]any{
"title": "Dreams/Live",
"track_number": float64(3),
"performer": map[string]any{
"name": "Fleetwood Mac",
},
"album": map[string]any{
"artist": map[string]any{"name": "Fleetwood Mac"},
},
}, nil
}
type fakeAlbumProvider struct {
url string
}
type fakePlaylistProvider struct {
url string
}
type fakeVideoProvider struct {
url string
}
type fakeResolvedAlbumProvider struct {
url string
downloadable provider.Downloadable
downloadableHits int
}
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 *fakeResolvedAlbumProvider) Source() string { return "qobuz" }
func (f *fakeAlbumProvider) Login(context.Context) error { return nil }
func (f *fakePlaylistProvider) Login(context.Context) error {
return nil
}
func (f *fakeVideoProvider) Login(context.Context) error { return nil }
func (f *fakeResolvedAlbumProvider) 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 *fakeResolvedAlbumProvider) 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 *fakeResolvedAlbumProvider) 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 *fakeResolvedAlbumProvider) 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{
"title": "Rumours",
"release_date_original": "1977-02-04",
"media_count": float64(2),
"maximum_bit_depth": float64(24),
"maximum_sampling_rate": float64(96),
"artist": map[string]any{"name": "Fleetwood Mac"},
"tracks": map[string]any{"items": []any{
map[string]any{"id": "t1"},
map[string]any{"id": "t2"},
}},
}, nil
}
tn := float64(1)
disc := float64(1)
title := "Dreams"
if id == "t2" {
tn = 2
disc = 2
title = "Go Your Own Way"
}
return map[string]any{
"title": title,
"track_number": tn,
"media_number": disc,
"performer": map[string]any{
"name": "Fleetwood Mac",
},
"album": map[string]any{
"title": "Rumours",
"artist": map[string]any{"name": "Fleetwood Mac"},
},
}, nil
}
func (f *fakePlaylistProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "playlist" {
return map[string]any{
"name": "Road Trip",
"tracks": map[string]any{
"items": []any{map[string]any{"id": "p1"}, map[string]any{"id": "p2"}},
},
}, nil
}
trackNum := float64(7)
title := "Track One"
if id == "p2" {
trackNum = 9
title = "Track Two"
}
return map[string]any{
"title": title,
"track_number": trackNum,
"performer": map[string]any{
"name": "Artist",
},
"album": map[string]any{
"title": "Original Album",
"artist": map[string]any{"name": "Artist"},
},
}, 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 *fakeResolvedAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "album" {
return map[string]any{
"title": "Fallback Album",
"release_date_original": "2020-01-01",
"artist": map[string]any{"name": "Fallback Artist"},
"tracks": map[string]any{"items": []any{map[string]any{"id": "t1"}}},
}, nil
}
return map[string]any{
"title": "Fallback Song",
"track_number": float64(1),
"performer": map[string]any{"name": "Fallback Artist"},
"album": map[string]any{"title": "Fallback Album", "artist": map[string]any{"name": "Fallback Artist"}},
}, nil
}
func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
func (f *fakeAlbumProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
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 *fakeResolvedAlbumProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
f.downloadableHits++
d := f.downloadable
if d.URL == "" {
d.URL = f.url
}
return &d, 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()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-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{
"qobuz": &fakeProvider{url: ts.URL},
},
Store: sqlite,
DL: download.New(),
Tagger: noopTagger{},
Pending: nil,
Media: nil,
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "track", "19512574"); 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, "03. Fleetwood Mac - Dreams_Live.flac")); err != nil {
t.Fatalf("expected downloaded file: %v", err)
}
ok, err := sqlite.IsDownloaded(ctx, "qobuz", "19512574")
if err != nil {
t.Fatalf("IsDownloaded() error = %v", err)
}
if !ok {
t.Fatalf("expected track marked downloaded")
}
}
func TestTrackRipFailsWhenTaggerReportsMissingFFmpeg(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-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{
"qobuz": &fakeProvider{url: ts.URL},
},
Store: sqlite,
DL: download.NewWithOptions(true, false, 0),
Tagger: failingTagger{err: fmt.Errorf("ffmpeg not found: %w", exec.ErrNotFound)},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "track", "19512574"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err = m.Resolve(ctx); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
err = m.Rip(ctx)
if err == nil {
t.Fatalf("expected rip failure")
}
ok, err := sqlite.IsDownloaded(ctx, "qobuz", "19512574")
if err != nil {
t.Fatalf("IsDownloaded() error = %v", err)
}
if ok {
t.Fatalf("expected track not marked downloaded")
}
}
func TestAlbumRipPipeline(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
d.Downloads.Concurrency = 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{
"qobuz": &fakeAlbumProvider{url: ts.URL},
},
Store: sqlite,
DL: download.New(),
Tagger: noopTagger{},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "album", "a1"); 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)
}
folder := filepath.Join(tmp, "Fleetwood Mac - Rumours (1977) [FLAC] [24B-96kHz]")
if _, err = os.Stat(filepath.Join(folder, "Disc 1", "01. Fleetwood Mac - Dreams.flac")); err != nil {
t.Fatalf("missing first album track: %v", err)
}
if _, err = os.Stat(filepath.Join(folder, "Disc 2", "02. Fleetwood Mac - Go Your Own Way.flac")); err != nil {
t.Fatalf("missing second album track: %v", err)
}
}
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 TestBuildTagMetadataUsesAlbumArtistOverride(t *testing.T) {
meta := map[string]any{
"title": "One Step Too Far",
"track_number": float64(15),
"performer": map[string]any{"name": "Faithless"},
"artist": map[string]any{"name": "Faithless"},
"album": map[string]any{
"id": "23324600",
"title": "Greatest Hits (Deluxe)",
"artist": map[string]any{"name": "Faithless"},
},
}
tags := buildTagMetadata(meta, "One Step Too Far", "tidal", "23324615", ripTrackOptions{albumArtist: "Dido", albumDiscTotal: 2})
if tags.AlbumArtist != "Dido" {
t.Fatalf("album artist = %q, want Dido", tags.AlbumArtist)
}
if tags.DiscNumber != 1 {
t.Fatalf("disc number = %d, want 1", tags.DiscNumber)
}
if tags.DiscTotal != 2 {
t.Fatalf("disc total = %d, want 2", tags.DiscTotal)
}
}
func TestBuildTagMetadataUsesAlbumContextTrackTotal(t *testing.T) {
meta := map[string]any{
"title": "ideal world (feat. higma)",
"track_number": float64(2),
"performer": map[string]any{"name": "Seren Azuma"},
"album": map[string]any{
"title": "YUKIHASU",
"artist": map[string]any{"name": "Seren Azuma"},
},
}
tags := buildTagMetadata(meta, "ideal world (feat. higma)", "qobuz", "295525879", ripTrackOptions{total: 11})
if tags.TrackTotal != 11 {
t.Fatalf("track total = %d, want 11", tags.TrackTotal)
}
}
func TestBuildTagMetadataPlaylistOmitsDiscTags(t *testing.T) {
meta := map[string]any{
"title": "One Step Too Far",
"track_number": float64(15),
"media_number": float64(2),
"numberOfVolumes": float64(2),
"numberOfTracks": float64(18),
"performer": map[string]any{"name": "Faithless"},
"artist": map[string]any{"name": "Faithless"},
"release_date": "2005-01-01",
"release_date_original": "2005-01-01",
"album": map[string]any{
"id": "23324600",
"title": "Greatest Hits (Deluxe)",
"artist": map[string]any{"name": "Faithless"},
},
}
playlistCfg := config.DefaultConfigData().Metadata
applyPlaylistMetadataOverrides(meta, playlistCfg, "Road Trip", 3)
tags := buildTagMetadata(meta, "One Step Too Far", "tidal", "23324615", ripTrackOptions{forPlaylist: true, playlistName: "Road Trip", playlistPos: 3, total: 20})
if tags.Album != "Road Trip" {
t.Fatalf("album = %q, want Road Trip", tags.Album)
}
if tags.TrackNumber != 3 {
t.Fatalf("track number = %d, want 3", tags.TrackNumber)
}
if tags.TrackTotal != 20 {
t.Fatalf("track total = %d, want 20", tags.TrackTotal)
}
if tags.DiscNumber != 0 {
t.Fatalf("disc number = %d, want 0", tags.DiscNumber)
}
if tags.DiscTotal != 0 {
t.Fatalf("disc total = %d, want 0", tags.DiscTotal)
}
if !tags.OmitDiscTags {
t.Fatalf("omit disc tags = %v, want true", tags.OmitDiscTags)
}
}
func TestTrackOutputPathFallsBackToDisc1(t *testing.T) {
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.DiscSubdirectories = true
d.Downloads.SourceSubdirectories = false
cfg := &config.Config{File: d, Session: d}
m := &Main{Config: cfg}
meta := map[string]any{
"title": "Song",
"track_number": float64(1),
"performer": map[string]any{"name": "Dido"},
"album": map[string]any{"id": "a", "title": "Greatest Hits", "artist": map[string]any{"name": "Dido"}},
}
path := m.trackOutputPath("tidal", "1", "Song", "flac", nil, meta, filepath.Join(tmp, "Album"), 2)
if !strings.Contains(path, string(filepath.Separator)+"Disc 1"+string(filepath.Separator)) {
t.Fatalf("expected Disc 1 subdir in path, got %q", path)
}
}
func TestPlaylistRipPipeline(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.Concurrency = false
d.Filepaths.RestrictCharacters = 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{
"qobuz": &fakePlaylistProvider{url: ts.URL},
},
Store: sqlite,
DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "playlist", "pl1"); 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)
}
folder := filepath.Join(tmp, "Road Trip")
if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil {
t.Fatalf("missing first playlist track: %v", err)
}
if _, err = os.Stat(filepath.Join(folder, "02. Artist - Track Two.flac")); err != nil {
t.Fatalf("missing second playlist track: %v", err)
}
}
func TestPlaylistRipUsesSourceSubdirectory(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.Concurrency = false
d.Downloads.SourceSubdirectories = true
d.Filepaths.RestrictCharacters = 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{
"qobuz": &fakePlaylistProvider{url: ts.URL},
},
Store: sqlite,
DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "playlist", "pl1"); 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)
}
folder := filepath.Join(tmp, "Qobuz", "Road Trip")
if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil {
t.Fatalf("missing first playlist track in source subdir: %v", err)
}
}
func TestRipPlaylistRequiresDeezerARL(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}}
err := m.ripPlaylist(context.Background(), nil, "deezer", "pl1", map[string]any{
"name": "Road Trip",
"tracks": map[string]any{"items": []any{map[string]any{"id": "p1"}}},
})
if err == nil || !strings.Contains(err.Error(), "deezer") {
t.Fatalf("expected deezer arl error, got %v", err)
}
}
func TestRipAlbumRequiresDeezerARL(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}}
err := m.ripAlbum(context.Background(), nil, "deezer", "alb1", map[string]any{})
if err == nil || !strings.Contains(err.Error(), "deezer") {
t.Fatalf("expected deezer arl error, got %v", err)
}
}
func TestRipPlaylistMixedRequiresDeezerAuth(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}}
err := m.ripPlaylistMixed(context.Background(), "mix1", "Mix", []PlaylistTrackRef{{Source: "deezer", ID: "1"}})
if err == nil || !strings.Contains(err.Error(), "deezer") {
t.Fatalf("expected deezer auth error, got %v", err)
}
}
func TestApplyQobuzArtistFiltersRepeats(t *testing.T) {
albums := []collectionAlbum{
{ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false},
{ID: "a2", Title: "Album X (Deluxe)", BitDepth: 24, Sampling: 96, Explicit: false},
{ID: "b1", Title: "Album B", BitDepth: 16, Sampling: 44.1, Explicit: false},
}
filtered := applyQobuzArtistFilters("artist", albums, config.QobuzDiscographyFilterConfig{Repeats: true})
if len(filtered) != 2 {
t.Fatalf("len(filtered)=%d want 2", len(filtered))
}
ids := map[string]bool{}
for _, a := range filtered {
ids[a.ID] = true
}
if !ids["a2"] || !ids["b1"] {
t.Fatalf("unexpected winners: %+v", ids)
}
}
func TestApplyQobuzArtistFiltersNonRemaster(t *testing.T) {
albums := []collectionAlbum{
{ID: "rm", Title: "Album X (Remastered)"},
{ID: "orig", Title: "Album X"},
}
filtered := applyQobuzArtistFilters("artist", albums, config.QobuzDiscographyFilterConfig{NonRemaster: true})
if len(filtered) != 1 {
t.Fatalf("len(filtered)=%d want 1", len(filtered))
}
if filtered[0].ID != "orig" {
t.Fatalf("unexpected album kept: %+v", filtered[0])
}
}
func TestTrackOutputPathSinglesUsesAlbumID(t *testing.T) {
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
d.Filepaths.AddSinglesToFolder = true
d.Filepaths.FolderFormat = "{id}"
d.Filepaths.TrackFormat = "{title}"
d.Filepaths.RestrictCharacters = false
m := &Main{Config: &config.Config{File: d, Session: d}}
meta := map[string]any{
"album": map[string]any{
"id": "album-123",
"title": "Album",
"artist": map[string]any{"name": "Artist"},
},
"performer": map[string]any{"name": "Artist"},
}
out := m.trackOutputPath("qobuz", "track-999", "Song", "flac", nil, meta, "", 0)
if got, want := filepath.Dir(out), filepath.Join(tmp, "album-123"); got != want {
t.Fatalf("trackOutputPath() dir=%q want %q", got, want)
}
}
func TestTrackOutputPathSinglesUsesResolvedAudioProfile(t *testing.T) {
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
d.Filepaths.AddSinglesToFolder = true
d.Filepaths.FolderFormat = "{container}-{bit_depth}-{sampling_rate}"
d.Filepaths.TrackFormat = "{title}"
d.Filepaths.RestrictCharacters = false
m := &Main{Config: &config.Config{File: d, Session: d}}
meta := map[string]any{
"album": map[string]any{
"id": "album-123",
"title": "Album",
"artist": map[string]any{"name": "Artist"},
},
"performer": map[string]any{"name": "Artist"},
}
dl := &provider.Downloadable{Extension: "mp3", Audio: provider.AudioProfile{Container: "MP3", Codec: "MP3", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}}
out := m.trackOutputPath("qobuz", "track-999", "Song", "mp3", dl, meta, "", 0)
if got, want := filepath.Dir(out), filepath.Join(tmp, "MP3-16-44.1"); got != want {
t.Fatalf("trackOutputPath() dir=%q want %q", got, want)
}
}
func TestRipAlbumUsesResolvedAudioProfileForFolderName(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
d.Downloads.Concurrency = false
d.Filepaths.RestrictCharacters = 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() }()
fake := &fakeResolvedAlbumProvider{
url: ts.URL,
downloadable: provider.Downloadable{
URL: ts.URL,
Extension: "mp3",
Source: "qobuz",
Audio: provider.AudioProfile{
Container: "MP3",
Codec: "MP3",
Quality: "HIGH",
BitDepth: 16,
SamplingRate: "44.1",
BitrateKbps: 320,
},
},
}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{
"qobuz": fake,
},
Store: sqlite,
DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "album", "a1"); 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)
}
folder := filepath.Join(tmp, "Fallback Artist - Fallback Album (2020) [MP3] [16B-44.1kHz]")
if _, err = os.Stat(filepath.Join(folder, "01. Fallback Artist - Fallback Song.mp3")); err != nil {
t.Fatalf("missing track in resolved-quality folder: %v", err)
}
if fake.downloadableHits != 1 {
t.Fatalf("GetDownloadable() calls=%d, want 1 (prefetched reuse)", fake.downloadableHits)
}
}
func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) {
meta := map[string]any{
"replayGain": float64(-7.25),
"peak": float64(0.989),
"album": map[string]any{
"title": "Album",
"replaygain_album_gain": float64(-8.1),
"replaygain_album_peak": float64(1.001),
},
"performer": map[string]any{"name": "Artist"},
}
tags := buildTagMetadata(meta, "Song", "tidal", "t1", ripTrackOptions{})
if tags.ReplaygainTrackGain != "-7.25 dB" {
t.Fatalf("track replaygain gain=%q", tags.ReplaygainTrackGain)
}
if tags.ReplaygainAlbumGain != "-8.1 dB" {
t.Fatalf("album replaygain gain=%q", tags.ReplaygainAlbumGain)
}
if tags.ReplaygainTrackPeak != "0.989" {
t.Fatalf("track replaygain peak=%q", tags.ReplaygainTrackPeak)
}
if tags.ReplaygainAlbumPeak != "1.001" {
t.Fatalf("album replaygain peak=%q", tags.ReplaygainAlbumPeak)
}
}
func TestBuildTagMetadataInitialKey(t *testing.T) {
meta := map[string]any{
"key": "E Minor",
"album": map[string]any{"title": "Album"},
"performer": map[string]any{"name": "Artist"},
}
tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{})
if tags.InitialKey != "Em" {
t.Fatalf("InitialKey=%q", tags.InitialKey)
}
}
func TestBuildTagMetadataArtistList(t *testing.T) {
meta := map[string]any{
"artist_names": []string{"Lost Frequencies", "Calum Scott", "Kungs"},
"performer": map[string]any{"name": "Lost Frequencies, Calum Scott & Kungs"},
"album": map[string]any{"title": "Album"},
}
tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{})
if tags.Artist != "Lost Frequencies, Calum Scott & Kungs" {
t.Fatalf("artist=%q", tags.Artist)
}
if got := strings.Join(tags.Artists, ";"); got != "Lost Frequencies;Calum Scott;Kungs" {
t.Fatalf("artists=%q", got)
}
}
func TestTitleFromMetadataSkipsOriginalMix(t *testing.T) {
meta := map[string]any{"title": "Dance Done", "version": "Original Mix"}
if got := titleFromMetadata(meta, "42"); got != "Dance Done" {
t.Fatalf("titleFromMetadata()=%q", got)
}
}
func TestTitleFromMetadataIncludesNonOriginalMix(t *testing.T) {
meta := map[string]any{"title": "Dance Done", "version": "Extended Mix"}
if got := titleFromMetadata(meta, "42"); got != "Dance Done (Extended Mix)" {
t.Fatalf("titleFromMetadata()=%q", got)
}
}
func TestNormalizeInitialKey(t *testing.T) {
tests := map[string]string{
"C Major": "C",
"Db Major": "Db",
"F# Major": "F#",
"E Minor": "Em",
"C# Minor": "C#m",
"Bb Minor": "Bbm",
"g# minor": "G#m",
"F#m": "F#m",
"Ab": "Ab",
"Not A Key": "Not A Key",
"C# Major": "C# Major",
"Db Minor": "Db Minor",
}
for input, want := range tests {
if got := normalizeInitialKey(input); got != want {
t.Fatalf("normalizeInitialKey(%q)=%q want %q", input, got, want)
}
}
}
func TestBuildTagMetadataReplayGainFallsBackToDeezerGain(t *testing.T) {
meta := map[string]any{
"gain": float64(-10),
"performer": map[string]any{"name": "Artist"},
"album": map[string]any{"title": "Album"},
}
tags := buildTagMetadata(meta, "Song", "deezer", "2675762392", ripTrackOptions{})
if tags.ReplaygainTrackGain != "-10 dB" {
t.Fatalf("track replaygain gain=%q", tags.ReplaygainTrackGain)
}
}
// countingAlbumProvider serves a large album and records how many track
// downloads were attempted, so a cancellation test can assert the rip actually
// stopped instead of grinding through the rest of the queue.
type countingAlbumProvider struct {
url string
trackCount int
mu sync.Mutex
attempts int
cancel context.CancelFunc
cancelAt int
}
func (f *countingAlbumProvider) Source() string { return "qobuz" }
func (f *countingAlbumProvider) Login(context.Context) error { return nil }
func (f *countingAlbumProvider) LoggedIn() bool { return true }
func (f *countingAlbumProvider) Close() error { return nil }
func (f *countingAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *countingAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "album" {
items := make([]any, 0, f.trackCount)
for i := 0; i < f.trackCount; i++ {
items = append(items, map[string]any{"id": fmt.Sprintf("t%d", i)})
}
return map[string]any{
"title": "Big Album",
"release_date_original": "1977-02-04",
"artist": map[string]any{"name": "Fleetwood Mac"},
"tracks": map[string]any{"items": items},
}, nil
}
// Count metadata fetches as download attempts and trip the cancel once
// enough tracks have been seen.
f.mu.Lock()
f.attempts++
n := f.attempts
f.mu.Unlock()
if f.cancel != nil && n == f.cancelAt {
f.cancel()
}
return map[string]any{
"title": "Track " + id,
"track_number": float64(1),
"performer": map[string]any{"name": "Fleetwood Mac"},
"album": map[string]any{
"title": "Big Album",
"artist": map[string]any{"name": "Fleetwood Mac"},
},
}, nil
}
func (f *countingAlbumProvider) GetDownloadable(_ context.Context, _ string, _ int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
func (f *countingAlbumProvider) attemptCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.attempts
}
// TestRipStopsOnCancellation is the regression test for Ctrl-C doing nothing.
// ctx was threaded through the rip but never checked, so cancelling left the
// loops running through every remaining track (and the retry ladder retrying
// on a dead context).
func TestRipStopsOnCancellation(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("fLaC-test-bytes"))
}))
defer ts.Close()
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
// Serial, so the cancellation point is deterministic.
d.Downloads.Concurrency = false
d.Artwork.Embed = false
d.Artwork.SaveArtwork = false
cfg := &config.Config{File: d, Session: d}
const total = 40
const cancelAfter = 5
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
prov := &countingAlbumProvider{url: ts.URL, trackCount: total, cancel: cancel, cancelAt: cancelAfter}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{"qobuz": prov},
Store: store.NewDummy(),
DL: download.New(),
Tagger: noopTagger{},
}
if err := m.AddByID(context.Background(), "qobuz", "album", "a1"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err := m.Resolve(context.Background()); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
err := m.Rip(ctx)
if err == nil {
t.Fatal("Rip() returned nil after cancellation, want an error")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("Rip() error = %v, want it to wrap context.Canceled", err)
}
// The rip must stop promptly. Without the cancellation checks it walked
// all 40 tracks; allow a small margin for the in-flight track.
if got := prov.attemptCount(); got > cancelAfter+2 {
t.Errorf("attempted %d track(s) after cancelling at %d; the rip did not stop", got, cancelAfter)
}
}
// TestRipTrackReturnsEarlyOnCancellation pins the funnel check: every worker
// path goes through ripTrack, so one guard there stops all of them.
func TestRipTrackReturnsEarlyOnCancellation(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": &fakeProvider{}},
Store: store.NewDummy(),
DL: download.New(),
Tagger: noopTagger{},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := m.ripTrack(ctx, &fakeProvider{}, "qobuz", "t1", "", ripTrackOptions{})
if !errors.Is(err, context.Canceled) {
t.Fatalf("ripTrack() error = %v, want context.Canceled", err)
}
}
// TestBookkeepingSurvivesCancellation checks that the failure record still
// lands when the rip was cancelled, which is when it matters most. The store
// writes deliberately use a detached context.
func TestBookkeepingSurvivesCancellation(t *testing.T) {
tmp := t.TempDir()
sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = sqlite.Close() }()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
m := &Main{Config: &config.Config{File: d, Session: d}, Store: sqlite, DL: download.New()}
ctx, cancel := context.WithCancel(context.Background())
cancel()
m.markFailed(ctx, "qobuz", "track", "t1")
// Read back with a live context.
failed, err := sqlite.IsFailed(context.Background(), "qobuz", "track", "t1")
if err != nil {
t.Fatal(err)
}
if !failed {
t.Fatal("failure record not written under a cancelled context")
}
}