mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
refactor(subsonic): extract lyric response building into lyrics.go
buildStructuredLyric had grown a ~70-line inline block doing agent indexing, a bespoke main-first agent sort, and per-agent cue-line assembly, pushing helpers.go past 700 lines. Move all lyric response builders into a dedicated lyrics.go and split the inline logic into focused units: a lyricAgents index with a named less() comparator, and buildCueLines for per-agent assembly. helpers.go drops from 705 to 533 lines; no behavior change (existing v2 cue/agent tests pass).
This commit is contained in:
parent
5cd2a672d6
commit
cb18e49eed
@ -495,178 +495,6 @@ func mapExplicitStatus(explicitStatus string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric {
|
||||
lines := make([]responses.Line, len(lyrics.Line))
|
||||
var cueLines []responses.CueLine
|
||||
agentOrderByID := make(map[string]int, len(lyrics.Agents))
|
||||
agentRoleByID := make(map[string]string, len(lyrics.Agents))
|
||||
responseAgents := make([]responses.Agent, 0, len(lyrics.Agents))
|
||||
|
||||
for i, agent := range lyrics.Agents {
|
||||
agentOrderByID[agent.ID] = i
|
||||
agentRoleByID[agent.ID] = agent.Role
|
||||
responseAgents = append(responseAgents, responses.Agent{
|
||||
ID: agent.ID,
|
||||
Role: agent.Role,
|
||||
Name: agent.Name,
|
||||
})
|
||||
}
|
||||
|
||||
for i, line := range lyrics.Line {
|
||||
lines[i] = responses.Line{
|
||||
Start: line.Start,
|
||||
Value: line.Value,
|
||||
}
|
||||
if !enhanced || len(line.Cue) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
agentOrder := make([]string, 0, 2)
|
||||
cuesByAgent := make(map[string][]model.Cue)
|
||||
for _, cue := range line.Cue {
|
||||
if cue.Start == nil {
|
||||
continue
|
||||
}
|
||||
agentID := strings.TrimSpace(cue.AgentID)
|
||||
if _, exists := cuesByAgent[agentID]; !exists {
|
||||
agentOrder = append(agentOrder, agentID)
|
||||
}
|
||||
cuesByAgent[agentID] = append(cuesByAgent[agentID], cue)
|
||||
}
|
||||
|
||||
sort.SliceStable(agentOrder, func(i, j int) bool {
|
||||
leftRole := agentRoleByID[agentOrder[i]]
|
||||
rightRole := agentRoleByID[agentOrder[j]]
|
||||
if leftRole == "main" && rightRole != "main" {
|
||||
return true
|
||||
}
|
||||
if rightRole == "main" && leftRole != "main" {
|
||||
return false
|
||||
}
|
||||
|
||||
leftOrder, leftOK := agentOrderByID[agentOrder[i]]
|
||||
rightOrder, rightOK := agentOrderByID[agentOrder[j]]
|
||||
if leftOK && rightOK && leftOrder != rightOrder {
|
||||
return leftOrder < rightOrder
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return i < j
|
||||
})
|
||||
|
||||
for _, agentID := range agentOrder {
|
||||
cueLine := responses.CueLine{
|
||||
Index: int32(i),
|
||||
Start: line.Start,
|
||||
End: line.End,
|
||||
Value: line.Value,
|
||||
Cue: buildLyricCues(cuesByAgent[agentID], line.End),
|
||||
}
|
||||
if agentID != "" {
|
||||
cueLine.AgentID = agentID
|
||||
}
|
||||
cueLines = append(cueLines, cueLine)
|
||||
}
|
||||
}
|
||||
|
||||
structured := responses.StructuredLyric{
|
||||
DisplayArtist: lyrics.DisplayArtist,
|
||||
DisplayTitle: lyrics.DisplayTitle,
|
||||
Lang: lyrics.Lang,
|
||||
Line: lines,
|
||||
CueLine: cueLines,
|
||||
Offset: lyrics.Offset,
|
||||
Synced: lyrics.Synced,
|
||||
}
|
||||
|
||||
if enhanced {
|
||||
structured.Kind = model.LyricKindOrMain(lyrics.Kind)
|
||||
if len(cueLines) > 0 && len(responseAgents) > 0 {
|
||||
structured.Agents = responseAgents
|
||||
}
|
||||
}
|
||||
|
||||
if structured.DisplayArtist == "" {
|
||||
structured.DisplayArtist = mf.Artist
|
||||
}
|
||||
if structured.DisplayTitle == "" {
|
||||
structured.DisplayTitle = mf.Title
|
||||
}
|
||||
|
||||
return structured
|
||||
}
|
||||
|
||||
func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue {
|
||||
if len(cues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only resolve end times when at least one cue carries one; otherwise the
|
||||
// group is start-only and must stay that way.
|
||||
hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil })
|
||||
if hasAnyEnd {
|
||||
cues = model.NormalizeCueEnds(cues, lineEnd)
|
||||
}
|
||||
|
||||
out := make([]responses.LyricCue, 0, len(cues))
|
||||
for i := range cues {
|
||||
if cues[i].Start == nil {
|
||||
continue
|
||||
}
|
||||
cue := responses.LyricCue{
|
||||
Start: *cues[i].Start,
|
||||
Value: cues[i].Value,
|
||||
ByteStart: cues[i].ByteStart,
|
||||
ByteEnd: cues[i].ByteEnd,
|
||||
}
|
||||
if hasAnyEnd {
|
||||
cue.End = cues[i].End
|
||||
}
|
||||
out = append(out, cue)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mainKindLyric returns the main-kind lyric to surface through the plain-text
|
||||
// legacy getLyrics endpoint, which has no notion of translation/pronunciation
|
||||
// tracks. It falls back to the first entry so untyped lyrics still resolve.
|
||||
func mainKindLyric(lyricsList model.LyricList) (model.Lyrics, bool) {
|
||||
if len(lyricsList) == 0 {
|
||||
return model.Lyrics{}, false
|
||||
}
|
||||
for _, l := range lyricsList {
|
||||
if model.LyricKindOrMain(l.Kind) == model.LyricKindMain {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return lyricsList[0], true
|
||||
}
|
||||
|
||||
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList {
|
||||
var filtered model.LyricList
|
||||
if enhanced {
|
||||
filtered = lyricsList
|
||||
} else {
|
||||
// Without enhanced, only return main-kind entries (a blank kind is main).
|
||||
for _, l := range lyricsList {
|
||||
if model.LyricKindOrMain(l.Kind) == model.LyricKindMain {
|
||||
filtered = append(filtered, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lyricList := make(responses.StructuredLyrics, len(filtered))
|
||||
for i, lyrics := range filtered {
|
||||
lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced)
|
||||
}
|
||||
|
||||
res := &responses.LyricsList{
|
||||
StructuredLyrics: lyricList,
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// getUserAccessibleLibraries returns the list of libraries the current user has access to.
|
||||
func getUserAccessibleLibraries(ctx context.Context) []model.Library {
|
||||
user := getUser(ctx)
|
||||
|
||||
196
server/subsonic/lyrics.go
Normal file
196
server/subsonic/lyrics.go
Normal file
@ -0,0 +1,196 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
)
|
||||
|
||||
// agentRoleMain is the OpenSubsonic agent role that marks the primary vocal
|
||||
// layer; its cue line is emitted before other agents sharing the same index.
|
||||
const agentRoleMain = "main"
|
||||
|
||||
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList {
|
||||
filtered := lyricsList
|
||||
if !enhanced {
|
||||
// Without enhanced, only return main-kind entries (a blank kind is main).
|
||||
filtered = nil
|
||||
for _, l := range lyricsList {
|
||||
if model.LyricKindOrMain(l.Kind) == model.LyricKindMain {
|
||||
filtered = append(filtered, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lyricList := make(responses.StructuredLyrics, len(filtered))
|
||||
for i, lyrics := range filtered {
|
||||
lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced)
|
||||
}
|
||||
return &responses.LyricsList{StructuredLyrics: lyricList}
|
||||
}
|
||||
|
||||
// mainKindLyric returns the main-kind lyric to surface through the plain-text
|
||||
// legacy getLyrics endpoint, which has no notion of translation/pronunciation
|
||||
// tracks. It falls back to the first entry so untyped lyrics still resolve.
|
||||
func mainKindLyric(lyricsList model.LyricList) (model.Lyrics, bool) {
|
||||
if len(lyricsList) == 0 {
|
||||
return model.Lyrics{}, false
|
||||
}
|
||||
for _, l := range lyricsList {
|
||||
if model.LyricKindOrMain(l.Kind) == model.LyricKindMain {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return lyricsList[0], true
|
||||
}
|
||||
|
||||
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric {
|
||||
agents := newLyricAgents(lyrics.Agents)
|
||||
|
||||
lines := make([]responses.Line, len(lyrics.Line))
|
||||
var cueLines []responses.CueLine
|
||||
for i, line := range lyrics.Line {
|
||||
lines[i] = responses.Line{Start: line.Start, Value: line.Value}
|
||||
if enhanced && len(line.Cue) > 0 {
|
||||
cueLines = append(cueLines, buildCueLines(line, int32(i), agents)...)
|
||||
}
|
||||
}
|
||||
|
||||
structured := responses.StructuredLyric{
|
||||
DisplayArtist: lyrics.DisplayArtist,
|
||||
DisplayTitle: lyrics.DisplayTitle,
|
||||
Lang: lyrics.Lang,
|
||||
Line: lines,
|
||||
CueLine: cueLines,
|
||||
Offset: lyrics.Offset,
|
||||
Synced: lyrics.Synced,
|
||||
}
|
||||
|
||||
if enhanced {
|
||||
structured.Kind = model.LyricKindOrMain(lyrics.Kind)
|
||||
if len(cueLines) > 0 && len(agents.response) > 0 {
|
||||
structured.Agents = agents.response
|
||||
}
|
||||
}
|
||||
|
||||
if structured.DisplayArtist == "" {
|
||||
structured.DisplayArtist = mf.Artist
|
||||
}
|
||||
if structured.DisplayTitle == "" {
|
||||
structured.DisplayTitle = mf.Title
|
||||
}
|
||||
return structured
|
||||
}
|
||||
|
||||
// lyricAgents indexes a lyric's agents by ID so cue lines can be ordered and
|
||||
// the response agent list reused without rescanning the slice per line.
|
||||
type lyricAgents struct {
|
||||
orderByID map[string]int
|
||||
roleByID map[string]string
|
||||
response []responses.Agent
|
||||
}
|
||||
|
||||
func newLyricAgents(agents []model.Agent) lyricAgents {
|
||||
a := lyricAgents{
|
||||
orderByID: make(map[string]int, len(agents)),
|
||||
roleByID: make(map[string]string, len(agents)),
|
||||
response: make([]responses.Agent, 0, len(agents)),
|
||||
}
|
||||
for i, agent := range agents {
|
||||
a.orderByID[agent.ID] = i
|
||||
a.roleByID[agent.ID] = agent.Role
|
||||
a.response = append(a.response, responses.Agent{ID: agent.ID, Role: agent.Role, Name: agent.Name})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// buildCueLines splits a line's cues by agent and emits one CueLine per agent,
|
||||
// ordered main-role first then by the agent's declared order.
|
||||
func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses.CueLine {
|
||||
agentOrder := make([]string, 0, 2)
|
||||
cuesByAgent := make(map[string][]model.Cue)
|
||||
for _, cue := range line.Cue {
|
||||
if cue.Start == nil {
|
||||
continue
|
||||
}
|
||||
agentID := strings.TrimSpace(cue.AgentID)
|
||||
if _, exists := cuesByAgent[agentID]; !exists {
|
||||
agentOrder = append(agentOrder, agentID)
|
||||
}
|
||||
cuesByAgent[agentID] = append(cuesByAgent[agentID], cue)
|
||||
}
|
||||
|
||||
sort.SliceStable(agentOrder, func(i, j int) bool {
|
||||
return agents.less(agentOrder[i], agentOrder[j], i, j)
|
||||
})
|
||||
|
||||
cueLines := make([]responses.CueLine, 0, len(agentOrder))
|
||||
for _, agentID := range agentOrder {
|
||||
cueLine := responses.CueLine{
|
||||
Index: index,
|
||||
Start: line.Start,
|
||||
End: line.End,
|
||||
Value: line.Value,
|
||||
Cue: buildLyricCues(cuesByAgent[agentID], line.End),
|
||||
}
|
||||
if agentID != "" {
|
||||
cueLine.AgentID = agentID
|
||||
}
|
||||
cueLines = append(cueLines, cueLine)
|
||||
}
|
||||
return cueLines
|
||||
}
|
||||
|
||||
// less orders two agent IDs: the main role wins, then the declared agent order,
|
||||
// then known-before-unknown, then the original encounter order (origI/origJ).
|
||||
func (a lyricAgents) less(left, right string, origI, origJ int) bool {
|
||||
leftMain := a.roleByID[left] == agentRoleMain
|
||||
rightMain := a.roleByID[right] == agentRoleMain
|
||||
if leftMain != rightMain {
|
||||
return leftMain
|
||||
}
|
||||
|
||||
leftOrder, leftOK := a.orderByID[left]
|
||||
rightOrder, rightOK := a.orderByID[right]
|
||||
if leftOK && rightOK && leftOrder != rightOrder {
|
||||
return leftOrder < rightOrder
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return origI < origJ
|
||||
}
|
||||
|
||||
func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue {
|
||||
if len(cues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only resolve end times when at least one cue carries one; otherwise the
|
||||
// group is start-only and must stay that way.
|
||||
hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil })
|
||||
if hasAnyEnd {
|
||||
cues = model.NormalizeCueEnds(cues, lineEnd)
|
||||
}
|
||||
|
||||
out := make([]responses.LyricCue, 0, len(cues))
|
||||
for i := range cues {
|
||||
if cues[i].Start == nil {
|
||||
continue
|
||||
}
|
||||
cue := responses.LyricCue{
|
||||
Start: *cues[i].Start,
|
||||
Value: cues[i].Value,
|
||||
ByteStart: cues[i].ByteStart,
|
||||
ByteEnd: cues[i].ByteEnd,
|
||||
}
|
||||
if hasAnyEnd {
|
||||
cue.End = cues[i].End
|
||||
}
|
||||
out = append(out, cue)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user