Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions cmd/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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("<html></html>")), 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")
}
Expand All @@ -251,18 +255,28 @@ 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(`<script src="/resources/1.0/bundle.js"></script>`)), 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":[]}}`)
}
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
Expand All @@ -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 `<script>var ytInitialData = {"contents":{"twoColumnSearchResultsRenderer":` +
`{"primaryContents":{"sectionListRenderer":{"contents":[{"itemSectionRenderer":` +
`{"contents":[{"videoRenderer":{"videoId":"abc123","title":{"runs":[{"text":"Title"}]},` +
`"ownerText":{"runs":[{"text":"Artist"}]},` +
`{"contents":[{"videoRenderer":{"videoId":"abc123","title":{"runs":[{"text":"` + title + `"}]},` +
`"ownerText":{"runs":[{"text":"` + owner + `"}]},` +
`"viewCountText":{"simpleText":"1.000.000 views"},` +
`"lengthText":{"simpleText":"3:00 minutes"},` +
`"publishedTimeText":{"simpleText":"1 year ago"}}}]}}]}}}}}</script>`
Expand Down
5 changes: 4 additions & 1 deletion cmd/lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
16 changes: 10 additions & 6 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
38 changes: 25 additions & 13 deletions provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package provider
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"

"github.com/arunsworld/nursery"
Expand All @@ -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))
}
Expand All @@ -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
}
19 changes: 14 additions & 5 deletions provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -89,22 +90,30 @@ 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")
}
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:")
}
Loading
Loading