mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 22:07:30 +02:00
Two related changes: a 68-track Qobuz album was gated by the API throttle
rather than the network, and Qobuz lyrics are now supported, which is what the
faster album path had to be designed around.
--- perf ---
ratelimit: token bucket instead of fixed spacing
The limiter spaced every request exactly interval apart with no burst, so at
the default 60 requests/minute it allowed exactly one request per second no
matter how many workers ran. Measured: 8 requests took 7s with 1 worker and
7s with 6, i.e. max_connections had no effect on anything touching the API.
Since a track needs its metadata and stream URL before downloading, workers
could only start a track every two seconds, which is why an album looked
serial even at 57 MiB/s per track.
New() now takes a burst (providers pass max_connections), idle time accrues
budget up to that burst, and burst is clamped to one minute's worth of
requests so a large max_connections cannot bypass a small configured rate.
app: reuse the track objects already in the album payload
ripAlbum fetched the album, kept only the track ids, then asked the provider
for every track again, doubling the request count. Providers opt in via
AlbumTracksAreComplete(); only Qobuz does, because it is only verified there
and because Tidal does extra work in its track lookup.
Verified against the live Qobuz API on a 68-track release: the embedded track
objects already carry title, version, track_number, media_number,
parental_warning, isrc, copyright, performer, performers, composer, duration,
audio_info, maximum_bit_depth, maximum_sampling_rate and
release_date_original. The only field a per-track lookup adds that this app
reads is the nested album object, so albumTrackMetadata attaches the parent
album with its own track list stripped, or a 68-track album would hold 68
copies of itself. trackMetaUsable refetches anyway when title or track number
is missing, so a thin payload cannot produce a wrongly named file.
Measured end-to-end through the real app and limiter at the shipped defaults,
68 tracks: 137 API calls / 2m11s before, 69 calls / 1m3s after.
--- qobuz lyrics ---
Qobuz's main API does not serve lyrics: has_lyrics is only a flag, extra=lyrics
adds nothing, and track/getLyrics does not exist. Its apps use a two-step flow
instead, confirmed live:
GET track/lyricsUrl?track_id=&app_id=&request_ts=&request_sig=
-> {"track_id","album_id","lyrics_url"} (signed CloudFront URL)
GET <lyrics_url>
-> {"original":{"type","lang","lines":[{"line","start","end"}]},
"translation_langs":[...],"writers","publishers":[...]}
type is plain, lsync or wsync; start/end are milliseconds; the line text is
under "line". Passing a language returns a translation instead of the original,
and only for languages that exist for the track (en 403s on an English song),
so the language parameter is deliberately omitted. A 403 on the signed URL is
how Qobuz represents "no lyrics", so it is not treated as an error.
Lyrics are exposed through a TrackLyrics method rather than inside GetMetadata,
because album rips skip the per-track metadata call. That keeps the fast path:
with lyrics on, an album costs the same two provider calls per track it always
did, and the lyrics document itself comes from a CDN that does not consume the
provider's request budget. With metadata.exclude = ["lyrics"] it drops to one
call per track and the full speedup applies.
Existing behaviour is preserved: Tidal and Deezer already attach lyrics in
their metadata, so attachLyrics leaves them alone, and a lyrics failure is
logged and ignored rather than failing a download.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1543 lines
47 KiB
Go
1543 lines
47 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")
|
|
}
|
|
}
|
|
|
|
// countingMetaProvider records how many per-track metadata lookups happen, so
|
|
// the album-payload optimization can be asserted rather than assumed.
|
|
type countingMetaProvider struct {
|
|
url string
|
|
complete bool
|
|
|
|
mu sync.Mutex
|
|
trackMeta int
|
|
albumMeta int
|
|
downloads int
|
|
}
|
|
|
|
func (f *countingMetaProvider) Source() string { return "qobuz" }
|
|
func (f *countingMetaProvider) Login(context.Context) error { return nil }
|
|
func (f *countingMetaProvider) LoggedIn() bool { return true }
|
|
func (f *countingMetaProvider) Close() error { return nil }
|
|
func (f *countingMetaProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// AlbumTracksAreComplete is only consulted when complete is true; the false
|
|
// case still satisfies the interface, which mirrors a provider that opts out.
|
|
func (f *countingMetaProvider) AlbumTracksAreComplete() bool { return f.complete }
|
|
|
|
func (f *countingMetaProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
|
|
f.mu.Lock()
|
|
if mediaType == "album" {
|
|
f.albumMeta++
|
|
} else {
|
|
f.trackMeta++
|
|
}
|
|
f.mu.Unlock()
|
|
|
|
if mediaType == "album" {
|
|
items := make([]any, 0, 3)
|
|
for i := 1; i <= 3; i++ {
|
|
items = append(items, map[string]any{
|
|
"id": fmt.Sprintf("t%d", i),
|
|
"title": fmt.Sprintf("Track %d", i),
|
|
"track_number": float64(i),
|
|
"media_number": float64(1),
|
|
"isrc": fmt.Sprintf("ISRC%d", i),
|
|
"copyright": "(C) Label",
|
|
"performer": map[string]any{"name": "Fleetwood Mac"},
|
|
})
|
|
}
|
|
return map[string]any{
|
|
"title": "Rumours",
|
|
"release_date_original": "1977-02-04",
|
|
"artist": map[string]any{"name": "Fleetwood Mac"},
|
|
"tracks": map[string]any{"items": items},
|
|
}, nil
|
|
}
|
|
|
|
return map[string]any{
|
|
"title": "Fetched " + id,
|
|
"track_number": float64(1),
|
|
"performer": map[string]any{"name": "Fleetwood Mac"},
|
|
"album": map[string]any{"title": "Rumours"},
|
|
}, nil
|
|
}
|
|
|
|
func (f *countingMetaProvider) GetDownloadable(_ context.Context, _ string, _ int) (*provider.Downloadable, error) {
|
|
f.mu.Lock()
|
|
f.downloads++
|
|
f.mu.Unlock()
|
|
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
|
|
}
|
|
|
|
func (f *countingMetaProvider) counts() (album, track, downloads int) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.albumMeta, f.trackMeta, f.downloads
|
|
}
|
|
|
|
func runAlbumRip(t *testing.T, prov *countingMetaProvider) string {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
d := config.DefaultConfigData()
|
|
d.Downloads.Folder = tmp
|
|
d.Downloads.SourceSubdirectories = false
|
|
d.Downloads.Concurrency = false
|
|
d.Artwork.Embed = false
|
|
d.Artwork.SaveArtwork = false
|
|
cfg := &config.Config{File: d, Session: d}
|
|
|
|
m := &Main{
|
|
Config: cfg,
|
|
Providers: map[string]provider.Client{"qobuz": prov},
|
|
Store: store.NewDummy(),
|
|
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)
|
|
}
|
|
return tmp
|
|
}
|
|
|
|
// TestAlbumRipSkipsPerTrackMetadataWhenEmbedded is the performance regression
|
|
// test: a provider that embeds complete track objects in its album payload
|
|
// must not be asked for each track again. Every such call goes through the
|
|
// requests-per-minute limiter, so on a 68-track album the redundant lookups
|
|
// were ~68 seconds of pure waiting.
|
|
func TestAlbumRipSkipsPerTrackMetadataWhenEmbedded(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("fLaC"))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
prov := &countingMetaProvider{url: ts.URL, complete: true}
|
|
tmp := runAlbumRip(t, prov)
|
|
|
|
album, track, _ := prov.counts()
|
|
if album != 1 {
|
|
t.Errorf("album metadata fetches = %d, want 1", album)
|
|
}
|
|
if track != 0 {
|
|
t.Errorf("per-track metadata fetches = %d, want 0 (album payload is complete)", track)
|
|
}
|
|
|
|
// The embedded titles must be what lands on disk, not the fetched ones.
|
|
albumDir := soleSubdir(t, tmp)
|
|
for i := 1; i <= 3; i++ {
|
|
name := fmt.Sprintf("%02d. Fleetwood Mac - Track %d.flac", i, i)
|
|
if _, err := os.Stat(filepath.Join(albumDir, name)); err != nil {
|
|
entries, _ := os.ReadDir(albumDir)
|
|
var got []string
|
|
for _, e := range entries {
|
|
got = append(got, e.Name())
|
|
}
|
|
t.Fatalf("expected %q from the embedded metadata; album dir contains %v", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAlbumRipFetchesPerTrackMetadataWhenNotEmbedded pins the opt-out path, so
|
|
// providers like Tidal (whose track lookup also fetches lyrics) keep working.
|
|
func TestAlbumRipFetchesPerTrackMetadataWhenNotEmbedded(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("fLaC"))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
prov := &countingMetaProvider{url: ts.URL, complete: false}
|
|
runAlbumRip(t, prov)
|
|
|
|
_, track, _ := prov.counts()
|
|
if track != 3 {
|
|
t.Errorf("per-track metadata fetches = %d, want 3 (provider opted out)", track)
|
|
}
|
|
}
|
|
|
|
// TestAlbumTrackMetadataAttachesParentAlbum covers the one field the live
|
|
// Qobuz check showed track/get adds: the nested album object.
|
|
func TestAlbumTrackMetadataAttachesParentAlbum(t *testing.T) {
|
|
albumMeta := map[string]any{
|
|
"title": "Rumours",
|
|
"replaygain_album_gain": -7.5,
|
|
"tracks": map[string]any{"items": []any{
|
|
map[string]any{"id": "t1", "title": "Dreams", "track_number": float64(1)},
|
|
}},
|
|
}
|
|
items, _ := anySlice(albumMeta["tracks"].(map[string]any)["items"])
|
|
|
|
got := albumTrackMetadata(albumMeta, items)
|
|
meta, ok := got["t1"]
|
|
if !ok {
|
|
t.Fatal("no metadata built for t1")
|
|
}
|
|
alb, ok := meta["album"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("parent album not attached")
|
|
}
|
|
if alb["title"] != "Rumours" {
|
|
t.Errorf("album title = %v", alb["title"])
|
|
}
|
|
// Album-level ReplayGain is read through trackMeta["album"].
|
|
if alb["replaygain_album_gain"] != -7.5 {
|
|
t.Errorf("album replaygain lost: %v", alb["replaygain_album_gain"])
|
|
}
|
|
// The attached album must not carry the whole track list back in.
|
|
if _, has := alb["tracks"]; has {
|
|
t.Error("attached album still has its track list; that is O(n^2) memory on a large album")
|
|
}
|
|
}
|
|
|
|
func TestTrackMetaUsable(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
meta map[string]any
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"empty", map[string]any{}, false},
|
|
{"complete", map[string]any{"title": "A", "track_number": float64(1)}, true},
|
|
{"no title", map[string]any{"track_number": float64(1)}, false},
|
|
{"blank title", map[string]any{"title": " ", "track_number": float64(1)}, false},
|
|
{"no track number", map[string]any{"title": "A"}, false},
|
|
{"zero track number", map[string]any{"title": "A", "track_number": float64(0)}, false},
|
|
{"string track number", map[string]any{"title": "A", "track_number": "2"}, true},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := trackMetaUsable(tc.meta); got != tc.want {
|
|
t.Errorf("%s: trackMetaUsable = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// soleSubdir returns the single directory inside dir, which for an album rip
|
|
// is the generated album folder.
|
|
func soleSubdir(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var dirs []string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
dirs = append(dirs, filepath.Join(dir, e.Name()))
|
|
}
|
|
}
|
|
if len(dirs) != 1 {
|
|
t.Fatalf("expected exactly one album folder in %s, got %v", dir, dirs)
|
|
}
|
|
return dirs[0]
|
|
}
|
|
|
|
// lyricsProvider is a countingMetaProvider that also serves lyrics, to prove
|
|
// lyrics still reach the tagger on the album fast path, where the per-track
|
|
// metadata call is skipped.
|
|
type lyricsProvider struct {
|
|
*countingMetaProvider
|
|
plain string
|
|
synced string
|
|
calls int
|
|
err error
|
|
}
|
|
|
|
func (f *lyricsProvider) TrackLyrics(context.Context, string) (string, string, error) {
|
|
f.mu.Lock()
|
|
f.calls++
|
|
f.mu.Unlock()
|
|
if f.err != nil {
|
|
return "", "", f.err
|
|
}
|
|
return f.plain, f.synced, nil
|
|
}
|
|
|
|
type capturingTagger struct {
|
|
mu sync.Mutex
|
|
seen []tag.Metadata
|
|
}
|
|
|
|
func (c *capturingTagger) TagFLAC(_ context.Context, _ string, meta tag.Metadata, _ string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.seen = append(c.seen, meta)
|
|
return nil
|
|
}
|
|
|
|
func (c *capturingTagger) lyrics() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := make([]string, 0, len(c.seen))
|
|
for _, m := range c.seen {
|
|
out = append(out, m.Lyrics)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runAlbumRipWithTagger(t *testing.T, prov provider.Client, tagger trackTagger, exclude ...string) {
|
|
t.Helper()
|
|
d := config.DefaultConfigData()
|
|
d.Downloads.Folder = t.TempDir()
|
|
d.Downloads.Concurrency = false
|
|
d.Artwork.Embed = false
|
|
d.Artwork.SaveArtwork = false
|
|
d.Metadata.Exclude = exclude
|
|
cfg := &config.Config{File: d, Session: d}
|
|
|
|
m := &Main{
|
|
Config: cfg,
|
|
Providers: map[string]provider.Client{"qobuz": prov},
|
|
Store: store.NewDummy(),
|
|
DL: download.New(),
|
|
Tagger: tagger,
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestAlbumRipAttachesLyricsOnFastPath(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("fLaC"))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
prov := &lyricsProvider{
|
|
countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true},
|
|
plain: "line one\nline two",
|
|
synced: "[00:01.00]line one",
|
|
}
|
|
tagger := &capturingTagger{}
|
|
runAlbumRipWithTagger(t, prov, tagger)
|
|
|
|
_, trackMeta, _ := prov.counts()
|
|
if trackMeta != 0 {
|
|
t.Errorf("per-track metadata fetches = %d, want 0 (fast path must be preserved)", trackMeta)
|
|
}
|
|
if prov.calls != 3 {
|
|
t.Errorf("TrackLyrics calls = %d, want 3 (one per track)", prov.calls)
|
|
}
|
|
|
|
got := tagger.lyrics()
|
|
if len(got) != 3 {
|
|
t.Fatalf("tagged %d tracks, want 3", len(got))
|
|
}
|
|
for i, l := range got {
|
|
// Synced lyrics win over plain, matching the existing behaviour.
|
|
if l != "[00:01.00]line one" {
|
|
t.Errorf("track %d lyrics = %q", i, l)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAlbumRipSkipsLyricsWhenExcluded(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("fLaC"))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
prov := &lyricsProvider{
|
|
countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true},
|
|
plain: "should not be fetched",
|
|
}
|
|
runAlbumRipWithTagger(t, prov, noopTagger{}, "lyrics")
|
|
|
|
if prov.calls != 0 {
|
|
t.Errorf("TrackLyrics calls = %d, want 0 when metadata.exclude contains lyrics", prov.calls)
|
|
}
|
|
}
|
|
|
|
// A lyrics failure is cosmetic and must never fail the download.
|
|
func TestAlbumRipSurvivesLyricsFailure(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("fLaC"))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
prov := &lyricsProvider{
|
|
countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true},
|
|
err: errors.New("lyrics service down"),
|
|
}
|
|
tagger := &capturingTagger{}
|
|
runAlbumRipWithTagger(t, prov, tagger)
|
|
|
|
if n := len(tagger.lyrics()); n != 3 {
|
|
t.Fatalf("tagged %d tracks, want 3 despite the lyrics failure", n)
|
|
}
|
|
for i, l := range tagger.lyrics() {
|
|
if l != "" {
|
|
t.Errorf("track %d should have no lyrics, got %q", i, l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Providers that already attach lyrics in their metadata (Tidal, Deezer) must
|
|
// not be asked again.
|
|
func TestLyricsNotRefetchedWhenMetadataAlreadyHasThem(t *testing.T) {
|
|
d := config.DefaultConfigData()
|
|
m := &Main{Config: &config.Config{File: d, Session: d}, DL: download.New()}
|
|
|
|
prov := &lyricsProvider{countingMetaProvider: &countingMetaProvider{}, plain: "fresh"}
|
|
meta := map[string]any{"lyrics_synced": "[00:00.00]already here"}
|
|
m.attachLyrics(context.Background(), prov, meta, "t1")
|
|
|
|
if prov.calls != 0 {
|
|
t.Errorf("TrackLyrics calls = %d, want 0 when metadata already carries lyrics", prov.calls)
|
|
}
|
|
if meta["lyrics_synced"] != "[00:00.00]already here" {
|
|
t.Errorf("existing lyrics overwritten: %v", meta["lyrics_synced"])
|
|
}
|
|
}
|