mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(lyrics): add support for os songLyrics v2, elrc word-level timings
This commit is contained in:
parent
823d851b75
commit
0ff86b1ca5
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
173
db/migrations/20260526195106_lyrics_v2_shape.go
Normal file
173
db/migrations/20260526195106_lyrics_v2_shape.go
Normal 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"`
|
||||
}
|
||||
195
model/lyrics.go
195
model/lyrics.go
@ -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: ×tamps[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: ×tamps[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
|
||||
|
||||
@ -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
220
model/lyricsfile.go
Normal 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
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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}},
|
||||
|
||||
@ -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}}),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user