diff --git a/.specify/memory/architecture.md b/.specify/memory/architecture.md index 965b449..65db37e 100644 --- a/.specify/memory/architecture.md +++ b/.specify/memory/architecture.md @@ -81,9 +81,6 @@ the artwork URL, the duration, and the upstream URL. environment variables, or release-time fallbacks. - **YouTube** (package `provider`): asset search and match scoring. -- **Qobuz** (package `provider`): catalog search through the - official API; stream resolution through third-party proxy - services. - **Genius** (package `lyrics`): search through the Genius API and lyrics extraction from the result page. The credential is the `GENIUS_TOKEN` environment variable or the release-time diff --git a/cmd/harness_test.go b/cmd/harness_test.go index d2816e6..de48537 100644 --- a/cmd/harness_test.go +++ b/cmd/harness_test.go @@ -174,9 +174,8 @@ type webScript struct { noLyrics bool // lrclib answers with empty lyrics lyricsLeadBlank bool // lrclib answers lyrics with leading blank lines artworkErr bool // the artwork URL answers 404 - providerErr bool // youtube and qobuz both fail + providerErr bool // the search provider fails noResults bool // providers answer successfully but find nothing - qobuzEmpty bool // qobuz finds nothing, youtube carries the search } // installWeb installs the Spotify router plus the scripted web fixtures @@ -194,12 +193,6 @@ 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": - return qobuzBundleResponse(web, request) - case "www.qobuz.com": - return qobuzSearchResponse(web), nil - case "dabmusic.xyz": - return dabResponse(web) case "ima.ge": // the artwork blob download if web.artworkErr { @@ -245,30 +238,6 @@ func youtubeResponse(web webScript) (*http.Response, error) { return rawResponse(200, "text/html", []byte(youtubePage())), 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, "/track/1") { - return rawResponse(200, "text/html", []byte(``)), nil - } - return rawResponse(200, "text/javascript", []byte(`var c={app_id:"123456789",app_secret:"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) { - if web.providerErr { - return nil, errors.New("ko") - } - return jsonResponse(200, `{"url":"https://cdn.qobuz.example/track.mp3"}`), nil -} - // jpegBytes encodes a real 1x1 JPEG: the artwork pipeline decodes the // downloaded picture, so placeholder bytes would not pass. func jpegBytes(t *testing.T) []byte { diff --git a/cmd/show_test.go b/cmd/show_test.go index 3537836..e208e5e 100644 --- a/cmd/show_test.go +++ b/cmd/show_test.go @@ -97,7 +97,9 @@ func TestCmdShowLyricsLeadingBlankLines(t *testing.T) { assert.Nil(t, sys.ErrOnly(testExecute(cmdShow(), path))) }) for _, line := range strings.Split(output, "\n") { - if strings.Contains(line, "Lyrics") { + // match the row by its label: the temporary path embedded in + // the Path row contains the test name, hence "Lyrics" itself + if strings.HasPrefix(strings.TrimSpace(line), "Lyrics") { assert.Contains(t, line, "first lyric line") assert.NotContains(t, line, fallback) return diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 1b08fa4..f7d61fa 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -82,7 +82,7 @@ func TestCmdSync(t *testing.T) { // the library carries the fixture track twice: the duplicate is // skipped by the decider "/v1/me/tracks": func(*http.Request) (int, string) { return 200, libraryBody("123", "123") }, - }, webScript{qobuzEmpty: true}) + }, webScript{}) // testing: with no collection supplied, the library is auto-enabled cmd := cmdSync() @@ -92,7 +92,7 @@ func TestCmdSync(t *testing.T) { assert.True(t, library) // testing: a full sync across every collection kind - musicDir, _ = syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ = syncEnv(t, nil, webScript{}) assert.Nil(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-l", "-p", "Mix", "-a", "album123", "-t", "123"))) _, err = os.Stat(filepath.Join(musicDir, "Artist - Title.mp3")) @@ -105,7 +105,7 @@ func TestCmdSync(t *testing.T) { } func TestCmdSyncLyricsLeadingBlankLines(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true, lyricsLeadBlank: true}) + musicDir, _ := syncEnv(t, nil, webScript{lyricsLeadBlank: true}) // testing: lyrics that arrive with leading blank lines print // their first content line, not the not-found fallback @@ -127,7 +127,7 @@ func TestCmdSyncInvalidEnvironment(t *testing.T) { } func TestCmdSyncOfflineIndex(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) writeTaggedFile(t, filepath.Join(musicDir, "Artist - Title.mp3"), "123") before, err := os.ReadFile(filepath.Join(musicDir, "Artist - Title.mp3")) assert.Nil(t, err) @@ -193,7 +193,7 @@ func TestRoutineAuthUsername(t *testing.T) { func TestCmdSyncLibraryFailure(t *testing.T) { musicDir, _ := syncEnv(t, map[string]func(*http.Request) (int, string){ "/v1/me/tracks": func(*http.Request) (int, string) { return apiError(500, "ko") }, - }, webScript{qobuzEmpty: true}) + }, webScript{}) // testing assert.EqualError(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-l")), "ko") @@ -202,7 +202,7 @@ func TestCmdSyncLibraryFailure(t *testing.T) { func TestCmdSyncPlaylistFailure(t *testing.T) { musicDir, _ := syncEnv(t, map[string]func(*http.Request) (int, string){ "/v1/playlists/": func(*http.Request) (int, string) { return apiError(500, "ko") }, - }, webScript{qobuzEmpty: true}) + }, webScript{}) // testing assert.EqualError(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-p", "Mix")), "ko") @@ -211,7 +211,7 @@ func TestCmdSyncPlaylistFailure(t *testing.T) { func TestCmdSyncAlbumFailure(t *testing.T) { musicDir, _ := syncEnv(t, map[string]func(*http.Request) (int, string){ "/v1/albums/": func(*http.Request) (int, string) { return apiError(500, "ko") }, - }, webScript{qobuzEmpty: true}) + }, webScript{}) // testing assert.EqualError(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-a", "album123")), "ko") @@ -220,14 +220,14 @@ func TestCmdSyncAlbumFailure(t *testing.T) { func TestCmdSyncTrackFailure(t *testing.T) { musicDir, _ := syncEnv(t, map[string]func(*http.Request) (int, string){ "/v1/tracks/": func(*http.Request) (int, string) { return apiError(500, "ko") }, - }, webScript{qobuzEmpty: true}) + }, webScript{}) // testing assert.EqualError(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-t", "123")), "ko") } func TestCmdSyncFix(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) stale := filepath.Join(musicDir, "old name.mp3") writeTaggedFile(t, stale, "123") @@ -241,7 +241,7 @@ func TestCmdSyncFix(t *testing.T) { } func TestCmdSyncFixOpenFailure(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) // testing: the file to fix does not exist missing := filepath.Join(musicDir, "missing.mp3") @@ -249,7 +249,7 @@ func TestCmdSyncFixOpenFailure(t *testing.T) { } func TestCmdSyncFixSpotifyIDFailure(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) untagged := filepath.Join(musicDir, "untagged.mp3") writeTaggedFile(t, untagged, "") @@ -259,7 +259,7 @@ func TestCmdSyncFixSpotifyIDFailure(t *testing.T) { } func TestCmdSyncDecideManual(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) feedStdin(t, "https://www.youtube.com/watch?v=abc123\n") // testing: the user-issued URL drives the download @@ -269,7 +269,7 @@ func TestCmdSyncDecideManual(t *testing.T) { } func TestCmdSyncDecideManualEmpty(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) feedStdin(t, "\n") // testing: an empty URL skips the track without failing the sync @@ -279,7 +279,7 @@ func TestCmdSyncDecideManualEmpty(t *testing.T) { } func TestCmdSyncDecideManualCollision(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) writeTaggedFile(t, filepath.Join(musicDir, "Artist - Title.mp3"), "other-id") feedStdin(t, "\n") @@ -291,7 +291,7 @@ func TestCmdSyncDecideManualCollision(t *testing.T) { func TestCmdSyncDecideDuplicateInstalled(t *testing.T) { musicDir, _ := syncEnv(t, map[string]func(*http.Request) (int, string){ "/v1/me/tracks": func(*http.Request) (int, string) { return 200, libraryBody("123", "123") }, - }, webScript{qobuzEmpty: true}) + }, webScript{}) // testing: the second copy of the track is skipped as a duplicate assert.Nil(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-l"))) @@ -300,7 +300,7 @@ func TestCmdSyncDecideDuplicateInstalled(t *testing.T) { } func TestCmdSyncFatalCollisionUnwinds(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) writeTaggedFile(t, filepath.Join(musicDir, "Artist - Title.mp3"), "other-id") // testing: the collision error aborts the pipeline, which unwinds @@ -310,7 +310,7 @@ func TestCmdSyncFatalCollisionUnwinds(t *testing.T) { } func TestCmdSyncDecideFilenameCollision(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) writeTaggedFile(t, filepath.Join(musicDir, "Artist - Title.mp3"), "other-id") // testing @@ -319,7 +319,7 @@ func TestCmdSyncDecideFilenameCollision(t *testing.T) { } func TestCmdSyncIgnoreCollisionsCompletes(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) existing := filepath.Join(musicDir, "Artist - Title.mp3") writeTaggedFile(t, existing, "other-id") before, err := os.ReadFile(existing) @@ -446,7 +446,7 @@ func TestRoutineProcessFailure(t *testing.T) { } func TestCmdSyncProcessorFailure(t *testing.T) { - musicDir, binDir := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, binDir := syncEnv(t, nil, webScript{}) // the track measures far from the loudness target and the // normalization encode fails: a hard processing failure assert.Nil(t, os.WriteFile(filepath.Join(binDir, "ffmpeg.far"), nil, 0o644)) @@ -457,7 +457,7 @@ func TestCmdSyncProcessorFailure(t *testing.T) { } func TestRoutineProcessLoudnessSkipped(t *testing.T) { - musicDir, binDir := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, binDir := syncEnv(t, nil, webScript{}) // ffmpeg answers without the loudnorm JSON summary: the loudness // measurement is skipped and the sync still completes assert.Nil(t, os.WriteFile(filepath.Join(binDir, "ffmpeg.badjson"), nil, 0o644)) @@ -542,7 +542,7 @@ func TestCmdSyncDecideNotFound(t *testing.T) { func TestCmdSyncDecideParallelFanout(t *testing.T) { musicDir, _ := syncEnv(t, map[string]func(*http.Request) (int, string){ "/v1/me/tracks": func(*http.Request) (int, string) { return 200, libraryBody("123", "456", "789") }, - }, webScript{qobuzEmpty: true}) + }, webScript{}) // testing: parallel deciders install every fetched track assert.Nil(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-l"))) @@ -553,7 +553,7 @@ func TestCmdSyncDecideParallelFanout(t *testing.T) { } func TestCmdSyncCollectFailure(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true, artworkErr: true}) + musicDir, _ := syncEnv(t, nil, webScript{artworkErr: true}) // testing: an artwork download failure drops the track during // collection, without failing the sync @@ -563,7 +563,7 @@ func TestCmdSyncCollectFailure(t *testing.T) { } func TestCmdSyncDownloadFailure(t *testing.T) { - musicDir, binDir := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, binDir := syncEnv(t, nil, webScript{}) assert.Nil(t, os.WriteFile(filepath.Join(binDir, "ytdlp.fail"), nil, 0o644)) // testing: a failing download drops the track, not the sync @@ -573,7 +573,7 @@ func TestCmdSyncDownloadFailure(t *testing.T) { } func TestCmdSyncLyricsFailure(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true, lyricsErr: true}) + musicDir, _ := syncEnv(t, nil, webScript{lyricsErr: true}) // testing: lyrics are a nice-to-have, the track still installs assert.Nil(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-l"))) @@ -604,7 +604,7 @@ func TestCmdSyncInstallerFailure(t *testing.T) { } func TestCmdSyncPlaylistEncoderFailure(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) // testing: an unknown playlist encoding skips the mix without // failing the sync @@ -621,7 +621,7 @@ func TestCmdSyncPlaylistEncoderFailure(t *testing.T) { } func TestCmdSyncPlaylistEncoderCloseFailure(t *testing.T) { - musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true}) + musicDir, _ := syncEnv(t, nil, webScript{}) // the playlist target path is occupied by a directory, so the // encoder cannot write the playlist file on close diff --git a/docs/about.md b/docs/about.md index 13ab40f..27708b1 100644 --- a/docs/about.md +++ b/docs/about.md @@ -2,7 +2,7 @@ ![demo](assets/demo.gif) -Pango (originally Spotitube) is a CLI application to authenticate to Spotify account, fetch music collections — such as account library, playlists, albums or specific tracks —, look them up on a defined set of providers (currently YouTube and Qobuz), download them and inflate the downloaded assets with metadata collected from Spotify. +Pango (originally Spotitube) is a CLI application to authenticate to Spotify account, fetch music collections — such as account library, playlists, albums or specific tracks —, look them up on a defined set of providers (currently YouTube), download them and inflate the downloaded assets with metadata collected from Spotify. Downloaded tracks are further enriched with lyrics fetched from Genius and LRCLIB, including synced LRC when available. diff --git a/docs/design.md b/docs/design.md index 4fabb5f..fa4d3b3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -31,7 +31,7 @@ That data is then parsed into a custom Track object which is passed to the Decid ## Decider -For each Track passed over by the Fetcher, it queries every provider defined (currently YouTube and Qobuz), looking for a result that best matches the given track data. +For each Track passed over by the Fetcher, it queries every provider defined (currently YouTube), looking for a result that best matches the given track data. ## Collector diff --git a/provider/provider_test.go b/provider/provider_test.go index d1239a3..30ac830 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -31,20 +31,24 @@ func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) } // stubHTTP routes all provider HTTP traffic through the given handler: -// the shared default transport (youtube) and the qobuz client transport -// are both replaced for the duration of the test. +// the shared default transport is replaced for the duration of the test. func stubHTTP(t *testing.T, handler func(*http.Request) (*http.Response, error)) { t.Helper() previousDefault := http.DefaultTransport - previousQobuz := qobuzHTTPClient.Transport http.DefaultTransport = roundTripFunc(handler) - qobuzHTTPClient.Transport = roundTripFunc(handler) t.Cleanup(func() { http.DefaultTransport = previousDefault - qobuzHTTPClient.Transport = previousQobuz }) } +// failingProvider is a provider whose search always fails, to exercise +// the partial failure path of Search alongside a working provider. +type failingProvider struct{} + +func (failingProvider) search(*entity.Track) ([]*Match, error) { + return nil, errors.New("ko") +} + func httpResponse(status int, body string) *http.Response { return &http.Response{ StatusCode: status, @@ -67,14 +71,11 @@ func BenchmarkProvider(b *testing.B) { } func TestSearch(t *testing.T) { - resetQobuz(t) stubHTTP(t, func(request *http.Request) (*http.Response, error) { - switch request.URL.Host { - case "www.youtube.com": + if request.URL.Host == "www.youtube.com" { return httpResponse(200, youtubeResultPage()), nil - default: - return qobuzFlow(request, qobuzScript{}) } + return nil, errors.New("unexpected host: " + request.URL.Host) }) // testing @@ -84,7 +85,6 @@ func TestSearch(t *testing.T) { } func TestSearchFailure(t *testing.T) { - resetQobuz(t) stubHTTP(t, func(*http.Request) (*http.Response, error) { return nil, errors.New("ko") }) @@ -95,12 +95,14 @@ func TestSearchFailure(t *testing.T) { } func TestSearchPartialFailure(t *testing.T) { - resetQobuz(t) + previous := providers + providers = append(append([]Provider{}, providers...), failingProvider{}) + t.Cleanup(func() { providers = previous }) stubHTTP(t, func(request *http.Request) (*http.Response, error) { if request.URL.Host == "www.youtube.com" { - return nil, errors.New("ko") + return httpResponse(200, youtubeResultPage()), nil } - return qobuzFlow(request, qobuzScript{}) + return nil, errors.New("unexpected host: " + request.URL.Host) }) // one provider succeeded → return its matches, no error diff --git a/provider/qobuz.go b/provider/qobuz.go deleted file mode 100644 index f12f236..0000000 --- a/provider/qobuz.go +++ /dev/null @@ -1,226 +0,0 @@ -package provider - -import ( - "encoding/json" - "fmt" - "hash/fnv" - "io" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "github.com/streambinder/pango/entity" -) - -const ( - qobuzAPIBase = "https://www.qobuz.com/api.json/0.2" - qobuzOpenShellURL = "https://open.qobuz.com/track/1" - qobuzQualityMP3 = "5" -) - -var ( - qobuzProxies = []string{ - "https://dabmusic.xyz/api/stream?trackId=%s&quality=" + qobuzQualityMP3, - } - 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 - - // cdn url cache: trackID → url, avoids re-resolving the same track - // across playlists within a single sync run - qobuzCDNCache sync.Map -) - -type qobuz struct{} - -func init() { - providers = append(providers, qobuz{}) -} - -func (qobuz) search(track *entity.Track) ([]*Match, error) { - trackID, err := qobuzSearchTrack(track) - if err != nil { - return nil, err - } - if trackID == 0 { - return nil, nil - } - - cdnURL, err := qobuzCDNURL(strconv.FormatInt(trackID, 10)) - if err != nil { - return nil, err - } - - return []*Match{{URL: cdnURL, Score: 100}}, nil -} - -func qobuzCredentials() (string, string, error) { - qobuzCredMu.Lock() - defer qobuzCredMu.Unlock() - - if qobuzCachedID != "" && qobuzCachedSecret != "" { - return qobuzCachedID, qobuzCachedSecret, nil - } - - req, err := http.NewRequest(http.MethodGet, qobuzOpenShellURL, nil) - if err != nil { - return "", "", err - } - req.Header.Set("User-Agent", "Mozilla/5.0") - - resp, err := qobuzHTTPClient.Do(req) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("qobuz: shell returned %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", err - } - - scriptMatch := qobuzBundleScriptPattern.FindSubmatch(body) - if scriptMatch == nil { - return "", "", fmt.Errorf("qobuz: bundle script not found in shell") - } - - bundleURL := string(scriptMatch[1]) - if strings.HasPrefix(bundleURL, "/") { - bundleURL = "https://open.qobuz.com" + bundleURL - } - - bundleReq, err := http.NewRequest(http.MethodGet, bundleURL, nil) - if err != nil { - return "", "", err - } - bundleReq.Header.Set("User-Agent", "Mozilla/5.0") - - bundleResp, err := qobuzHTTPClient.Do(bundleReq) - if err != nil { - return "", "", 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 - } - - 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 -} - -func qobuzSearchTrack(track *entity.Track) (int64, error) { - appID, appSecret, err := qobuzCredentials() - if err != nil { - return 0, err - } - - 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)) - - params := url.Values{ - "query": {query}, - "limit": {"1"}, - "app_id": {appID}, - "request_ts": {ts}, - "request_sig": {sig}, - } - - req, err := http.NewRequest(http.MethodGet, qobuzAPIBase+"/track/search?"+params.Encode(), nil) - if err != nil { - return 0, err - } - req.Header.Set("User-Agent", "Mozilla/5.0") - req.Header.Set("X-App-Id", appID) - req.Header.Set("Accept", "application/json") - - resp, err := qobuzHTTPClient.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return 0, fmt.Errorf("qobuz: search returned %s", resp.Status) - } - - var payload struct { - Tracks struct { - Items []struct { - ID int64 `json:"id"` - Performer struct { - Name string `json:"name"` - } `json:"performer"` - } `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return 0, err - } - - items := payload.Tracks.Items - if len(items) == 0 { - return 0, nil - } - - 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 { - return cached.(string), nil - } - - for _, proxy := range qobuzProxies { - resp, err := qobuzHTTPClient.Get(fmt.Sprintf(proxy, trackID)) - if err != nil { - continue - } - - var payload struct { - URL string `json:"url"` - } - err = json.NewDecoder(resp.Body).Decode(&payload) - resp.Body.Close() - - if err == nil && payload.URL != "" { - qobuzCDNCache.Store(trackID, payload.URL) - return payload.URL, nil - } - } - - return "", fmt.Errorf("qobuz: all proxies failed for track %s", trackID) -} diff --git a/provider/qobuz_test.go b/provider/qobuz_test.go deleted file mode 100644 index facb510..0000000 --- a/provider/qobuz_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package provider - -import ( - "errors" - "net/http" - "sync" - "testing" - - "github.com/stretchr/testify/assert" -) - -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"}` -) - -// 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. -type qobuzScript struct { - shellStatus int - shellBody string - shellErr error - shellReadErr bool - bundleStatus int - bundleBody string - bundleErr error - bundleReadErr bool - searchStatus int - searchBody string - searchErr error - proxyStatus int - proxyBody string - proxyErr error -} - -func qobuzStage(status int, body string, err error, readErr bool) (*http.Response, error) { - if err != nil { - return nil, err - } - if status == 0 { - status = 200 - } - response := httpResponse(status, body) - if readErr { - response.Body = errReader{errors.New("ko")} - } - 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": - body := script.shellBody - if body == "" { - body = qobuzShellHTML - } - return qobuzStage(script.shellStatus, body, script.shellErr, script.shellReadErr) - case request.URL.Host == "open.qobuz.com": - body := script.bundleBody - if body == "" { - body = qobuzBundleJS - } - return qobuzStage(script.bundleStatus, body, script.bundleErr, script.bundleReadErr) - 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. -func resetQobuz(t *testing.T) { - t.Helper() - reset := func() { - qobuzCredMu.Lock() - defer qobuzCredMu.Unlock() - qobuzCachedID = "" - qobuzCachedSecret = "" - qobuzCDNCache = sync.Map{} - } - reset() - t.Cleanup(reset) -} - -func BenchmarkQobuz(b *testing.B) { - for i := 0; i < b.N; i++ { - TestQobuzSearch(&testing.T{}) - } -} - -func TestQobuzSearch(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{}) - }) - - matches, err := qobuz{}.search(track) - assert.Nil(t, err) - assert.Len(t, matches, 1) - assert.Equal(t, 100, matches[0].Score) - assert.Equal(t, "https://cdn.qobuz.example/track.mp3", matches[0].URL) -} - -func TestQobuzSearchCacheHit(t *testing.T) { - resetQobuz(t) - qobuzCDNCache.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")}) - }) - - matches, err := qobuz{}.search(track) - assert.Nil(t, err) - assert.Len(t, matches, 1) - assert.Equal(t, "https://cached.example/track.mp3", matches[0].URL) -} - -func TestQobuzSearchCredentialsFailure(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{shellErr: errors.New("ko")}) - }) - - matches, err := qobuz{}.search(track) - assert.NotNil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzSearchRequestFailure(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{searchErr: errors.New("ko")}) - }) - - matches, err := qobuz{}.search(track) - assert.NotNil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzSearchNonOKStatus(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{searchStatus: 500}) - }) - - matches, err := qobuz{}.search(track) - assert.NotNil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzSearchMalformedResponse(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{searchBody: `{not json}`}) - }) - - matches, err := qobuz{}.search(track) - assert.NotNil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzSearchNoItems(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{searchBody: `{"tracks":{"items":[]}}`}) - }) - - matches, err := qobuz{}.search(track) - assert.Nil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzSearchAllProxiesFailed(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyErr: errors.New("proxy down")}) - }) - - matches, err := qobuz{}.search(track) - assert.NotNil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzSearchProxyBadJSON(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyBody: "not json"}) - }) - - matches, err := qobuz{}.search(track) - assert.NotNil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzSearchProxyEmptyURL(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyBody: `{"url":""}`}) - }) - - matches, err := qobuz{}.search(track) - assert.NotNil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzSearchProxyNonOKStatus(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyStatus: 500, proxyBody: "error page"}) - }) - - matches, err := qobuz{}.search(track) - assert.NotNil(t, err) - assert.Nil(t, matches) -} - -func TestQobuzCredentials(t *testing.T) { - resetQobuz(t) - callCount := 0 - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - callCount++ - return qobuzFlow(request, qobuzScript{}) - }) - - id, secret, err := qobuzCredentials() - assert.Nil(t, err) - assert.Equal(t, "123456789", id) - assert.Equal(t, "00000000000000000000000000000000", secret) - - // the second call is served by the credentials cache: no new requests - id2, secret2, err2 := qobuzCredentials() - assert.Nil(t, err2) - assert.Equal(t, id, id2) - assert.Equal(t, secret, secret2) - assert.Equal(t, 2, callCount) -} - -func TestQobuzCredentialsShellFailure(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{shellErr: errors.New("ko")}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -func TestQobuzCredentialsShellNonOK(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{shellStatus: 500}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -func TestQobuzCredentialsNoBundleScript(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{shellBody: ""}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -func TestQobuzCredentialsBundleFailure(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{bundleErr: errors.New("ko")}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -func TestQobuzCredentialsBundleNonOK(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{bundleStatus: 500}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -func TestQobuzCredentialsNoCredentialsInBundle(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{bundleBody: "var config={};"}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -func TestQobuzCredentialsShellReadFailure(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{shellReadErr: true}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -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: ""}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -func TestQobuzCredentialsBundleReadFailure(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{bundleReadErr: true}) - }) - - _, _, err := qobuzCredentials() - assert.NotNil(t, err) -} - -func TestQobuzCDNURL(t *testing.T) { - resetQobuz(t) - callCount := 0 - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - callCount++ - return qobuzFlow(request, qobuzScript{proxyBody: `{"url":"https://cdn.example/track.mp3"}`}) - }) - - cdnURL, err := qobuzCDNURL("424242") - assert.Nil(t, err) - assert.Equal(t, "https://cdn.example/track.mp3", cdnURL) - - // the second resolution is served by the CDN cache - cdnURL2, err2 := qobuzCDNURL("424242") - assert.Nil(t, err2) - assert.Equal(t, cdnURL, cdnURL2) - assert.Equal(t, 1, callCount) -} - -func TestQobuzCDNURLAllFailed(t *testing.T) { - resetQobuz(t) - stubHTTP(t, func(request *http.Request) (*http.Response, error) { - return qobuzFlow(request, qobuzScript{proxyErr: errors.New("proxy down")}) - }) - - _, err := qobuzCDNURL("434343") - assert.NotNil(t, err) -} diff --git a/specs/001-lyrics-reporting/plan.md b/specs/001-lyrics-reporting/plan.md index 2629f84..c8e1700 100644 --- a/specs/001-lyrics-reporting/plan.md +++ b/specs/001-lyrics-reporting/plan.md @@ -54,7 +54,7 @@ stub every network dependency; the exported surface of the - **Normalization point**: one helper at the lyrics boundary, not one fix per composer. A new composer can emit the same leading - blank lines tomorrow; the boundary rule covers every source, + empty lines tomorrow; the boundary rule covers every source, the legacy cache, and both entry points in one place. - **Normalization form**: strip the leading whitespace run. A synchronized text starts with a timestamp bracket and carries no @@ -90,7 +90,7 @@ sys/string.go # FirstContentLine next to FirstLine sys/string_test.go # projection cases cmd/sync.go # collect log line uses the new projection cmd/show.go # Lyrics row uses the new projection -cmd/show_test.go # stored frame with leading blank lines +cmd/show_test.go # stored frame with leading empty lines cmd/sync_test.go # end-to-end lyrics line through a scripted source -cmd/harness_test.go # the web script gains a leading-blank lyrics answer +cmd/harness_test.go # the web script gains a leading-empty lyrics answer ``` diff --git a/specs/001-lyrics-reporting/spec.md b/specs/001-lyrics-reporting/spec.md index e8c437d..2f85e06 100644 --- a/specs/001-lyrics-reporting/spec.md +++ b/specs/001-lyrics-reporting/spec.md @@ -26,7 +26,7 @@ machine of the owner, and by reproduction): the full lyrics text, and the synchronization itself had written the file during the run. `lyrics.Search` writes the cache only on a hit, so the search had found the text. -2. The cached text begins with a blank line. The page parser of one +2. The cached text begins with an empty line. The page parser of one lyrics composer emits a line break before the first text node, so every text from that composer starts with an empty first line. @@ -41,7 +41,7 @@ machine of the owner, and by reproduction): installed file kept its size across its runs while its timestamps moved: the persistence had worked in both tools. 5. Reproduction: a file that carries a lyrics frame whose text - begins with a blank line reads the full text back through the + begins with an empty line reads the full text back through the ID3 layer, and `show` reports the frame as unset. **Assumed cause check**: The cause is not a source outage: the @@ -55,7 +55,7 @@ states the rule in the section Honest Reporting. **Outcome**: Specify a smaller correction than the report implied. No change to the search and no change to the persistence. The -lyrics boundary returns text without leading blank lines, and both +lyrics boundary returns text without leading empty lines, and both displays read the first non-empty line. ## User Scenarios and Testing @@ -70,14 +70,14 @@ tool. A false negative hides every real success and sends the owner to debug a healthy stage. **Independent Test**: Run a synchronization against a scripted -lyrics source that answers a text with a leading blank line. The +lyrics source that answers a text with a leading empty line. The printed lyrics line carries the first content line, not the not-found fallback. **Acceptance Scenarios**: 1. **Given** a lyrics source that returns a text starting with - blank lines, **When** the collect stage processes the track, + empty lines, **When** the collect stage processes the track, **Then** the run output shows the first non-empty line of the text. 2. **Given** a lyrics source that returns no text, **When** the @@ -90,16 +90,16 @@ The owner runs `show` on an installed track. The track carries a lyrics frame. The Lyrics row shows an excerpt of the stored text. **Why this priority**: Stored frames from runs before the fix keep -the leading blank line. Normalizing new results alone cannot repair +the leading empty line. Normalizing new results alone cannot repair the reporting for the existing library. **Independent Test**: Write a fixture file whose lyrics frame -starts with blank lines and run `show`. The Lyrics row carries the +starts with empty lines and run `show`. The Lyrics row carries the first non-empty line, not the unset fallback. **Acceptance Scenarios**: -1. **Given** a stored lyrics frame that starts with blank lines, +1. **Given** a stored lyrics frame that starts with empty lines, **When** the owner runs `show`, **Then** the Lyrics row shows the first non-empty line of the frame. 2. **Given** a stored track without a lyrics frame, **When** the @@ -117,18 +117,18 @@ write it into files. A shared normalization at the boundary repairs every consumer at once. **Independent Test**: Stub a composer that returns a text starting -with blank lines. `Search` and `Get` return the text without the -leading blank lines, and the cache file holds the normalized text. +with empty lines. `Search` and `Get` return the text without the +leading empty lines, and the cache file holds the normalized text. **Acceptance Scenarios**: -1. **Given** a composer that returns a text starting with blank +1. **Given** a composer that returns a text starting with empty lines, **When** a consumer calls `Search`, **Then** the result and the cache file start at the first content line. 2. **Given** a cache file written by an earlier version, with - leading blank lines, **When** a consumer calls `Search`, + leading empty lines, **When** a consumer calls `Search`, **Then** the result starts at the first content line. -3. **Given** a cache file that holds only blank lines, **When** a +3. **Given** a cache file that holds only empty lines, **When** a consumer calls `Search`, **Then** the cache counts as a miss and the composers run. @@ -137,10 +137,10 @@ leading blank lines, and the cache file holds the normalized text. ### Functional Requirements - **FR-001**: `lyrics.Search` and `lyrics.Get` return a lyrics - text with no leading blank lines. + text with no leading empty lines. - **FR-002**: The lyrics cache stores the normalized text, and a cache read normalizes a legacy entry. A cache entry that holds - only blank lines counts as a miss. + only empty lines counts as a miss. - **FR-003**: The collect log line and the `show` Lyrics row excerpt the first non-empty line of the value. The fallback text appears only for an actually empty value. @@ -161,12 +161,12 @@ leading blank lines, and the cache file holds the normalized text. ### Measurable Outcomes - **SC-001**: Unit tests prove that a composer result with leading - blank lines yields a normalized result and a normalized cache + empty lines yields a normalized result and a normalized cache file from `Search`, and a normalized result from `Get`. - **SC-002**: A command test proves that `show` prints the first - content line for a stored frame with leading blank lines. + content line for a stored frame with leading empty lines. - **SC-003**: A command test proves that a full synchronization - whose lyrics source answers with leading blank lines prints the + whose lyrics source answers with leading empty lines prints the first content line, not the not-found fallback. - **SC-004**: The full test suite passes with the per-package coverage gate at 90 percent, locally and in CI. diff --git a/specs/001-lyrics-reporting/tasks.md b/specs/001-lyrics-reporting/tasks.md index dd43fa4..81575e3 100644 --- a/specs/001-lyrics-reporting/tasks.md +++ b/specs/001-lyrics-reporting/tasks.md @@ -33,8 +33,8 @@ content line. Story 1 depends on this phase through `Search`. result in `Get` - [x] T004 [US3] Cover the normalization in `lyrics/composer_test.go`: a composer result with leading - blank lines, the cache file content, a legacy cache entry, - a blank-only cache entry, and a `Get` result + empty lines, the cache file content, a legacy cache entry, + an empty-only cache entry, and a `Get` result ## Phase 3: Display Projection (Stories 1 and 2) @@ -50,10 +50,10 @@ the fallback fires only for an empty value. new projection - [x] T008 [US2] Cover the stored-frame case in `cmd/show_test.go`: a fixture whose lyrics frame starts - with blank lines prints the first content line + with empty lines prints the first content line - [x] T009 [US1] Cover the run output in `cmd/sync_test.go`: the web script in `cmd/harness_test.go` gains a lyrics answer - with leading blank lines, and the printed lyrics line + with leading empty lines, and the printed lyrics line carries the first content line ## Phase 4: Verification