feat(subsonic): add structured sidecar lyrics support with OpenSubsonic v2 karaoke cues and agent layers (#5076)
Some checks are pending
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions

Expand backend lyrics support with richer sidecar formats and upgrade the
OpenSubsonic songLyrics implementation to the version 2 structured karaoke
contract, while preserving version 1 behavior by default.

Sidecar formats and parsing:
- Add a TTML parser (core/lyrics/ttml.go): clock time, offset time, bare
  decimal seconds, nested timing contexts, and token-level <span> timing for
  word/syllable karaoke. Parses Apple Music-style metadata tracks (translation
  and pronunciation/transliteration) and agent metadata into per-track agents[]
  plus per-cue-line agentId. Hydrates missing line timing from cue timing.
- Add an SRT parser (core/lyrics/srt.go).
- Add a LRCLIB Lyricsfile (.yaml/.yml) parser (model/lyricsfile.go): maps
  per-word lines[].words[] to cues with inclusive UTF-8 byte offsets and
  attributes overlapping lines to synthetic voice agents so parallel vocals
  split correctly in the enhanced response.
- Extend LRC parsing for Enhanced LRC inline <mm:ss.xx> word-timing markers.
- Add UTF-8 BOM and UTF-16 LE support for TTML/LRC sidecars.
- Parse the above formats from embedded tags as well as sidecar files.

Source resolution:
- Default lyricspriority is now
  ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded" so the new formats are
  discoverable without manual configuration.
- Preserve configured source priority across duplicate media-file candidates
  instead of only checking the first DB match, so higher-priority sidecar
  lyrics on older duplicates can still win.
- Raise the embedded-lyrics tag maxLength to 1 MB to fit word-timed
  TTML/Enhanced-LRC karaoke for a full song.

OpenSubsonic songLyrics v2:
- Advertise songLyrics versions [1, 2].
- With enhanced=true, getLyricsBySongId may return structuredLyrics.kind
  (main/translation/pronunciation), cueLine[] line-level karaoke groupings,
  cueLine.cue[] timed words/syllables with required UTF-8 byteStart/byteEnd,
  reusable structuredLyrics.agents[], and cueLine.agentId references.
- Without enhanced=true, the response stays v1-compatible: no kind, no cueLine,
  no agents, no non-main tracks; the existing line[] payload is always
  populated so legacy clients keep working.

Contract details:
- cueLine is emitted only for synced lyrics with cue data.
- Within a cueLine, cue.end is normalized all-or-none and overlaps are removed;
  overlaps across separate cueLines remain valid for parallel vocal layers.
- Missing cue end-times are filled from the next cue or the parent line.
- When cueLines share an index, the one whose agent has role "main" is first.
- LyricCue.Value is serialized as XML chardata; cues with nil start are skipped
  rather than serialized as 0.

Refactoring:
- Move pure format parsers into model/ (lyrics.go, lyrics_ttml.go,
  lyrics_srt.go, lyrics_embedded.go, lyricsfile.go) and extract Subsonic
  response building into server/subsonic/lyrics.go.
- Centralize lyric-kind constants and add Lyrics.EffectiveKind/IsMainKind.
- Add gg.Clone helper.

Spec references:
  https://github.com/opensubsonic/open-subsonic-api/discussions/213
  https://github.com/opensubsonic/open-subsonic-api/pull/218 (songLyrics v2)
  https://github.com/opensubsonic/open-subsonic-api/pull/228 (cue byte offsets)
This commit is contained in:
Yuuta 2026-06-19 19:00:58 +03:00 committed by GitHub
parent ecba19a08e
commit 3a14faa033
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 4582 additions and 353 deletions

View file

@ -52,6 +52,7 @@ A share of the revenue helps fund the development of Navidrome at no additional
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
- Ready to use binaries for all major platforms, including **Raspberry Pi**
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
- Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`)
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**

View file

@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(manager)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)

View file

@ -776,7 +776,7 @@ func setViperDefaults() {
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("artistimagefolder", "")
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)

View file

@ -4,56 +4,122 @@ import (
"context"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
)
// Lyrics can fetch lyrics for a media file.
type Lyrics interface {
// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy
// artist/title lookup, so source-priority resolution can still reach older
// matches without turning it into an unbounded table scan.
const maxLegacyLyricsCandidates = 10
// Provider fetches lyrics for a single media file. It is the contract
// implemented by individual lyrics sources, such as plugins.
type Provider interface {
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
}
// Lyrics resolves lyrics for media files, honoring the configured source
// priority.
type Lyrics interface {
Provider
GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error)
}
// PluginLoader discovers and loads lyrics provider plugins.
type PluginLoader interface {
LoadLyricsProvider(name string) (Lyrics, bool)
LoadLyricsProvider(name string) (Provider, bool)
}
type lyricsService struct {
ds model.DataStore
pluginLoader PluginLoader
}
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
// system is available.
func NewLyrics(pluginLoader PluginLoader) Lyrics {
return &lyricsService{pluginLoader: pluginLoader}
func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics {
return &lyricsService{ds: ds, pluginLoader: pluginLoader}
}
// GetLyrics returns lyrics for the given media file, trying sources in the
// order specified by conf.Server.LyricsPriority.
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
var lyricsList model.LyricList
var err error
return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf})
}
// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup,
// scanning a bounded window of duplicate matches so source priority still wins
// across them.
func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
opts := songsByArtistTitleWithLyricsFirst(artist, title)
opts.Max = maxLegacyLyricsCandidates
mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts)
if err != nil {
return nil, err
}
if len(mediaFiles) == 0 {
return nil, nil
}
candidates := make([]*model.MediaFile, 0, len(mediaFiles))
for i := range mediaFiles {
candidates = append(candidates, &mediaFiles[i])
}
return l.getLyricsForCandidates(ctx, candidates)
}
func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions {
return model.QueryOptions{
Sort: "lyrics, updated_at",
Order: "desc",
Filters: And{
Eq{"missing": false},
Eq{"title": title},
Or{
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
},
},
}
}
func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) {
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
pattern = strings.TrimSpace(pattern)
switch {
case strings.EqualFold(pattern, "embedded"):
lyricsList, err = fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
default:
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
if pattern == "" {
continue
}
if err != nil {
log.Error(ctx, "error getting lyrics", "source", pattern, err)
}
for _, mf := range mediaFiles {
if mf == nil {
continue
}
if len(lyricsList) > 0 {
return lyricsList, nil
lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern)
if err != nil {
log.Error(ctx, "error getting lyrics", "source", pattern, err)
continue
}
if len(lyricsList) > 0 {
return lyricsList, nil
}
}
}
return nil, nil
}
func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) {
switch {
case strings.EqualFold(pattern, "embedded"):
return fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
return fromExternalFile(ctx, mf, pattern)
default:
return l.fromPlugin(ctx, mf, pattern)
}
}

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -16,7 +17,7 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("sources", func() {
var _ = Describe("Lyrics", func() {
var mf model.MediaFile
var ctx context.Context
@ -44,6 +45,71 @@ var _ = Describe("sources", func() {
},
}
elrcLyrics := model.LyricList{
model.Lyrics{
DisplayArtist: "ELRC Artist",
DisplayTitle: "ELRC Song",
Lang: "eng",
Line: []model.Line{
{
Start: new(int64(1000)),
End: new(int64(3000)),
Value: "Lead words",
Cue: []model.Cue{
{
Start: new(int64(1000)),
End: new(int64(1500)),
Value: "Lead ",
ByteStart: 0,
ByteEnd: 4,
},
{
Start: new(int64(1500)),
End: new(int64(3000)),
Value: "words",
ByteStart: 5,
ByteEnd: 9,
},
},
},
{
Start: new(int64(3000)),
Value: "Fallback line",
},
},
Synced: true,
},
}
ttmlLyrics := model.LyricList{
model.Lyrics{
Kind: "main",
Lang: "eng",
Line: []model.Line{
{
Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: new(int64(22800)),
Value: "You know the rules and so do I",
},
},
Synced: true,
},
model.Lyrics{
Kind: "main",
Lang: "por",
Line: []model.Line{
{
Start: new(int64(18800)),
Value: "Nao somos estranhos ao amor",
},
},
Synced: true,
},
}
unsyncedLyrics := model.LyricList{
model.Lyrics{
Lang: "xxx",
@ -59,6 +125,25 @@ var _ = Describe("sources", func() {
},
}
srtLyrics := model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{
Start: new(int64(18800)),
End: new(int64(22800)),
Value: "We're from subtitles",
},
{
Start: new(int64(22801)),
End: new(int64(26000)),
Value: "Another subtitle line",
},
},
Synced: true,
},
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@ -68,19 +153,100 @@ var _ = Describe("sources", func() {
Lyrics: string(lyricsJson),
Path: "tests/fixtures/test.mp3",
}
ctx = context.Background()
ctx = GinkgoT().Context()
})
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics),
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics),
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics),
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics))
It("resolves source priority across duplicate media files", func() {
conf.Server.LyricsPriority = ".ttml,embedded"
embeddedJSON, err := json.Marshal(embeddedLyrics)
Expect(err).To(BeNil())
repo := &tests.MockMediaFileRepo{}
repo.SetData(model.MediaFiles{
{
Lyrics: string(embeddedJSON),
Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3",
},
{
Lyrics: "[]",
Path: "tests/fixtures/test.mp3",
},
})
svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil)
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
Expect(err).To(BeNil())
Expect(list).To(Equal(ttmlLyrics))
})
It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() {
dir, err := os.MkdirTemp("", "lyrics-case-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(os.RemoveAll(dir)).To(Succeed())
})
probe := filepath.Join(dir, "CASECHECK")
Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed())
_, err = os.Stat(filepath.Join(dir, "casecheck"))
if err == nil {
Skip("filesystem is case-insensitive")
}
Expect(os.IsNotExist(err)).To(BeTrue())
conf.Server.LyricsPriority = ".LRC"
Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed())
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &model.MediaFile{
LibraryPath: dir,
Path: "song.mp3",
})
Expect(err).To(BeNil())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(Equal([]model.Line{
{Start: new(int64(1000)), Value: "Upper suffix"},
}))
})
It("falls through generic YAML sidecars that are not Lyricsfile documents", func() {
dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(os.RemoveAll(dir)).To(Succeed())
})
Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed())
conf.Server.LyricsPriority = ".yaml,.lrc"
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &model.MediaFile{
LibraryPath: dir,
Path: "song.mp3",
})
Expect(err).To(BeNil())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(Equal([]model.Line{
{Start: new(int64(1000)), Value: "Fallback line"},
}))
})
Context("Errors", func() {
var RegularUserContext = XContext
@ -110,7 +276,7 @@ var _ = Describe("sources", func() {
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
@ -119,7 +285,7 @@ var _ = Describe("sources", func() {
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
@ -137,7 +303,7 @@ var _ = Describe("sources", func() {
It("should return lyrics from a plugin", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -147,7 +313,7 @@ var _ = Describe("sources", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mf.Lyrics = "" // No embedded lyrics
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -156,7 +322,7 @@ var _ = Describe("sources", func() {
It("should skip plugin if embedded has lyrics", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
@ -165,7 +331,7 @@ var _ = Describe("sources", func() {
It("should skip unknown plugin names gracefully", func() {
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
mockLoader.notFound = true
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
@ -175,7 +341,7 @@ var _ = Describe("sources", func() {
conf.Server.LyricsPriority = "MyLyricsPlugin"
mockLoader.pluginName = "MyLyricsPlugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -184,12 +350,55 @@ var _ = Describe("sources", func() {
It("should handle plugin error gracefully", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
mockLoader.err = fmt.Errorf("plugin error")
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
})
var _ = Describe("GetLyricsByArtistTitle", func() {
var svc lyrics.Lyrics
var repo *tests.MockMediaFileRepo
var ds *tests.MockDataStore
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded"
repo = &tests.MockMediaFileRepo{}
ds = &tests.MockDataStore{MockedMediaFile: repo}
svc = lyrics.NewLyrics(ds, nil)
})
It("bounds the query to a duplicate window", func() {
repo.SetData(model.MediaFiles{})
_, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
Expect(err).ToNot(HaveOccurred())
Expect(repo.Options.Max).To(Equal(10))
})
It("returns nil when no media file matches", func() {
repo.SetData(model.MediaFiles{})
list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song")
Expect(err).ToNot(HaveOccurred())
Expect(list).To(BeNil())
})
It("resolves lyrics from the matched media files", func() {
embedded, err := model.ToLyrics("eng", "Embedded lyrics line")
Expect(err).ToNot(HaveOccurred())
embeddedJSON, err := json.Marshal(model.LyricList{*embedded})
Expect(err).ToNot(HaveOccurred())
repo.SetData(model.MediaFiles{
{ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)},
})
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line"))
})
})
})
type mockPluginLoader struct {
@ -206,7 +415,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string {
return []string{"test-lyrics-plugin"}
}
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
if m.notFound {
return nil, false
}

View file

@ -36,18 +36,19 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
return nil, err
}
lyrics, err := model.ToLyrics("xxx", string(contents))
list, err := model.ParseLyricsFile(suffix, contents)
if err != nil {
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
log.Error(ctx, "error parsing external lyric file", "path", externalLyric, err)
return nil, err
} else if lyrics == nil {
}
if len(list) == 0 {
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
return nil, nil
}
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
return model.LyricList{*lyrics}, nil
return list, nil
}
// fromPlugin attempts to load lyrics from a plugin with the given name.

View file

@ -61,52 +61,26 @@ var _ = Describe("sources", func() {
Expect(lyrics).To(HaveLen(0))
})
It("should return synchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
// fromExternalFile delegates format parsing to model.ParseLyricsFile; the
// per-format parser output is covered exhaustively in the model package.
// Here we only verify each suffix is read from disk and routed to a parser.
DescribeTable("should read the sidecar file and route its suffix to a parser",
func(path, suffix string, expectSynced bool) {
mf := model.MediaFile{Path: path}
lyrics, err := fromExternalFile(ctx, &mf, suffix)
Expect(err).To(BeNil())
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Line: []model.Line{
{
Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: new(int64(-100)),
Synced: true,
},
}))
})
It("should return unsynchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
Expect(err).To(BeNil())
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
Synced: false,
},
}))
})
Expect(err).To(BeNil())
Expect(lyrics).ToNot(BeEmpty())
Expect(lyrics[0].Line).ToNot(BeEmpty())
Expect(lyrics[0].Synced).To(Equal(expectSynced))
},
Entry(".lrc synced", "tests/fixtures/test.mp3", ".lrc", true),
Entry(".elrc enhanced", "tests/fixtures/test.mp3", ".elrc", true),
Entry(".txt plain", "tests/fixtures/test.mp3", ".txt", false),
Entry(".srt subtitles", "tests/fixtures/test.mp3", ".srt", true),
Entry(".ttml multilingual", "tests/fixtures/test.mp3", ".ttml", true),
Entry(".yaml lyricsfile", "tests/fixtures/test.mp3", ".yaml", true),
)
It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
// The function looks for <basePath-without-ext><suffix>, so we need to pass
@ -141,5 +115,34 @@ var _ = Describe("sources", func() {
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
})
It("should handle TTML files with UTF-8 BOM marker", func() {
mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".ttml")
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(1))
Expect(lyrics[0].Kind).To(Equal("main"))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[0].Line).To(HaveLen(1))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line"))
})
It("should handle UTF-16 BE encoded TTML files", func() {
mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".ttml")
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(1))
Expect(lyrics[0].Kind).To(Equal("main"))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[0].Line).To(HaveLen(2))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one"))
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two"))
})
})
})

View file

@ -2,43 +2,93 @@ package model
import (
"cmp"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/str"
)
type Cue struct {
Start *int64 `structs:"start,omitempty" json:"start,omitempty"`
End *int64 `structs:"end,omitempty" json:"end,omitempty"`
Value string `structs:"value" json:"value"`
ByteStart int `structs:"byteStart" json:"byteStart"`
ByteEnd int `structs:"byteEnd" json:"byteEnd"`
AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"`
}
type Agent struct {
ID string `structs:"id" json:"id"`
Role string `structs:"role" json:"role"`
Name string `structs:"name,omitempty" json:"name,omitempty"`
}
type Line struct {
Start *int64 `structs:"start,omitempty" json:"start,omitempty"`
End *int64 `structs:"end,omitempty" json:"end,omitempty"`
Value string `structs:"value" json:"value"`
Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"`
}
type Lyrics struct {
DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"`
DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"`
Lang string `structs:"lang" json:"lang"`
Line []Line `structs:"line" json:"line"`
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
Synced bool `structs:"synced" json:"synced"`
DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"`
DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"`
Kind string `structs:"kind,omitempty" json:"kind,omitempty"`
Lang string `structs:"lang" json:"lang"`
Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"`
Line []Line `structs:"line" json:"line"`
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
Synced bool `structs:"synced" json:"synced"`
}
// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are
// the canonical wire values; keep them in sync with the spec.
const (
LyricKindMain = "main"
LyricKindTranslation = "translation"
LyricKindPronunciation = "pronunciation"
)
// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm]
const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?\]`
const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]`
var (
// Should either be at the beginning of file, or beginning of line
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
timeRegex = regexp.MustCompile(timeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
// Enhanced LRC: inline word-level timing markers like <00:12.34>
enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>`
enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString)
)
func (l Lyrics) IsEmpty() bool {
return len(l.Line) == 0
}
// IsMainKind reports whether the lyric is the main track. A blank kind is an
// untyped (single-track) lyric, which the contract treats as main.
func (l Lyrics) IsMainKind() bool {
return l.EffectiveKind() == LyricKindMain
}
// EffectiveKind returns the lyric kind, defaulting to LyricKindMain when blank.
// A blank kind means an untyped (single-track) lyric, which the contract treats
// as main.
func (l Lyrics) EffectiveKind() string {
if strings.TrimSpace(l.Kind) == "" {
return LyricKindMain
}
return l.Kind
}
func ToLyrics(language, text string) (*Lyrics, error) {
text = str.SanitizeText(text)
@ -105,10 +155,13 @@ func ToLyrics(language, text string) (*Lyrics, error) {
}
if validLine {
value, baseCues := parseEnhancedLine(priorLine)
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &timestamps[idx],
Value: strings.TrimSpace(priorLine),
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
})
}
timestamps = nil
@ -153,10 +206,13 @@ func ToLyrics(language, text string) (*Lyrics, error) {
}
if validLine {
value, baseCues := parseEnhancedLine(priorLine)
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &timestamps[idx],
Value: strings.TrimSpace(priorLine),
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
})
}
}
@ -173,13 +229,170 @@ func ToLyrics(language, text string) (*Lyrics, error) {
DisplayArtist: artist,
DisplayTitle: title,
Lang: language,
Line: structuredLines,
Line: NormalizeCueLines(structuredLines),
Offset: offset,
Synced: synced,
}
return &lyrics, nil
}
// ParseLyricsFile parses a sidecar lyrics file, dispatching on its extension to
// the matching format parser. Unknown extensions fall back to the generic
// LRC/plain-text parser. It is the single owner of the suffix→parser mapping,
// mirroring [ParseEmbedded] for tag-embedded lyrics.
func ParseLyricsFile(suffix string, contents []byte) (LyricList, error) {
var list LyricList
var err error
switch {
case strings.EqualFold(suffix, ".ttml"):
list, err = ParseTTML(contents)
case strings.EqualFold(suffix, ".srt"):
list, err = ParseSRT(contents)
case strings.EqualFold(suffix, ".yaml"), strings.EqualFold(suffix, ".yml"):
list, err = ParseLyricsfile(string(contents))
default:
var lyric *Lyrics
lyric, err = ToLyrics("xxx", string(contents))
if lyric != nil {
list = LyricList{*lyric}
}
}
if err != nil {
return nil, fmt.Errorf("parsing %s lyrics: %w", strings.TrimPrefix(suffix, "."), err)
}
return list, nil
}
// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers
// and computes UTF-8 byte offsets against the final stripped line value.
func parseEnhancedLine(text string) (string, []Cue) {
matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return strings.TrimSpace(text), nil
}
type segment struct {
start int64
rawStart int
rawEnd int
}
segments := make([]segment, 0, len(matches))
var rawValue strings.Builder
for i, match := range matches {
timeMs, err := parseTime(
// Rewrite <...> as [...] so parseTime can handle it with the same logic
"["+text[match[0]+1:match[1]-1]+"]",
// Adjust match indices to point into our rewritten string (need start/end pairs for each group)
[]int{
0, match[1] - match[0],
adjustGroup(match, 2), adjustGroup(match, 3),
adjustGroup(match, 4), adjustGroup(match, 5),
adjustGroup(match, 6), adjustGroup(match, 7),
adjustGroup(match, 8), adjustGroup(match, 9),
},
)
if err != nil {
continue
}
// Text runs from after this marker to the start of the next marker (or end of string)
textStart := match[1]
var textEnd int
if i+1 < len(matches) {
textEnd = matches[i+1][0]
} else {
textEnd = len(text)
}
word := text[textStart:textEnd]
if word == "" {
continue
}
rawStart := rawValue.Len()
rawValue.WriteString(word)
segments = append(segments, segment{
start: timeMs,
rawStart: rawStart,
rawEnd: rawValue.Len(),
})
}
if len(segments) == 0 {
return strings.TrimSpace(stripEnhancedMarkers(text)), nil
}
finalRaw := rawValue.String()
leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace))
rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace))
trimmedEnd := len(finalRaw) - rightTrimBytes
if trimmedEnd < leftTrimBytes {
trimmedEnd = leftTrimBytes
}
cues := make([]Cue, 0, len(segments))
for _, seg := range segments {
start := seg.start
byteStart := max(seg.rawStart, leftTrimBytes)
byteEnd := min(seg.rawEnd, trimmedEnd)
if byteStart >= byteEnd {
continue
}
cues = append(cues, Cue{
Start: &start,
Value: finalRaw[byteStart:byteEnd],
ByteStart: byteStart - leftTrimBytes,
ByteEnd: byteEnd - leftTrimBytes - 1,
})
}
return strings.TrimSpace(finalRaw), cues
}
// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string.
// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same.
func adjustGroup(match []int, groupIdx int) int {
orig := match[groupIdx]
if orig == -1 {
return -1
}
// Offset is: original position minus the position of '<' in the original, plus 1 for '['
return orig - match[0]
}
// stripEnhancedMarkers removes all <mm:ss.mm> inline markers from text,
// returning the plain lyric text.
func stripEnhancedMarkers(text string) string {
return enhancedLRCRegex.ReplaceAllString(text, "")
}
// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End
// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute
// timestamps anchored at the line's first occurrence, so repeated-line LRC
// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the
// second occurrence to point at the correct moment. Returned *int64 pointers
// are freshly allocated so the input slice is never aliased into the result.
func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue {
if len(baseCues) == 0 {
return nil
}
out := make([]Cue, len(baseCues))
for i, c := range baseCues {
out[i] = c
if c.Start != nil {
s := *c.Start + offsetMs
out[i].Start = &s
}
if c.End != nil {
e := *c.End + offsetMs
out[i].End = &e
}
}
return out
}
func parseTime(line string, match []int) (int64, error) {
var hours, millis int64
var err error
@ -227,3 +440,142 @@ func parseTime(line string, match []int) (int64, error) {
}
type LyricList []Lyrics
// Main returns the main-kind lyric, falling back to the first entry so untyped
// lyrics still resolve. The bool is false only when the list is empty. It is
// used to surface a single lyric through the plain-text legacy getLyrics
// endpoint, which has no notion of translation/pronunciation tracks.
func (ll LyricList) Main() (Lyrics, bool) {
if len(ll) == 0 {
return Lyrics{}, false
}
for _, l := range ll {
if l.IsMainKind() {
return l, true
}
}
return ll[0], true
}
func NormalizeLyrics(lyrics Lyrics) Lyrics {
lyrics.Line = NormalizeCueLines(lyrics.Line)
if len(lyrics.Agents) == 0 {
lyrics.Agents = nil
}
return lyrics
}
func NormalizeCueLines(lines []Line) []Line {
if len(lines) == 0 {
return lines
}
normalized := make([]Line, len(lines))
copy(normalized, lines)
for i := range normalized {
if len(normalized[i].Cue) > 0 {
normalized[i].Cue = slices.Clone(normalized[i].Cue)
}
var fallbackEnd *int64
if normalized[i].End != nil {
v := *normalized[i].End
fallbackEnd = &v
} else if i+1 < len(normalized) && normalized[i+1].Start != nil {
v := *normalized[i+1].Start
fallbackEnd = &v
}
normalized[i] = normalizeCueLine(normalized[i], fallbackEnd)
}
return normalized
}
func NormalizeLineTiming(line Line) Line {
if len(line.Cue) == 0 {
return line
}
var earliestStart *int64
var latestEnd *int64
for i := range line.Cue {
token := line.Cue[i]
if token.Start != nil {
if earliestStart == nil || *token.Start < *earliestStart {
v := *token.Start
earliestStart = &v
}
}
candidateEnd := token.End
if candidateEnd == nil {
candidateEnd = token.Start
}
if candidateEnd != nil {
if latestEnd == nil || *candidateEnd > *latestEnd {
v := *candidateEnd
latestEnd = &v
}
}
}
if line.Start == nil && earliestStart != nil {
v := *earliestStart
line.Start = &v
}
if line.End == nil && latestEnd != nil {
v := *latestEnd
line.End = &v
}
return line
}
func normalizeCueLine(line Line, fallbackEnd *int64) Line {
if len(line.Cue) == 0 {
return line
}
line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd)
return NormalizeLineTiming(line)
}
// NormalizeCueEnds resolves missing cue end times within a single ordered cue
// group: each end is filled from the next cue's start, then from fallbackEnd,
// and is clamped so it never precedes the cue's own start nor overruns the next
// cue. End times are all-or-none — if any cue still lacks an end afterwards, all
// ends in the group are cleared. The input slice is never mutated.
func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue {
if len(cues) == 0 {
return cues
}
out := slices.Clone(cues)
for i := range out {
end := out[i].End
if end == nil {
if i+1 < len(out) && out[i+1].Start != nil {
end = out[i+1].Start
} else {
end = fallbackEnd
}
}
if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start {
end = out[i+1].Start
}
if end != nil && out[i].Start != nil && *end < *out[i].Start {
end = out[i].Start
}
out[i].End = gg.Clone(end)
}
for i := range out {
if out[i].End == nil {
for j := range out {
out[j].End = nil
}
break
}
}
return out
}

55
model/lyrics_embedded.go Normal file
View file

@ -0,0 +1,55 @@
package model
import (
"encoding/xml"
"strings"
"github.com/navidrome/navidrome/log"
)
// ParseEmbedded parses lyrics read from media-file metadata tags. It detects rich
// payloads before falling back to the generic LRC/plain-text parser, because
// text sanitization would otherwise strip TTML XML markup.
func ParseEmbedded(language, text string) (LyricList, error) {
text = strings.TrimPrefix(text, "\ufeff")
if isTTMLDocument(text) {
list, err := parseTTMLWithDefaultLang([]byte(text), language)
if err == nil && len(list) > 0 {
return list, nil
}
if err != nil {
log.Warn("Error parsing embedded TTML lyrics, falling back to plain lyrics", "error", err)
}
}
list, err := parseSRTWithLanguage([]byte(text), language)
if err == nil && len(list) > 0 {
return list, nil
}
if err != nil && strings.Contains(text, "-->") {
log.Warn("Error parsing embedded SRT lyrics, falling back to plain lyrics", "error", err)
}
lyric, err := ToLyrics(language, text)
if err != nil {
return nil, err
}
if lyric == nil || lyric.IsEmpty() {
return nil, nil
}
return LyricList{*lyric}, nil
}
func isTTMLDocument(text string) bool {
decoder := xml.NewDecoder(strings.NewReader(strings.TrimSpace(text)))
for {
token, err := decoder.Token()
if err != nil {
return false
}
if start, ok := token.(xml.StartElement); ok {
return strings.EqualFold(start.Name.Local, "tt")
}
}
}

View file

@ -0,0 +1,160 @@
package model
import (
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ParseEmbedded", func() {
It("should parse embedded TTML with the tag language as the default", func() {
content := `<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
<head>
<metadata>
<ttm:agent xml:id="lead" ttm:type="person">
<ttm:name>Lead Vocal</ttm:name>
</ttm:agent>
</metadata>
</head>
<body>
<div>
<p begin="00:00:01.000" end="00:00:03.000">
<span begin="00:00:01.000" end="00:00:02.000" ttm:agent="lead">Hello </span><span begin="00:00:02.000" end="00:00:03.000" ttm:agent="lead">world</span>
</p>
</div>
</body>
</tt>`
list, err := ParseEmbedded("ENG", content)
// ParseEmbedded's job is to detect TTML and apply the tag language as the
// default; the parser's cue/agent details are covered in lyrics_ttml_test.go.
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Kind).To(Equal("main"))
Expect(list[0].Lang).To(Equal("eng"))
Expect(list[0].Synced).To(BeTrue())
Expect(list[0].Line[0].Value).To(Equal("Hello world"))
})
It("should preserve embedded TTML translation and pronunciation tracks", func() {
content := `<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
<head>
<metadata>
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
<translations>
<translation xml:lang="es">
<text for="L1">Hola</text>
</translation>
</translations>
<transliterations>
<transliteration xml:lang="ja-Latn">
<text for="L1"><span begin="00:00:01.000" end="00:00:01.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:00:01.300" end="00:00:01.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text>
</transliteration>
</transliterations>
</iTunesMetadata>
</metadata>
</head>
<body xml:lang="ja">
<div>
<p begin="00:00:01.000" end="00:00:02.000" itunes:key="L1">こんにちは</p>
</div>
</body>
</tt>`
list, err := ParseEmbedded("eng", content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(3))
Expect(list[0].Kind).To(Equal("main"))
Expect(list[0].Lang).To(Equal("ja"))
Expect(list[0].Line[0].Value).To(Equal("こんにちは"))
Expect(list[1].Kind).To(Equal("translation"))
Expect(list[1].Lang).To(Equal("es"))
Expect(list[1].Line[0].Value).To(Equal("Hola"))
Expect(list[2].Kind).To(Equal("pronunciation"))
Expect(list[2].Lang).To(Equal("ja-latn"))
Expect(list[2].Line[0].Value).To(Equal("konni"))
Expect(list[2].Line[0].Cue).To(HaveLen(2))
})
It("should parse embedded SRT with the tag language", func() {
content := `1
00:00:18,800 --> 00:00:22,800
We're from subtitles
2
00:00:22,801 --> 00:00:26,000
Another subtitle line`
list, err := ParseEmbedded("POR", content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(Equal(LyricList{
{
Lang: "por",
Line: []Line{
{
Start: new(int64(18800)),
End: new(int64(22800)),
Value: "We're from subtitles",
},
{
Start: new(int64(22801)),
End: new(int64(26000)),
Value: "Another subtitle line",
},
},
Synced: true,
},
}))
})
It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() {
content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle"
list, err := ParseEmbedded("eng", content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(Equal([]Line{
{Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"},
{Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"},
}))
})
It("should keep embedded enhanced LRC cues", func() {
content := "[00:01.00]<00:01.00>Lead <00:01.50>words"
list, err := ParseEmbedded("eng", content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Lang).To(Equal("eng"))
Expect(list[0].Synced).To(BeTrue())
Expect(list[0].Line[0].Value).To(Equal("Lead words"))
Expect(list[0].Line[0].Cue).To(HaveLen(2))
})
It("should fall back to plain lyrics when embedded TTML is invalid", func() {
content := `<tt xmlns="http://www.w3.org/ns/ttml">
<body>
<p begin="not-a-time">Broken</p>
</body>
</tt>`
list, err := ParseEmbedded("eng", content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Lang).To(Equal("eng"))
Expect(list[0].Synced).To(BeFalse())
Expect(list[0].Line).ToNot(BeEmpty())
values := make([]string, 0, len(list[0].Line))
for _, line := range list[0].Line {
values = append(values, line.Value)
}
Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken"))
})
})

167
model/lyrics_srt.go Normal file
View file

@ -0,0 +1,167 @@
package model
import (
"bytes"
"regexp"
"strconv"
"strings"
"github.com/navidrome/navidrome/utils/str"
)
var (
srtTimeRegex = regexp.MustCompile(`^\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*$`)
srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`)
)
func ParseSRT(contents []byte) (LyricList, error) {
return parseSRTWithLanguage(contents, "xxx")
}
func parseSRTWithLanguage(contents []byte, language string) (LyricList, error) {
raw := strings.ReplaceAll(string(contents), "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n")
blocks := splitSRTBlocks(raw)
lines := make([]Line, 0, len(blocks))
for _, block := range blocks {
line, ok, err := parseSRTBlock(block)
if err != nil {
return nil, err
}
if ok {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return nil, nil
}
lyrics := NormalizeLyrics(Lyrics{
Lang: normalizeLyricLang(language),
Line: lines,
Synced: true,
})
return LyricList{lyrics}, nil
}
func splitSRTBlocks(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := srtBlockSeparatorRegex.Split(raw, -1)
blocks := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
blocks = append(blocks, part)
}
}
return blocks
}
func parseSRTBlock(block string) (Line, bool, error) {
scanner := bytes.Split([]byte(block), []byte("\n"))
if len(scanner) == 0 {
return Line{}, false, nil
}
lines := make([]string, 0, len(scanner))
for _, line := range scanner {
lines = append(lines, strings.TrimSpace(string(line)))
}
if len(lines) == 0 {
return Line{}, false, nil
}
startIdx := 0
if digitsOnly(lines[0]) {
startIdx = 1
}
if startIdx >= len(lines) {
return Line{}, false, nil
}
timing := strings.Split(lines[startIdx], "-->")
if len(timing) != 2 {
return Line{}, false, nil
}
startMs, err := parseSRTTime(timing[0])
if err != nil {
return Line{}, false, err
}
endMs, err := parseSRTTime(timing[1])
if err != nil {
return Line{}, false, err
}
textLines := make([]string, 0, len(lines)-startIdx-1)
for _, line := range lines[startIdx+1:] {
if line == "" {
continue
}
textLines = append(textLines, line)
}
value := str.SanitizeText(strings.Join(textLines, "\n"))
if value == "" {
return Line{}, false, nil
}
return Line{
Start: &startMs,
End: &endMs,
Value: value,
}, true, nil
}
func parseSRTTime(value string) (int64, error) {
match := srtTimeRegex.FindStringSubmatch(strings.TrimSpace(value))
if match == nil {
return 0, strconv.ErrSyntax
}
hours, err := strconv.ParseInt(match[1], 10, 64)
if err != nil {
return 0, err
}
minutes, err := strconv.ParseInt(match[2], 10, 64)
if err != nil {
return 0, err
}
seconds, err := strconv.ParseInt(match[3], 10, 64)
if err != nil {
return 0, err
}
millis, err := strconv.ParseInt(match[4], 10, 64)
if err != nil {
return 0, err
}
switch len(match[4]) {
case 1:
millis *= 100
case 2:
millis *= 10
}
return (((hours*60)+minutes)*60+seconds)*1000 + millis, nil
}
func digitsOnly(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}

View file

@ -108,4 +108,203 @@ var _ = Describe("ToLyrics", func() {
{Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"},
}))
})
It("should parse Enhanced LRC with word-level timing", func() {
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(HaveLen(2))
t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500)
line0 := lyrics.Line[0]
Expect(line0.Start).To(Equal(&t1000))
Expect(line0.End).To(Equal(&t3000))
Expect(line0.Value).To(Equal("Some lyrics here"))
Expect(line0.Cue).To(Equal([]Cue{
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4},
{Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11},
{Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15},
}))
line1 := lyrics.Line[1]
Expect(line1.Start).To(Equal(&t3000))
Expect(line1.End).To(Equal(&t3500))
Expect(line1.Value).To(Equal("More words"))
Expect(line1.Cue).To(Equal([]Cue{
{Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4},
{Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9},
}))
Expect(line1.Cue[1].End).To(BeNil())
})
It("should not parse malformed Enhanced LRC timing markers", func() {
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01a50>Not a marker")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(1000)), Value: "<00:01a50>Not a marker"},
}))
})
It("should handle mixed Enhanced and plain LRC lines", func() {
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Line).To(HaveLen(3))
t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500)
t3000 := int64(3000)
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4},
{Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10},
}))
Expect(lyrics.Line[0].Value).To(Equal("Some lyrics"))
Expect(lyrics.Line[0].End).To(Equal(&t3000))
Expect(lyrics.Line[1].Cue).To(BeNil())
Expect(lyrics.Line[1].Value).To(Equal("Plain line"))
Expect(lyrics.Line[2].Cue).To(Equal([]Cue{
{Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4},
{Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9},
}))
Expect(lyrics.Line[2].Value).To(Equal("More words"))
})
It("should preserve byte offsets for Enhanced LRC cues", func() {
lyrics, err := ToLyrics("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Line).To(HaveLen(1))
t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600)
line := lyrics.Line[0]
Expect(line.Value).To(Equal("Oh love me tonight"))
Expect(line.Cue).To(Equal([]Cue{
{Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2},
{Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6},
{Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10},
{Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17},
}))
})
It("should shift inline ELRC word timestamps for each repeated line occurrence", func() {
lyrics, err := ToLyrics("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Line).To(HaveLen(2))
t10000 := int64(10000)
t10100 := int64(10100)
t10500 := int64(10500)
t30000 := int64(30000)
t30100 := int64(30100)
t30500 := int64(30500)
Expect(lyrics.Line[0].Start).To(Equal(&t10000))
Expect(lyrics.Line[0].End).To(Equal(&t30000))
Expect(lyrics.Line[0].Value).To(Equal("Hello world"))
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
{Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5},
{Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10},
}))
Expect(lyrics.Line[1].Start).To(Equal(&t30000))
Expect(lyrics.Line[1].End).To(Equal(&t30500))
Expect(lyrics.Line[1].Value).To(Equal("Hello world"))
Expect(lyrics.Line[1].Cue).To(Equal([]Cue{
{Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5},
{Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10},
}))
})
})
var _ = Describe("NormalizeCueLines", func() {
It("should not mutate caller cue slices when filling missing cue end times", func() {
start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000)
lines := []Line{
{
Start: &start0,
Value: "Some lyrics",
Cue: []Cue{
{Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4},
{Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10},
},
},
{
Start: &nextLineStart,
Value: "Next line",
},
}
normalized := NormalizeCueLines(lines)
Expect(normalized[0].Cue[0].End).To(Equal(&start1))
Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart))
Expect(lines[0].Cue[0].End).To(BeNil())
Expect(lines[0].Cue[1].End).To(BeNil())
})
})
var _ = Describe("Lyrics.EffectiveKind", func() {
It("defaults a blank kind to main", func() {
Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain))
Expect(Lyrics{Kind: " "}.EffectiveKind()).To(Equal(LyricKindMain))
})
It("returns the kind as-is when set", func() {
Expect(Lyrics{Kind: LyricKindTranslation}.EffectiveKind()).To(Equal(LyricKindTranslation))
})
})
var _ = Describe("Lyrics.IsMainKind", func() {
It("is true for a blank (untyped) kind", func() {
Expect(Lyrics{}.IsMainKind()).To(BeTrue())
})
It("is true for the main kind", func() {
Expect(Lyrics{Kind: LyricKindMain}.IsMainKind()).To(BeTrue())
})
It("is false for translation and pronunciation kinds", func() {
Expect(Lyrics{Kind: LyricKindTranslation}.IsMainKind()).To(BeFalse())
Expect(Lyrics{Kind: LyricKindPronunciation}.IsMainKind()).To(BeFalse())
})
})
var _ = Describe("LyricList.Main", func() {
It("returns false when the list is empty", func() {
_, ok := LyricList{}.Main()
Expect(ok).To(BeFalse())
})
It("returns the main-kind entry when present", func() {
list := LyricList{
{Kind: LyricKindTranslation, Lang: "en"},
{Kind: LyricKindMain, Lang: "xxx"},
}
main, ok := list.Main()
Expect(ok).To(BeTrue())
Expect(main.Kind).To(Equal(LyricKindMain))
})
It("falls back to the first entry when no main kind exists", func() {
list := LyricList{
{Kind: LyricKindTranslation, Lang: "en"},
{Kind: LyricKindPronunciation, Lang: "ja"},
}
main, ok := list.Main()
Expect(ok).To(BeTrue())
Expect(main.Lang).To(Equal("en"))
})
It("treats a blank kind as main", func() {
list := LyricList{
{Kind: LyricKindTranslation, Lang: "en"},
{Lang: "xxx"},
}
main, ok := list.Main()
Expect(ok).To(BeTrue())
Expect(main.Lang).To(Equal("xxx"))
})
})

1256
model/lyrics_ttml.go Normal file

File diff suppressed because it is too large Load diff

429
model/lyrics_ttml_test.go Normal file
View file

@ -0,0 +1,429 @@
package model
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ParseTTML", func() {
Describe("Multi-language and timing", func() {
It("should parse multiple language divs with inherited offsets and frame/tick timing", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10">
<body>
<div xml:lang="eng" begin="1s">
<p begin="2s">Line one</p>
<p begin="00:00:04:15.1"><span>Line two</span><br/>with break</p>
</div>
<div xml:lang="por">
<p begin="45t">Linha</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(2))
By("parsing the English track")
eng := list[0]
Expect(eng.Lang).To(Equal("eng"))
Expect(eng.Synced).To(BeTrue())
Expect(eng.Line[0].Start).To(Equal(new(int64(3000))))
Expect(eng.Line[0].Value).To(Equal("Line one"))
Expect(eng.Line[1].Start).To(Equal(new(int64(4517))))
Expect(eng.Line[1].Value).To(Equal("Line two\nwith break"))
By("parsing the Portuguese track")
por := list[1]
Expect(por.Lang).To(Equal("por"))
Expect(por.Line[0].Start).To(Equal(new(int64(4500))))
Expect(por.Line[0].Value).To(Equal("Linha"))
})
})
Describe("Unsupported cue handling", func() {
It("should skip wallclock cues and keep valid ones", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml">
<body xml:lang="eng">
<div>
<p begin="wallclock(2026-01-01T00:00:00Z)">Skip me</p>
<p begin="1s">Keep me</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(HaveLen(1))
Expect(list[0].Line[0].Start).To(Equal(new(int64(1000))))
Expect(list[0].Line[0].Value).To(Equal("Keep me"))
})
})
Describe("Begin/End/Dur with inheritance", func() {
It("should correctly accumulate nested timing from body, div, and p elements", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml">
<body xml:lang="eng" begin="10s">
<div begin="5s" dur="8s">
<p begin="1s" dur="2s">First line</p>
<p begin="3s" end="5s">Second line</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Lang).To(Equal("eng"))
Expect(list[0].Line).To(HaveLen(2))
Expect(list[0].Line[0].Start).To(Equal(new(int64(16000))))
Expect(list[0].Line[0].Value).To(Equal("First line"))
Expect(list[0].Line[1].Start).To(Equal(new(int64(18000))))
Expect(list[0].Line[1].Value).To(Equal("Second line"))
})
})
Describe("Non-standard bare second offsets", func() {
It("should parse bare decimal numbers as seconds", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml">
<body xml:lang="eng" begin="10">
<div>
<p begin="0.170">First line</p>
<p begin="3.710">Second line</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(HaveLen(2))
Expect(list[0].Line[0].Start).To(Equal(new(int64(10170))))
Expect(list[0].Line[0].Value).To(Equal("First line"))
Expect(list[0].Line[1].Start).To(Equal(new(int64(13710))))
Expect(list[0].Line[1].Value).To(Equal("Second line"))
})
})
Describe("Word timing tokens", func() {
It("should extract timed tokens from spans including background role", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
<body xml:lang="eng">
<div>
<p begin="00:01.000" end="00:03.000">
<span begin="00:01.000" end="00:01.400">He</span><span begin="00:01.400" end="00:01.800">llo</span>
<span ttm:role="x-bg"><span begin="00:02.000" end="00:02.500">echo</span></span>
</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Agents).To(Equal([]Agent{
{ID: "main", Role: "main"},
{ID: "__nd_bg__|main", Role: "bg"},
}))
Expect(list[0].Line).To(HaveLen(1))
line := list[0].Line[0]
Expect(line.Start).To(Equal(new(int64(1000))))
Expect(line.Value).To(Equal("Hello\necho"))
Expect(line.End).To(Equal(new(int64(3000))))
Expect(line.Cue).To(HaveLen(3))
Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(1000)), End: new(int64(1400)), Value: "He", ByteStart: 0, ByteEnd: 1, AgentID: "main"}))
Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(1400)), End: new(int64(1800)), Value: "llo", ByteStart: 2, ByteEnd: 4, AgentID: "main"}))
Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2500)), Value: "echo", ByteStart: 6, ByteEnd: 9, AgentID: "__nd_bg__|main"}))
})
It("should append role tokens exactly instead of using substring matches", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
<body xml:lang="eng">
<div>
<p begin="00:01.000" end="00:03.000" ttm:role="not-x-bg"><span begin="00:01.000" end="00:01.400">Lead</span><span ttm:role="x-bg"><span begin="00:02.000" end="00:02.500">Echo</span></span></p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Agents).To(Equal([]Agent{
{ID: "main", Role: "main"},
{ID: "__nd_bg__|main", Role: "bg"},
}))
Expect(list[0].Line).To(HaveLen(1))
Expect(list[0].Line[0].Cue).To(HaveLen(2))
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("main"))
Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|main"))
})
It("should parse named TTML agents into main, voice, and group roles", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
<head>
<metadata>
<ttm:agent xml:id="v1" type="person"><ttm:name>Chris Martin</ttm:name></ttm:agent>
<ttm:agent xml:id="v2" type="person"><ttm:name>Jin</ttm:name></ttm:agent>
<ttm:agent xml:id="v1000" type="group"><ttm:name>All</ttm:name></ttm:agent>
</metadata>
</head>
<body xml:lang="eng">
<div>
<p begin="1s" end="2s" ttm:agent="v1"><span begin="1s" end="1.5s">You</span></p>
<p begin="2s" end="3s" ttm:agent="v2"><span begin="2s" end="2.5s">and</span></p>
<p begin="3s" end="4s" ttm:agent="v1000"><span begin="3s" end="3.5s">All</span></p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Agents).To(Equal([]Agent{
{ID: "v1", Role: "main", Name: "Chris Martin"},
{ID: "v2", Role: "voice", Name: "Jin"},
{ID: "v1000", Role: "group", Name: "All"},
}))
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("v1"))
Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("v2"))
Expect(list[0].Line[2].Cue[0].AgentID).To(Equal("v1000"))
})
It("should avoid collisions between derived background agents and explicit TTML agent ids", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
<head>
<metadata>
<ttm:agent xml:id="lead" type="person"><ttm:name>Lead</ttm:name></ttm:agent>
<ttm:agent xml:id="lead__bg" type="person"><ttm:name>Existing Background Id</ttm:name></ttm:agent>
</metadata>
</head>
<body xml:lang="eng">
<div>
<p begin="1s" end="2s" ttm:agent="lead">
<span begin="1s" end="1.4s">Lead</span>
<span ttm:role="x-bg"><span begin="1.5s" end="1.8s">Echo</span></span>
</p>
<p begin="2s" end="3s" ttm:agent="lead__bg">
<span begin="2s" end="2.5s">Named</span>
</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Agents).To(Equal([]Agent{
{ID: "lead", Role: "main", Name: "Lead"},
{ID: "__nd_bg__|lead", Role: "bg", Name: "Lead"},
{ID: "lead__bg", Role: "voice", Name: "Existing Background Id"},
}))
Expect(list[0].Line).To(HaveLen(2))
Expect(list[0].Line[0].Cue).To(HaveLen(2))
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("lead"))
Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|lead"))
Expect(list[0].Line[1].Cue).To(HaveLen(1))
Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("lead__bg"))
})
It("should fill missing cue agent ids with the resolved main agent", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
<head>
<metadata>
<ttm:agent xml:id="guest" type="person"><ttm:name>Guest Vocal</ttm:name></ttm:agent>
</metadata>
</head>
<body xml:lang="eng">
<div>
<p begin="1s" end="3s">
<span begin="1s" end="1.4s">Lead</span>
<span begin="2s" end="2.4s" ttm:agent="guest">Guest</span>
</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Agents).To(Equal([]Agent{
{ID: "guest", Role: "main", Name: "Guest Vocal"},
}))
Expect(list[0].Line).To(HaveLen(1))
Expect(list[0].Line[0].Cue).To(HaveLen(2))
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("guest"))
Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("guest"))
})
})
Describe("Ambiguous decimal timing", func() {
It("should prefer absolute timing when values fall inside parent window", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml">
<body xml:lang="eng">
<div begin="37.870" end="45.570">
<p begin="43.444" end="45.570">
<span begin="43.444" end="43.716">go</span>
<span begin="43.716" end="43.887">go</span>
</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(HaveLen(1))
line := list[0].Line[0]
Expect(line.Start).To(Equal(new(int64(43444))))
Expect(line.Value).To(Equal("go\ngo"))
Expect(line.End).To(Equal(new(int64(45570))))
Expect(line.Cue).To(HaveLen(2))
Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1}))
Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(43716)), End: new(int64(43887)), Value: "go", ByteStart: 3, ByteEnd: 4}))
})
})
Describe("Unsynced fallback", func() {
It("should return unsynced lyrics when no timing is present", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml">
<body>
<div>
<p>No timing here</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Lang).To(Equal("xxx"))
Expect(list[0].Synced).To(BeFalse())
Expect(list[0].Line).To(HaveLen(1))
Expect(list[0].Line[0].Start).To(BeNil())
Expect(list[0].Line[0].Value).To(Equal("No timing here"))
})
})
Describe("Metadata tracks", func() {
It("should produce main, translation, and pronunciation tracks from iTunesMetadata", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
<head>
<metadata>
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
<translations>
<translation xml:lang="es">
<text for="L1">Hola</text>
<text for="MISSING">Skip me</text>
</translation>
</translations>
<transliterations>
<transliteration xml:lang="ja-Latn">
<text for="L2"><span begin="00:02.000" end="00:02.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:02.300" end="00:02.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text>
</transliteration>
</transliterations>
</iTunesMetadata>
</metadata>
</head>
<body xml:lang="ja">
<div>
<p begin="00:01.000" end="00:01.500" itunes:key="L1">こんにちは</p>
<p begin="00:02.000" end="00:02.700" itunes:key="L2">こんばんは</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(3))
By("checking the main track")
main := list[0]
Expect(main.Kind).To(Equal("main"))
Expect(main.Lang).To(Equal("ja"))
Expect(main.Line).To(HaveLen(2))
By("checking the translation track")
translation := list[1]
Expect(translation.Kind).To(Equal("translation"))
Expect(translation.Lang).To(Equal("es"))
Expect(translation.Line).To(HaveLen(1))
Expect(translation.Line[0].Start).To(Equal(new(int64(1000))))
Expect(translation.Line[0].Value).To(Equal("Hola"))
Expect(translation.Line[0].End).To(Equal(new(int64(1500))))
By("checking the pronunciation track")
pronunciation := list[2]
Expect(pronunciation.Kind).To(Equal("pronunciation"))
Expect(pronunciation.Lang).To(Equal("ja-latn"))
Expect(pronunciation.Line).To(HaveLen(1))
Expect(pronunciation.Line[0].Start).To(Equal(new(int64(2000))))
Expect(pronunciation.Line[0].Value).To(Equal("konni"))
Expect(pronunciation.Line[0].End).To(Equal(new(int64(2600))))
Expect(pronunciation.Line[0].Cue).To(HaveLen(2))
Expect(pronunciation.Line[0].Cue[0]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2300)), Value: "ko", ByteStart: 0, ByteEnd: 1}))
Expect(pronunciation.Line[0].Cue[1]).To(Equal(Cue{Start: new(int64(2300)), End: new(int64(2600)), Value: "nni", ByteStart: 2, ByteEnd: 4}))
})
})
Describe("Pronunciation with bare decimal end times", func() {
It("should correctly parse bare decimal times in transliteration spans", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
<head>
<metadata>
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
<transliterations>
<transliteration xml:lang="ja-Latn">
<text for="L1"><span begin="2.747" end="3.018" xmlns="http://www.w3.org/ns/ttml">I</span> <span begin="3.018" end="3.179" xmlns="http://www.w3.org/ns/ttml">woke</span> <span begin="3.179" end="3.582" xmlns="http://www.w3.org/ns/ttml">up</span></text>
</transliteration>
</transliterations>
</iTunesMetadata>
</metadata>
</head>
<body xml:lang="ja">
<div>
<p begin="00:02.747" end="00:04.000" itunes:key="L1">起きた</p>
</div>
</body>
</tt>`)
list, err := ParseTTML(content)
Expect(err).ToNot(HaveOccurred())
var pronunciation *Lyrics
for i := range list {
if list[i].Kind == "pronunciation" {
pronunciation = &list[i]
break
}
}
Expect(pronunciation).ToNot(BeNil())
Expect(pronunciation.Line).To(HaveLen(1))
line := pronunciation.Line[0]
Expect(line.Start).To(Equal(new(int64(2747))))
Expect(line.Value).To(Equal("I woke up"))
Expect(line.Cue).To(HaveLen(3))
Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(2747)), End: new(int64(3018)), Value: "I", ByteStart: 0, ByteEnd: 0}))
Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(3018)), End: new(int64(3179)), Value: "woke", ByteStart: 2, ByteEnd: 5}))
Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8}))
})
})
})

276
model/lyricsfile.go Normal file
View file

@ -0,0 +1,276 @@
package model
import (
"fmt"
"strings"
"github.com/navidrome/navidrome/utils/str"
"gopkg.in/yaml.v3"
)
// ParseLyricsfile parses a LRCLIB Lyricsfile YAML document
// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md)
// into a model.LyricList containing a single main Lyrics entry. Returns
// (nil, nil) when the input parses as YAML but does not declare Lyricsfile
// version 1.0.
//
// When the source contains per-word timing via lines[].words[], each word
// becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and
// overlapping lines are attributed to synthetic voice agents via lowest-free
// voice ID assignment so the OpenSubsonic v2 enhanced response can split
// parallel vocals.
func ParseLyricsfile(text string) (LyricList, error) {
var doc lyricsfileDocument
dec := yaml.NewDecoder(strings.NewReader(text))
dec.KnownFields(false)
if err := dec.Decode(&doc); err != nil {
return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err)
}
if strings.TrimSpace(doc.Version) != lyricsfileVersion {
return nil, nil
}
lyrics := Lyrics{
DisplayArtist: str.SanitizeText(doc.Metadata.Artist),
DisplayTitle: str.SanitizeText(doc.Metadata.Title),
Lang: normalizeLyricLang(doc.Metadata.Language),
Kind: LyricKindMain,
}
if doc.Metadata.OffsetMs != 0 {
off := doc.Metadata.OffsetMs
lyrics.Offset = &off
}
if doc.Metadata.Instrumental {
return LyricList{NormalizeLyrics(lyrics)}, nil
}
if len(doc.Lines) == 0 {
lines := buildPlainLyricsfileLines(doc.Plain)
if len(lines) == 0 {
return nil, nil
}
lyrics.Line = lines
return LyricList{NormalizeLyrics(lyrics)}, nil
}
lines, agents := buildLyricsfileLines(doc.Lines)
lyrics.Line = lines
lyrics.Agents = agents
lyrics.Synced = true
return LyricList{NormalizeLyrics(lyrics)}, nil
}
const lyricsfileVersion = "1.0"
type lyricsfileDocument struct {
Version string `yaml:"version"`
Metadata lyricsfileMetadata `yaml:"metadata"`
Lines []lyricsfileLineEntry `yaml:"lines"`
Plain string `yaml:"plain"`
}
type lyricsfileMetadata struct {
Title string `yaml:"title"`
Artist string `yaml:"artist"`
Album string `yaml:"album"`
DurationMs int64 `yaml:"duration_ms"`
OffsetMs int64 `yaml:"offset_ms"`
Language string `yaml:"language"`
Instrumental bool `yaml:"instrumental"`
}
type lyricsfileLineEntry struct {
Text string `yaml:"text"`
StartMs int64 `yaml:"start_ms"`
EndMs *int64 `yaml:"end_ms"`
Words []lyricsfileWordEntry `yaml:"words"`
}
type lyricsfileWordEntry struct {
Text string `yaml:"text"`
StartMs int64 `yaml:"start_ms"`
EndMs *int64 `yaml:"end_ms"`
}
// buildLyricsfileLines converts YAML line entries to model.Line entries with
// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free
// voice ID). The Agents slice is emitted only when at least one cue carries
// attribution AND more than one voice is used; otherwise AgentIDs are
// stripped so the wire shape stays simple per the OpenSubsonic spec rule
// "agents should not be emitted without cueLine data".
func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) {
if len(entries) == 0 {
return nil, nil
}
// Resolved end timestamps per entry: explicit end_ms, final word end_ms,
// then the next entry's start. The last entry's end stays nil when no
// explicit or word-level end is available.
ends := make([]*int64, len(entries))
for i := range entries {
var nextStart *int64
if i+1 < len(entries) {
v := entries[i+1].StartMs
nextStart = &v
}
ends[i] = lyricsfileLineEnd(entries[i], nextStart)
}
active := map[int]int64{}
maxVoice := -1
anyCues := false
lines := make([]Line, 0, len(entries))
for i, entry := range entries {
for vID, vEnd := range active {
if vEnd <= entry.StartMs {
delete(active, vID)
}
}
voiceID := 0
for {
if _, busy := active[voiceID]; !busy {
break
}
voiceID++
}
if voiceID > maxVoice {
maxVoice = voiceID
}
agentID := fmt.Sprintf("voice-%d", voiceID)
cues, value := wordsToLineCues(entry, agentID)
if len(cues) > 0 {
anyCues = true
}
startMs := entry.StartMs
line := Line{
Start: &startMs,
End: ends[i],
Value: value,
Cue: cues,
}
lines = append(lines, line)
var endMs int64
if ends[i] != nil {
endMs = *ends[i]
} else {
endMs = entry.StartMs
}
active[voiceID] = endMs
}
// Monophonic source, or attribution that has nowhere to land: emit no
// agents and strip per-cue AgentIDs to keep the wire shape simple.
if maxVoice <= 0 || !anyCues {
for i := range lines {
for j := range lines[i].Cue {
lines[i].Cue[j].AgentID = ""
}
}
return lines, nil
}
agents := make([]Agent, 0, maxVoice+1)
for v := 0; v <= maxVoice; v++ {
role := "voice"
if v == 0 {
role = "main"
}
agents = append(agents, Agent{
ID: fmt.Sprintf("voice-%d", v),
Role: role,
})
}
return lines, agents
}
func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 {
if entry.EndMs != nil {
v := *entry.EndMs
return &v
}
if len(entry.Words) > 0 {
lastWord := entry.Words[len(entry.Words)-1]
if lastWord.EndMs != nil {
v := *lastWord.EndMs
return &v
}
}
if nextStart != nil {
v := *nextStart
return &v
}
return nil
}
func buildPlainLyricsfileLines(plain string) []Line {
plain = str.SanitizeText(plain)
rawLines := strings.Split(plain, "\n")
lines := make([]Line, 0, len(rawLines))
for _, raw := range rawLines {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
lines = append(lines, Line{Value: value})
}
return lines
}
// wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue
// entries with inclusive UTF-8 byte offsets into the reconstructed line
// value. The line value is built from cue text concatenation rather than
// trusting entry.Text, because the Lyricsfile spec only requires word.text
// to "approximate" line.text - byte offsets must always land inside
// Line.Value.
func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) {
if len(entry.Words) == 0 {
return nil, str.SanitizeText(entry.Text)
}
var sb strings.Builder
for _, w := range entry.Words {
sb.WriteString(w.Text)
}
lineValue := sb.String()
cues := make([]Cue, len(entry.Words))
cursor := 0
for i, w := range entry.Words {
valueBytes := len(w.Text)
bs := cursor
be := bs
if valueBytes > 0 {
be = bs + valueBytes - 1
cursor = be + 1
}
s := w.StartMs
cue := Cue{
Start: &s,
Value: w.Text,
ByteStart: bs,
ByteEnd: be,
AgentID: agentID,
}
if w.EndMs != nil {
e := *w.EndMs
cue.End = &e
}
cues[i] = cue
}
for i := 0; i < len(cues)-1; i++ {
if cues[i].End == nil && cues[i+1].Start != nil {
v := *cues[i+1].Start
cues[i].End = &v
}
}
return cues, lineValue
}

283
model/lyricsfile_test.go Normal file
View file

@ -0,0 +1,283 @@
package model_test
import (
. "github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ParseLyricsfile", func() {
DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker",
func(input string) {
lyrics, err := ParseLyricsfile(input)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(BeNil())
},
Entry("arbitrary YAML", "hello: world\n"),
Entry("Lyricsfile-shaped but unversioned", `metadata:
title: 'Looks close'
lines:
- text: "But should not be claimed"
start_ms: 1000
`),
)
It("returns an error for invalid YAML", func() {
_, err := ParseLyricsfile("not: valid: yaml: [")
Expect(err).To(HaveOccurred())
})
It("parses line-level metadata without cues", func() {
input := `version: '1.0'
metadata:
title: 'Sample Track'
artist: 'Test Artist'
language: 'eng'
offset_ms: -100
lines:
- text: "We're no strangers to love"
start_ms: 18800
- text: "You know the rules and so do I"
start_ms: 22801
`
lyrics, err := ParseLyricsfile(input)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(1))
l := lyrics[0]
Expect(l.Kind).To(Equal("main"))
Expect(l.Lang).To(Equal("eng"))
Expect(l.DisplayArtist).To(Equal("Test Artist"))
Expect(l.DisplayTitle).To(Equal("Sample Track"))
Expect(l.Synced).To(BeTrue())
Expect(l.Offset).ToNot(BeNil())
Expect(*l.Offset).To(Equal(int64(-100)))
Expect(l.Agents).To(BeNil())
Expect(l.Line).To(HaveLen(2))
Expect(*l.Line[0].Start).To(Equal(int64(18800)))
Expect(l.Line[0].End).ToNot(BeNil())
Expect(*l.Line[0].End).To(Equal(int64(22801)))
Expect(l.Line[0].Value).To(Equal("We're no strangers to love"))
Expect(l.Line[0].Cue).To(BeNil())
Expect(*l.Line[1].Start).To(Equal(int64(22801)))
Expect(l.Line[1].End).To(BeNil())
Expect(l.Line[1].Value).To(Equal("You know the rules and so do I"))
Expect(l.Line[1].Cue).To(BeNil())
})
It("parses plain-only Lyricsfile lyrics as unsynced lines", func() {
input := `version: '1.0'
metadata:
title: 'Plain Track'
artist: 'Plain Artist'
language: 'en'
lines: []
plain: |
[Verse 1]
First line
Second line
`
lyrics, err := ParseLyricsfile(input)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(1))
l := lyrics[0]
Expect(l.Kind).To(Equal("main"))
Expect(l.Lang).To(Equal("en"))
Expect(l.DisplayArtist).To(Equal("Plain Artist"))
Expect(l.DisplayTitle).To(Equal("Plain Track"))
Expect(l.Synced).To(BeFalse())
Expect(l.Agents).To(BeNil())
Expect(l.Line).To(Equal([]Line{
{Value: "[Verse 1]"},
{Value: "First line"},
{Value: "Second line"},
}))
})
It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() {
input := `version: '1.0'
metadata:
title: 'Karaoke'
artist: 'Singer'
language: 'eng'
lines:
- text: "Hello world"
start_ms: 1000
end_ms: 3000
words:
- text: "Hello "
start_ms: 1000
end_ms: 1500
- text: "world"
start_ms: 1500
end_ms: 3000
`
lyrics, err := ParseLyricsfile(input)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(1))
l := lyrics[0]
Expect(l.Synced).To(BeTrue())
Expect(l.Agents).To(BeNil())
Expect(l.Line).To(HaveLen(1))
line := l.Line[0]
Expect(*line.Start).To(Equal(int64(1000)))
Expect(*line.End).To(Equal(int64(3000)))
Expect(line.Value).To(Equal("Hello world"))
Expect(line.Cue).To(HaveLen(2))
Expect(*line.Cue[0].Start).To(Equal(int64(1000)))
Expect(*line.Cue[0].End).To(Equal(int64(1500)))
Expect(line.Cue[0].Value).To(Equal("Hello "))
Expect(line.Cue[0].ByteStart).To(Equal(0))
Expect(line.Cue[0].ByteEnd).To(Equal(5))
Expect(line.Cue[0].AgentID).To(Equal(""))
Expect(*line.Cue[1].Start).To(Equal(int64(1500)))
Expect(*line.Cue[1].End).To(Equal(int64(3000)))
Expect(line.Cue[1].Value).To(Equal("world"))
Expect(line.Cue[1].ByteStart).To(Equal(6))
Expect(line.Cue[1].ByteEnd).To(Equal(10))
Expect(line.Cue[1].AgentID).To(Equal(""))
})
It("prefers final word end_ms over next line start when inferring line end", func() {
input := `version: '1.0'
metadata:
title: 'Overlap From Words'
lines:
- text: "Long vocal"
start_ms: 1000
words:
- text: "Long "
start_ms: 1000
end_ms: 2000
- text: "vocal"
start_ms: 2000
end_ms: 4000
- text: "echo"
start_ms: 3000
end_ms: 3500
words:
- text: "echo"
start_ms: 3000
end_ms: 3500
`
lyrics, err := ParseLyricsfile(input)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(1))
l := lyrics[0]
Expect(l.Agents).To(Equal([]Agent{
{ID: "voice-0", Role: "main"},
{ID: "voice-1", Role: "voice"},
}))
Expect(l.Line).To(HaveLen(2))
Expect(l.Line[0].End).ToNot(BeNil())
Expect(*l.Line[0].End).To(Equal(int64(4000)))
Expect(l.Line[0].Cue[1].End).To(Equal(l.Line[0].End))
Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1"))
})
It("synthesises voice agents for overlapping lines and attributes per-cue", func() {
input := `version: '1.0'
metadata:
title: 'Duet'
lines:
- text: "Lead vocal"
start_ms: 1000
end_ms: 4000
words:
- text: "Lead "
start_ms: 1000
end_ms: 2000
- text: "vocal"
start_ms: 2000
end_ms: 4000
- text: "echo"
start_ms: 2000
end_ms: 3000
words:
- text: "echo"
start_ms: 2000
end_ms: 3000
`
lyrics, err := ParseLyricsfile(input)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(1))
l := lyrics[0]
Expect(l.Agents).To(Equal([]Agent{
{ID: "voice-0", Role: "main"},
{ID: "voice-1", Role: "voice"},
}))
Expect(l.Line).To(HaveLen(2))
Expect(l.Line[0].Value).To(Equal("Lead vocal"))
Expect(*l.Line[0].Start).To(Equal(int64(1000)))
Expect(*l.Line[0].End).To(Equal(int64(4000)))
Expect(l.Line[0].Cue).To(HaveLen(2))
Expect(l.Line[0].Cue[0].AgentID).To(Equal("voice-0"))
Expect(l.Line[0].Cue[1].AgentID).To(Equal("voice-0"))
Expect(l.Line[0].Cue[0].ByteStart).To(Equal(0))
Expect(l.Line[0].Cue[0].ByteEnd).To(Equal(4))
Expect(l.Line[0].Cue[1].ByteStart).To(Equal(5))
Expect(l.Line[0].Cue[1].ByteEnd).To(Equal(9))
Expect(l.Line[1].Value).To(Equal("echo"))
Expect(*l.Line[1].Start).To(Equal(int64(2000)))
Expect(*l.Line[1].End).To(Equal(int64(3000)))
Expect(l.Line[1].Cue).To(HaveLen(1))
Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1"))
Expect(l.Line[1].Cue[0].ByteStart).To(Equal(0))
Expect(l.Line[1].Cue[0].ByteEnd).To(Equal(3))
})
It("emits empty lines with Synced=false for instrumental tracks", func() {
input := `version: '1.0'
metadata:
title: 'Solo Piano'
artist: 'Composer'
language: 'eng'
instrumental: true
`
lyrics, err := ParseLyricsfile(input)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(1))
l := lyrics[0]
Expect(l.Kind).To(Equal("main"))
Expect(l.Lang).To(Equal("eng"))
Expect(l.DisplayArtist).To(Equal("Composer"))
Expect(l.DisplayTitle).To(Equal("Solo Piano"))
Expect(l.Synced).To(BeFalse())
Expect(l.Line).To(BeEmpty())
Expect(l.Agents).To(BeNil())
})
It("strips agent attribution when overlapping lines carry no cues", func() {
input := `version: '1.0'
lines:
- text: "Lead"
start_ms: 1000
end_ms: 4000
- text: "echo"
start_ms: 2000
end_ms: 3000
`
lyrics, err := ParseLyricsfile(input)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(1))
l := lyrics[0]
Expect(l.Line).To(HaveLen(2))
Expect(l.Agents).To(BeNil())
Expect(l.Line[0].Cue).To(BeNil())
Expect(l.Line[1].Cue).To(BeNil())
})
})

View file

@ -143,13 +143,15 @@ func (md Metadata) mapLyrics() string {
lang := raw.Key()
text := raw.Value()
lyrics, err := model.ToLyrics(lang, text)
lyrics, err := model.ParseEmbedded(lang, text)
if err != nil {
log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err)
continue
}
if !lyrics.IsEmpty() {
lyricList = append(lyricList, *lyrics)
for _, lyric := range lyrics {
if !lyric.IsEmpty() {
lyricList = append(lyricList, lyric)
}
}
}

View file

@ -105,7 +105,7 @@ var _ = Describe("Metadata", func() {
props.Tags = model.RawTags{
"Title": {strings.Repeat("a", 2048)},
"Comment": {strings.Repeat("a", 8192)},
"lyrics:xxx": {strings.Repeat("a", 60000)},
"lyrics:xxx": {strings.Repeat("a", 2_000_000)},
}
md = metadata.New(filePath, props)
@ -116,9 +116,10 @@ var _ = Describe("Metadata", func() {
Expect(pair).To(HaveLen(1))
Expect(pair[0].Key()).To(Equal("xxx"))
// Lyrics keep a much larger cap so word-timed karaoke survives.
// Note: a total of 6 characters are lost from maxLength from
// the key portion and separator
Expect(pair[0].Value()).To(HaveLen(32762))
// the key portion and separator.
Expect(pair[0].Value()).To(HaveLen(1048570))
})
It("should split multiple values", func() {

View file

@ -241,7 +241,7 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin)
}
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin)
}

View file

@ -110,7 +110,9 @@ main:
lyrics:
# Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx`
aliases: [ uslt:description, lyrics, unsyncedlyrics ]
maxLength: 32768
# Generous cap to fit word-timed TTML/Enhanced-LRC karaoke for a full song,
# while still bounding against pathological tags.
maxLength: 1048576
type: pair # ex: lyrics:eng, lyrics:xxx
comment:
aliases: [ comm:description, comment, ©cmt, description, icmt ]

View file

@ -501,7 +501,7 @@ func setupTestDB() {
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
lyrics.NewLyrics(nil),
lyrics.NewLyrics(ds, nil),
decider,
nil,
)

View file

@ -47,7 +47,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
lyrics.NewLyrics(nil),
lyrics.NewLyrics(ds, nil),
decider,
sonicSvc,
)

View file

@ -106,21 +106,6 @@ func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options {
return addDefaultFilters(options)
}
func SongsByArtistTitleWithLyricsFirst(artist, title string) Options {
return addDefaultFilters(Options{
Sort: "lyrics, updated_at",
Order: "desc",
Max: 1,
Filters: And{
Eq{"title": title},
Or{
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
},
},
})
}
func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options {
if len(musicFolderIds) == 0 {
return opts

View file

@ -495,48 +495,6 @@ func mapExplicitStatus(explicitStatus string) string {
return ""
}
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric {
lines := make([]responses.Line, len(lyrics.Line))
for i, line := range lyrics.Line {
lines[i] = responses.Line{
Start: line.Start,
Value: line.Value,
}
}
structured := responses.StructuredLyric{
DisplayArtist: lyrics.DisplayArtist,
DisplayTitle: lyrics.DisplayTitle,
Lang: lyrics.Lang,
Line: lines,
Offset: lyrics.Offset,
Synced: lyrics.Synced,
}
if structured.DisplayArtist == "" {
structured.DisplayArtist = mf.Artist
}
if structured.DisplayTitle == "" {
structured.DisplayTitle = mf.Title
}
return structured
}
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList {
lyricList := make(responses.StructuredLyrics, len(lyricsList))
for i, lyrics := range lyricsList {
lyricList[i] = buildStructuredLyric(mf, lyrics)
}
res := &responses.LyricsList{
StructuredLyrics: lyricList,
}
return res
}
// getUserAccessibleLibraries returns the list of libraries the current user has access to.
func getUserAccessibleLibraries(ctx context.Context) []model.Library {
user := getUser(ctx)

181
server/subsonic/lyrics.go Normal file
View file

@ -0,0 +1,181 @@
package subsonic
import (
"slices"
"sort"
"strings"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
)
// agentRoleMain is the OpenSubsonic agent role that marks the primary vocal
// layer; its cue line is emitted before other agents sharing the same index.
const agentRoleMain = "main"
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList {
filtered := lyricsList
if !enhanced {
// Without enhanced, only return main-kind entries (a blank kind is main).
filtered = nil
for _, l := range lyricsList {
if l.IsMainKind() {
filtered = append(filtered, l)
}
}
}
lyricList := make(responses.StructuredLyrics, len(filtered))
for i, lyrics := range filtered {
lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced)
}
return &responses.LyricsList{StructuredLyrics: lyricList}
}
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric {
agents := newLyricAgents(lyrics.Agents)
lines := make([]responses.Line, len(lyrics.Line))
var cueLines []responses.CueLine
for i, line := range lyrics.Line {
lines[i] = responses.Line{Start: line.Start, Value: line.Value}
if enhanced && len(line.Cue) > 0 {
cueLines = append(cueLines, buildCueLines(line, int32(i), agents)...)
}
}
structured := responses.StructuredLyric{
DisplayArtist: lyrics.DisplayArtist,
DisplayTitle: lyrics.DisplayTitle,
Lang: lyrics.Lang,
Line: lines,
CueLine: cueLines,
Offset: lyrics.Offset,
Synced: lyrics.Synced,
}
if enhanced {
structured.Kind = lyrics.EffectiveKind()
if len(cueLines) > 0 && len(agents.response) > 0 {
structured.Agents = agents.response
}
}
if structured.DisplayArtist == "" {
structured.DisplayArtist = mf.Artist
}
if structured.DisplayTitle == "" {
structured.DisplayTitle = mf.Title
}
return structured
}
// lyricAgents indexes a lyric's agents by ID so cue lines can be ordered and
// the response agent list reused without rescanning the slice per line.
type lyricAgents struct {
orderByID map[string]int
roleByID map[string]string
response []responses.Agent
}
func newLyricAgents(agents []model.Agent) lyricAgents {
a := lyricAgents{
orderByID: make(map[string]int, len(agents)),
roleByID: make(map[string]string, len(agents)),
response: make([]responses.Agent, 0, len(agents)),
}
for i, agent := range agents {
a.orderByID[agent.ID] = i
a.roleByID[agent.ID] = agent.Role
a.response = append(a.response, responses.Agent{ID: agent.ID, Role: agent.Role, Name: agent.Name})
}
return a
}
// buildCueLines splits a line's cues by agent and emits one CueLine per agent,
// ordered main-role first then by the agent's declared order.
func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses.CueLine {
agentOrder := make([]string, 0, 2)
cuesByAgent := make(map[string][]model.Cue)
for _, cue := range line.Cue {
if cue.Start == nil {
continue
}
agentID := strings.TrimSpace(cue.AgentID)
if _, exists := cuesByAgent[agentID]; !exists {
agentOrder = append(agentOrder, agentID)
}
cuesByAgent[agentID] = append(cuesByAgent[agentID], cue)
}
sort.SliceStable(agentOrder, func(i, j int) bool {
return agents.less(agentOrder[i], agentOrder[j], i, j)
})
cueLines := make([]responses.CueLine, 0, len(agentOrder))
for _, agentID := range agentOrder {
cueLine := responses.CueLine{
Index: index,
Start: line.Start,
End: line.End,
Value: line.Value,
Cue: buildLyricCues(cuesByAgent[agentID], line.End),
}
if agentID != "" {
cueLine.AgentID = agentID
}
cueLines = append(cueLines, cueLine)
}
return cueLines
}
// less orders two agent IDs: the main role wins, then the declared agent order,
// then known-before-unknown, then the original encounter order (origI/origJ).
func (a lyricAgents) less(left, right string, origI, origJ int) bool {
leftMain := a.roleByID[left] == agentRoleMain
rightMain := a.roleByID[right] == agentRoleMain
if leftMain != rightMain {
return leftMain
}
leftOrder, leftOK := a.orderByID[left]
rightOrder, rightOK := a.orderByID[right]
if leftOK && rightOK && leftOrder != rightOrder {
return leftOrder < rightOrder
}
if leftOK != rightOK {
return leftOK
}
return origI < origJ
}
func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue {
if len(cues) == 0 {
return nil
}
// Only resolve end times when at least one cue carries one; otherwise the
// group is start-only and must stay that way.
hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil })
if hasAnyEnd {
cues = model.NormalizeCueEnds(cues, lineEnd)
}
out := make([]responses.LyricCue, 0, len(cues))
for i := range cues {
if cues[i].Start == nil {
continue
}
cue := responses.LyricCue{
Start: *cues[i].Start,
Value: cues[i].Value,
ByteStart: cues[i].ByteStart,
ByteEnd: cues[i].ByteEnd,
}
if hasAnyEnd {
cue.End = cues[i].End
}
out = append(out, cue)
}
return out
}

View file

@ -0,0 +1,618 @@
package subsonic
import (
"encoding/json"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GetLyricsBySongId", func() {
var router *Router
var ds model.DataStore
mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}}
BeforeEach(func() {
ds = &tests.MockDataStore{
MockedMediaFile: mockRepo,
}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil)
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"
})
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
var times = []int64{18800, 22801}
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
Expect(actual).ToNot(BeNil())
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
for i, realLyric := range actual.StructuredLyrics {
expectedLyric := expected.StructuredLyrics[i]
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
Expect(realLyric.Kind).To(Equal(expectedLyric.Kind))
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
Expect(realLyric.Agents).To(Equal(expectedLyric.Agents))
if expectedLyric.Offset == nil {
Expect(realLyric.Offset).To(BeNil())
} else {
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
}
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
for j, realLine := range realLyric.Line {
expectedLine := expectedLyric.Line[j]
Expect(realLine.Value).To(Equal(expectedLine.Value))
if expectedLine.Start == nil {
Expect(realLine.Start).To(BeNil())
} else {
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
}
}
Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine)))
for j, realCueLine := range realLyric.CueLine {
expectedCueLine := expectedLyric.CueLine[j]
Expect(realCueLine.Index).To(Equal(expectedCueLine.Index))
Expect(realCueLine.Value).To(Equal(expectedCueLine.Value))
Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID))
if expectedCueLine.Start == nil {
Expect(realCueLine.Start).To(BeNil())
} else {
Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start))
}
if expectedCueLine.End == nil {
Expect(realCueLine.End).To(BeNil())
} else {
Expect(*realCueLine.End).To(Equal(*expectedCueLine.End))
}
Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue)))
for k, realCue := range realCueLine.Cue {
expectedCue := expectedCueLine.Cue[k]
Expect(realCue.Value).To(Equal(expectedCue.Value))
Expect(realCue.Start).To(Equal(expectedCue.Start))
Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart))
Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd))
if expectedCue.End == nil {
Expect(realCue.End).To(BeNil())
} else {
Expect(*realCue.End).To(Equal(*expectedCue.End))
}
}
}
}
}
It("should return mixed lyrics", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced, *unsynced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
Lang: "eng",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
},
{
Lang: "xxx",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: false,
Line: []responses.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
},
},
})
})
It("should parse lrc metadata", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
Offset: new(int64(-100)),
},
},
})
})
It("should return multilingual TTML sidecar lyrics", func() {
conf.Server.LyricsPriority = ".ttml,embedded"
r := newGetRequest("id=1")
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Path: "tests/fixtures/test.mp3",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
porTime := int64(18800)
ttmlTime := int64(22800)
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Lang: "eng",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &ttmlTime,
Value: "You know the rules and so do I",
},
},
},
{
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Lang: "por",
Synced: true,
Line: []responses.Line{
{
Start: &porTime,
Value: "Nao somos estranhos ao amor",
},
},
},
},
})
})
It("should return metadata-linked translation and pronunciation tracks from TTML", func() {
conf.Server.LyricsPriority = ".ttml,embedded"
r := newGetRequest("id=1&enhanced=true")
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Path: "tests/fixtures/test-metadata.mp3",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
mainStartA := int64(1000)
mainStartB := int64(2000)
tokenStartA := int64(2000)
tokenEndA := int64(2300)
tokenStartB := int64(2300)
tokenEndB := int64(2600)
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Kind: "main",
Lang: "ja",
Synced: true,
Line: []responses.Line{
{
Start: &mainStartA,
Value: "こんにちは",
},
{
Start: &mainStartB,
Value: "こんばんは",
},
},
},
{
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Kind: "translation",
Lang: "es",
Synced: true,
Line: []responses.Line{
{
Start: &mainStartA,
Value: "Hola",
},
},
},
{
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Kind: "pronunciation",
Lang: "ja-latn",
Synced: true,
Line: []responses.Line{
{
Start: &mainStartB,
Value: "konni",
},
},
CueLine: []responses.CueLine{
{
Index: 0,
Start: &mainStartB,
End: &tokenEndB,
Value: "konni",
Cue: []responses.LyricCue{
{
Start: tokenStartA,
End: &tokenEndA,
ByteStart: 0,
ByteEnd: 1,
Value: "ko",
},
{
Start: tokenStartB,
End: &tokenEndB,
ByteStart: 2,
ByteEnd: 4,
Value: "nni",
},
},
},
},
},
},
})
})
It("should return cue lines for songLyrics v2 clients with enhanced=true", func() {
r := newGetRequest("id=1&enhanced=true")
lineStart := int64(1000)
lineEnd := int64(3000)
tokenStartA := int64(1000)
tokenEndA := int64(1400)
tokenStartB := int64(2000)
tokenEndB := int64(2500)
lyricsJson, err := json.Marshal(model.LyricList{
{
Lang: "eng",
Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}},
Synced: true,
Line: []model.Line{
{
Start: &lineStart,
End: &lineEnd,
Value: "Hello echo",
Cue: []model.Cue{
{
Start: &tokenStartA,
End: &tokenEndA,
Value: "Hello",
ByteStart: 0,
ByteEnd: 4,
AgentID: "lead",
},
{
Start: &tokenStartB,
End: &tokenEndB,
Value: "echo",
ByteStart: 6,
ByteEnd: 9,
AgentID: "__nd_bg__|lead",
},
},
},
},
},
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Kind: "main",
Lang: "eng",
Synced: true,
Agents: []responses.Agent{
{ID: "lead", Role: "main"},
{ID: "__nd_bg__|lead", Role: "bg"},
},
Line: []responses.Line{
{
Start: &lineStart,
Value: "Hello echo",
},
},
CueLine: []responses.CueLine{
{
Index: 0,
Start: &lineStart,
End: &lineEnd,
Value: "Hello echo",
AgentID: "lead",
Cue: []responses.LyricCue{
{
Start: tokenStartA,
End: &tokenEndA,
ByteStart: 0,
ByteEnd: 4,
Value: "Hello",
},
},
},
{
Index: 0,
Start: &lineStart,
End: &lineEnd,
Value: "Hello echo",
AgentID: "__nd_bg__|lead",
Cue: []responses.LyricCue{
{
Start: tokenStartB,
End: &tokenEndB,
ByteStart: 6,
ByteEnd: 9,
Value: "echo",
},
},
},
},
},
},
})
})
It("should keep enhanced line-level lyrics when no cue data is available", func() {
r := newGetRequest("id=1&enhanced=true")
lineStart := int64(1000)
lineEnd := int64(3000)
lyricsJSON, err := json.Marshal(model.LyricList{
{
Kind: "main",
Lang: "eng",
Synced: true,
Line: []model.Line{
{
Start: &lineStart,
End: &lineEnd,
Value: "Line without word timing",
},
},
},
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJSON),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Kind: "main",
Lang: "eng",
Synced: true,
Line: []responses.Line{
{
Start: &lineStart,
Value: "Line without word timing",
},
},
},
},
})
})
It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() {
r := newGetRequest("id=1&enhanced=true")
asciiLineStart := int64(0)
asciiLineEnd := int64(2400)
asciiCueStartA := int64(0)
asciiCueEndA := int64(300)
asciiCueStartB := int64(900)
asciiCueEndB := int64(1300)
asciiCueStartC := int64(1300)
asciiCueEndC := int64(1600)
asciiCueStartD := int64(1600)
utfLineStart := int64(2747)
utfLineEnd := int64(6214)
utfCueStartA := int64(2747)
utfCueEndA := int64(3018)
utfCueStartB := int64(3018)
utfCueEndB := int64(3179)
utfCueStartC := int64(3582)
utfCueEndC := int64(4100)
utfCueStartD := int64(4500)
utfCueEndD := int64(6214)
lyricsJSON, err := json.Marshal(model.LyricList{
{
Lang: "eng",
Synced: true,
Line: []model.Line{
{
Start: &asciiLineStart,
End: &asciiLineEnd,
Value: "Oh love love me tonight",
Cue: []model.Cue{
{Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1},
{Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11},
{Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14},
{Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22},
},
},
{
Start: &utfLineStart,
End: &utfLineEnd,
Value: "눈을 뜬 순간",
Cue: []model.Cue{
{Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2},
{Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5},
{Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9},
{Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16},
},
},
},
},
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJSON),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Kind: "main",
Lang: "eng",
Synced: true,
Line: []responses.Line{
{Start: &asciiLineStart, Value: "Oh love love me tonight"},
{Start: &utfLineStart, Value: "눈을 뜬 순간"},
},
CueLine: []responses.CueLine{
{
Index: 0,
Start: &asciiLineStart,
End: &asciiLineEnd,
Value: "Oh love love me tonight",
Cue: []responses.LyricCue{
{Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1},
{Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11},
{Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14},
{Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22},
},
},
{
Index: 1,
Start: &utfLineStart,
End: &utfLineEnd,
Value: "눈을 뜬 순간",
Cue: []responses.LyricCue{
{Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2},
{Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5},
{Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9},
{Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16},
},
},
},
},
},
})
})
})

View file

@ -13,7 +13,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/server/subsonic/filter"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/gravatar"
"github.com/navidrome/navidrome/utils/req"
@ -98,22 +97,13 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
response := newResponse()
lyricsResponse := responses.Lyrics{}
response.Lyrics = &lyricsResponse
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title))
structuredLyrics, err := api.lyrics.GetLyricsByArtistTitle(r.Context(), artist, title)
if err != nil {
return nil, err
}
if len(mediaFiles) == 0 {
return response, nil
}
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0])
if err != nil {
return nil, err
}
if len(structuredLyrics) == 0 {
mainLyric, ok := structuredLyrics.Main()
if !ok {
return response, nil
}
@ -121,10 +111,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
lyricsResponse.Title = title
var lyricsText strings.Builder
for _, line := range structuredLyrics[0].Line {
for _, line := range mainLyric.Line {
lyricsText.WriteString(line.Value + "\n")
}
lyricsResponse.Value = lyricsText.String()
return response, nil
@ -146,8 +135,10 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
return nil, err
}
enhanced, _ := req.Params(r).Bool("enhanced")
response := newResponse()
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics)
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced)
return response, nil
}

View file

@ -16,7 +16,6 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -34,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
MockedMediaFile: mockRepo,
}
artwork = &fakeArtwork{data: "image data"}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil)
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil)
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"
@ -119,28 +118,12 @@ var _ = Describe("MediaRetrievalController", func() {
})
Expect(err).ToNot(HaveOccurred())
baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
mockRepo.SetData(model.MediaFiles{
{
ID: "2",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer
},
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older
},
{
ID: "3",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyrics(r)
@ -149,6 +132,26 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
})
It("should surface the main-kind track when translation tracks are present", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
start := int64(0)
lyricsJSON, err := json.Marshal(model.LyricList{
{Kind: model.LyricKindTranslation, Lang: "por", Line: []model.Line{{Start: &start, Value: "Nunca vou te decepcionar"}}},
{Kind: model.LyricKindMain, Lang: "eng", Line: []model.Line{{Start: &start, Value: "Never gonna let you down"}}},
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJSON),
},
})
response, err := router.GetLyrics(r)
Expect(err).ToNot(HaveOccurred())
Expect(response.Lyrics.Value).To(Equal("Never gonna let you down\n"))
})
It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() {
r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa")
mockRepo.SetData(model.MediaFiles{})
@ -167,12 +170,6 @@ var _ = Describe("MediaRetrievalController", func() {
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
},
{
Path: "tests/fixtures/test.mp3",
ID: "2",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
},
})
response, err := router.GetLyrics(r)
Expect(err).ToNot(HaveOccurred())
@ -181,142 +178,6 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
})
})
Describe("GetLyricsBySongId", func() {
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
var times = []int64{18800, 22801}
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
Expect(actual).ToNot(BeNil())
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
for i, realLyric := range actual.StructuredLyrics {
expectedLyric := expected.StructuredLyrics[i]
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
if expectedLyric.Offset == nil {
Expect(realLyric.Offset).To(BeNil())
} else {
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
}
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
for j, realLine := range realLyric.Line {
expectedLine := expectedLyric.Line[j]
Expect(realLine.Value).To(Equal(expectedLine.Value))
if expectedLine.Start == nil {
Expect(realLine.Start).To(BeNil())
} else {
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
}
}
}
}
It("should return mixed lyrics", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced, *unsynced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
Lang: "eng",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
},
{
Lang: "xxx",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: false,
Line: []responses.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
},
},
})
})
It("should parse lrc metadata", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
Offset: new(int64(-100)),
},
},
})
})
})
})
type fakeArtwork struct {

View file

@ -11,7 +11,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
extensions := responses.OpenSubsonicExtensions{
{Name: "transcodeOffset", Versions: []int32{1}},
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1, 2}},
{Name: "indexBasedQueue", Versions: []int32{1}},
{Name: "transcoding", Versions: []int32{1}},
{Name: "playbackReport", Versions: []int32{1}},

View file

@ -58,7 +58,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
HaveLen(6),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
@ -88,7 +88,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
HaveLen(7),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),

View file

@ -547,13 +547,39 @@ type Line struct {
Value string `xml:",chardata" json:"value"`
}
type LyricCue struct {
Start int64 `xml:"start,attr" json:"start"`
End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"`
ByteStart int `xml:"byteStart,attr" json:"byteStart"`
ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"`
Value string `xml:",chardata" json:"value"`
}
type Agent struct {
ID string `xml:"id,attr" json:"id"`
Role string `xml:"role,attr" json:"role"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
}
type CueLine struct {
Index int32 `xml:"index,attr" json:"index"`
Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"`
End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"`
Value string `xml:"value,attr" json:"value"`
AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"`
Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"`
}
type StructuredLyric struct {
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
Lang string `xml:"lang,attr" json:"lang"`
Line []Line `xml:"line" json:"line"`
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
Synced bool `xml:"synced,attr" json:"synced"`
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"`
Lang string `xml:"lang,attr" json:"lang"`
Line []Line `xml:"line" json:"line"`
Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"`
CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"`
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
Synced bool `xml:"synced,attr" json:"synced"`
}
type StructuredLyrics []StructuredLyric

2
tests/fixtures/bom-test.ttml vendored Normal file
View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml"><body><div xml:lang="eng"><p begin="00:00:00.00">BOM test line</p></div></body></tt>

BIN
tests/fixtures/bom-utf16-test.ttml vendored Normal file

Binary file not shown.

6
tests/fixtures/test-enhanced.lrc vendored Normal file
View file

@ -0,0 +1,6 @@
[ar:Test Artist]
[ti:Enhanced Test]
[lang:eng]
[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here
[00:03.00]<00:03.00>More <00:03.50>words
[00:05.00]Plain line without inline markers

6
tests/fixtures/test-instrumental.yaml vendored Normal file
View file

@ -0,0 +1,6 @@
version: '1.0'
metadata:
title: 'Solo Piano'
artist: 'Composer'
language: 'eng'
instrumental: true

25
tests/fixtures/test-metadata.ttml vendored Normal file
View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
<head>
<metadata>
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
<translations>
<translation xml:lang="es">
<text for="L1">Hola</text>
</translation>
</translations>
<transliterations>
<transliteration xml:lang="ja-Latn">
<text for="L2"><span begin="00:02.000" end="00:02.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:02.300" end="00:02.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text>
</transliteration>
</transliterations>
</iTunesMetadata>
</metadata>
</head>
<body xml:lang="ja">
<div>
<p begin="00:01.000" end="00:01.500" itunes:key="L1">こんにちは</p>
<p begin="00:02.000" end="00:02.700" itunes:key="L2">こんばんは</p>
</div>
</body>
</tt>

24
tests/fixtures/test-overlapping.yaml vendored Normal file
View file

@ -0,0 +1,24 @@
version: '1.0'
metadata:
title: 'Duet'
artist: 'Lead and Echo'
language: 'eng'
lines:
- text: "Lead vocal"
start_ms: 1000
end_ms: 4000
words:
- text: "Lead "
start_ms: 1000
end_ms: 2000
- text: "vocal"
start_ms: 2000
end_ms: 4000
- text: "echo"
start_ms: 2000
end_ms: 3000
words:
- text: "echo"
start_ms: 2000
end_ms: 3000

17
tests/fixtures/test-words.yaml vendored Normal file
View file

@ -0,0 +1,17 @@
version: '1.0'
metadata:
title: 'Karaoke Test'
artist: 'Test Artist'
language: 'eng'
lines:
- text: "Hello world"
start_ms: 1000
end_ms: 3000
words:
- text: "Hello "
start_ms: 1000
end_ms: 1500
- text: "world"
start_ms: 1500
end_ms: 3000

5
tests/fixtures/test.elrc vendored Normal file
View file

@ -0,0 +1,5 @@
[ar:ELRC Artist]
[ti:ELRC Song]
[lang:eng]
[00:01.00]<00:01.00>Lead <00:01.50>words
[00:03.00]Fallback line

7
tests/fixtures/test.srt vendored Normal file
View file

@ -0,0 +1,7 @@
1
00:00:18,800 --> 00:00:22,800
We're from subtitles
2
00:00:22,801 --> 00:00:26,000
Another subtitle line

12
tests/fixtures/test.ttml vendored Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10">
<body>
<div xml:lang="eng">
<p begin="00:00:18.80">We're no strangers to love</p>
<p begin="00:00:22:24">You know the rules and so do I</p>
</div>
<div xml:lang="por">
<p begin="188t">Nao somos estranhos ao amor</p>
</div>
</body>
</tt>

12
tests/fixtures/test.yaml vendored Normal file
View file

@ -0,0 +1,12 @@
version: '1.0'
metadata:
title: 'Sample Track'
artist: 'Test Artist'
language: 'eng'
offset_ms: -100
lines:
- text: "We're no strangers to love"
start_ms: 18800
- text: "You know the rules and so do I"
start_ms: 22801

View file

@ -5,7 +5,7 @@ import (
"io/fs"
)
//go:embed build/*
//go:embed all:build
var filesystem embed.FS
func BuildAssets() fs.FS {

View file

@ -16,3 +16,13 @@ func If[T any](cond bool, v1, v2 T) T {
}
return v2
}
// Clone returns a pointer to a fresh copy of *p, or nil if p is nil. Use it to
// avoid aliasing the pointed-to value when a separate *T is needed.
func Clone[T any](p *T) *T {
if p == nil {
return nil
}
v := *p
return &v
}

View file

@ -46,4 +46,25 @@ var _ = Describe("GG", func() {
Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2))
})
})
Describe("Clone", func() {
It("returns a pointer to a copy of the value", func() {
original := 123
cloned := gg.Clone(&original)
Expect(cloned).To(HaveValue(Equal(123)))
Expect(cloned).NotTo(BeIdenticalTo(&original))
})
It("does not alias the original value", func() {
original := 123
cloned := gg.Clone(&original)
original = 456
Expect(*cloned).To(Equal(123))
})
It("returns nil when the input is nil", func() {
var v *int
Expect(gg.Clone(v)).To(BeNil())
})
})
})