mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
The no-space form after the timestamp is correct and unchanged: LRC is [mm:ss.xx]text, and the Deezer output already matches. Two real issues behind that question, though. Qobuz separates verses with an entry whose line is empty and which has no start or end at all. Those were dropped entirely, so the plain lyrics tag came out as one undifferentiated block. They are now kept as single blank lines, collapsed so a run of separators cannot produce several blanks, and trimmed at both ends so a tag never begins or ends with one. Checked against the live track: 41 plain lines with 8 stanza breaks, where before it was 33 flat lines. They are still excluded from the LRC, which is the correct behaviour rather than an omission: a synced entry needs a timestamp, and these have none, so emitting one would park a blank lyric at the wrong moment. Separately, an empty line that *does* carry a timestamp is a different thing: a display-clear marker. Qobuz does not appear to emit those, but other lyrics sources do, and the parser now handles them properly by writing a bare [mm:ss.xx] entry into the LRC and keeping it out of the plain text. The LRC itself is unchanged for real data: 33 entries, every one timestamped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
634 lines
18 KiB
Go
634 lines
18 KiB
Go
package qobuz
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"streamrip-go/internal/config"
|
|
)
|
|
|
|
func TestQualityMap(t *testing.T) {
|
|
tests := []struct {
|
|
in int
|
|
want int
|
|
}{
|
|
{1, 5},
|
|
{2, 6},
|
|
{3, 7},
|
|
{4, 27},
|
|
{0, 7},
|
|
{99, 7},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := qualityMap(tt.in)
|
|
if got != tt.want {
|
|
t.Fatalf("qualityMap(%d)=%d want %d", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseTrackMetadata(t *testing.T) {
|
|
resp := map[string]any{
|
|
"id": "19512574",
|
|
"title": "Dreams",
|
|
"version": "Remastered",
|
|
"track_number": float64(2),
|
|
"media_number": float64(1),
|
|
"parental_warning": false,
|
|
"maximum_bit_depth": float64(24),
|
|
"maximum_sampling_rate": float64(96),
|
|
"performer": map[string]any{
|
|
"name": "Fleetwood Mac",
|
|
},
|
|
"album": map[string]any{
|
|
"title": "Rumours",
|
|
},
|
|
}
|
|
|
|
m, err := ParseTrackMetadata(resp)
|
|
if err != nil {
|
|
t.Fatalf("ParseTrackMetadata() error = %v", err)
|
|
}
|
|
if m.ID != "19512574" || m.Title != "Dreams" || m.Album != "Rumours" || m.Artist != "Fleetwood Mac" {
|
|
t.Fatalf("unexpected metadata: %+v", m)
|
|
}
|
|
if m.Quality != 3 {
|
|
t.Fatalf("quality = %d, want 3", m.Quality)
|
|
}
|
|
}
|
|
|
|
func TestGetPlaylistPagination(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
offset := r.URL.Query().Get("offset")
|
|
if offset == "" {
|
|
offset = "0"
|
|
}
|
|
|
|
resp := map[string]any{}
|
|
switch offset {
|
|
case "0":
|
|
resp = map[string]any{
|
|
"tracks_count": 1200,
|
|
"tracks": map[string]any{"items": makeItems(0, 500)},
|
|
}
|
|
case "500":
|
|
resp = map[string]any{"tracks": map[string]any{"items": makeItems(500, 1000)}}
|
|
case "1000":
|
|
resp = map[string]any{"tracks": map[string]any{"items": makeItems(1000, 1200)}}
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"})
|
|
return
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := newTestClient(t)
|
|
c.loggedIn = true
|
|
c.baseURL = ts.URL
|
|
|
|
raw, err := c.GetMetadata(context.Background(), "playlist-id", "playlist")
|
|
if err != nil {
|
|
t.Fatalf("GetMetadata() error = %v", err)
|
|
}
|
|
tracks, ok := mapValue(raw["tracks"])
|
|
if !ok {
|
|
t.Fatalf("tracks missing")
|
|
}
|
|
items, ok := tracks["items"].([]any)
|
|
if !ok {
|
|
t.Fatalf("items missing")
|
|
}
|
|
if len(items) != 1200 {
|
|
t.Fatalf("len(items) = %d, want 1200", len(items))
|
|
}
|
|
}
|
|
|
|
func TestGetLabelPagination(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
offset := r.URL.Query().Get("offset")
|
|
if offset == "" {
|
|
offset = "0"
|
|
}
|
|
|
|
resp := map[string]any{}
|
|
switch offset {
|
|
case "0":
|
|
resp = map[string]any{
|
|
"albums_count": 700,
|
|
"albums": map[string]any{"items": makeItems(0, 500)},
|
|
}
|
|
case "500":
|
|
resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 700)}}
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"})
|
|
return
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := newTestClient(t)
|
|
c.loggedIn = true
|
|
c.baseURL = ts.URL
|
|
|
|
raw, err := c.GetMetadata(context.Background(), "label-id", "label")
|
|
if err != nil {
|
|
t.Fatalf("GetMetadata() error = %v", err)
|
|
}
|
|
albums, ok := mapValue(raw["albums"])
|
|
if !ok {
|
|
t.Fatalf("albums missing")
|
|
}
|
|
items, ok := albums["items"].([]any)
|
|
if !ok {
|
|
t.Fatalf("items missing")
|
|
}
|
|
if len(items) != 700 {
|
|
t.Fatalf("len(items) = %d, want 700", len(items))
|
|
}
|
|
}
|
|
|
|
func TestGetArtistPagination(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
offset := r.URL.Query().Get("offset")
|
|
if offset == "" {
|
|
offset = "0"
|
|
}
|
|
|
|
resp := map[string]any{}
|
|
switch offset {
|
|
case "0":
|
|
resp = map[string]any{
|
|
"albums_count": 620,
|
|
"albums": map[string]any{"items": makeItems(0, 500)},
|
|
}
|
|
case "500":
|
|
resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 620)}}
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"})
|
|
return
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := newTestClient(t)
|
|
c.loggedIn = true
|
|
c.baseURL = ts.URL
|
|
|
|
raw, err := c.GetMetadata(context.Background(), "artist-id", "artist")
|
|
if err != nil {
|
|
t.Fatalf("GetMetadata() error = %v", err)
|
|
}
|
|
albums, ok := mapValue(raw["albums"])
|
|
if !ok {
|
|
t.Fatalf("albums missing")
|
|
}
|
|
items, ok := albums["items"].([]any)
|
|
if !ok {
|
|
t.Fatalf("items missing")
|
|
}
|
|
if len(items) != 620 {
|
|
t.Fatalf("len(items) = %d, want 620", len(items))
|
|
}
|
|
}
|
|
|
|
func newTestClient(t *testing.T) *Client {
|
|
t.Helper()
|
|
d := testConfigData()
|
|
d.Qobuz.AppID = "12345"
|
|
cfg := &config.Config{File: d, Session: d}
|
|
return New(cfg)
|
|
}
|
|
|
|
func makeItems(start, end int) []map[string]any {
|
|
items := make([]map[string]any, 0, end-start)
|
|
for i := start; i < end; i++ {
|
|
items = append(items, map[string]any{"id": i})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func TestLoginRefreshesAppCredentialsWhenSecretInvalid(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/user/login":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"user_auth_token": "uat-token"})
|
|
case "/track/getFileUrl":
|
|
if r.Header.Get("X-App-Id") != "new-app" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "bad app"})
|
|
return
|
|
}
|
|
tsValue := r.URL.Query().Get("request_ts")
|
|
sig := r.URL.Query().Get("request_sig")
|
|
if sig != qobuzSecretSig(tsValue, "good-secret") {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "bad secret"})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "ok secret"})
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := testConfigData()
|
|
d.Qobuz.EmailOrUserID = "user@example.com"
|
|
d.Qobuz.PasswordOrToken = "hash"
|
|
d.Qobuz.AppID = "old-app"
|
|
d.Qobuz.Secrets = []string{"bad-secret"}
|
|
cfg := &config.Config{File: d, Session: d}
|
|
c := New(cfg)
|
|
c.baseURL = ts.URL
|
|
c.fetchCfg = func(context.Context) (string, []string, error) {
|
|
return "new-app", []string{"good-secret"}, nil
|
|
}
|
|
|
|
if err := c.Login(context.Background()); err != nil {
|
|
t.Fatalf("Login() error = %v", err)
|
|
}
|
|
if !c.loggedIn {
|
|
t.Fatalf("expected logged-in client")
|
|
}
|
|
if c.secret != "good-secret" {
|
|
t.Fatalf("secret = %q, want good-secret", c.secret)
|
|
}
|
|
if c.cfg.Session.Qobuz.AppID != "new-app" {
|
|
t.Fatalf("session app id = %q", c.cfg.Session.Qobuz.AppID)
|
|
}
|
|
}
|
|
|
|
func TestLoginRetriesAfterRefreshingAppCredentials(t *testing.T) {
|
|
loginCalls := 0
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/user/login":
|
|
loginCalls++
|
|
if r.URL.Query().Get("app_id") != "new-app" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "expired app id"})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"user_auth_token": "uat-token"})
|
|
case "/track/getFileUrl":
|
|
tsValue := r.URL.Query().Get("request_ts")
|
|
sig := r.URL.Query().Get("request_sig")
|
|
if r.Header.Get("X-App-Id") == "new-app" && sig == qobuzSecretSig(tsValue, "good-secret") {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "ok secret"})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "bad secret"})
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
|
|
d := testConfigData()
|
|
d.Qobuz.EmailOrUserID = "user@example.com"
|
|
d.Qobuz.PasswordOrToken = "hash"
|
|
d.Qobuz.AppID = "old-app"
|
|
d.Qobuz.Secrets = []string{"old-secret"}
|
|
cfg := &config.Config{File: d, Session: d}
|
|
c := New(cfg)
|
|
c.baseURL = ts.URL
|
|
c.fetchCfg = func(context.Context) (string, []string, error) {
|
|
return "new-app", []string{"good-secret"}, nil
|
|
}
|
|
|
|
if err := c.Login(context.Background()); err != nil {
|
|
t.Fatalf("Login() error = %v", err)
|
|
}
|
|
if loginCalls < 2 {
|
|
t.Fatalf("expected login retry after refresh, calls=%d", loginCalls)
|
|
}
|
|
}
|
|
|
|
func TestQobuzDownloadExtension(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resp map[string]any
|
|
quality int
|
|
url string
|
|
want string
|
|
}{
|
|
{name: "from url flac", resp: map[string]any{}, quality: 1, url: "https://cdn.example/a.flac?token=1", want: "flac"},
|
|
{name: "from url mp3", resp: map[string]any{}, quality: 4, url: "https://cdn.example/a.mp3", want: "mp3"},
|
|
{name: "from mime type", resp: map[string]any{"mime_type": "audio/flac"}, quality: 1, url: "https://cdn.example/stream", want: "flac"},
|
|
{name: "from format id", resp: map[string]any{"format_id": float64(5)}, quality: 4, url: "https://cdn.example/stream", want: "mp3"},
|
|
{name: "fallback quality", resp: map[string]any{}, quality: 3, url: "https://cdn.example/stream", want: "flac"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := qobuzDownloadExtension(tt.resp, tt.quality, tt.url); got != tt.want {
|
|
t.Fatalf("%s: qobuzDownloadExtension()=%q want %q", tt.name, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetDownloadableUsesReturnedURLExtension(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/track/getFileUrl" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"url": "https://cdn.example/track.mp3?token=abc"})
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := newTestClient(t)
|
|
c.loggedIn = true
|
|
c.secret = "secret"
|
|
c.baseURL = ts.URL
|
|
|
|
d, err := c.GetDownloadable(context.Background(), "19512574", 4)
|
|
if err != nil {
|
|
t.Fatalf("GetDownloadable() error = %v", err)
|
|
}
|
|
if d.Extension != "mp3" {
|
|
t.Fatalf("extension = %q, want mp3", d.Extension)
|
|
}
|
|
if d.Audio.Container != "MP3" || d.Audio.Codec != "MP3" {
|
|
t.Fatalf("unexpected audio profile: %+v", d.Audio)
|
|
}
|
|
}
|
|
|
|
func TestQobuzAudioProfileUsesExactReturnedQuality(t *testing.T) {
|
|
profile := qobuzAudioProfile(map[string]any{
|
|
"format_id": float64(7),
|
|
"bit_depth": float64(24),
|
|
"sampling_rate": float64(44.1),
|
|
}, 4, "flac")
|
|
if profile.BitDepth != 24 || profile.SamplingRate != "44.1" || profile.Quality != "HI_RES" {
|
|
t.Fatalf("unexpected profile: %+v", profile)
|
|
}
|
|
}
|
|
|
|
func TestQobuzAudioProfileAvoidsGuessingHiResSampleRate(t *testing.T) {
|
|
profile := qobuzAudioProfile(map[string]any{"format_id": float64(7)}, 4, "flac")
|
|
if profile.BitDepth != 24 || profile.SamplingRate != "" || profile.Quality != "HI_RES" {
|
|
t.Fatalf("unexpected profile: %+v", profile)
|
|
}
|
|
}
|
|
|
|
func qobuzSecretSig(requestTS, secret string) string {
|
|
raw := "trackgetFileUrlformat_id27intentstreamtrack_id19512574" + requestTS + secret
|
|
hash := md5.Sum([]byte(raw))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
func TestRefreshAppCredentialsRejectsEmptyData(t *testing.T) {
|
|
d := testConfigData()
|
|
c := New(&config.Config{File: d, Session: d})
|
|
c.fetchCfg = func(context.Context) (string, []string, error) {
|
|
return "", []string{" "}, nil
|
|
}
|
|
err := c.refreshAppCredentials(context.Background(), &c.cfg.Session.Qobuz)
|
|
if err == nil {
|
|
t.Fatalf("expected error for empty refreshed app credentials")
|
|
}
|
|
}
|
|
|
|
// testConfigData returns defaults with request throttling disabled.
|
|
//
|
|
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
|
|
// which accounted for most of this suite's runtime (deezer alone was 31s).
|
|
func testConfigData() config.ConfigData {
|
|
d := config.DefaultConfigData()
|
|
d.Downloads.RequestsPerMinute = -1
|
|
return d
|
|
}
|
|
|
|
// newLyricsServer serves the two-step Qobuz lyrics flow: track/lyricsUrl
|
|
// returns a URL on this same server, which then serves the document.
|
|
func newLyricsServer(t *testing.T, doc any, docStatus int) *httptest.Server {
|
|
t.Helper()
|
|
var srv *httptest.Server
|
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/track/lyricsUrl":
|
|
if r.URL.Query().Get("request_sig") == "" {
|
|
t.Error("track/lyricsUrl called without a request signature")
|
|
}
|
|
if r.URL.Query().Get("language") != "" {
|
|
t.Error("language must be omitted so the original lyrics are returned, not a translation")
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"track_id": r.URL.Query().Get("track_id"),
|
|
"album_id": "a1",
|
|
"lyrics_url": srv.URL + "/doc.json?Signature=x",
|
|
})
|
|
case "/doc.json":
|
|
if docStatus != http.StatusOK {
|
|
w.WriteHeader(docStatus)
|
|
_, _ = w.Write([]byte(`<?xml version="1.0"?><Error><Code>AccessDenied</Code></Error>`))
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(doc)
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func lyricsClient(t *testing.T, srv *httptest.Server, exclude ...string) *Client {
|
|
t.Helper()
|
|
d := testConfigData()
|
|
d.Metadata.Exclude = exclude
|
|
c := New(&config.Config{File: d, Session: d})
|
|
c.baseURL = srv.URL
|
|
c.loggedIn = true
|
|
c.secret = "secret"
|
|
return c
|
|
}
|
|
|
|
func TestTrackLyricsLineSynced(t *testing.T) {
|
|
srv := newLyricsServer(t, map[string]any{
|
|
"track_id": "42",
|
|
"translation_langs": []any{"fr", "de"},
|
|
"original": map[string]any{
|
|
"type": "lsync",
|
|
"lang": "en",
|
|
"lines": []any{
|
|
map[string]any{"line": "First line", "start": 5780, "end": 8710},
|
|
map[string]any{"line": "", "start": 9000, "end": 9100},
|
|
map[string]any{"line": "Second line", "start": 65250, "end": 68000},
|
|
},
|
|
},
|
|
}, http.StatusOK)
|
|
|
|
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
|
|
if err != nil {
|
|
t.Fatalf("TrackLyrics() error = %v", err)
|
|
}
|
|
// The timestamped blank is a display-clear marker: it belongs in the LRC
|
|
// but not in the readable plain text.
|
|
if plain != "First line\nSecond line" {
|
|
t.Errorf("plain = %q", plain)
|
|
}
|
|
want := "[00:05.78]First line\n[00:09.00]\n[01:05.25]Second line"
|
|
if lrc != want {
|
|
t.Errorf("lrc = %q, want %q", lrc, want)
|
|
}
|
|
}
|
|
|
|
func TestTrackLyricsPlainHasNoTimestamps(t *testing.T) {
|
|
srv := newLyricsServer(t, map[string]any{
|
|
"original": map[string]any{
|
|
"type": "plain",
|
|
"lines": []any{
|
|
map[string]any{"line": "Just text"},
|
|
map[string]any{"line": "More text"},
|
|
},
|
|
},
|
|
}, http.StatusOK)
|
|
|
|
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if plain != "Just text\nMore text" {
|
|
t.Errorf("plain = %q", plain)
|
|
}
|
|
if lrc != "" {
|
|
t.Errorf("plain lyrics must not produce an LRC transcript, got %q", lrc)
|
|
}
|
|
}
|
|
|
|
func TestTrackLyricsWordSyncedUsesLineTimings(t *testing.T) {
|
|
srv := newLyricsServer(t, map[string]any{
|
|
"original": map[string]any{
|
|
"type": "wsync",
|
|
"lines": []any{
|
|
map[string]any{
|
|
"line": "Hello world",
|
|
"start": 1500,
|
|
"words": []any{
|
|
map[string]any{"text": "Hello", "start": 1500, "end": 1800},
|
|
map[string]any{"text": "world", "start": 1800, "end": 2100},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, http.StatusOK)
|
|
|
|
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if plain != "Hello world" {
|
|
t.Errorf("plain = %q", plain)
|
|
}
|
|
if lrc != "[00:01.50]Hello world" {
|
|
t.Errorf("lrc = %q", lrc)
|
|
}
|
|
}
|
|
|
|
// A missing document 403s on the signed URL; that is how Qobuz says "no
|
|
// lyrics", so it must not fail the rip.
|
|
func TestTrackLyricsMissingDocumentIsNotAnError(t *testing.T) {
|
|
srv := newLyricsServer(t, nil, http.StatusForbidden)
|
|
|
|
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
|
|
if err != nil {
|
|
t.Fatalf("a missing lyrics document must not be an error, got %v", err)
|
|
}
|
|
if plain != "" || lrc != "" {
|
|
t.Errorf("expected no lyrics, got plain=%q lrc=%q", plain, lrc)
|
|
}
|
|
}
|
|
|
|
func TestTrackLyricsRespectsMetadataExclude(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("no request should be made when lyrics are excluded, got %s", r.URL.Path)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plain, lrc, err := lyricsClient(t, srv, "lyrics").TrackLyrics(context.Background(), "42")
|
|
if err != nil || plain != "" || lrc != "" {
|
|
t.Fatalf("exclude should short-circuit; got plain=%q lrc=%q err=%v", plain, lrc, err)
|
|
}
|
|
}
|
|
|
|
func TestTrackLyricsEmptyIDIsNoOp(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("no request expected for an empty track id, got %s", r.URL.Path)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), " "); err != nil || plain != "" || lrc != "" {
|
|
t.Fatalf("got plain=%q lrc=%q err=%v", plain, lrc, err)
|
|
}
|
|
}
|
|
|
|
// TestTrackLyricsKeepsVerseBreaks covers Qobuz's real separator shape: an
|
|
// empty line with no start or end. Those keep the plain text in stanzas but
|
|
// cannot become LRC entries, since there is no timestamp to use.
|
|
func TestTrackLyricsKeepsVerseBreaks(t *testing.T) {
|
|
srv := newLyricsServer(t, map[string]any{
|
|
"original": map[string]any{
|
|
"type": "lsync",
|
|
"lines": []any{
|
|
map[string]any{"line": "verse one line one", "start": 1000},
|
|
map[string]any{"line": "verse one line two", "start": 2000},
|
|
map[string]any{"line": ""},
|
|
map[string]any{"line": ""},
|
|
map[string]any{"line": "verse two line one", "start": 3000},
|
|
map[string]any{"line": ""},
|
|
},
|
|
},
|
|
}, http.StatusOK)
|
|
|
|
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantPlain := "verse one line one\nverse one line two\n\nverse two line one"
|
|
if plain != wantPlain {
|
|
t.Errorf("plain = %q, want %q", plain, wantPlain)
|
|
}
|
|
wantLRC := "[00:01.00]verse one line one\n[00:02.00]verse one line two\n[00:03.00]verse two line one"
|
|
if lrc != wantLRC {
|
|
t.Errorf("lrc = %q, want %q", lrc, wantLRC)
|
|
}
|
|
}
|
|
|
|
// TestTrackLyricsLeadingSeparatorDropped guards against a lyric tag that
|
|
// starts with a blank line.
|
|
func TestTrackLyricsLeadingSeparatorDropped(t *testing.T) {
|
|
srv := newLyricsServer(t, map[string]any{
|
|
"original": map[string]any{
|
|
"type": "plain",
|
|
"lines": []any{map[string]any{"line": ""}, map[string]any{"line": "first real line"}},
|
|
},
|
|
}, http.StatusOK)
|
|
|
|
plain, _, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if plain != "first real line" {
|
|
t.Errorf("plain = %q, want no leading blank", plain)
|
|
}
|
|
}
|