mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 23:25:30 +02:00
388 lines
10 KiB
Go
388 lines
10 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 := config.DefaultConfigData()
|
|
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 := config.DefaultConfigData()
|
|
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 := config.DefaultConfigData()
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 := config.DefaultConfigData()
|
|
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")
|
|
}
|
|
}
|