feat(lyrics): add support for os songLyrics v2, elrc word-level timings

This commit is contained in:
Rufei Zhou 2026-05-31 21:17:15 -07:00
parent 823d851b75
commit 0ff86b1ca5
18 changed files with 913 additions and 133 deletions

View File

@ -113,13 +113,15 @@ var _ = Describe("Extractor", func() {
Describe("lyrics", func() {
makeLyrics := func(code, secondLine string) model.Lyrics {
s0 := int64(0)
s1 := int64(2500)
return model.Lyrics{
DisplayArtist: "",
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: new(int64(0)), Value: "This is"},
{Start: new(int64(2500)), Value: secondLine},
CueLine: []model.CueLine{
{Index: 0, Start: &s0, End: &s1, Value: "This is"},
{Index: 1, Start: &s1, Value: secondLine},
},
Offset: nil,
Synced: true,

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", ".yaml,.lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)

View File

@ -24,18 +24,23 @@ var _ = Describe("sources", func() {
unsynced, _ := model.ToLyrics("xxx", badLyrics)
embeddedLyrics := model.LyricList{*unsynced}
syncStart0 := int64(18800)
syncStart1 := int64(22801)
syncedLyrics := model.LyricList{
model.Lyrics{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Line: []model.Line{
CueLine: []model.CueLine{
{
Start: new(int64(18800)),
Index: 0,
Start: &syncStart0,
End: &syncStart1,
Value: "We're no strangers to love",
},
{
Start: new(int64(22801)),
Index: 1,
Start: &syncStart1,
Value: "You know the rules and so do I",
},
},
@ -47,11 +52,13 @@ var _ = Describe("sources", func() {
unsyncedLyrics := model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
CueLine: []model.CueLine{
{
Index: 0,
Value: "We're no strangers to love",
},
{
Index: 1,
Value: "You know the rules and so do I",
},
},

View File

@ -5,6 +5,7 @@ import (
"errors"
"os"
"path"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -36,7 +37,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
return nil, err
}
lyrics, err := model.ToLyrics("xxx", string(contents))
lyrics, err := parseLyricsByExt(suffix, string(contents))
if err != nil {
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
return nil, err
@ -50,6 +51,18 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
return model.LyricList{*lyrics}, nil
}
// parseLyricsByExt dispatches between the YAML Lyricsfile parser and the
// LRC/plain text parser based on file extension. .yaml and .yml are both
// recognized as Lyricsfile candidates.
func parseLyricsByExt(suffix, contents string) (*model.Lyrics, error) {
switch strings.ToLower(suffix) {
case ".yaml", ".yml":
return model.ParseLyricsfile(contents)
default:
return model.ToLyrics("xxx", contents)
}
}
// fromPlugin attempts to load lyrics from a plugin with the given name.
func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
if l.pluginLoader == nil {

View File

@ -66,18 +66,23 @@ var _ = Describe("sources", func() {
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
Expect(err).To(BeNil())
s0 := int64(18800)
s1 := int64(22801)
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Line: []model.Line{
CueLine: []model.CueLine{
{
Start: new(int64(18800)),
Index: 0,
Start: &s0,
End: &s1,
Value: "We're no strangers to love",
},
{
Start: new(int64(22801)),
Index: 1,
Start: &s1,
Value: "You know the rules and so do I",
},
},
@ -95,11 +100,13 @@ var _ = Describe("sources", func() {
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
CueLine: []model.CueLine{
{
Index: 0,
Value: "We're no strangers to love",
},
{
Index: 1,
Value: "You know the rules and so do I",
},
},
@ -120,9 +127,9 @@ var _ = Describe("sources", func() {
// The critical assertion: even with BOM, synced should be true
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
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(ContainSubstring("作曲"))
Expect(lyrics[0].CueLine).To(HaveLen(1))
Expect(lyrics[0].CueLine[0].Start).To(Equal(new(int64(0))))
Expect(lyrics[0].CueLine[0].Value).To(ContainSubstring("作曲"))
})
It("should handle UTF-16 LE encoded LRC files", func() {
@ -135,11 +142,11 @@ var _ = Describe("sources", func() {
// UTF-16 should be properly converted to UTF-8
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
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("We're no strangers to love"))
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"))
Expect(lyrics[0].CueLine).To(HaveLen(2))
Expect(lyrics[0].CueLine[0].Start).To(Equal(new(int64(18800))))
Expect(lyrics[0].CueLine[0].Value).To(Equal("We're no strangers to love"))
Expect(lyrics[0].CueLine[1].Start).To(Equal(new(int64(22801))))
Expect(lyrics[0].CueLine[1].Value).To(Equal("You know the rules and so do I"))
})
})
})

View File

@ -0,0 +1,173 @@
package migrations
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upLyricsV2Shape, downLyricsV2Shape)
}
// upLyricsV2Shape reshapes the JSON stored in media_file.lyrics from the
// legacy v1 [{line: [...]}, ...] structure to the canonical v2 shape
// [{cueLine: [...], synced, ...}]. This is a synchronous in-place rewrite
// performed during migration. After the rewrite a full rescan is forced so
// any external Lyricsfile YAML or ELRC sidecars are picked up to populate
// the now-available v2 fields (per-word cues, agents, kind).
func upLyricsV2Shape(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `select id, lyrics from media_file where lyrics is not null and lyrics != '' and lyrics != '[]'`)
if err != nil {
return fmt.Errorf("scan media_file.lyrics: %w", err)
}
type updatePair struct {
id string
payload string
}
var updates []updatePair
for rows.Next() {
var id, lyricsJSON string
if err := rows.Scan(&id, &lyricsJSON); err != nil {
_ = rows.Close()
return fmt.Errorf("scan row: %w", err)
}
reshaped, err := reshapeLyricsV1ToV2(lyricsJSON)
if err != nil {
// Skip malformed rows; the forced rescan below will attempt to
// repopulate them from source.
continue
}
if reshaped == "" {
continue
}
updates = append(updates, updatePair{id: id, payload: reshaped})
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("scan media_file.lyrics: %w", err)
}
if err := rows.Close(); err != nil {
return err
}
stmt, err := tx.PrepareContext(ctx, `update media_file set lyrics = ? where id = ?`)
if err != nil {
return fmt.Errorf("prepare update: %w", err)
}
defer stmt.Close()
for _, u := range updates {
if _, err := stmt.ExecContext(ctx, u.payload, u.id); err != nil {
return fmt.Errorf("update lyrics for id=%s: %w", u.id, err)
}
}
notice(tx, "Reshaped existing lyrics to v2; a full rescan will run to populate enhanced lyric data from sources")
return forceFullRescan(tx)
}
func downLyricsV2Shape(_ context.Context, _ *sql.Tx) error {
return nil
}
// reshapeLyricsV1ToV2 takes a JSON document that was historically a v1
// LyricList ([{lang, line: [{start, value}], ...}]) and rewrites it as the
// canonical v2 shape ([{lang, cueLine: [{index, start, end, value}], ...}]).
//
// All structures are local to the migration so that future changes to the
// model package do not silently change the migration's wire interpretation.
func reshapeLyricsV1ToV2(input string) (string, error) {
if input == "" {
return "", nil
}
var src []v1Lyric
if err := json.Unmarshal([]byte(input), &src); err != nil {
return "", err
}
out := make([]v2Lyric, 0, len(src))
for _, l := range src {
// Skip rows that already look like the new shape.
if len(l.Line) == 0 && len(l.CueLine) > 0 {
out = append(out, v2Lyric{
DisplayArtist: l.DisplayArtist,
DisplayTitle: l.DisplayTitle,
Lang: l.Lang,
Offset: l.Offset,
Synced: l.Synced,
CueLine: l.CueLine,
})
continue
}
cueLines := make([]v2CueLine, len(l.Line))
for i, ln := range l.Line {
cueLines[i] = v2CueLine{
Index: i,
Start: ln.Start,
Value: ln.Value,
}
}
// Infer end-of-line from the next line's start.
for i := 0; i < len(cueLines)-1; i++ {
if cueLines[i].End == nil && cueLines[i+1].Start != nil {
cueLines[i].End = cueLines[i+1].Start
}
}
out = append(out, v2Lyric{
DisplayArtist: l.DisplayArtist,
DisplayTitle: l.DisplayTitle,
Lang: l.Lang,
Offset: l.Offset,
Synced: l.Synced,
CueLine: cueLines,
})
}
bs, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(bs), nil
}
// v1Lyric is the historical wire shape. CueLine is included so that rows that
// were already migrated by a partial run are passed through unchanged.
type v1Lyric struct {
DisplayArtist string `json:"displayArtist,omitempty"`
DisplayTitle string `json:"displayTitle,omitempty"`
Lang string `json:"lang"`
Line []v1Line `json:"line,omitempty"`
CueLine []v2CueLine `json:"cueLine,omitempty"`
Offset *int64 `json:"offset,omitempty"`
Synced bool `json:"synced"`
}
type v1Line struct {
Start *int64 `json:"start,omitempty"`
Value string `json:"value"`
}
type v2Lyric struct {
DisplayArtist string `json:"displayArtist,omitempty"`
DisplayTitle string `json:"displayTitle,omitempty"`
Lang string `json:"lang"`
Offset *int64 `json:"offset,omitempty"`
Synced bool `json:"synced"`
CueLine []v2CueLine `json:"cueLine,omitempty"`
}
type v2CueLine struct {
Index int `json:"index"`
Start *int64 `json:"start,omitempty"`
End *int64 `json:"end,omitempty"`
Value string `json:"value"`
}

View File

@ -11,39 +11,85 @@ import (
"github.com/navidrome/navidrome/utils/str"
)
type Line struct {
// LyricKind enumerates the v2 OpenSubsonic lyric kinds.
type LyricKind string
const (
LyricKindMain LyricKind = "main"
LyricKindTranslation LyricKind = "translation"
LyricKindPronunciation LyricKind = "pronunciation"
)
// Cue is a single sub-line timed unit (a word or syllable) inside a CueLine.
// Used for word-level karaoke timing.
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"`
}
// CueLine is the canonical v2-shaped representation of a single timed lyric
// line. Index identifies a logical lyrical moment: cuelines that share an
// index are intended to render simultaneously and are disambiguated by AgentID.
// In the absence of overlapping vocals Index equals the cueLine's position in
// the slice.
type CueLine struct {
Index int `structs:"index" json:"index"`
Start *int64 `structs:"start,omitempty" json:"start,omitempty"`
End *int64 `structs:"end,omitempty" json:"end,omitempty"`
Value string `structs:"value" json:"value"`
AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"`
Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"`
}
// Agent declares a vocalist referenced by CueLine.AgentID.
type Agent struct {
ID string `structs:"id" json:"id"`
Name string `structs:"name,omitempty" json:"name,omitempty"`
Role string `structs:"role,omitempty" json:"role,omitempty"`
}
// Lyrics is the canonical v2-shaped lyrics document. The legacy v1 wire shape
// (Line[]) is derived at response build time by collapsing CueLine[].
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"`
Lang string `structs:"lang" json:"lang"`
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
Synced bool `structs:"synced" json:"synced"`
Kind LyricKind `structs:"kind,omitempty" json:"kind,omitempty"`
Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"`
CueLine []CueLine `structs:"cueLine,omitempty" json:"cueLine,omitempty"`
}
func (l Lyrics) IsEmpty() bool {
return len(l.CueLine) == 0
}
// 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})?\]`
// ELRC inline word-timing markers, e.g. <00:12.45> at the start of a word.
const wordTimeRegexString = `<([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):([^]]+)]`)
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
timeRegex = regexp.MustCompile(timeRegexString)
wordTimeRegex = regexp.MustCompile(wordTimeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
)
func (l Lyrics) IsEmpty() bool {
return len(l.Line) == 0
}
// ToLyrics parses a textual lyrics source (plain text, LRC, or ELRC) into the
// canonical Lyrics structure. ELRC inline word-timing markers (`<mm:ss.xx>`)
// are recognized within timestamped lines and produce per-cue word-level data.
// CueLine.End is inferred as the next CueLine's Start; the last CueLine's End
// is left nil.
func ToLyrics(language, text string) (*Lyrics, error) {
text = str.SanitizeText(text)
lines := strings.Split(text, "\n")
structuredLines := make([]Line, 0, len(lines)*2)
cueLines := make([]CueLine, 0, len(lines)*2)
artist := ""
title := ""
@ -51,6 +97,7 @@ func ToLyrics(language, text string) (*Lyrics, error) {
synced := syncRegex.MatchString(text)
priorLine := ""
priorCues := []Cue(nil)
validLine := false
repeated := false
var timestamps []int64
@ -63,8 +110,6 @@ func ToLyrics(language, text string) (*Lyrics, error) {
}
continue
}
var text string
var time *int64 = nil
if synced {
idTag := lrcIdRegex.FindStringSubmatch(line)
@ -105,13 +150,9 @@ func ToLyrics(language, text string) (*Lyrics, error) {
}
if validLine {
for idx := range timestamps {
structuredLines = append(structuredLines, Line{
Start: &timestamps[idx],
Value: strings.TrimSpace(priorLine),
})
}
flushPriorLine(&cueLines, timestamps, priorLine, priorCues)
timestamps = nil
priorCues = nil
}
end := 0
@ -139,47 +180,127 @@ func ToLyrics(language, text string) (*Lyrics, error) {
if end >= len(line) {
priorLine = ""
} else {
priorLine = strings.TrimSpace(line[end:])
rest := strings.TrimSpace(line[end:])
priorLine, priorCues = parseELRCLine(rest)
}
validLine = true
} else {
text = line
structuredLines = append(structuredLines, Line{
Start: time,
Value: text,
cueLines = append(cueLines, CueLine{
Index: len(cueLines),
Value: line,
})
}
}
if validLine {
for idx := range timestamps {
structuredLines = append(structuredLines, Line{
Start: &timestamps[idx],
Value: strings.TrimSpace(priorLine),
})
}
flushPriorLine(&cueLines, timestamps, priorLine, priorCues)
}
// If there are repeated values, there is no guarantee that they are in order
// In this, case, sort the lyrics by start time
// In this case, sort the lyrics by start time and reassign indices.
if repeated {
slices.SortFunc(structuredLines, func(a, b Line) int {
slices.SortFunc(cueLines, func(a, b CueLine) int {
return cmp.Compare(*a.Start, *b.Start)
})
for i := range cueLines {
cueLines[i].Index = i
}
}
// Infer end-of-line from the next line's start (line-only data).
for i := 0; i < len(cueLines)-1; i++ {
if cueLines[i].End == nil && cueLines[i+1].Start != nil {
cueLines[i].End = cueLines[i+1].Start
}
}
lyrics := Lyrics{
DisplayArtist: artist,
DisplayTitle: title,
Lang: language,
Line: structuredLines,
CueLine: cueLines,
Offset: offset,
Synced: synced,
}
return &lyrics, nil
}
// flushPriorLine emits one CueLine per accumulated timestamp, copying the
// shared text/cue data into each. The last "Repeated" use-case is preserved
// (a single text repeats at multiple timestamps).
func flushPriorLine(cueLines *[]CueLine, timestamps []int64, text string, cues []Cue) {
trimmed := strings.TrimSpace(text)
for idx := range timestamps {
startCopy := timestamps[idx]
cl := CueLine{
Index: len(*cueLines),
Start: &startCopy,
Value: trimmed,
}
// Cues are only meaningful for the first occurrence in a repeat; copy
// them onto each cueLine so each independent index has its own cue list.
if len(cues) > 0 {
cl.Cue = make([]Cue, len(cues))
copy(cl.Cue, cues)
}
*cueLines = append(*cueLines, cl)
}
}
// parseELRCLine takes the text following a line-level [mm:ss.xx] marker and
// extracts inline ELRC word timestamps `<mm:ss.xx>word `. It returns the
// concatenated text (which equals cueLine.value when cues are present) and
// the list of cues. When no inline markers are present the cues slice is nil
// and the returned text is the input unchanged.
func parseELRCLine(text string) (string, []Cue) {
matches := wordTimeRegex.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return text, nil
}
// Anything before the first marker is text without a cue timestamp; we
// fold it onto the start of the first cue (most common case is no
// preamble at all).
var preamble string
first := matches[0]
if first[0] > 0 {
preamble = text[:first[0]]
}
cues := make([]Cue, 0, len(matches))
for i, m := range matches {
startMs, err := parseTime(text, m)
if err != nil {
// Malformed timestamp; treat the rest as plain text.
return text, nil
}
ts := startMs
var cueValue string
if i+1 < len(matches) {
cueValue = text[m[1]:matches[i+1][0]]
} else {
cueValue = text[m[1]:]
}
if i == 0 && preamble != "" {
cueValue = preamble + cueValue
}
cues = append(cues, Cue{Start: &ts, Value: cueValue})
}
// Set each cue's End = next cue's Start. Last cue's End remains nil.
for i := 0; i < len(cues)-1; i++ {
cues[i].End = cues[i+1].Start
}
// CueLine.Value is the concatenation of cue values per the v2 spec.
var sb strings.Builder
for _, c := range cues {
sb.WriteString(c.Value)
}
return sb.String(), cues
}
func parseTime(line string, match []int) (int64, error) {
var hours, millis int64
var err error

View File

@ -6,6 +6,19 @@ import (
. "github.com/onsi/gomega"
)
// withEnd takes a slice of CueLine (typically the expected one) and infers
// each non-final cueLine's End from the next cueLine's Start, mirroring the
// behavior baked into ToLyrics. Tests express only Start/Value and rely on
// this helper for End so the expectation tables stay readable.
func withEnd(in []CueLine) []CueLine {
for i := 0; i < len(in)-1; i++ {
if in[i].End == nil && in[i+1].Start != nil {
in[i].End = in[i+1].Start
}
}
return in
}
var _ = Describe("ToLyrics", func() {
It("should parse tags with spaces", func() {
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
@ -27,42 +40,42 @@ var _ = Describe("ToLyrics", func() {
lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(0)), Value: "Hi there"},
{Start: new(int64(10040)), Value: ""},
{Start: new(int64(40000)), Value: "Test"},
{Start: new(int64(1000 * 60 * 60)), Value: "late"},
}))
Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{
{Index: 0, Start: new(int64(0)), Value: "Hi there"},
{Index: 1, Start: new(int64(10040)), Value: ""},
{Index: 2, Start: new(int64(40000)), Value: "Test"},
{Index: 3, Start: new(int64(1000 * 60 * 60)), Value: "late"},
})))
})
It("Should support multiple timestamps per line", func() {
lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(0)), Value: "Repeated"},
{Start: new(int64(10000)), Value: "Repeated"},
{Start: new(int64(13 * 60 * 1000)), Value: ""},
{Start: new(int64(1000 * 60 * 60 * 51)), Value: ""},
}))
Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{
{Index: 0, Start: new(int64(0)), Value: "Repeated"},
{Index: 1, Start: new(int64(10000)), Value: "Repeated"},
{Index: 2, Start: new(int64(13 * 60 * 1000)), Value: ""},
{Index: 3, Start: new(int64(1000 * 60 * 60 * 51)), Value: ""},
})))
})
It("Should support parsing multiline string", func() {
lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"},
{Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"},
}))
Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{
{Index: 0, Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"},
{Index: 1, Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"},
})))
})
It("Does not match timestamp in middle of line", func() {
lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeFalse())
Expect(lyrics.Line).To(Equal([]Line{
{Value: "This could [00:00:00] be a synced file"},
Expect(lyrics.CueLine).To(Equal([]CueLine{
{Index: 0, Value: "This could [00:00:00] be a synced file"},
}))
})
@ -70,18 +83,18 @@ var _ = Describe("ToLyrics", func() {
lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"},
{Start: new(int64(1000)), Value: "Line 2"},
}))
Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{
{Index: 0, Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"},
{Index: 1, Start: new(int64(1000)), Value: "Line 2"},
})))
})
It("Ignores lines in synchronized lyric prior to first timestamp", func() {
lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(0)), Value: "Text"},
Expect(lyrics.CueLine).To(Equal([]CueLine{
{Index: 0, Start: new(int64(0)), Value: "Text"},
}))
})
@ -89,23 +102,44 @@ var _ = Describe("ToLyrics", func() {
lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(1)), Value: "a"},
{Start: new(int64(10)), Value: "b"},
{Start: new(int64(100)), Value: "c"},
}))
Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{
{Index: 0, Start: new(int64(1)), Value: "a"},
{Index: 1, Start: new(int64(10)), Value: "b"},
{Index: 2, Start: new(int64(100)), Value: "c"},
})))
})
It("Properly sorts repeated lyrics out of order", func() {
lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(0)), Value: "Repeated"},
{Start: new(int64(10000)), Value: "Test"},
{Start: new(int64(40000)), Value: "Not repeated"},
{Start: new(int64(13 * 60 * 1000)), Value: "Repeated"},
{Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"},
}))
Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{
{Index: 0, Start: new(int64(0)), Value: "Repeated"},
{Index: 1, Start: new(int64(10000)), Value: "Test"},
{Index: 2, Start: new(int64(40000)), Value: "Not repeated"},
{Index: 3, Start: new(int64(13 * 60 * 1000)), Value: "Repeated"},
{Index: 4, Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"},
})))
})
Describe("ELRC inline word timing", func() {
It("parses inline <mm:ss.xx> word markers into cues", func() {
lyrics, err := ToLyrics("eng", "[00:00.00]<00:00.10>Hello <00:00.50>world")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.CueLine).To(HaveLen(1))
cl := lyrics.CueLine[0]
Expect(cl.Value).To(Equal("Hello world"))
Expect(cl.Cue).To(HaveLen(2))
Expect(*cl.Cue[0].Start).To(Equal(int64(100)))
Expect(cl.Cue[0].Value).To(Equal("Hello "))
Expect(*cl.Cue[1].Start).To(Equal(int64(500)))
Expect(cl.Cue[1].Value).To(Equal("world"))
// First cue's End is inferred to second cue's Start.
Expect(cl.Cue[0].End).ToNot(BeNil())
Expect(*cl.Cue[0].End).To(Equal(int64(500)))
// Last cue has no inferred end.
Expect(cl.Cue[1].End).To(BeNil())
})
})
})

220
model/lyricsfile.go Normal file
View File

@ -0,0 +1,220 @@
package model
import (
"errors"
"fmt"
"strings"
"github.com/navidrome/navidrome/utils/str"
"gopkg.in/yaml.v3"
)
// ParseLyricsfile parses a Lyricsfile YAML document
// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) and produces
// the canonical Lyrics representation. Returns a non-nil error if the input
// is not a valid YAML document or does not appear to be a Lyricsfile
func ParseLyricsfile(text string) (*Lyrics, 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 YAML document: %w", err)
}
// Shape validation: a Lyricsfile must have at least version + metadata.
// We accept slightly relaxed inputs (no version) only when both metadata
// and lines are populated
if doc.Version == "" && doc.Metadata.isEmpty() && len(doc.Lines) == 0 && !doc.Metadata.Instrumental {
return nil, errors.New("YAML document does not appear to be a Lyricsfile (missing version, metadata, and lines)")
}
lyrics := &Lyrics{
DisplayArtist: str.SanitizeText(doc.Metadata.Artist),
DisplayTitle: str.SanitizeText(doc.Metadata.Title),
Lang: str.SanitizeText(doc.Metadata.Language),
Synced: false,
}
if lyrics.Lang == "" {
lyrics.Lang = "xxx"
}
if doc.Metadata.OffsetMs != 0 {
off := doc.Metadata.OffsetMs
lyrics.Offset = &off
}
if doc.Metadata.Instrumental {
// Instrumental tracks are represented as an empty cue list with
// Synced=false. Clients infer instrumental status elsewhere.
return lyrics, nil
}
if len(doc.Lines) == 0 {
return lyrics, nil
}
cueLines, agents := buildLyricsfileCueLines(doc.Lines)
lyrics.CueLine = cueLines
lyrics.Agents = agents
lyrics.Synced = true
return lyrics, nil
}
type lyricsfileDocument struct {
Version string `yaml:"version"`
Metadata lyricsfileMetadata `yaml:"metadata"`
Lines []lyricsfileLineEntry `yaml:"lines"`
Plain string `yaml:"plain"`
Extra map[string]yaml.Node `yaml:",inline"`
}
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"`
}
func (m lyricsfileMetadata) isEmpty() bool {
return m.Title == "" && m.Artist == "" && m.Album == "" &&
m.DurationMs == 0 && m.OffsetMs == 0 && m.Language == "" && !m.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"`
}
// buildLyricsfileCueLines runs the streaming overlap-clustering algorithm over
// the parsed Lyricsfile lines and produces:
// - one CueLine per line, with Index reflecting cluster membership and
// AgentID assigned via the lowest-free voice rule.
// - the synthetic Agents slice. When the song has no overlapping vocals
// (only voice-0 ever used), the Agents slice and per-line AgentID are
// left empty so the wire format stays simple.
func buildLyricsfileCueLines(entries []lyricsfileLineEntry) ([]CueLine, []Agent) {
cueLines := make([]CueLine, 0, len(entries))
// Resolved end timestamps for each entry (handles missing end_ms by
// inferring from the next line's start; the last line stays open).
ends := make([]*int64, len(entries))
for i := range entries {
if entries[i].EndMs != nil {
endCopy := *entries[i].EndMs
ends[i] = &endCopy
} else if i+1 < len(entries) {
startCopy := entries[i+1].StartMs
ends[i] = &startCopy
}
}
// active maps voice ID -> end_ms of the line currently held by that voice.
active := map[int]int64{}
currentIndex := -1
maxVoiceUsed := -1
hasPriorLine := false
for i, entry := range entries {
// Prune voices whose end <= this line's start.
for v, e := range active {
if e <= entry.StartMs {
delete(active, v)
}
}
// New cluster when the active set is empty before adding this line.
if !hasPriorLine || len(active) == 0 {
currentIndex++
}
// Lowest-free voice ID.
voiceID := 0
for {
if _, busy := active[voiceID]; !busy {
break
}
voiceID++
}
if voiceID > maxVoiceUsed {
maxVoiceUsed = voiceID
}
startCopy := entry.StartMs
cl := CueLine{
Index: currentIndex,
Start: &startCopy,
End: ends[i],
Value: entry.Text,
AgentID: fmt.Sprintf("voice-%d", voiceID),
Cue: wordsToCues(entry.Words),
}
cueLines = append(cueLines, cl)
// Track this voice as active until its resolved end.
var endMs int64
if ends[i] != nil {
endMs = *ends[i]
} else {
// No known end; treat as immediately freed so future lines aren't
// blocked. The voice continues to render based on its Start alone.
endMs = entry.StartMs
}
active[voiceID] = endMs
hasPriorLine = true
}
// If we never used more than voice-0, the song is monophonic. Strip the
// AgentID fields and return no Agents slice for a clean wire shape.
if maxVoiceUsed <= 0 {
for i := range cueLines {
cueLines[i].AgentID = ""
}
return cueLines, nil
}
agents := make([]Agent, 0, maxVoiceUsed+1)
for v := 0; v <= maxVoiceUsed; v++ {
a := Agent{ID: fmt.Sprintf("voice-%d", v)}
if v == 0 {
a.Role = string(LyricKindMain)
}
agents = append(agents, a)
}
return cueLines, agents
}
// wordsToCues converts Lyricsfile word entries into model Cues. Cue.End is
// taken from word.end_ms when present; otherwise inferred from the next word's
// start (last word's End stays nil if it has no explicit end_ms).
func wordsToCues(words []lyricsfileWordEntry) []Cue {
if len(words) == 0 {
return nil
}
cues := make([]Cue, len(words))
for i, w := range words {
startCopy := w.StartMs
cues[i].Start = &startCopy
cues[i].Value = w.Text
if w.EndMs != nil {
endCopy := *w.EndMs
cues[i].End = &endCopy
}
}
for i := 0; i < len(cues)-1; i++ {
if cues[i].End == nil && cues[i+1].Start != nil {
cues[i].End = cues[i+1].Start
}
}
return cues
}

View File

@ -105,12 +105,14 @@ var _ = Describe("ToMediaFile", func() {
err := json.Unmarshal([]byte(mf.Lyrics), &actual)
Expect(err).ToNot(HaveOccurred())
engStart0 := int64(0)
engStart1 := int64(2500)
expected := model.LyricList{
{Lang: "eng", Line: []model.Line{
{Value: "This is", Start: new(int64(0))},
{Value: "English SYLT", Start: new(int64(2500))},
{Lang: "eng", CueLine: []model.CueLine{
{Index: 0, Value: "This is", Start: &engStart0, End: &engStart1},
{Index: 1, Value: "English SYLT", Start: &engStart1},
}, Synced: true},
{Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false},
{Lang: "xxx", CueLine: []model.CueLine{{Index: 0, Value: "Lyrics"}}, Synced: false},
}
sort.Slice(actual, func(i, j int) bool { return actual[i].Lang < actual[j].Lang })
sort.Slice(expected, func(i, j int) bool { return expected[i].Lang < expected[j].Lang })

View File

@ -52,8 +52,8 @@ var _ = Describe("LyricsPlugin", Ordered, func() {
result, err := provider.GetLyrics(GinkgoT().Context(), track)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Line).ToNot(BeEmpty())
Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song"))
Expect(result[0].CueLine).ToNot(BeEmpty())
Expect(result[0].CueLine[0].Value).To(ContainSubstring("Test Song"))
})
It("defaults language to 'xxx' when plugin does not provide one", func() {

View File

@ -20,20 +20,21 @@ var _ = Describe("Tags", func() {
var secondTs int64 = 2500
makeLyrics := func(synced bool, lang, secondLine string) model.Lyrics {
lines := []model.Line{
{Value: "This is"},
{Value: secondLine},
cueLines := []model.CueLine{
{Index: 0, Value: "This is"},
{Index: 1, Value: secondLine},
}
if synced {
lines[0].Start = &zero
lines[1].Start = &secondTs
cueLines[0].Start = &zero
cueLines[0].End = &secondTs
cueLines[1].Start = &secondTs
}
lyrics := model.Lyrics{
Lang: lang,
Line: lines,
Synced: synced,
Lang: lang,
CueLine: cueLines,
Synced: synced,
}
return lyrics
@ -45,7 +46,7 @@ var _ = Describe("Tags", func() {
if langDiff != 0 {
return langDiff
}
return cmp.Compare(a.Line[1].Value, b.Line[1].Value)
return cmp.Compare(a.CueLine[1].Value, b.CueLine[1].Value)
})
return lines

View File

@ -494,15 +494,10 @@ 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,
}
}
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric {
// V1 line-level shape: collapse one CueLine per logical moment (lowest
// AgentID wins when multiple agents share an Index).
lines := buildV1Lines(lyrics.CueLine)
structured := responses.StructuredLyric{
DisplayArtist: lyrics.DisplayArtist,
@ -513,6 +508,17 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.St
Synced: lyrics.Synced,
}
if enhanced {
structured.Kind = string(lyrics.Kind)
if len(lyrics.Agents) > 0 {
structured.Agents = make([]responses.Agent, len(lyrics.Agents))
for i, a := range lyrics.Agents {
structured.Agents[i] = responses.Agent{ID: a.ID, Name: a.Name, Role: a.Role}
}
}
structured.CueLine = buildV2CueLines(lyrics.CueLine)
}
if structured.DisplayArtist == "" {
structured.DisplayArtist = mf.Artist
}
@ -523,11 +529,112 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.St
return structured
}
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList {
// buildV1Lines collapses canonical CueLines into the v1 wire shape. When
// multiple cuelines share an Index (overlapping vocals), only the first one
// (lowest agentID) is included to keep v1-only clients deterministic.
func buildV1Lines(cueLines []model.CueLine) []responses.Line {
if len(cueLines) == 0 {
return nil
}
lines := make([]responses.Line, 0, len(cueLines))
seenIndex := -1
for _, cl := range cueLines {
if cl.Index == seenIndex {
continue
}
seenIndex = cl.Index
lines = append(lines, responses.Line{
Start: cl.Start,
Value: cl.Value,
})
}
return lines
}
// buildV2CueLines emits the v2 cueLine[] structure. Line-only cuelines (no
// per-word data) are skipped
func buildV2CueLines(cueLines []model.CueLine) []responses.CueLine {
if len(cueLines) == 0 {
return nil
}
out := make([]responses.CueLine, 0, len(cueLines))
for _, cl := range cueLines {
if len(cl.Cue) == 0 {
continue
}
out = append(out, responses.CueLine{
Index: cl.Index,
Start: cl.Start,
End: cl.End,
Value: cl.Value,
AgentID: cl.AgentID,
Cue: buildCue(cl),
})
}
if len(out) == 0 {
return nil
}
return out
}
// buildCue maps each model.Cue to one responses.Cue with inclusive UTF-8
// ByteStart/ByteEnd offsets into cl.Value.
func buildCue(cl model.CueLine) []responses.Cue {
if len(cl.Cue) == 0 {
return nil
}
ends := make([]*int64, len(cl.Cue))
for i, c := range cl.Cue {
ends[i] = c.End
}
if ends[len(ends)-1] == nil && cl.End != nil {
e := *cl.End
ends[len(ends)-1] = &e
}
allHaveEnd := true
anyEnd := false
for _, e := range ends {
if e != nil {
anyEnd = true
} else {
allHaveEnd = false
}
}
if anyEnd && !allHaveEnd {
for i := range ends {
ends[i] = nil
}
}
cues := make([]responses.Cue, len(cl.Cue))
var byteCursor int64
for i, c := range cl.Cue {
valueBytes := int64(len(c.Value))
bs := byteCursor
be := bs
if valueBytes > 0 {
be = bs + valueBytes - 1
byteCursor = be + 1
}
bsCopy, beCopy := bs, be
cues[i] = responses.Cue{
Start: c.Start,
End: ends[i],
Value: c.Value,
ByteStart: &bsCopy,
ByteEnd: &beCopy,
}
}
return cues
}
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList {
lyricList := make(responses.StructuredLyrics, len(lyricsList))
for i, lyrics := range lyricsList {
lyricList[i] = buildStructuredLyric(mf, lyrics)
lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced)
}
res := &responses.LyricsList{

View File

@ -121,8 +121,8 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
lyricsResponse.Title = title
var lyricsText strings.Builder
for _, line := range structuredLyrics[0].Line {
lyricsText.WriteString(line.Value + "\n")
for _, cl := range structuredLyrics[0].CueLine {
lyricsText.WriteString(cl.Value + "\n")
}
lyricsResponse.Value = lyricsText.String()
@ -131,10 +131,12 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
}
func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, error) {
id, err := req.Params(r).String("id")
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
enhanced, _ := p.Bool("enhanced")
mediaFile, err := api.ds.MediaFile(r.Context()).Get(id)
if err != nil {
@ -147,7 +149,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
}
response := newResponse()
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics)
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced)
return response, nil
}

View File

@ -316,6 +316,71 @@ var _ = Describe("MediaRetrievalController", func() {
},
})
})
When("enhanced=true is requested", func() {
setLyrics := func(lrc string) {
parsed, err := model.ToLyrics("eng", lrc)
Expect(err).ToNot(HaveOccurred())
lyricsJson, err := json.Marshal(model.LyricList{*parsed})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
}})
}
It("omits cueLine entirely when all lines are line-only LRC", func() {
setLyrics(syncedLyrics)
response, err := router.GetLyricsBySongId(newGetRequest("id=1", "enhanced=true"))
Expect(err).ToNot(HaveOccurred())
Expect(response.LyricsList.StructuredLyrics).To(HaveLen(1))
Expect(response.LyricsList.StructuredLyrics[0].Line).To(HaveLen(2))
Expect(response.LyricsList.StructuredLyrics[0].CueLine).To(BeNil())
})
It("emits cueLine with per-word cue[] and inclusive byte offsets for ELRC word data, skipping line-only lines", func() {
setLyrics("[00:18.80]<00:18.80>We're <00:19.20>no <00:19.50>strangers\n[00:22.801]Line without word data")
response, err := router.GetLyricsBySongId(newGetRequest("id=1", "enhanced=true"))
Expect(err).ToNot(HaveOccurred())
Expect(response.LyricsList.StructuredLyrics).To(HaveLen(1))
structured := response.LyricsList.StructuredLyrics[0]
Expect(structured.Line).To(HaveLen(2))
Expect(structured.CueLine).To(HaveLen(1))
cl := structured.CueLine[0]
Expect(cl.Index).To(Equal(0))
Expect(cl.Value).To(Equal("We're no strangers"))
Expect(cl.Start).ToNot(BeNil())
Expect(*cl.Start).To(Equal(int64(18800)))
Expect(cl.End).ToNot(BeNil())
Expect(*cl.End).To(Equal(int64(22801)))
Expect(cl.Cue).To(HaveLen(3))
Expect(cl.Cue[0].Value).To(Equal("We're "))
Expect(*cl.Cue[0].Start).To(Equal(int64(18800)))
Expect(*cl.Cue[0].End).To(Equal(int64(19200)))
Expect(*cl.Cue[0].ByteStart).To(Equal(int64(0)))
Expect(*cl.Cue[0].ByteEnd).To(Equal(int64(5)))
Expect(cl.Cue[1].Value).To(Equal("no "))
Expect(*cl.Cue[1].Start).To(Equal(int64(19200)))
Expect(*cl.Cue[1].End).To(Equal(int64(19500)))
Expect(*cl.Cue[1].ByteStart).To(Equal(int64(6)))
Expect(*cl.Cue[1].ByteEnd).To(Equal(int64(8)))
Expect(cl.Cue[2].Value).To(Equal("strangers"))
Expect(*cl.Cue[2].Start).To(Equal(int64(19500)))
Expect(*cl.Cue[2].End).To(Equal(int64(22801)))
Expect(*cl.Cue[2].ByteStart).To(Equal(int64(9)))
Expect(*cl.Cue[2].ByteEnd).To(Equal(int64(17)))
})
})
})
})

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 Cue struct {
Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"`
End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"`
ByteStart *int64 `xml:"byteStart,attr,omitempty" json:"byteStart,omitempty"`
ByteEnd *int64 `xml:"byteEnd,attr,omitempty" json:"byteEnd,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type CueLine struct {
Index int `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 []Cue `xml:"cue,omitempty" json:"cue,omitempty"`
}
type Agent struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
Role string `xml:"role,attr,omitempty" json:"role,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"`
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"`
Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"`
Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"`
CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"`
}
type StructuredLyrics []StructuredLyric