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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .specify/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 1 addition & 32 deletions cmd/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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(`<script src="/resources/1.0/js/main.js"></script>`)), 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 {
Expand Down
4 changes: 3 additions & 1 deletion cmd/show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 26 additions & 26 deletions cmd/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"))
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")

Expand All @@ -241,15 +241,15 @@ 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")
assert.NotNil(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-f", missing)))
}

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, "")

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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")

Expand All @@ -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")))
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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")))
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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")))
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 16 additions & 14 deletions provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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")
})
Expand All @@ -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
Expand Down
Loading
Loading