From 32f7bc5819c9be71d0b294f950eeb692dca2d89d Mon Sep 17 00:00:00 2001 From: streambinder Date: Mon, 26 Oct 2026 13:27:25 +0100 Subject: [PATCH] fix(provider): resolve qobuz streams via the official API The dabmusic proxy is Cloudflare-gated and its failures were hidden. --- cmd/harness_test.go | 42 ++++- cmd/lookup.go | 5 +- cmd/sync.go | 16 +- docs/installation.md | 6 + provider/provider.go | 38 +++-- provider/provider_test.go | 19 ++- provider/qobuz.go | 347 +++++++++++++++++++++++++++++++------- provider/qobuz_test.go | 237 +++++++++++++++++++------- provider/youtube.go | 19 ++- provider/youtube_test.go | 23 ++- 10 files changed, 585 insertions(+), 167 deletions(-) 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(`]+src="(/resources/[^"]+\.js)"`) + qobuzOpenBundleScriptPattern = regexp.MustCompile(`]+src="([^"]+/js/main\.js|/resources/[^"]+/js/main\.js)"`) + qobuzOpenCredentialsPattern = regexp.MustCompile(`app_id:"(?P\d{9})",app_secret:"(?P[a-f0-9]{32})"`) + // the web player bundle carries one api config per environment; + // the production entry is the pair the player itself signs with, + // with a generic pair as fallback if the labels move + qobuzCredentialsPatterns = []*regexp.Regexp{ + regexp.MustCompile(`production:\{api:\{appId:"(?P\d+)",appSecret:"(?P[a-f0-9]{32})"\}`), + regexp.MustCompile(`appId:"(?P\d+)",appSecret:"(?P[a-f0-9]{32})"`), } - qobuzBundleScriptPattern = regexp.MustCompile(`]+src="([^"]+/js/main\.js|/resources/[^"]+/js/main\.js)"`) - qobuzCredentialsPattern = regexp.MustCompile(`app_id:"(?P\d{9})",app_secret:"(?P[a-f0-9]{32})"`) qobuzHTTPClient = &http.Client{Timeout: 10 * time.Second} - qobuzCredMu sync.Mutex - qobuzCachedID string - qobuzCachedSecret string + qobuzCredMu sync.Mutex + qobuzCachedID string + qobuzCachedSecrets []string + qobuzFileSecret string - // cdn url cache: trackID → url, avoids re-resolving the same track - // across playlists within a single sync run - qobuzCDNCache sync.Map + qobuzSearchCachedID string + qobuzSearchCachedSecret string + + // stream url cache: trackID → url, avoids re-resolving the same + // track across playlists within a single sync run + qobuzURLCache sync.Map ) type qobuz struct{} @@ -46,6 +68,10 @@ func init() { providers = append(providers, qobuz{}) } +func (qobuz) name() string { + return "qobuz" +} + func (qobuz) search(track *entity.Track) ([]*Match, error) { trackID, err := qobuzSearchTrack(track) if err != nil { @@ -55,87 +81,201 @@ func (qobuz) search(track *entity.Track) ([]*Match, error) { return nil, nil } - cdnURL, err := qobuzCDNURL(strconv.FormatInt(trackID, 10)) + streamURL, err := qobuzFileURL(strconv.FormatInt(trackID, 10)) if err != nil { return nil, err } - return []*Match{{URL: cdnURL, Score: 100}}, nil + return []*Match{{URL: streamURL, Score: 100}}, nil } -func qobuzCredentials() (string, string, error) { +// qobuzFileCredentials scrapes the web player app ID and the +// candidate signing secrets for stream resolution from the +// play.qobuz.com bundle. The app secret printed in the bundle +// config does not sign: the signing secrets are derived from the +// bundle seed data (see qobuzDeriveSecrets), and the API accepts +// exactly one of the candidates, so callers try them in order. +func qobuzFileCredentials() (string, []string, error) { qobuzCredMu.Lock() defer qobuzCredMu.Unlock() - if qobuzCachedID != "" && qobuzCachedSecret != "" { - return qobuzCachedID, qobuzCachedSecret, nil + if qobuzCachedID != "" && len(qobuzCachedSecrets) > 0 { + return qobuzCachedID, qobuzCachedSecrets, nil } - req, err := http.NewRequest(http.MethodGet, qobuzOpenShellURL, nil) + bundle, err := qobuzFetchBundle(qobuzPlayShellURL, qobuzBundleScriptPattern) + if err != nil { + return "", nil, err + } + + var credMatch [][]byte + for _, pattern := range qobuzCredentialsPatterns { + if credMatch = pattern.FindSubmatch(bundle); credMatch != nil { + break + } + } + if credMatch == nil { + return "", nil, fmt.Errorf("credentials not found in bundle") + } + + qobuzCachedID = string(credMatch[1]) + qobuzCachedSecrets = qobuzDeriveSecrets(bundle) + if len(qobuzCachedSecrets) == 0 { + qobuzCachedSecrets = []string{string(credMatch[2])} + } + + return qobuzCachedID, qobuzCachedSecrets, nil +} + +var qobuzSeedTimezonePattern = regexp.MustCompile(`[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)`) + +// qobuzDeriveSecrets reconstructs the candidate signing secrets +// hidden in the web player bundle: each environment seed is joined +// with the info and extras fragments registered for its timezone, +// the last 44 characters are dropped, and the rest is +// base64-decoded. The second timezone found takes priority, which +// mirrors the conditional order in the bundle. +func qobuzDeriveSecrets(bundle []byte) []string { + type zone struct { + name string + parts []string + } + var ( + zones []*zone + byName = map[string]*zone{} + ) + for _, match := range qobuzSeedTimezonePattern.FindAllSubmatch(bundle, -1) { + name := string(match[2]) + entry := byName[name] + if entry == nil { + entry = &zone{name: name} + byName[name] = entry + zones = append(zones, entry) + } + entry.parts = []string{string(match[1])} + } + if len(zones) == 0 { + return nil + } + if len(zones) >= 2 { + reordered := make([]*zone, 0, len(zones)) + reordered = append(reordered, zones[1], zones[0]) + zones = append(reordered, zones[2:]...) + } + + capitalized := make([]string, len(zones)) + for i, entry := range zones { + capitalized[i] = strings.ToUpper(entry.name[:1]) + entry.name[1:] + } + infoPattern := regexp.MustCompile(`name:"\w+/(?P` + strings.Join(capitalized, "|") + + `)",info:"(?P[\w=]+)",extras:"(?P[\w=]+)"`) + for _, match := range infoPattern.FindAllSubmatch(bundle, -1) { + if entry := byName[strings.ToLower(string(match[1]))]; entry != nil { + entry.parts = append(entry.parts, string(match[2]), string(match[3])) + } + } + + secrets := []string{} + for _, entry := range zones { + joined := strings.Join(entry.parts, "") + if len(joined) <= 44 { + continue + } + decoded, err := base64.StdEncoding.DecodeString(joined[:len(joined)-44]) + if err != nil || len(decoded) == 0 { + continue + } + secrets = append(secrets, string(decoded)) + } + + return secrets +} + +// qobuzSearchCredentials scrapes the embed app credentials used for +// catalog search, from the open.qobuz.com bundle. +func qobuzSearchCredentials() (string, string, error) { + qobuzCredMu.Lock() + defer qobuzCredMu.Unlock() + + if qobuzSearchCachedID != "" && qobuzSearchCachedSecret != "" { + return qobuzSearchCachedID, qobuzSearchCachedSecret, nil + } + + bundle, err := qobuzFetchBundle(qobuzOpenShellURL, qobuzOpenBundleScriptPattern) if err != nil { return "", "", err } + + credMatch := qobuzOpenCredentialsPattern.FindSubmatch(bundle) + if credMatch == nil { + return "", "", fmt.Errorf("credentials not found in bundle") + } + + qobuzSearchCachedID = string(credMatch[1]) + qobuzSearchCachedSecret = string(credMatch[2]) + + return qobuzSearchCachedID, qobuzSearchCachedSecret, nil +} + +// qobuzFetchBundle fetches a qobuz shell page and returns the +// JavaScript bundle the shell loads. +func qobuzFetchBundle(shellURL string, scriptPattern *regexp.Regexp) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, shellURL, nil) + if err != nil { + return nil, err + } req.Header.Set("User-Agent", "Mozilla/5.0") resp, err := qobuzHTTPClient.Do(req) if err != nil { - return "", "", err + return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("qobuz: shell returned %s", resp.Status) + return nil, fmt.Errorf("shell returned %s", resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { - return "", "", err + return nil, err } - scriptMatch := qobuzBundleScriptPattern.FindSubmatch(body) + scriptMatch := scriptPattern.FindSubmatch(body) if scriptMatch == nil { - return "", "", fmt.Errorf("qobuz: bundle script not found in shell") + return nil, fmt.Errorf("bundle script not found in shell") } bundleURL := string(scriptMatch[1]) if strings.HasPrefix(bundleURL, "/") { - bundleURL = "https://open.qobuz.com" + bundleURL + shell, parseErr := url.Parse(shellURL) + if parseErr != nil { + return nil, parseErr + } + bundleURL = shell.Scheme + "://" + shell.Host + bundleURL } bundleReq, err := http.NewRequest(http.MethodGet, bundleURL, nil) if err != nil { - return "", "", err + return nil, err } bundleReq.Header.Set("User-Agent", "Mozilla/5.0") bundleResp, err := qobuzHTTPClient.Do(bundleReq) if err != nil { - return "", "", err + return nil, err } defer bundleResp.Body.Close() if bundleResp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("qobuz: bundle returned %s", bundleResp.Status) - } - - bundle, err := io.ReadAll(bundleResp.Body) - if err != nil { - return "", "", err + return nil, fmt.Errorf("bundle returned %s", bundleResp.Status) } - credMatch := qobuzCredentialsPattern.FindSubmatch(bundle) - if credMatch == nil { - return "", "", fmt.Errorf("qobuz: credentials not found in bundle") - } - - qobuzCachedID = string(credMatch[1]) - qobuzCachedSecret = string(credMatch[2]) - - return qobuzCachedID, qobuzCachedSecret, nil + return io.ReadAll(bundleResp.Body) } func qobuzSearchTrack(track *entity.Track) (int64, error) { - appID, appSecret, err := qobuzCredentials() + appID, appSecret, err := qobuzSearchCredentials() if err != nil { return 0, err } @@ -143,11 +283,11 @@ func qobuzSearchTrack(track *entity.Track) (int64, error) { query := fmt.Sprintf("%s %s", track.Song(), track.Artist()) ts := strconv.FormatInt(time.Now().Unix(), 10) - // qobuz API signature is vestigial for track/search — not validated server-side; - // use fnv128 to avoid importing crypto/md5 - h := fnv.New128() - h.Write([]byte("tracksearch" + "query" + query + "limit" + "1" + ts + appSecret)) - sig := fmt.Sprintf("%x", h.Sum(nil)) + // MD5 over the endpoint name, the arguments in key order, the + // timestamp and the app secret, as for track/getFileUrl + sigBytes := md5.Sum([]byte( //nolint:gosec // mandated by the qobuz API signature scheme + "tracksearch" + "limit" + "1" + "query" + query + ts + appSecret)) + sig := fmt.Sprintf("%x", sigBytes) params := url.Values{ "query": {query}, @@ -172,7 +312,7 @@ func qobuzSearchTrack(track *entity.Track) (int64, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return 0, fmt.Errorf("qobuz: search returned %s", resp.Status) + return 0, fmt.Errorf("search returned %s", resp.Status) } var payload struct { @@ -197,30 +337,109 @@ func qobuzSearchTrack(track *entity.Track) (int64, error) { return items[0].ID, nil } -// qobuzCDNURL resolves a track ID to a CDN streaming URL via proxy services. -// results are cached per-process to avoid redundant lookups across playlists. -func qobuzCDNURL(trackID string) (string, error) { - if cached, ok := qobuzCDNCache.Load(trackID); ok { +// qobuzFileURL resolves a track ID to a full stream URL through the +// official track/getFileUrl endpoint, signed with one of the +// derived app secrets and authenticated with the user's own token. +// A candidate secret whose signature the API rejects (400) falls +// through to the next candidate; the first secret the API accepts +// is remembered for the rest of the process. Results are cached +// per-process to avoid redundant lookups across playlists. +func qobuzFileURL(trackID string) (string, error) { + if cached, ok := qobuzURLCache.Load(trackID); ok { return cached.(string), nil } - for _, proxy := range qobuzProxies { - resp, err := qobuzHTTPClient.Get(fmt.Sprintf(proxy, trackID)) - if err != nil { + token := os.Getenv(qobuzTokenEnv) + if token == "" { + return "", fmt.Errorf("%s is not set: without a user token the API only serves 30-second samples", qobuzTokenEnv) + } + + appID, candidates, err := qobuzFileCredentials() + if err != nil { + return "", err + } + if qobuzFileSecret != "" { + candidates = []string{qobuzFileSecret} + } + + var lastErr error + for _, secret := range candidates { + streamURL, rejected, err := qobuzFileURLAttempt(trackID, appID, secret, token) + if rejected { + lastErr = err continue } - - var payload struct { - URL string `json:"url"` + if err != nil { + return "", err } - err = json.NewDecoder(resp.Body).Decode(&payload) - resp.Body.Close() + qobuzFileSecret = secret + qobuzURLCache.Store(trackID, streamURL) + return streamURL, nil + } - if err == nil && payload.URL != "" { - qobuzCDNCache.Store(trackID, payload.URL) - return payload.URL, nil + return "", lastErr +} + +// qobuzFileURLAttempt performs one signed getFileUrl call. The +// rejected flag reports a 400 answer, which is how the API refuses +// a signature built with the wrong candidate secret. +func qobuzFileURLAttempt(trackID, appID, secret, token string) (string, bool, error) { + ts := strconv.FormatInt(time.Now().Unix(), 10) + sigBytes := md5.Sum([]byte( //nolint:gosec // mandated by the qobuz API signature scheme + "trackgetFileUrl" + "format_id" + qobuzQualityMP3 + "intentstream" + "track_id" + trackID + ts + secret)) + sig := fmt.Sprintf("%x", sigBytes) + + params := url.Values{ + "track_id": {trackID}, + "format_id": {qobuzQualityMP3}, + "intent": {"stream"}, + "app_id": {appID}, + "request_ts": {ts}, + "request_sig": {sig}, + } + + req, err := http.NewRequest(http.MethodGet, qobuzAPIBase+"/track/getFileUrl?"+params.Encode(), nil) + if err != nil { + return "", false, err + } + req.Header.Set("User-Agent", "Mozilla/5.0") + req.Header.Set("X-App-Id", appID) + req.Header.Set("X-User-Auth-Token", token) + + resp, err := qobuzHTTPClient.Do(req) + if err != nil { + return "", false, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusBadRequest { + return "", true, fmt.Errorf("getFileUrl returned %s", resp.Status) + } + if resp.StatusCode != http.StatusOK { + return "", false, fmt.Errorf("getFileUrl returned %s", resp.Status) + } + + var payload struct { + URL string `json:"url"` + Sample bool `json:"sample"` + Restrictions []struct { + Code string `json:"code"` + } `json:"restrictions"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", false, err + } + if payload.URL == "" { + return "", false, fmt.Errorf("getFileUrl returned no URL for track %s", trackID) + } + if payload.Sample { + codes := make([]string, 0, len(payload.Restrictions)) + for _, restriction := range payload.Restrictions { + codes = append(codes, restriction.Code) } + return "", false, fmt.Errorf("getFileUrl returned a 30-second sample for track %s (restrictions: %s): the token in %s is invalid or has no streaming entitlement", + trackID, strings.Join(codes, ", "), qobuzTokenEnv) } - return "", fmt.Errorf("qobuz: all proxies failed for track %s", trackID) + return payload.URL, false, nil } diff --git a/provider/qobuz_test.go b/provider/qobuz_test.go index facb510..a2a0923 100644 --- a/provider/qobuz_test.go +++ b/provider/qobuz_test.go @@ -1,8 +1,11 @@ package provider import ( + "crypto/md5" //nolint:gosec // md5 is the signature scheme mandated by the qobuz API, not a security control "errors" + "fmt" "net/http" + "strings" "sync" "testing" @@ -12,14 +15,24 @@ import ( const qobuzSearchResponse = `{"tracks":{"items":[{"id":138731318,"performer":{"name":"Artist"}}]}}` const ( - qobuzShellHTML = `` - qobuzBundleJS = `var config={app_id:"123456789",app_secret:"00000000000000000000000000000000"};` - qobuzProxyJSON = `{"url":"https://cdn.qobuz.example/track.mp3"}` + qobuzShellHTML = `` + qobuzBundleJS = `var config={production:{api:{appId:"123456789",appSecret:"00000000000000000000000000000000"}}};` + qobuzOpenShellHTML = `` + qobuzOpenBundleJS = `var config={app_id:"123456789",app_secret:"00000000000000000000000000000000"};` + qobuzFileJSON = `{"url":"https://cdn.qobuz.example/track.mp3","sample":false}` ) +// setQobuzToken installs a dummy user token, so the file URL stage can +// run; the token value itself is never asserted. +func setQobuzToken(t *testing.T) { + t.Helper() + t.Setenv(qobuzTokenEnv, "test-token") +} + // qobuzScript scripts the four HTTP stages of the qobuz flow: the shell // page, the JS bundle carrying the credentials, the catalog search and -// the CDN proxy. Zero fields select the successful default response. +// the getFileUrl resolution. Zero fields select the successful default +// response. type qobuzScript struct { shellStatus int shellBody string @@ -32,9 +45,9 @@ type qobuzScript struct { searchStatus int searchBody string searchErr error - proxyStatus int - proxyBody string - proxyErr error + fileStatus int + fileBody string + fileErr error } func qobuzStage(status int, body string, err error, readErr bool) (*http.Response, error) { @@ -51,47 +64,63 @@ func qobuzStage(status int, body string, err error, readErr bool) (*http.Respons return response, nil } -// qobuzFlow answers a request of any qobuz stage according to the script. -func qobuzFlow(request *http.Request, script qobuzScript) (*http.Response, error) { - switch { - case request.URL.Host == "open.qobuz.com" && request.URL.Path == "/track/1": +// qobuzCredStage answers a shell or bundle request of either +// credential scrape, with per-app default bodies. +func qobuzCredStage(script qobuzScript, shell bool, shellDefault, bundleDefault string) (*http.Response, error) { + if shell { body := script.shellBody if body == "" { - body = qobuzShellHTML + body = shellDefault } return qobuzStage(script.shellStatus, body, script.shellErr, script.shellReadErr) + } + body := script.bundleBody + if body == "" { + body = bundleDefault + } + return qobuzStage(script.bundleStatus, body, script.bundleErr, script.bundleReadErr) +} + +// qobuzFlow answers a request of any qobuz stage according to the script. +func qobuzFlow(request *http.Request, script qobuzScript) (*http.Response, error) { + switch { + case request.URL.Host == "open.qobuz.com" && request.URL.Path == "/track/1": + return qobuzCredStage(script, true, qobuzOpenShellHTML, qobuzOpenBundleJS) case request.URL.Host == "open.qobuz.com": - body := script.bundleBody + return qobuzCredStage(script, false, qobuzOpenShellHTML, qobuzOpenBundleJS) + case request.URL.Host == "play.qobuz.com" && request.URL.Path == "/login": + return qobuzCredStage(script, true, qobuzShellHTML, qobuzBundleJS) + case request.URL.Host == "play.qobuz.com": + return qobuzCredStage(script, false, qobuzShellHTML, qobuzBundleJS) + case request.URL.Host == "www.qobuz.com" && strings.Contains(request.URL.Path, "getFileUrl"): + body := script.fileBody if body == "" { - body = qobuzBundleJS + body = qobuzFileJSON } - return qobuzStage(script.bundleStatus, body, script.bundleErr, script.bundleReadErr) + return qobuzStage(script.fileStatus, body, script.fileErr, false) case request.URL.Host == "www.qobuz.com": body := script.searchBody if body == "" { body = qobuzSearchResponse } return qobuzStage(script.searchStatus, body, script.searchErr, false) - case request.URL.Host == "dabmusic.xyz": - body := script.proxyBody - if body == "" { - body = qobuzProxyJSON - } - return qobuzStage(script.proxyStatus, body, script.proxyErr, false) } return httpResponse(404, ""), nil } -// resetQobuz clears the credentials and CDN caches, so each test starts -// from a cold qobuz state regardless of test order. +// resetQobuz clears the credentials and stream URL caches, so each +// test starts from a cold qobuz state regardless of test order. func resetQobuz(t *testing.T) { t.Helper() reset := func() { qobuzCredMu.Lock() defer qobuzCredMu.Unlock() qobuzCachedID = "" - qobuzCachedSecret = "" - qobuzCDNCache = sync.Map{} + qobuzCachedSecrets = nil + qobuzFileSecret = "" + qobuzSearchCachedID = "" + qobuzSearchCachedSecret = "" + qobuzURLCache = sync.Map{} } reset() t.Cleanup(reset) @@ -105,6 +134,7 @@ func BenchmarkQobuz(b *testing.B) { func TestQobuzSearch(t *testing.T) { resetQobuz(t) + setQobuzToken(t) stubHTTP(t, func(request *http.Request) (*http.Response, error) { return qobuzFlow(request, qobuzScript{}) }) @@ -118,10 +148,10 @@ func TestQobuzSearch(t *testing.T) { func TestQobuzSearchCacheHit(t *testing.T) { resetQobuz(t) - qobuzCDNCache.Store("138731318", "https://cached.example/track.mp3") + qobuzURLCache.Store("138731318", "https://cached.example/track.mp3") stubHTTP(t, func(request *http.Request) (*http.Response, error) { - // the proxy stage must never be reached: the CDN cache answers first - return qobuzFlow(request, qobuzScript{proxyErr: errors.New("must not be called")}) + // the file stage must never be reached: the URL cache answers first + return qobuzFlow(request, qobuzScript{fileErr: errors.New("must not be called")}) }) matches, err := qobuz{}.search(track) @@ -185,10 +215,36 @@ func TestQobuzSearchNoItems(t *testing.T) { assert.Nil(t, matches) } -func TestQobuzSearchAllProxiesFailed(t *testing.T) { +func TestQobuzSearchNoToken(t *testing.T) { + resetQobuz(t) + t.Setenv(qobuzTokenEnv, "") + stubHTTP(t, func(request *http.Request) (*http.Response, error) { + return qobuzFlow(request, qobuzScript{}) + }) + + matches, err := qobuz{}.search(track) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), qobuzTokenEnv) + assert.Nil(t, matches) +} + +func TestQobuzSearchFileFailure(t *testing.T) { + resetQobuz(t) + setQobuzToken(t) + stubHTTP(t, func(request *http.Request) (*http.Response, error) { + return qobuzFlow(request, qobuzScript{fileErr: errors.New("ko")}) + }) + + matches, err := qobuz{}.search(track) + assert.NotNil(t, err) + assert.Nil(t, matches) +} + +func TestQobuzSearchFileBadJSON(t *testing.T) { resetQobuz(t) + setQobuzToken(t) stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyErr: errors.New("proxy down")}) + return qobuzFlow(request, qobuzScript{fileBody: "not json"}) }) matches, err := qobuz{}.search(track) @@ -196,10 +252,11 @@ func TestQobuzSearchAllProxiesFailed(t *testing.T) { assert.Nil(t, matches) } -func TestQobuzSearchProxyBadJSON(t *testing.T) { +func TestQobuzSearchFileEmptyURL(t *testing.T) { resetQobuz(t) + setQobuzToken(t) stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyBody: "not json"}) + return qobuzFlow(request, qobuzScript{fileBody: `{"url":"","sample":false}`}) }) matches, err := qobuz{}.search(track) @@ -207,21 +264,25 @@ func TestQobuzSearchProxyBadJSON(t *testing.T) { assert.Nil(t, matches) } -func TestQobuzSearchProxyEmptyURL(t *testing.T) { +func TestQobuzSearchFileSample(t *testing.T) { resetQobuz(t) + setQobuzToken(t) stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyBody: `{"url":""}`}) + return qobuzFlow(request, qobuzScript{fileBody: `{"url":"https://cdn.qobuz.example/sample.mp3","sample":true,"restrictions":[{"code":"UserUnauthenticated"}]}`}) }) matches, err := qobuz{}.search(track) assert.NotNil(t, err) + assert.Contains(t, err.Error(), "sample") + assert.Contains(t, err.Error(), "UserUnauthenticated") assert.Nil(t, matches) } -func TestQobuzSearchProxyNonOKStatus(t *testing.T) { +func TestQobuzSearchFileNonOKStatus(t *testing.T) { resetQobuz(t) + setQobuzToken(t) stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyStatus: 500, proxyBody: "error page"}) + return qobuzFlow(request, qobuzScript{fileStatus: 500, fileBody: "error page"}) }) matches, err := qobuz{}.search(track) @@ -237,13 +298,13 @@ func TestQobuzCredentials(t *testing.T) { return qobuzFlow(request, qobuzScript{}) }) - id, secret, err := qobuzCredentials() + id, secret, err := qobuzFileCredentials() assert.Nil(t, err) assert.Equal(t, "123456789", id) - assert.Equal(t, "00000000000000000000000000000000", secret) + assert.Equal(t, []string{"00000000000000000000000000000000"}, secret) // the second call is served by the credentials cache: no new requests - id2, secret2, err2 := qobuzCredentials() + id2, secret2, err2 := qobuzFileCredentials() assert.Nil(t, err2) assert.Equal(t, id, id2) assert.Equal(t, secret, secret2) @@ -256,7 +317,7 @@ func TestQobuzCredentialsShellFailure(t *testing.T) { return qobuzFlow(request, qobuzScript{shellErr: errors.New("ko")}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } @@ -266,7 +327,7 @@ func TestQobuzCredentialsShellNonOK(t *testing.T) { return qobuzFlow(request, qobuzScript{shellStatus: 500}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } @@ -276,7 +337,7 @@ func TestQobuzCredentialsNoBundleScript(t *testing.T) { return qobuzFlow(request, qobuzScript{shellBody: ""}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } @@ -286,7 +347,7 @@ func TestQobuzCredentialsBundleFailure(t *testing.T) { return qobuzFlow(request, qobuzScript{bundleErr: errors.New("ko")}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } @@ -296,7 +357,7 @@ func TestQobuzCredentialsBundleNonOK(t *testing.T) { return qobuzFlow(request, qobuzScript{bundleStatus: 500}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } @@ -306,7 +367,7 @@ func TestQobuzCredentialsNoCredentialsInBundle(t *testing.T) { return qobuzFlow(request, qobuzScript{bundleBody: "var config={};"}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } @@ -316,7 +377,7 @@ func TestQobuzCredentialsShellReadFailure(t *testing.T) { return qobuzFlow(request, qobuzScript{shellReadErr: true}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } @@ -324,10 +385,10 @@ func TestQobuzCredentialsBundleRequestBuildFailure(t *testing.T) { resetQobuz(t) stubHTTP(t, func(request *http.Request) (*http.Response, error) { // a bundle URL with a control character makes http.NewRequest fail - return qobuzFlow(request, qobuzScript{shellBody: ""}) + return qobuzFlow(request, qobuzScript{shellBody: ""}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } @@ -337,35 +398,91 @@ func TestQobuzCredentialsBundleReadFailure(t *testing.T) { return qobuzFlow(request, qobuzScript{bundleReadErr: true}) }) - _, _, err := qobuzCredentials() + _, _, err := qobuzFileCredentials() assert.NotNil(t, err) } -func TestQobuzCDNURL(t *testing.T) { +func TestQobuzFileURL(t *testing.T) { resetQobuz(t) + setQobuzToken(t) callCount := 0 stubHTTP(t, func(request *http.Request) (*http.Response, error) { callCount++ - return qobuzFlow(request, qobuzScript{proxyBody: `{"url":"https://cdn.example/track.mp3"}`}) + return qobuzFlow(request, qobuzScript{fileBody: `{"url":"https://cdn.example/track.mp3","sample":false}`}) }) - cdnURL, err := qobuzCDNURL("424242") + fileURL, err := qobuzFileURL("424242") assert.Nil(t, err) - assert.Equal(t, "https://cdn.example/track.mp3", cdnURL) + assert.Equal(t, "https://cdn.example/track.mp3", fileURL) - // the second resolution is served by the CDN cache - cdnURL2, err2 := qobuzCDNURL("424242") + // the second resolution is served by the URL cache: the first one + // took three calls (shell, bundle, getFileUrl), the second none + fileURL2, err2 := qobuzFileURL("424242") assert.Nil(t, err2) - assert.Equal(t, cdnURL, cdnURL2) - assert.Equal(t, 1, callCount) + assert.Equal(t, fileURL, fileURL2) + assert.Equal(t, 3, callCount) } -func TestQobuzCDNURLAllFailed(t *testing.T) { +func TestQobuzFileURLFailure(t *testing.T) { resetQobuz(t) + setQobuzToken(t) stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyErr: errors.New("proxy down")}) + return qobuzFlow(request, qobuzScript{fileErr: errors.New("ko")}) }) - _, err := qobuzCDNURL("434343") + _, err := qobuzFileURL("434343") assert.NotNil(t, err) } + +// qobuzSeededBundle renders a web player bundle whose seed data +// derives two candidate secrets: berlin, the second timezone found +// and therefore the first candidate, decodes to "berlin-secret"; +// london decodes to "test-secret-value". +func qobuzSeededBundle() string { + return `var config={production:{api:{appId:"123456789",appSecret:"00000000000000000000000000000000"}}};` + + `a.initialSeed("dGVzdC1z",window.utimezone.london);` + + `b.initialSeed("YmVybGlu",window.utimezone.berlin);` + + `name:"app/London",info:"ZWNyZXQt",extras:"dmFsdWU=` + strings.Repeat("A", 44) + `";` + + `name:"app/Berlin",info:"LXNlY3Jl",extras:"dA==` + strings.Repeat("B", 44) + `";` +} + +func TestQobuzDeriveSecrets(t *testing.T) { + assert.Equal(t, + []string{"berlin-secret", "test-secret-value"}, + qobuzDeriveSecrets([]byte(qobuzSeededBundle()))) + assert.Empty(t, qobuzDeriveSecrets([]byte(qobuzBundleJS))) +} + +func TestQobuzFileURLSecondCandidate(t *testing.T) { + resetQobuz(t) + setQobuzToken(t) + attempts := 0 + stubHTTP(t, func(request *http.Request) (*http.Response, error) { + if request.URL.Host == "www.qobuz.com" { + attempts++ + query := request.URL.Query() + sigFor := func(secret string) string { + sigBytes := md5.Sum([]byte("trackgetFileUrl" + "format_id" + "5" + + "intent" + "stream" + "track_id" + "424242" + query.Get("request_ts") + secret)) + return fmt.Sprintf("%x", sigBytes) + } + switch query.Get("request_sig") { + case sigFor("berlin-secret"): + // the API refuses a signature built with the wrong candidate + return httpResponse(400, `{}`), nil + case sigFor("test-secret-value"): + return httpResponse(200, `{"url":"https://cdn.example/track.mp3","sample":false}`), nil + } + return httpResponse(500, `{}`), nil + } + return qobuzFlow(request, qobuzScript{bundleBody: qobuzSeededBundle()}) + }) + + // testing: the first candidate is rejected, the second resolves, + // and the accepted secret is remembered + fileURL, err := qobuzFileURL("424242") + assert.Nil(t, err) + assert.Equal(t, "https://cdn.example/track.mp3", fileURL) + assert.Equal(t, 2, attempts) + assert.Equal(t, "test-secret-value", qobuzFileSecret) +} diff --git a/provider/youtube.go b/provider/youtube.go index b667b88..7499d98 100644 --- a/provider/youtube.go +++ b/provider/youtube.go @@ -104,6 +104,10 @@ func init() { providers = append(providers, youTube{}) } +func (youTube) name() string { + return "youtube" +} + func sanitizeYouTubeQuery(q string) string { // YouTube interprets -word as exclusion operator, filter out hyphens // to avoid unintended query filtering @@ -129,7 +133,7 @@ func (provider youTube) search(track *entity.Track) ([]*Match, error) { // this won't resolve by retrying — fail fast and let the // caller's circuit breaker handle the cascade if strings.Contains(err.Error(), "stopped after") && strings.Contains(err.Error(), "redirects") { - return nil, false, fmt.Errorf("youtube: blocked by google captcha") + return nil, false, fmt.Errorf("blocked by google captcha") } return nil, false, err } @@ -139,7 +143,7 @@ func (provider youTube) search(track *entity.Track) ([]*Match, error) { sys.SleepUntilRetry(response.Header) return nil, true, nil } else if response.StatusCode != 200 { - return nil, false, errors.New("cannot fetch results on youtube: " + response.Status) + return nil, false, errors.New("cannot fetch results: " + response.Status) } result, parseErr := provider.parseResults(track, query, response.Body) @@ -150,7 +154,7 @@ func (provider youTube) search(track *entity.Track) ([]*Match, error) { } return matches, err } - return nil, errors.New("youtube: max retries exceeded") + return nil, errors.New("max retries exceeded") } func (provider youTube) parseResults(track *entity.Track, query string, body io.Reader) ([]*Match, error) { @@ -166,11 +170,12 @@ func (provider youTube) parseResults(track *entity.Track, query string, body io. return strings.TrimSuffix(strings.TrimSpace(selection.Text()[len(prefix):]), ";") }), "") if json == "" { - return []*Match{}, nil + return nil, errors.New("no search results data in page") } var ( matches []*Match + videos int data youTubeInitialData ) if err := jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal([]byte(json), &data); err != nil { @@ -178,6 +183,9 @@ func (provider youTube) parseResults(track *entity.Track, query string, body io. } for _, section := range data.Contents.TwoColumnSearchResultsRenderer.PrimaryContents.SectionListRenderer.Contents { for _, result := range section.ItemSectionRenderer.Contents { + if result.VideoRenderer.VideoID != "" { + videos++ + } for _, title := range result.VideoRenderer.Title.Runs { match := youTubeResult{ track: track, @@ -229,6 +237,9 @@ func (provider youTube) parseResults(track *entity.Track, query string, body io. } } } + if videos == 0 { + return nil, errors.New("no video results in search page") + } return matches, nil } diff --git a/provider/youtube_test.go b/provider/youtube_test.go index 0dbe6e2..7a50295 100644 --- a/provider/youtube_test.go +++ b/provider/youtube_test.go @@ -137,7 +137,7 @@ func TestYouTubeSearchMaxRetriesExceeded(t *testing.T) { }) // testing - assert.EqualError(t, sys.ErrOnly(youTube{}.search(track)), "youtube: max retries exceeded") + assert.EqualError(t, sys.ErrOnly(youTube{}.search(track)), "max retries exceeded") } func TestYouTubeSearchTooManyRequests(t *testing.T) { @@ -149,7 +149,7 @@ func TestYouTubeSearchTooManyRequests(t *testing.T) { response.Header.Set("Retry-After", "0") return response, nil } - return httpResponse(200, ""), nil + return httpResponse(200, youtubeResultPage()), nil }) // testing @@ -164,7 +164,7 @@ func TestYouTubeSearchRedirectLoop(t *testing.T) { }) // testing: captcha redirect fails fast without retrying - assert.EqualError(t, sys.ErrOnly(youTube{}.search(track)), "youtube: blocked by google captcha") + assert.EqualError(t, sys.ErrOnly(youTube{}.search(track)), "blocked by google captcha") } func TestYouTubeSearchNoData(t *testing.T) { @@ -172,8 +172,21 @@ func TestYouTubeSearchNoData(t *testing.T) { return httpResponse(200, ""), nil }) - // testing - assert.Nil(t, sys.ErrOnly(youTube{}.search(track))) + // testing: a page without results data is a provider failure, + // not a silent zero-match answer + assert.EqualError(t, sys.ErrOnly(youTube{}.search(track)), "no search results data in page") +} + +func TestYouTubeSearchNoVideos(t *testing.T) { + stubHTTP(t, func(*http.Request) (*http.Response, error) { + return httpResponse(200, ``), nil + }) + + // testing: results data with no video renderer (for example a + // no-results promo) is a provider failure, not a silent miss + assert.EqualError(t, sys.ErrOnly(youTube{}.search(track)), "no video results in search page") } func TestYouTubeSearchFailingRequest(t *testing.T) {