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
16 changes: 10 additions & 6 deletions cmd/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,13 @@ func rawResponse(status int, contentType string, body []byte) *http.Response {
// webScript scripts the non-Spotify web boundaries: the lyrics providers,
// the search providers and artwork downloads.
type webScript struct {
lyricsErr bool // lrclib and genius both fail
noLyrics bool // lrclib answers with empty lyrics
artworkErr bool // the artwork URL answers 404
providerErr bool // youtube and qobuz both fail
noResults bool // providers answer successfully but find nothing
qobuzEmpty bool // qobuz finds nothing, youtube carries the search
lyricsErr bool // lrclib and genius both fail
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
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 Down Expand Up @@ -221,6 +222,9 @@ func lrclibResponse(web webScript) (*http.Response, error) {
if web.noLyrics {
return jsonResponse(200, `{"plainLyrics":"","syncedLyrics":""}`), nil
}
if web.lyricsLeadBlank {
return jsonResponse(200, "{\"plainLyrics\":\"\\n\\nsome lyrics\",\"syncedLyrics\":\"\"}"), nil
}
return jsonResponse(200, `{"plainLyrics":"some lyrics","syncedLyrics":""}`), nil
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func cmdShow() *cobra.Command {
return d + "s"
}(tag.Duration()))
fmt.Fprintln(table, "Upstream URL\t", sys.Fallback(tag.UpstreamURL(), fallback))
fmt.Fprintln(table, "Lyrics\t", sys.Fallback(sys.Excerpt(sys.FirstLine(tag.UnsynchronizedLyrics()), 64), fallback))
fmt.Fprintln(table, "Lyrics\t", sys.Fallback(sys.Excerpt(sys.FirstContentLine(tag.UnsynchronizedLyrics()), 64), fallback))
fmt.Fprintln(table, "Artwork\t", func(mimeType string, data []byte) string {
if len(data) > 0 {
return fmt.Sprintf("%s (%s)", mimeType, sys.HumanizeBytes(len(data)))
Expand Down
44 changes: 44 additions & 0 deletions cmd/show_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cmd

import (
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/bogem/id3v2/v2"
Expand All @@ -11,6 +13,23 @@ import (
"github.com/stretchr/testify/assert"
)

// captureStdout returns what fn writes to the standard output.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
reader, writer, err := os.Pipe()
assert.Nil(t, err)
previous := os.Stdout
os.Stdout = writer
t.Cleanup(func() { os.Stdout = previous })

fn()

assert.Nil(t, writer.Close())
contents, err := io.ReadAll(reader)
assert.Nil(t, err)
return string(contents)
}

// writeShowTrack creates a real audio file carrying a full ID3 tag.
func writeShowTrack(t *testing.T, path string, picture bool) {
t.Helper()
Expand Down Expand Up @@ -61,3 +80,28 @@ func TestCmdShowPictureFallback(t *testing.T) {
// testing: a tag without a picture falls back to a text notice
assert.Nil(t, sys.ErrOnly(testExecute(cmdShow(), path)))
}

func TestCmdShowLyricsLeadingBlankLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "track.mp3")
writeShowTrack(t, path, false)

tag, err := id3.Open(path, id3v2.Options{Parse: true})
assert.Nil(t, err)
tag.SetLyrics("Title", "\n\nfirst lyric line\nsecond lyric line")
assert.Nil(t, tag.Save())
assert.Nil(t, tag.Close())

// testing: a stored frame that starts with blank lines reports
// its first content line on the Lyrics row, not the fallback
output := captureStdout(t, func() {
assert.Nil(t, sys.ErrOnly(testExecute(cmdShow(), path)))
})
for _, line := range strings.Split(output, "\n") {
if strings.Contains(line, "Lyrics") {
assert.Contains(t, line, "first lyric line")
assert.NotContains(t, line, fallback)
return
}
}
t.Fatal("no Lyrics row in the show output")
}
2 changes: 1 addition & 1 deletion cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ func routineCollectLyrics(track *entity.Track) func(context.Context, chan error)
}
tui.Lot("compose").Wipe()
track.Lyrics = lyrics
tui.Printf("lyrics for %s by %s: %s", track.Title, track.Artist(), sys.Fallback(sys.Excerpt(sys.FirstLine(lyrics), 64), "not found"))
tui.Printf("lyrics for %s by %s: %s", track.Title, track.Artist(), sys.Fallback(sys.Excerpt(sys.FirstContentLine(lyrics), 64), "not found"))
}
}

Expand Down
12 changes: 12 additions & 0 deletions cmd/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ func TestCmdSync(t *testing.T) {
assert.Contains(t, string(mix), "Artist - Title.mp3")
}

func TestCmdSyncLyricsLeadingBlankLines(t *testing.T) {
musicDir, _ := syncEnv(t, nil, webScript{qobuzEmpty: true, lyricsLeadBlank: true})

// testing: lyrics that arrive with leading blank lines print
// their first content line, not the not-found fallback
output := captureStdout(t, func() {
assert.Nil(t, sys.ErrOnly(testExecute(cmdSync(), "--plain", "-o", musicDir, "-l")))
})
assert.Contains(t, output, "lyrics for Title by Artist: some lyrics")
assert.NotContains(t, output, "lyrics for Title by Artist: not found")
}

func TestCmdSyncInvalidEnvironment(t *testing.T) {
resetState(t)
seedSession(t)
Expand Down
21 changes: 18 additions & 3 deletions lyrics/composer.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package lyrics

import (
"bytes"
"context"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"unicode"

"github.com/arunsworld/nursery"
"github.com/streambinder/pango/entity"
Expand Down Expand Up @@ -103,10 +105,22 @@ func chooseComposition(first, latter []byte) []byte {
}
}

// normalize strips the leading whitespace of a lyrics text, so the
// text starts at its first content line whatever composer produced
// it: a page parser can emit line breaks before the first text node
func normalize(data []byte) []byte {
return bytes.TrimLeftFunc(data, unicode.IsSpace)
}

// not found entries return no error
func Search(track *entity.Track) (string, error) {
if bytes, err := os.ReadFile(track.Path().Lyrics()); err == nil {
return string(bytes), nil
if cached, err := os.ReadFile(track.Path().Lyrics()); err == nil {
// an entry of an earlier version can carry leading blank
// lines: normalize the entry on read, and count an entry
// that holds no content as a miss
if text := normalize(cached); len(text) > 0 {
return string(text), nil
}
}

var (
Expand Down Expand Up @@ -152,6 +166,7 @@ func Search(track *entity.Track) (string, error) {
return "", failures[0]
}

result = normalize(result)
if len(result) == 0 {
return "", nil
}
Expand Down Expand Up @@ -207,5 +222,5 @@ func Get(url string) (string, error) {
return "", failures[0]
}

return string(result), nil
return string(normalize(result)), nil
}
48 changes: 48 additions & 0 deletions lyrics/composer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,51 @@ func TestGetPartialFailure(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, "lyrics", lyrics)
}

func TestSearchNormalizesLeadingBlankLines(t *testing.T) {
uncached := uncachedTrack(t, "test-normalize")
setComposers(t, stubComposer{searchResult: []byte("\n\n[Verse 1]\nlyrics")})

// testing: the result starts at its first content line, and the
// cache file holds the same normalized text
lyrics, err := Search(uncached)
assert.Nil(t, err)
assert.Equal(t, "[Verse 1]\nlyrics", lyrics)

cached, err := os.ReadFile(uncached.Path().Lyrics())
assert.Nil(t, err)
assert.Equal(t, "[Verse 1]\nlyrics", string(cached))
}

func TestSearchNormalizesCached(t *testing.T) {
cached := uncachedTrack(t, "test-cached-normalize")
assert.Nil(t, os.MkdirAll(filepath.Dir(cached.Path().Lyrics()), 0o755))
assert.Nil(t, os.WriteFile(cached.Path().Lyrics(), []byte("\nlyrics"), 0o600))
setComposers(t, stubComposer{searchErr: errors.New("must not be called")})

// testing: a cache entry of an earlier version is normalized on read
lyrics, err := Search(cached)
assert.Nil(t, err)
assert.Equal(t, "lyrics", lyrics)
}

func TestSearchBlankCacheIsMiss(t *testing.T) {
cached := uncachedTrack(t, "test-cached-blank")
assert.Nil(t, os.MkdirAll(filepath.Dir(cached.Path().Lyrics()), 0o755))
assert.Nil(t, os.WriteFile(cached.Path().Lyrics(), []byte("\n\n"), 0o600))
setComposers(t, stubComposer{searchResult: []byte("lyrics")})

// testing: an entry that holds only blank lines counts as a miss
lyrics, err := Search(cached)
assert.Nil(t, err)
assert.Equal(t, "lyrics", lyrics)
}

func TestGetNormalizesLeadingBlankLines(t *testing.T) {
setComposers(t, stubComposer{getResult: []byte("\nlyrics")})

// testing
lyrics, err := Get("http://localhost")
assert.Nil(t, err)
assert.Equal(t, "lyrics", lyrics)
}
96 changes: 96 additions & 0 deletions specs/001-lyrics-reporting/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Implementation Plan: Truthful Lyrics Reporting

**Branch**: `feat/lyrics-reporting` | **Date**: 2026-10-26 |
**Spec**: `specs/001-lyrics-reporting/spec.md`

**Input**: Feature specification from
`/specs/001-lyrics-reporting/spec.md`

## Summary

The lyrics boundary learns one normalization, and the two lyrics
displays learn one projection. A helper in `lyrics/composer.go`
strips leading whitespace from a lyrics text. `Search` applies
the helper to the merged result before the cache write and the
return, to a cache entry on read, and `Get` applies the helper to
its result. A new `sys.FirstContentLine` returns the first line
whose trimmed content is not empty, and the collect log line in
`cmd/sync.go` and the Lyrics row in `cmd/show.go` excerpt through
that projection instead of `sys.FirstLine`. Nothing else changes:
the composer merge rules, the failure accounting, and the frame
format stay as they are.

## Technical Context

**Language/Version**: Go, version from `go.mod`

**Primary Dependencies**: the standard library only; no new
dependency

**Storage**: the lyrics cache file under the XDG cache directory,
one file per track

**Testing**: table and stub tests with the established suite;
command tests run against scripted transports and a fake tool PATH

**Constraints**: per-package coverage at 90 percent; unit tests
stub every network dependency; the exported surface of the
`lyrics` package stays compatible

## Constitution Check

- Graceful Enrichment: the change keeps the degradation contract.
Collection still fails only when every composer fails.
- Honest Reporting: the change implements the rule directly. The
fallback of each display now depends on the value, not on a
projection that can be empty for a present value.
- Library Surface: `Search` and `Get` keep their signatures.
Consumers receive a cleaner text at their next pin bump; no
breaking change.
- Specification Cycle: this document and the task list complete
the plan and tasks steps of cycle 001.

## Decisions

- **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,
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
leading whitespace, so the rule leaves synchronized texts
untouched. Lyrics lines carry no significant leading whitespace.
- **Legacy cache**: normalize on read instead of migrating files.
A cache entry that normalizes to empty held no content, so the
read falls through to the composers.
- **Display projection**: add `sys.FirstContentLine` next to
`sys.FirstLine`. `FirstLine` keeps its contract for its other
semantic; the two lyrics displays switch to the new projection.
- **No change in the companion tools**: the tools import the
`lyrics` package and inherit the normalization when they bump
the module pin. Their own excerpts already read the whole text.

## Project Structure

### Documentation (this feature)

```text
specs/001-lyrics-reporting/
├── spec.md
├── plan.md
└── tasks.md
```

### Source Code (repository root)

```text
lyrics/composer.go # normalization helper, applied in Search and Get
lyrics/composer_test.go # normalization, cache, and legacy cache cases
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/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
```
Loading
Loading