refactor(lyrics): move lyric kind logic into model methods

Turn the lyric-kind helpers into methods on the model types so the domain
logic lives next to the data instead of in the subsonic layer:

- Lyrics.EffectiveKind() replaces the free LyricKindOrMain(string) function
- Lyrics.IsMainKind() centralizes the main-kind predicate
- LyricList.Main() replaces the subsonic-local mainKindLyric helper, with
  the main-kind-then-first fallback for the legacy plain-text getLyrics

The subsonic build helpers now call these methods, removing the duplicated
inline kind check. Behavior is unchanged. Adds model tests for all three.
This commit is contained in:
Deluan 2026-06-19 08:26:55 -04:00
parent 965daa492e
commit 6f50c280ad
4 changed files with 98 additions and 27 deletions

View File

@ -55,15 +55,6 @@ const (
LyricKindPronunciation = "pronunciation"
)
// LyricKindOrMain returns kind, defaulting to LyricKindMain when empty. A blank
// kind means an untyped (single-track) lyric, which the contract treats as main.
func LyricKindOrMain(kind string) string {
if strings.TrimSpace(kind) == "" {
return LyricKindMain
}
return kind
}
// 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})?\]`
@ -82,6 +73,22 @@ func (l Lyrics) IsEmpty() bool {
return len(l.Line) == 0
}
// IsMainKind reports whether the lyric is the main track. A blank kind is an
// untyped (single-track) lyric, which the contract treats as main.
func (l Lyrics) IsMainKind() bool {
return l.EffectiveKind() == LyricKindMain
}
// EffectiveKind returns the lyric kind, defaulting to LyricKindMain when blank.
// A blank kind means an untyped (single-track) lyric, which the contract treats
// as main.
func (l Lyrics) EffectiveKind() string {
if strings.TrimSpace(l.Kind) == "" {
return LyricKindMain
}
return l.Kind
}
func ToLyrics(language, text string) (*Lyrics, error) {
text = str.SanitizeText(text)
@ -434,6 +441,22 @@ func parseTime(line string, match []int) (int64, error) {
type LyricList []Lyrics
// Main returns the main-kind lyric, falling back to the first entry so untyped
// lyrics still resolve. The bool is false only when the list is empty. It is
// used to surface a single lyric through the plain-text legacy getLyrics
// endpoint, which has no notion of translation/pronunciation tracks.
func (ll LyricList) Main() (Lyrics, bool) {
if len(ll) == 0 {
return Lyrics{}, false
}
for _, l := range ll {
if l.IsMainKind() {
return l, true
}
}
return ll[0], true
}
func NormalizeLyrics(lyrics Lyrics) Lyrics {
lyrics.Line = NormalizeCueLines(lyrics.Line)
if len(lyrics.Agents) == 0 {

View File

@ -255,3 +255,66 @@ var _ = Describe("NormalizeCueLines", func() {
Expect(lines[0].Cue[1].End).To(BeNil())
})
})
var _ = Describe("Lyrics.EffectiveKind", func() {
It("defaults a blank kind to main", func() {
Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain))
Expect(Lyrics{Kind: " "}.EffectiveKind()).To(Equal(LyricKindMain))
})
It("returns the kind as-is when set", func() {
Expect(Lyrics{Kind: LyricKindTranslation}.EffectiveKind()).To(Equal(LyricKindTranslation))
})
})
var _ = Describe("Lyrics.IsMainKind", func() {
It("is true for a blank (untyped) kind", func() {
Expect(Lyrics{}.IsMainKind()).To(BeTrue())
})
It("is true for the main kind", func() {
Expect(Lyrics{Kind: LyricKindMain}.IsMainKind()).To(BeTrue())
})
It("is false for translation and pronunciation kinds", func() {
Expect(Lyrics{Kind: LyricKindTranslation}.IsMainKind()).To(BeFalse())
Expect(Lyrics{Kind: LyricKindPronunciation}.IsMainKind()).To(BeFalse())
})
})
var _ = Describe("LyricList.Main", func() {
It("returns false when the list is empty", func() {
_, ok := LyricList{}.Main()
Expect(ok).To(BeFalse())
})
It("returns the main-kind entry when present", func() {
list := LyricList{
{Kind: LyricKindTranslation, Lang: "en"},
{Kind: LyricKindMain, Lang: "xxx"},
}
main, ok := list.Main()
Expect(ok).To(BeTrue())
Expect(main.Kind).To(Equal(LyricKindMain))
})
It("falls back to the first entry when no main kind exists", func() {
list := LyricList{
{Kind: LyricKindTranslation, Lang: "en"},
{Kind: LyricKindPronunciation, Lang: "ja"},
}
main, ok := list.Main()
Expect(ok).To(BeTrue())
Expect(main.Lang).To(Equal("en"))
})
It("treats a blank kind as main", func() {
list := LyricList{
{Kind: LyricKindTranslation, Lang: "en"},
{Lang: "xxx"},
}
main, ok := list.Main()
Expect(ok).To(BeTrue())
Expect(main.Lang).To(Equal("xxx"))
})
})

View File

@ -19,7 +19,7 @@ func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced b
// 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 {
if l.IsMainKind() {
filtered = append(filtered, l)
}
}
@ -32,21 +32,6 @@ func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced b
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)
@ -70,7 +55,7 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo
}
if enhanced {
structured.Kind = model.LyricKindOrMain(lyrics.Kind)
structured.Kind = lyrics.EffectiveKind()
if len(cueLines) > 0 && len(agents.response) > 0 {
structured.Agents = agents.response
}

View File

@ -119,7 +119,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return nil, err
}
mainLyric, ok := mainKindLyric(structuredLyrics)
mainLyric, ok := structuredLyrics.Main()
if !ok {
return response, nil
}