mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Expand backend lyrics support with richer sidecar formats and upgrade the OpenSubsonic songLyrics implementation to the version 2 structured karaoke contract, while preserving version 1 behavior by default. Sidecar formats and parsing: - Add a TTML parser (core/lyrics/ttml.go): clock time, offset time, bare decimal seconds, nested timing contexts, and token-level <span> timing for word/syllable karaoke. Parses Apple Music-style metadata tracks (translation and pronunciation/transliteration) and agent metadata into per-track agents[] plus per-cue-line agentId. Hydrates missing line timing from cue timing. - Add an SRT parser (core/lyrics/srt.go). - Add a LRCLIB Lyricsfile (.yaml/.yml) parser (model/lyricsfile.go): maps per-word lines[].words[] to cues with inclusive UTF-8 byte offsets and attributes overlapping lines to synthetic voice agents so parallel vocals split correctly in the enhanced response. - Extend LRC parsing for Enhanced LRC inline <mm:ss.xx> word-timing markers. - Add UTF-8 BOM and UTF-16 LE support for TTML/LRC sidecars. - Parse the above formats from embedded tags as well as sidecar files. Source resolution: - Default lyricspriority is now ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded" so the new formats are discoverable without manual configuration. - Preserve configured source priority across duplicate media-file candidates instead of only checking the first DB match, so higher-priority sidecar lyrics on older duplicates can still win. - Raise the embedded-lyrics tag maxLength to 1 MB to fit word-timed TTML/Enhanced-LRC karaoke for a full song. OpenSubsonic songLyrics v2: - Advertise songLyrics versions [1, 2]. - With enhanced=true, getLyricsBySongId may return structuredLyrics.kind (main/translation/pronunciation), cueLine[] line-level karaoke groupings, cueLine.cue[] timed words/syllables with required UTF-8 byteStart/byteEnd, reusable structuredLyrics.agents[], and cueLine.agentId references. - Without enhanced=true, the response stays v1-compatible: no kind, no cueLine, no agents, no non-main tracks; the existing line[] payload is always populated so legacy clients keep working. Contract details: - cueLine is emitted only for synced lyrics with cue data. - Within a cueLine, cue.end is normalized all-or-none and overlaps are removed; overlaps across separate cueLines remain valid for parallel vocal layers. - Missing cue end-times are filled from the next cue or the parent line. - When cueLines share an index, the one whose agent has role "main" is first. - LyricCue.Value is serialized as XML chardata; cues with nil start are skipped rather than serialized as 0. Refactoring: - Move pure format parsers into model/ (lyrics.go, lyrics_ttml.go, lyrics_srt.go, lyrics_embedded.go, lyricsfile.go) and extract Subsonic response building into server/subsonic/lyrics.go. - Centralize lyric-kind constants and add Lyrics.EffectiveKind/IsMainKind. - Add gg.Clone helper. Spec references: https://github.com/opensubsonic/open-subsonic-api/discussions/213 https://github.com/opensubsonic/open-subsonic-api/pull/218 (songLyrics v2) https://github.com/opensubsonic/open-subsonic-api/pull/228 (cue byte offsets)
126 lines
3.7 KiB
Go
126 lines
3.7 KiB
Go
package lyrics
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/persistence"
|
|
)
|
|
|
|
// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy
|
|
// artist/title lookup, so source-priority resolution can still reach older
|
|
// matches without turning it into an unbounded table scan.
|
|
const maxLegacyLyricsCandidates = 10
|
|
|
|
// Provider fetches lyrics for a single media file. It is the contract
|
|
// implemented by individual lyrics sources, such as plugins.
|
|
type Provider interface {
|
|
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
|
|
}
|
|
|
|
// Lyrics resolves lyrics for media files, honoring the configured source
|
|
// priority.
|
|
type Lyrics interface {
|
|
Provider
|
|
GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error)
|
|
}
|
|
|
|
// PluginLoader discovers and loads lyrics provider plugins.
|
|
type PluginLoader interface {
|
|
LoadLyricsProvider(name string) (Provider, bool)
|
|
}
|
|
|
|
type lyricsService struct {
|
|
ds model.DataStore
|
|
pluginLoader PluginLoader
|
|
}
|
|
|
|
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
|
|
// system is available.
|
|
func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics {
|
|
return &lyricsService{ds: ds, pluginLoader: pluginLoader}
|
|
}
|
|
|
|
// GetLyrics returns lyrics for the given media file, trying sources in the
|
|
// order specified by conf.Server.LyricsPriority.
|
|
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
|
return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf})
|
|
}
|
|
|
|
// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup,
|
|
// scanning a bounded window of duplicate matches so source priority still wins
|
|
// across them.
|
|
func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
|
|
opts := songsByArtistTitleWithLyricsFirst(artist, title)
|
|
opts.Max = maxLegacyLyricsCandidates
|
|
mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(mediaFiles) == 0 {
|
|
return nil, nil
|
|
}
|
|
candidates := make([]*model.MediaFile, 0, len(mediaFiles))
|
|
for i := range mediaFiles {
|
|
candidates = append(candidates, &mediaFiles[i])
|
|
}
|
|
return l.getLyricsForCandidates(ctx, candidates)
|
|
}
|
|
|
|
func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions {
|
|
return model.QueryOptions{
|
|
Sort: "lyrics, updated_at",
|
|
Order: "desc",
|
|
Filters: And{
|
|
Eq{"missing": false},
|
|
Eq{"title": title},
|
|
Or{
|
|
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
|
|
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) {
|
|
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
|
|
pattern = strings.TrimSpace(pattern)
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
|
|
for _, mf := range mediaFiles {
|
|
if mf == nil {
|
|
continue
|
|
}
|
|
|
|
lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern)
|
|
if err != nil {
|
|
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
|
continue
|
|
}
|
|
|
|
if len(lyricsList) > 0 {
|
|
return lyricsList, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) {
|
|
switch {
|
|
case strings.EqualFold(pattern, "embedded"):
|
|
return fromEmbedded(ctx, mf)
|
|
case strings.HasPrefix(pattern, "."):
|
|
return fromExternalFile(ctx, mf, pattern)
|
|
default:
|
|
return l.fromPlugin(ctx, mf, pattern)
|
|
}
|
|
}
|