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 @@

-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(`