diff --git a/cmd/harness_test.go b/cmd/harness_test.go
index 28ca43d..fe88340 100644
--- a/cmd/harness_test.go
+++ b/cmd/harness_test.go
@@ -182,6 +182,7 @@ type webScript struct {
// as the default transport.
func installWeb(t *testing.T, overrides map[string]func(*http.Request) (int, string), web webScript) {
t.Helper()
+ t.Setenv("QOBUZ_TOKEN", "test-token")
router := newSpotifyRouter(overrides)
installTransport(t, roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch request.URL.Host {
@@ -193,12 +194,15 @@ func installWeb(t *testing.T, overrides map[string]func(*http.Request) (int, str
return geniusResponse(web), nil
case "www.youtube.com":
return youtubeResponse(web)
- case "open.qobuz.com":
+ case "play.qobuz.com":
return qobuzBundleResponse(web, request)
+ case "open.qobuz.com":
+ return qobuzOpenBundleResponse(web, request)
case "www.qobuz.com":
+ if strings.Contains(request.URL.Path, "getFileUrl") {
+ return qobuzFileResponse(web)
+ }
return qobuzSearchResponse(web), nil
- case "dabmusic.xyz":
- return dabResponse(web)
case "ima.ge":
// the artwork blob download
if web.artworkErr {
@@ -236,12 +240,12 @@ func youtubeResponse(web webScript) (*http.Response, error) {
return nil, errors.New("ko")
}
if web.noResults {
- return rawResponse(200, "text/html", []byte("")), nil
+ return rawResponse(200, "text/html", []byte(youtubeNoMatchPage())), nil
}
return rawResponse(200, "text/html", []byte(youtubePage())), nil
}
-func qobuzBundleResponse(web webScript, request *http.Request) (*http.Response, error) {
+func qobuzOpenBundleResponse(web webScript, request *http.Request) (*http.Response, error) {
if web.providerErr {
return nil, errors.New("ko")
}
@@ -251,6 +255,16 @@ func qobuzBundleResponse(web webScript, request *http.Request) (*http.Response,
return rawResponse(200, "text/javascript", []byte(`var c={app_id:"123456789",app_secret:"00000000000000000000000000000000"};`)), nil
}
+func qobuzBundleResponse(web webScript, request *http.Request) (*http.Response, error) {
+ if web.providerErr {
+ return nil, errors.New("ko")
+ }
+ if strings.HasSuffix(request.URL.Path, "/login") {
+ return rawResponse(200, "text/html", []byte(``)), nil
+ }
+ return rawResponse(200, "text/javascript", []byte(`var c={production:{api:{appId:"123456789",appSecret:"00000000000000000000000000000000"}}};`)), nil
+}
+
func qobuzSearchResponse(web webScript) *http.Response {
if web.noResults || web.qobuzEmpty {
return jsonResponse(200, `{"tracks":{"items":[]}}`)
@@ -258,11 +272,11 @@ func qobuzSearchResponse(web webScript) *http.Response {
return jsonResponse(200, `{"tracks":{"items":[{"id":138731318,"performer":{"name":"Artist"}}]}}`)
}
-func dabResponse(web webScript) (*http.Response, error) {
+func qobuzFileResponse(web webScript) (*http.Response, error) {
if web.providerErr {
return nil, errors.New("ko")
}
- return jsonResponse(200, `{"url":"https://cdn.qobuz.example/track.mp3"}`), nil
+ return jsonResponse(200, `{"url":"https://cdn.qobuz.example/track.mp3","sample":false}`), nil
}
// jpegBytes encodes a real 1x1 JPEG: the artwork pipeline decodes the
@@ -278,10 +292,20 @@ func jpegBytes(t *testing.T) []byte {
// youtubePage renders a minimal YouTube results page carrying one video
// matching the fixture track.
func youtubePage() string {
+ return youtubePageFor("Title", "Artist")
+}
+
+// youtubeNoMatchPage renders a results page whose only video does not
+// match the track under test, so the provider answers with no matches.
+func youtubeNoMatchPage() string {
+ return youtubePageFor("Unrelated", "Stranger")
+}
+
+func youtubePageFor(title, owner string) string {
return ``
diff --git a/cmd/lookup.go b/cmd/lookup.go
index 3686100..d9bc5c7 100644
--- a/cmd/lookup.go
+++ b/cmd/lookup.go
@@ -97,12 +97,15 @@ func routineLookupProvider(providerChannel chan interface{}) func(context.Contex
track := event.(*entity.Track)
matches, err := provider.Search(track)
switch {
- case err != nil:
+ case len(matches) == 0 && err != nil:
fmt.Println(colorRed+prefix, track.ID, sys.Pad(track.Artist()), sys.Pad(track.Title), err, colorReset)
case len(matches) == 0:
fmt.Println(colorRed+prefix, track.ID, sys.Pad(track.Artist()), sys.Pad(track.Title), "no result", colorReset)
default:
fmt.Println(prefix, track.ID, sys.Pad(track.Artist()), sys.Pad(track.Title), matches[0].URL, matches[0].Score)
+ if err != nil {
+ fmt.Println(colorRed+prefix, track.ID, sys.Pad(track.Artist()), sys.Pad(track.Title), "provider warning:", err, colorReset)
+ }
}
}
}
diff --git a/cmd/sync.go b/cmd/sync.go
index 34a6355..c12f6a9 100644
--- a/cmd/sync.go
+++ b/cmd/sync.go
@@ -512,16 +512,20 @@ func decideTrack(ch chan error, consecutiveFailures *atomic.Int32, track *entity
}
matches, err := provider.Search(track)
- if err != nil {
- consecutiveFailures.Add(1)
- tui.AnchorPrintf("%s by %s (id: %s) search failed: %v", track.Title, track.Artist(), track.ID, err)
+ if len(matches) == 0 {
+ if err != nil {
+ consecutiveFailures.Add(1)
+ tui.AnchorPrintf("%s by %s (id: %s) search failed: %v", track.Title, track.Artist(), track.ID, err)
+ return false
+ }
+ consecutiveFailures.Store(0)
+ tui.AnchorPrintf("%s by %s (id: %s) not found", track.Title, track.Artist(), track.ID)
return false
}
consecutiveFailures.Store(0)
- if len(matches) == 0 {
- tui.AnchorPrintf("%s by %s (id: %s) not found", track.Title, track.Artist(), track.ID)
- return false
+ if err != nil {
+ tui.AnchorPrintf("%s by %s (id: %s) provider warning: %v", track.Title, track.Artist(), track.ID, err)
}
track.UpstreamURL = matches[0].URL
routineQueues[routineTypeCollect] <- track
diff --git a/docs/installation.md b/docs/installation.md
index db1a366..d50de8f 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -41,6 +41,12 @@ Install them via your package manager (e.g. `apt install ffmpeg yt-dlp`, `brew i
### Embedding API keys
By default, Pango will use `SPOTIFY_ID`, `SPOTIFY_KEY` and `GENIUS_TOKEN` environment variables to authenticate to the corresponding APIs.
+
+The Qobuz provider resolves stream URLs through the official Qobuz API with the user's own account.
+Set `QOBUZ_TOKEN` to the `user_auth_token` of a logged-in [play.qobuz.com](https://play.qobuz.com) session.
+To read that value, open play.qobuz.com in the browser and open the developer tools on the Network tab.
+Reload the page, select the `login` request, and open its Response tab: the token is the `user_auth_token` field of the JSON body.
+Without it, Qobuz tracks are skipped with an explicit error: unauthenticated API requests only return 30-second samples.
If those are not found, though, it will fall back to the fallback fields defined in the corresponding source code modules (which, in turn, are empty, by default).
In order to build a binary which contains these fields, the following formula can be used:
diff --git a/provider/provider.go b/provider/provider.go
index 8ccef38..1830697 100644
--- a/provider/provider.go
+++ b/provider/provider.go
@@ -3,7 +3,9 @@ package provider
import (
"context"
"errors"
+ "fmt"
"sort"
+ "strings"
"sync"
"github.com/arunsworld/nursery"
@@ -21,29 +23,35 @@ type Match struct {
}
type Provider interface {
+ name() string
search(track *entity.Track) ([]*Match, error)
}
+// Search queries every provider concurrently and returns the matches
+// ranked by score. A provider failure never discards the matches of
+// the other providers: the returned error aggregates every failure,
+// each prefixed with the provider name, alongside the matches. The
+// callers surface that error, so a broken provider is not mistaken
+// for a track that was not found. The error is nil only when every
+// provider answered.
func Search(track *entity.Track) ([]*Match, error) {
var (
- workers []nursery.ConcurrentJob
- matches []*Match
- mu sync.Mutex
- errCount int
+ workers []nursery.ConcurrentJob
+ matches []*Match
+ errs []error
+ mu sync.Mutex
)
for _, provider := range providers {
workers = append(workers, func(p Provider) func(ctx context.Context, ch chan error) {
return func(_ context.Context, _ chan error) {
scopedMatches, err := p.search(track)
+ mu.Lock()
+ defer mu.Unlock()
if err != nil {
- mu.Lock()
- errCount++
- mu.Unlock()
+ errs = append(errs, fmt.Errorf("%s: %w", p.name(), err))
return
}
- mu.Lock()
matches = append(matches, scopedMatches...)
- mu.Unlock()
}
}(provider))
}
@@ -52,13 +60,17 @@ func Search(track *entity.Track) ([]*Match, error) {
return nil, err
}
- if errCount == len(workers) {
- return nil, errors.New("all providers failed")
- }
-
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].Score > matches[j].Score
})
+ if len(errs) > 0 {
+ msgs := make([]string, 0, len(errs))
+ for _, err := range errs {
+ msgs = append(msgs, err.Error())
+ }
+ return matches, errors.New(strings.Join(msgs, "; "))
+ }
+
return matches, nil
}
diff --git a/provider/provider_test.go b/provider/provider_test.go
index d1239a3..9445cfc 100644
--- a/provider/provider_test.go
+++ b/provider/provider_test.go
@@ -68,6 +68,7 @@ func BenchmarkProvider(b *testing.B) {
func TestSearch(t *testing.T) {
resetQobuz(t)
+ setQobuzToken(t)
stubHTTP(t, func(request *http.Request) (*http.Response, error) {
switch request.URL.Host {
case "www.youtube.com":
@@ -89,13 +90,18 @@ func TestSearchFailure(t *testing.T) {
return nil, errors.New("ko")
})
- // all providers failed → propagate as error
- _, err := Search(track)
- assert.EqualError(t, err, "all providers failed")
+ // all providers failed → no matches, and the error names each
+ // provider and its failure instead of a generic message
+ matches, err := Search(track)
+ assert.Empty(t, matches)
+ assert.NotNil(t, err)
+ assert.Contains(t, err.Error(), "qobuz:")
+ assert.Contains(t, err.Error(), "youtube:")
}
func TestSearchPartialFailure(t *testing.T) {
resetQobuz(t)
+ setQobuzToken(t)
stubHTTP(t, func(request *http.Request) (*http.Response, error) {
if request.URL.Host == "www.youtube.com" {
return nil, errors.New("ko")
@@ -103,8 +109,11 @@ func TestSearchPartialFailure(t *testing.T) {
return qobuzFlow(request, qobuzScript{})
})
- // one provider succeeded → return its matches, no error
+ // one provider succeeded → its matches are returned, and the other
+ // provider's failure is surfaced alongside them, not swallowed
matches, err := Search(track)
- assert.Nil(t, err)
assert.NotEmpty(t, matches)
+ assert.NotNil(t, err)
+ assert.Contains(t, err.Error(), "youtube:")
+ assert.NotContains(t, err.Error(), "qobuz:")
}
diff --git a/provider/qobuz.go b/provider/qobuz.go
index f12f236..8257d70 100644
--- a/provider/qobuz.go
+++ b/provider/qobuz.go
@@ -1,12 +1,14 @@
package provider
import (
+ "crypto/md5" //nolint:gosec // md5 is the signature scheme mandated by the qobuz API, not a security control
+ "encoding/base64"
"encoding/json"
"fmt"
- "hash/fnv"
"io"
"net/http"
"net/url"
+ "os"
"regexp"
"strconv"
"strings"
@@ -17,27 +19,47 @@ import (
)
const (
- qobuzAPIBase = "https://www.qobuz.com/api.json/0.2"
+ qobuzAPIBase = "https://www.qobuz.com/api.json/0.2"
+ // two apps, two jobs: catalog search runs under the
+ // open.qobuz.com embed app, which answers unsigned; stream
+ // resolution runs under the play.qobuz.com web player production
+ // app, the one holding streaming rights and the one the user's
+ // token is issued to (the embed app resolves samples only, and
+ // the player app refuses unsigned catalog calls)
qobuzOpenShellURL = "https://open.qobuz.com/track/1"
+ qobuzPlayShellURL = "https://play.qobuz.com/login"
qobuzQualityMP3 = "5"
+ // environment variable carrying the user's own qobuz
+ // user_auth_token, taken from a logged-in play.qobuz.com session;
+ // without it the API only serves 30-second samples
+ qobuzTokenEnv = "QOBUZ_TOKEN" // #nosec G101 -- the name of the environment variable, not a credential
)
var (
- qobuzProxies = []string{
- "https://dabmusic.xyz/api/stream?trackId=%s&quality=" + qobuzQualityMP3,
+ qobuzBundleScriptPattern = regexp.MustCompile(`