mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
feat(subsonic): add structured sidecar lyrics support with OpenSubsonic v2 karaoke cues and agent layers (#5076)
Some checks are pending
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Some checks are pending
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
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)
This commit is contained in:
parent
ecba19a08e
commit
3a14faa033
45 changed files with 4582 additions and 353 deletions
|
|
@ -52,6 +52,7 @@ A share of the revenue helps fund the development of Navidrome at no additional
|
|||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
||||
- Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`)
|
||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
||||
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||
|
|
|
|||
|
|
@ -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", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
|
|
|
|||
|
|
@ -4,56 +4,122 @@ 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"
|
||||
)
|
||||
|
||||
// Lyrics can fetch lyrics for a media file.
|
||||
type Lyrics interface {
|
||||
// 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) (Lyrics, bool)
|
||||
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(pluginLoader PluginLoader) Lyrics {
|
||||
return &lyricsService{pluginLoader: pluginLoader}
|
||||
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) {
|
||||
var lyricsList model.LyricList
|
||||
var err 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)
|
||||
switch {
|
||||
case strings.EqualFold(pattern, "embedded"):
|
||||
lyricsList, err = fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
|
||||
default:
|
||||
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||
}
|
||||
for _, mf := range mediaFiles {
|
||||
if mf == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
|
|
@ -16,7 +17,7 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
var _ = Describe("Lyrics", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
|
|
@ -44,6 +45,71 @@ var _ = Describe("sources", func() {
|
|||
},
|
||||
}
|
||||
|
||||
elrcLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "ELRC Artist",
|
||||
DisplayTitle: "ELRC Song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(1000)),
|
||||
End: new(int64(3000)),
|
||||
Value: "Lead words",
|
||||
Cue: []model.Cue{
|
||||
{
|
||||
Start: new(int64(1000)),
|
||||
End: new(int64(1500)),
|
||||
Value: "Lead ",
|
||||
ByteStart: 0,
|
||||
ByteEnd: 4,
|
||||
},
|
||||
{
|
||||
Start: new(int64(1500)),
|
||||
End: new(int64(3000)),
|
||||
Value: "words",
|
||||
ByteStart: 5,
|
||||
ByteEnd: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Start: new(int64(3000)),
|
||||
Value: "Fallback line",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
ttmlLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22800)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
model.Lyrics{
|
||||
Kind: "main",
|
||||
Lang: "por",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Value: "Nao somos estranhos ao amor",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
unsyncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
|
|
@ -59,6 +125,25 @@ var _ = Describe("sources", func() {
|
|||
},
|
||||
}
|
||||
|
||||
srtLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
End: new(int64(22800)),
|
||||
Value: "We're from subtitles",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22801)),
|
||||
End: new(int64(26000)),
|
||||
Value: "Another subtitle line",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
|
|
@ -68,19 +153,100 @@ var _ = Describe("sources", func() {
|
|||
Lyrics: string(lyricsJson),
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
}
|
||||
ctx = context.Background()
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
|
||||
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics),
|
||||
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics),
|
||||
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics))
|
||||
|
||||
It("resolves source priority across duplicate media files", func() {
|
||||
conf.Server.LyricsPriority = ".ttml,embedded"
|
||||
embeddedJSON, err := json.Marshal(embeddedLyrics)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
repo := &tests.MockMediaFileRepo{}
|
||||
repo.SetData(model.MediaFiles{
|
||||
{
|
||||
Lyrics: string(embeddedJSON),
|
||||
Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3",
|
||||
},
|
||||
{
|
||||
Lyrics: "[]",
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
},
|
||||
})
|
||||
svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil)
|
||||
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(ttmlLyrics))
|
||||
})
|
||||
|
||||
It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() {
|
||||
dir, err := os.MkdirTemp("", "lyrics-case-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
Expect(os.RemoveAll(dir)).To(Succeed())
|
||||
})
|
||||
|
||||
probe := filepath.Join(dir, "CASECHECK")
|
||||
Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed())
|
||||
_, err = os.Stat(filepath.Join(dir, "casecheck"))
|
||||
if err == nil {
|
||||
Skip("filesystem is case-insensitive")
|
||||
}
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
conf.Server.LyricsPriority = ".LRC"
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed())
|
||||
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &model.MediaFile{
|
||||
LibraryPath: dir,
|
||||
Path: "song.mp3",
|
||||
})
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(Equal([]model.Line{
|
||||
{Start: new(int64(1000)), Value: "Upper suffix"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("falls through generic YAML sidecars that are not Lyricsfile documents", func() {
|
||||
dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
Expect(os.RemoveAll(dir)).To(Succeed())
|
||||
})
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed())
|
||||
|
||||
conf.Server.LyricsPriority = ".yaml,.lrc"
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &model.MediaFile{
|
||||
LibraryPath: dir,
|
||||
Path: "song.mp3",
|
||||
})
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(Equal([]model.Line{
|
||||
{Start: new(int64(1000)), Value: "Fallback line"},
|
||||
}))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
var RegularUserContext = XContext
|
||||
|
|
@ -110,7 +276,7 @@ var _ = Describe("sources", func() {
|
|||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
|
|
@ -119,7 +285,7 @@ var _ = Describe("sources", func() {
|
|||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
|
|
@ -137,7 +303,7 @@ var _ = Describe("sources", func() {
|
|||
It("should return lyrics from a plugin", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
|
|
@ -147,7 +313,7 @@ var _ = Describe("sources", func() {
|
|||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mf.Lyrics = "" // No embedded lyrics
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
|
|
@ -156,7 +322,7 @@ var _ = Describe("sources", func() {
|
|||
It("should skip plugin if embedded has lyrics", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
|
||||
|
|
@ -165,7 +331,7 @@ var _ = Describe("sources", func() {
|
|||
It("should skip unknown plugin names gracefully", func() {
|
||||
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
|
||||
mockLoader.notFound = true
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
|
|
@ -175,7 +341,7 @@ var _ = Describe("sources", func() {
|
|||
conf.Server.LyricsPriority = "MyLyricsPlugin"
|
||||
mockLoader.pluginName = "MyLyricsPlugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
|
|
@ -184,12 +350,55 @@ var _ = Describe("sources", func() {
|
|||
It("should handle plugin error gracefully", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
|
||||
mockLoader.err = fmt.Errorf("plugin error")
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("GetLyricsByArtistTitle", func() {
|
||||
var svc lyrics.Lyrics
|
||||
var repo *tests.MockMediaFileRepo
|
||||
var ds *tests.MockDataStore
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded"
|
||||
repo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: repo}
|
||||
svc = lyrics.NewLyrics(ds, nil)
|
||||
})
|
||||
|
||||
It("bounds the query to a duplicate window", func() {
|
||||
repo.SetData(model.MediaFiles{})
|
||||
_, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(repo.Options.Max).To(Equal(10))
|
||||
})
|
||||
|
||||
It("returns nil when no media file matches", func() {
|
||||
repo.SetData(model.MediaFiles{})
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(BeNil())
|
||||
})
|
||||
|
||||
It("resolves lyrics from the matched media files", func() {
|
||||
embedded, err := model.ToLyrics("eng", "Embedded lyrics line")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
embeddedJSON, err := json.Marshal(model.LyricList{*embedded})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
repo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)},
|
||||
})
|
||||
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPluginLoader struct {
|
||||
|
|
@ -206,7 +415,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string {
|
|||
return []string{"test-lyrics-plugin"}
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
|
||||
if m.notFound {
|
||||
return nil, false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,18 +36,19 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
|||
return nil, err
|
||||
}
|
||||
|
||||
lyrics, err := model.ToLyrics("xxx", string(contents))
|
||||
list, err := model.ParseLyricsFile(suffix, contents)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
|
||||
log.Error(ctx, "error parsing external lyric file", "path", externalLyric, err)
|
||||
return nil, err
|
||||
} else if lyrics == nil {
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||
|
|
|
|||
|
|
@ -61,52 +61,26 @@ var _ = Describe("sources", func() {
|
|||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return synchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
// fromExternalFile delegates format parsing to model.ParseLyricsFile; the
|
||||
// per-format parser output is covered exhaustively in the model package.
|
||||
// Here we only verify each suffix is read from disk and routed to a parser.
|
||||
DescribeTable("should read the sidecar file and route its suffix to a parser",
|
||||
func(path, suffix string, expectSynced bool) {
|
||||
mf := model.MediaFile{Path: path}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, suffix)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: new(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should return unsynchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Line).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Synced).To(Equal(expectSynced))
|
||||
},
|
||||
Entry(".lrc synced", "tests/fixtures/test.mp3", ".lrc", true),
|
||||
Entry(".elrc enhanced", "tests/fixtures/test.mp3", ".elrc", true),
|
||||
Entry(".txt plain", "tests/fixtures/test.mp3", ".txt", false),
|
||||
Entry(".srt subtitles", "tests/fixtures/test.mp3", ".srt", true),
|
||||
Entry(".ttml multilingual", "tests/fixtures/test.mp3", ".ttml", true),
|
||||
Entry(".yaml lyricsfile", "tests/fixtures/test.mp3", ".yaml", true),
|
||||
)
|
||||
|
||||
It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
|
||||
// The function looks for <basePath-without-ext><suffix>, so we need to pass
|
||||
|
|
@ -141,5 +115,34 @@ var _ = Describe("sources", func() {
|
|||
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"))
|
||||
})
|
||||
|
||||
It("should handle TTML files with UTF-8 BOM marker", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".ttml")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
Expect(lyrics[0].Kind).To(Equal("main"))
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
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(Equal("BOM test line"))
|
||||
})
|
||||
|
||||
It("should handle UTF-16 BE encoded TTML files", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".ttml")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
Expect(lyrics[0].Kind).To(Equal("main"))
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
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("UTF16 line one"))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two"))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
|
|
|||
376
model/lyrics.go
376
model/lyrics.go
|
|
@ -2,43 +2,93 @@ package model
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
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"`
|
||||
ByteStart int `structs:"byteStart" json:"byteStart"`
|
||||
ByteEnd int `structs:"byteEnd" json:"byteEnd"`
|
||||
AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"`
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
Role string `structs:"role" json:"role"`
|
||||
Name string `structs:"name,omitempty" json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type Line struct {
|
||||
Start *int64 `structs:"start,omitempty" json:"start,omitempty"`
|
||||
End *int64 `structs:"end,omitempty" json:"end,omitempty"`
|
||||
Value string `structs:"value" json:"value"`
|
||||
Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Kind string `structs:"kind,omitempty" json:"kind,omitempty"`
|
||||
Lang string `structs:"lang" json:"lang"`
|
||||
Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"`
|
||||
Line []Line `structs:"line" json:"line"`
|
||||
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
|
||||
Synced bool `structs:"synced" json:"synced"`
|
||||
}
|
||||
|
||||
// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are
|
||||
// the canonical wire values; keep them in sync with the spec.
|
||||
const (
|
||||
LyricKindMain = "main"
|
||||
LyricKindTranslation = "translation"
|
||||
LyricKindPronunciation = "pronunciation"
|
||||
)
|
||||
|
||||
// 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})?\]`
|
||||
const timeRegexString = `\[([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):([^]]+)]`)
|
||||
|
||||
// Enhanced LRC: inline word-level timing markers like <00:12.34>
|
||||
enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>`
|
||||
enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString)
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -105,10 +155,13 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
|||
}
|
||||
|
||||
if validLine {
|
||||
value, baseCues := parseEnhancedLine(priorLine)
|
||||
for idx := range timestamps {
|
||||
startCopy := timestamps[idx]
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: ×tamps[idx],
|
||||
Value: strings.TrimSpace(priorLine),
|
||||
Start: &startCopy,
|
||||
Value: value,
|
||||
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
|
||||
})
|
||||
}
|
||||
timestamps = nil
|
||||
|
|
@ -153,10 +206,13 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
|||
}
|
||||
|
||||
if validLine {
|
||||
value, baseCues := parseEnhancedLine(priorLine)
|
||||
for idx := range timestamps {
|
||||
startCopy := timestamps[idx]
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: ×tamps[idx],
|
||||
Value: strings.TrimSpace(priorLine),
|
||||
Start: &startCopy,
|
||||
Value: value,
|
||||
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -173,13 +229,170 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
|||
DisplayArtist: artist,
|
||||
DisplayTitle: title,
|
||||
Lang: language,
|
||||
Line: structuredLines,
|
||||
Line: NormalizeCueLines(structuredLines),
|
||||
Offset: offset,
|
||||
Synced: synced,
|
||||
}
|
||||
return &lyrics, nil
|
||||
}
|
||||
|
||||
// ParseLyricsFile parses a sidecar lyrics file, dispatching on its extension to
|
||||
// the matching format parser. Unknown extensions fall back to the generic
|
||||
// LRC/plain-text parser. It is the single owner of the suffix→parser mapping,
|
||||
// mirroring [ParseEmbedded] for tag-embedded lyrics.
|
||||
func ParseLyricsFile(suffix string, contents []byte) (LyricList, error) {
|
||||
var list LyricList
|
||||
var err error
|
||||
switch {
|
||||
case strings.EqualFold(suffix, ".ttml"):
|
||||
list, err = ParseTTML(contents)
|
||||
case strings.EqualFold(suffix, ".srt"):
|
||||
list, err = ParseSRT(contents)
|
||||
case strings.EqualFold(suffix, ".yaml"), strings.EqualFold(suffix, ".yml"):
|
||||
list, err = ParseLyricsfile(string(contents))
|
||||
default:
|
||||
var lyric *Lyrics
|
||||
lyric, err = ToLyrics("xxx", string(contents))
|
||||
if lyric != nil {
|
||||
list = LyricList{*lyric}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s lyrics: %w", strings.TrimPrefix(suffix, "."), err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers
|
||||
// and computes UTF-8 byte offsets against the final stripped line value.
|
||||
func parseEnhancedLine(text string) (string, []Cue) {
|
||||
matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
type segment struct {
|
||||
start int64
|
||||
rawStart int
|
||||
rawEnd int
|
||||
}
|
||||
|
||||
segments := make([]segment, 0, len(matches))
|
||||
var rawValue strings.Builder
|
||||
for i, match := range matches {
|
||||
timeMs, err := parseTime(
|
||||
// Rewrite <...> as [...] so parseTime can handle it with the same logic
|
||||
"["+text[match[0]+1:match[1]-1]+"]",
|
||||
// Adjust match indices to point into our rewritten string (need start/end pairs for each group)
|
||||
[]int{
|
||||
0, match[1] - match[0],
|
||||
adjustGroup(match, 2), adjustGroup(match, 3),
|
||||
adjustGroup(match, 4), adjustGroup(match, 5),
|
||||
adjustGroup(match, 6), adjustGroup(match, 7),
|
||||
adjustGroup(match, 8), adjustGroup(match, 9),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Text runs from after this marker to the start of the next marker (or end of string)
|
||||
textStart := match[1]
|
||||
var textEnd int
|
||||
if i+1 < len(matches) {
|
||||
textEnd = matches[i+1][0]
|
||||
} else {
|
||||
textEnd = len(text)
|
||||
}
|
||||
|
||||
word := text[textStart:textEnd]
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
rawStart := rawValue.Len()
|
||||
rawValue.WriteString(word)
|
||||
segments = append(segments, segment{
|
||||
start: timeMs,
|
||||
rawStart: rawStart,
|
||||
rawEnd: rawValue.Len(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(segments) == 0 {
|
||||
return strings.TrimSpace(stripEnhancedMarkers(text)), nil
|
||||
}
|
||||
|
||||
finalRaw := rawValue.String()
|
||||
leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace))
|
||||
rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace))
|
||||
trimmedEnd := len(finalRaw) - rightTrimBytes
|
||||
if trimmedEnd < leftTrimBytes {
|
||||
trimmedEnd = leftTrimBytes
|
||||
}
|
||||
|
||||
cues := make([]Cue, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
start := seg.start
|
||||
byteStart := max(seg.rawStart, leftTrimBytes)
|
||||
byteEnd := min(seg.rawEnd, trimmedEnd)
|
||||
if byteStart >= byteEnd {
|
||||
continue
|
||||
}
|
||||
|
||||
cues = append(cues, Cue{
|
||||
Start: &start,
|
||||
Value: finalRaw[byteStart:byteEnd],
|
||||
ByteStart: byteStart - leftTrimBytes,
|
||||
ByteEnd: byteEnd - leftTrimBytes - 1,
|
||||
})
|
||||
}
|
||||
|
||||
return strings.TrimSpace(finalRaw), cues
|
||||
}
|
||||
|
||||
// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string.
|
||||
// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same.
|
||||
func adjustGroup(match []int, groupIdx int) int {
|
||||
orig := match[groupIdx]
|
||||
if orig == -1 {
|
||||
return -1
|
||||
}
|
||||
// Offset is: original position minus the position of '<' in the original, plus 1 for '['
|
||||
return orig - match[0]
|
||||
}
|
||||
|
||||
// stripEnhancedMarkers removes all <mm:ss.mm> inline markers from text,
|
||||
// returning the plain lyric text.
|
||||
func stripEnhancedMarkers(text string) string {
|
||||
return enhancedLRCRegex.ReplaceAllString(text, "")
|
||||
}
|
||||
|
||||
// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End
|
||||
// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute
|
||||
// timestamps anchored at the line's first occurrence, so repeated-line LRC
|
||||
// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the
|
||||
// second occurrence to point at the correct moment. Returned *int64 pointers
|
||||
// are freshly allocated so the input slice is never aliased into the result.
|
||||
func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue {
|
||||
if len(baseCues) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Cue, len(baseCues))
|
||||
for i, c := range baseCues {
|
||||
out[i] = c
|
||||
if c.Start != nil {
|
||||
s := *c.Start + offsetMs
|
||||
out[i].Start = &s
|
||||
}
|
||||
if c.End != nil {
|
||||
e := *c.End + offsetMs
|
||||
out[i].End = &e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseTime(line string, match []int) (int64, error) {
|
||||
var hours, millis int64
|
||||
var err error
|
||||
|
|
@ -227,3 +440,142 @@ 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 {
|
||||
lyrics.Agents = nil
|
||||
}
|
||||
return lyrics
|
||||
}
|
||||
|
||||
func NormalizeCueLines(lines []Line) []Line {
|
||||
if len(lines) == 0 {
|
||||
return lines
|
||||
}
|
||||
|
||||
normalized := make([]Line, len(lines))
|
||||
copy(normalized, lines)
|
||||
|
||||
for i := range normalized {
|
||||
if len(normalized[i].Cue) > 0 {
|
||||
normalized[i].Cue = slices.Clone(normalized[i].Cue)
|
||||
}
|
||||
|
||||
var fallbackEnd *int64
|
||||
if normalized[i].End != nil {
|
||||
v := *normalized[i].End
|
||||
fallbackEnd = &v
|
||||
} else if i+1 < len(normalized) && normalized[i+1].Start != nil {
|
||||
v := *normalized[i+1].Start
|
||||
fallbackEnd = &v
|
||||
}
|
||||
|
||||
normalized[i] = normalizeCueLine(normalized[i], fallbackEnd)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func NormalizeLineTiming(line Line) Line {
|
||||
if len(line.Cue) == 0 {
|
||||
return line
|
||||
}
|
||||
|
||||
var earliestStart *int64
|
||||
var latestEnd *int64
|
||||
for i := range line.Cue {
|
||||
token := line.Cue[i]
|
||||
if token.Start != nil {
|
||||
if earliestStart == nil || *token.Start < *earliestStart {
|
||||
v := *token.Start
|
||||
earliestStart = &v
|
||||
}
|
||||
}
|
||||
|
||||
candidateEnd := token.End
|
||||
if candidateEnd == nil {
|
||||
candidateEnd = token.Start
|
||||
}
|
||||
if candidateEnd != nil {
|
||||
if latestEnd == nil || *candidateEnd > *latestEnd {
|
||||
v := *candidateEnd
|
||||
latestEnd = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if line.Start == nil && earliestStart != nil {
|
||||
v := *earliestStart
|
||||
line.Start = &v
|
||||
}
|
||||
if line.End == nil && latestEnd != nil {
|
||||
v := *latestEnd
|
||||
line.End = &v
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func normalizeCueLine(line Line, fallbackEnd *int64) Line {
|
||||
if len(line.Cue) == 0 {
|
||||
return line
|
||||
}
|
||||
line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd)
|
||||
return NormalizeLineTiming(line)
|
||||
}
|
||||
|
||||
// NormalizeCueEnds resolves missing cue end times within a single ordered cue
|
||||
// group: each end is filled from the next cue's start, then from fallbackEnd,
|
||||
// and is clamped so it never precedes the cue's own start nor overruns the next
|
||||
// cue. End times are all-or-none — if any cue still lacks an end afterwards, all
|
||||
// ends in the group are cleared. The input slice is never mutated.
|
||||
func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue {
|
||||
if len(cues) == 0 {
|
||||
return cues
|
||||
}
|
||||
|
||||
out := slices.Clone(cues)
|
||||
for i := range out {
|
||||
end := out[i].End
|
||||
if end == nil {
|
||||
if i+1 < len(out) && out[i+1].Start != nil {
|
||||
end = out[i+1].Start
|
||||
} else {
|
||||
end = fallbackEnd
|
||||
}
|
||||
}
|
||||
if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start {
|
||||
end = out[i+1].Start
|
||||
}
|
||||
if end != nil && out[i].Start != nil && *end < *out[i].Start {
|
||||
end = out[i].Start
|
||||
}
|
||||
out[i].End = gg.Clone(end)
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
if out[i].End == nil {
|
||||
for j := range out {
|
||||
out[j].End = nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
55
model/lyrics_embedded.go
Normal file
55
model/lyrics_embedded.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// ParseEmbedded parses lyrics read from media-file metadata tags. It detects rich
|
||||
// payloads before falling back to the generic LRC/plain-text parser, because
|
||||
// text sanitization would otherwise strip TTML XML markup.
|
||||
func ParseEmbedded(language, text string) (LyricList, error) {
|
||||
text = strings.TrimPrefix(text, "\ufeff")
|
||||
|
||||
if isTTMLDocument(text) {
|
||||
list, err := parseTTMLWithDefaultLang([]byte(text), language)
|
||||
if err == nil && len(list) > 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn("Error parsing embedded TTML lyrics, falling back to plain lyrics", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
list, err := parseSRTWithLanguage([]byte(text), language)
|
||||
if err == nil && len(list) > 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err != nil && strings.Contains(text, "-->") {
|
||||
log.Warn("Error parsing embedded SRT lyrics, falling back to plain lyrics", "error", err)
|
||||
}
|
||||
|
||||
lyric, err := ToLyrics(language, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lyric == nil || lyric.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return LyricList{*lyric}, nil
|
||||
}
|
||||
|
||||
func isTTMLDocument(text string) bool {
|
||||
decoder := xml.NewDecoder(strings.NewReader(strings.TrimSpace(text)))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if start, ok := token.(xml.StartElement); ok {
|
||||
return strings.EqualFold(start.Name.Local, "tt")
|
||||
}
|
||||
}
|
||||
}
|
||||
160
model/lyrics_embedded_test.go
Normal file
160
model/lyrics_embedded_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseEmbedded", func() {
|
||||
It("should parse embedded TTML with the tag language as the default", func() {
|
||||
content := `<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
|
||||
<head>
|
||||
<metadata>
|
||||
<ttm:agent xml:id="lead" ttm:type="person">
|
||||
<ttm:name>Lead Vocal</ttm:name>
|
||||
</ttm:agent>
|
||||
</metadata>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<p begin="00:00:01.000" end="00:00:03.000">
|
||||
<span begin="00:00:01.000" end="00:00:02.000" ttm:agent="lead">Hello </span><span begin="00:00:02.000" end="00:00:03.000" ttm:agent="lead">world</span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseEmbedded("ENG", content)
|
||||
|
||||
// ParseEmbedded's job is to detect TTML and apply the tag language as the
|
||||
// default; the parser's cue/agent details are covered in lyrics_ttml_test.go.
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Kind).To(Equal("main"))
|
||||
Expect(list[0].Lang).To(Equal("eng"))
|
||||
Expect(list[0].Synced).To(BeTrue())
|
||||
Expect(list[0].Line[0].Value).To(Equal("Hello world"))
|
||||
})
|
||||
|
||||
It("should preserve embedded TTML translation and pronunciation tracks", func() {
|
||||
content := `<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
|
||||
<head>
|
||||
<metadata>
|
||||
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
|
||||
<translations>
|
||||
<translation xml:lang="es">
|
||||
<text for="L1">Hola</text>
|
||||
</translation>
|
||||
</translations>
|
||||
<transliterations>
|
||||
<transliteration xml:lang="ja-Latn">
|
||||
<text for="L1"><span begin="00:00:01.000" end="00:00:01.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:00:01.300" end="00:00:01.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text>
|
||||
</transliteration>
|
||||
</transliterations>
|
||||
</iTunesMetadata>
|
||||
</metadata>
|
||||
</head>
|
||||
<body xml:lang="ja">
|
||||
<div>
|
||||
<p begin="00:00:01.000" end="00:00:02.000" itunes:key="L1">こんにちは</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseEmbedded("eng", content)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(3))
|
||||
Expect(list[0].Kind).To(Equal("main"))
|
||||
Expect(list[0].Lang).To(Equal("ja"))
|
||||
Expect(list[0].Line[0].Value).To(Equal("こんにちは"))
|
||||
Expect(list[1].Kind).To(Equal("translation"))
|
||||
Expect(list[1].Lang).To(Equal("es"))
|
||||
Expect(list[1].Line[0].Value).To(Equal("Hola"))
|
||||
Expect(list[2].Kind).To(Equal("pronunciation"))
|
||||
Expect(list[2].Lang).To(Equal("ja-latn"))
|
||||
Expect(list[2].Line[0].Value).To(Equal("konni"))
|
||||
Expect(list[2].Line[0].Cue).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should parse embedded SRT with the tag language", func() {
|
||||
content := `1
|
||||
00:00:18,800 --> 00:00:22,800
|
||||
We're from subtitles
|
||||
|
||||
2
|
||||
00:00:22,801 --> 00:00:26,000
|
||||
Another subtitle line`
|
||||
|
||||
list, err := ParseEmbedded("POR", content)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(Equal(LyricList{
|
||||
{
|
||||
Lang: "por",
|
||||
Line: []Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
End: new(int64(22800)),
|
||||
Value: "We're from subtitles",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22801)),
|
||||
End: new(int64(26000)),
|
||||
Value: "Another subtitle line",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() {
|
||||
content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle"
|
||||
|
||||
list, err := ParseEmbedded("eng", content)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(Equal([]Line{
|
||||
{Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"},
|
||||
{Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should keep embedded enhanced LRC cues", func() {
|
||||
content := "[00:01.00]<00:01.00>Lead <00:01.50>words"
|
||||
|
||||
list, err := ParseEmbedded("eng", content)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Lang).To(Equal("eng"))
|
||||
Expect(list[0].Synced).To(BeTrue())
|
||||
Expect(list[0].Line[0].Value).To(Equal("Lead words"))
|
||||
Expect(list[0].Line[0].Cue).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should fall back to plain lyrics when embedded TTML is invalid", func() {
|
||||
content := `<tt xmlns="http://www.w3.org/ns/ttml">
|
||||
<body>
|
||||
<p begin="not-a-time">Broken</p>
|
||||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseEmbedded("eng", content)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Lang).To(Equal("eng"))
|
||||
Expect(list[0].Synced).To(BeFalse())
|
||||
Expect(list[0].Line).ToNot(BeEmpty())
|
||||
values := make([]string, 0, len(list[0].Line))
|
||||
for _, line := range list[0].Line {
|
||||
values = append(values, line.Value)
|
||||
}
|
||||
Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken"))
|
||||
})
|
||||
})
|
||||
167
model/lyrics_srt.go
Normal file
167
model/lyrics_srt.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
var (
|
||||
srtTimeRegex = regexp.MustCompile(`^\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*$`)
|
||||
srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`)
|
||||
)
|
||||
|
||||
func ParseSRT(contents []byte) (LyricList, error) {
|
||||
return parseSRTWithLanguage(contents, "xxx")
|
||||
}
|
||||
|
||||
func parseSRTWithLanguage(contents []byte, language string) (LyricList, error) {
|
||||
raw := strings.ReplaceAll(string(contents), "\r\n", "\n")
|
||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||
|
||||
blocks := splitSRTBlocks(raw)
|
||||
lines := make([]Line, 0, len(blocks))
|
||||
|
||||
for _, block := range blocks {
|
||||
line, ok, err := parseSRTBlock(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lyrics := NormalizeLyrics(Lyrics{
|
||||
Lang: normalizeLyricLang(language),
|
||||
Line: lines,
|
||||
Synced: true,
|
||||
})
|
||||
return LyricList{lyrics}, nil
|
||||
}
|
||||
|
||||
func splitSRTBlocks(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := srtBlockSeparatorRegex.Split(raw, -1)
|
||||
blocks := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
blocks = append(blocks, part)
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func parseSRTBlock(block string) (Line, bool, error) {
|
||||
scanner := bytes.Split([]byte(block), []byte("\n"))
|
||||
if len(scanner) == 0 {
|
||||
return Line{}, false, nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(scanner))
|
||||
for _, line := range scanner {
|
||||
lines = append(lines, strings.TrimSpace(string(line)))
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return Line{}, false, nil
|
||||
}
|
||||
|
||||
startIdx := 0
|
||||
if digitsOnly(lines[0]) {
|
||||
startIdx = 1
|
||||
}
|
||||
if startIdx >= len(lines) {
|
||||
return Line{}, false, nil
|
||||
}
|
||||
|
||||
timing := strings.Split(lines[startIdx], "-->")
|
||||
if len(timing) != 2 {
|
||||
return Line{}, false, nil
|
||||
}
|
||||
|
||||
startMs, err := parseSRTTime(timing[0])
|
||||
if err != nil {
|
||||
return Line{}, false, err
|
||||
}
|
||||
endMs, err := parseSRTTime(timing[1])
|
||||
if err != nil {
|
||||
return Line{}, false, err
|
||||
}
|
||||
|
||||
textLines := make([]string, 0, len(lines)-startIdx-1)
|
||||
for _, line := range lines[startIdx+1:] {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
textLines = append(textLines, line)
|
||||
}
|
||||
|
||||
value := str.SanitizeText(strings.Join(textLines, "\n"))
|
||||
if value == "" {
|
||||
return Line{}, false, nil
|
||||
}
|
||||
|
||||
return Line{
|
||||
Start: &startMs,
|
||||
End: &endMs,
|
||||
Value: value,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func parseSRTTime(value string) (int64, error) {
|
||||
match := srtTimeRegex.FindStringSubmatch(strings.TrimSpace(value))
|
||||
if match == nil {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
|
||||
hours, err := strconv.ParseInt(match[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
minutes, err := strconv.ParseInt(match[2], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seconds, err := strconv.ParseInt(match[3], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
millis, err := strconv.ParseInt(match[4], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch len(match[4]) {
|
||||
case 1:
|
||||
millis *= 100
|
||||
case 2:
|
||||
millis *= 10
|
||||
}
|
||||
|
||||
return (((hours*60)+minutes)*60+seconds)*1000 + millis, nil
|
||||
}
|
||||
|
||||
func digitsOnly(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, ch := range value {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -108,4 +108,203 @@ var _ = Describe("ToLyrics", func() {
|
|||
{Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should parse Enhanced LRC with word-level timing", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(HaveLen(2))
|
||||
|
||||
t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500)
|
||||
|
||||
line0 := lyrics.Line[0]
|
||||
Expect(line0.Start).To(Equal(&t1000))
|
||||
Expect(line0.End).To(Equal(&t3000))
|
||||
Expect(line0.Value).To(Equal("Some lyrics here"))
|
||||
Expect(line0.Cue).To(Equal([]Cue{
|
||||
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4},
|
||||
{Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11},
|
||||
{Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15},
|
||||
}))
|
||||
|
||||
line1 := lyrics.Line[1]
|
||||
Expect(line1.Start).To(Equal(&t3000))
|
||||
Expect(line1.End).To(Equal(&t3500))
|
||||
Expect(line1.Value).To(Equal("More words"))
|
||||
Expect(line1.Cue).To(Equal([]Cue{
|
||||
{Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4},
|
||||
{Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9},
|
||||
}))
|
||||
|
||||
Expect(line1.Cue[1].End).To(BeNil())
|
||||
})
|
||||
|
||||
It("should not parse malformed Enhanced LRC timing markers", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01a50>Not a marker")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: new(int64(1000)), Value: "<00:01a50>Not a marker"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should handle mixed Enhanced and plain LRC lines", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Line).To(HaveLen(3))
|
||||
|
||||
t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500)
|
||||
t3000 := int64(3000)
|
||||
|
||||
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
|
||||
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4},
|
||||
{Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10},
|
||||
}))
|
||||
Expect(lyrics.Line[0].Value).To(Equal("Some lyrics"))
|
||||
Expect(lyrics.Line[0].End).To(Equal(&t3000))
|
||||
|
||||
Expect(lyrics.Line[1].Cue).To(BeNil())
|
||||
Expect(lyrics.Line[1].Value).To(Equal("Plain line"))
|
||||
|
||||
Expect(lyrics.Line[2].Cue).To(Equal([]Cue{
|
||||
{Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4},
|
||||
{Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9},
|
||||
}))
|
||||
Expect(lyrics.Line[2].Value).To(Equal("More words"))
|
||||
})
|
||||
|
||||
It("should preserve byte offsets for Enhanced LRC cues", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Line).To(HaveLen(1))
|
||||
|
||||
t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600)
|
||||
line := lyrics.Line[0]
|
||||
Expect(line.Value).To(Equal("Oh love me tonight"))
|
||||
Expect(line.Cue).To(Equal([]Cue{
|
||||
{Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2},
|
||||
{Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6},
|
||||
{Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10},
|
||||
{Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should shift inline ELRC word timestamps for each repeated line occurrence", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Line).To(HaveLen(2))
|
||||
|
||||
t10000 := int64(10000)
|
||||
t10100 := int64(10100)
|
||||
t10500 := int64(10500)
|
||||
t30000 := int64(30000)
|
||||
t30100 := int64(30100)
|
||||
t30500 := int64(30500)
|
||||
|
||||
Expect(lyrics.Line[0].Start).To(Equal(&t10000))
|
||||
Expect(lyrics.Line[0].End).To(Equal(&t30000))
|
||||
Expect(lyrics.Line[0].Value).To(Equal("Hello world"))
|
||||
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
|
||||
{Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5},
|
||||
{Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10},
|
||||
}))
|
||||
|
||||
Expect(lyrics.Line[1].Start).To(Equal(&t30000))
|
||||
Expect(lyrics.Line[1].End).To(Equal(&t30500))
|
||||
Expect(lyrics.Line[1].Value).To(Equal("Hello world"))
|
||||
Expect(lyrics.Line[1].Cue).To(Equal([]Cue{
|
||||
{Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5},
|
||||
{Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("NormalizeCueLines", func() {
|
||||
It("should not mutate caller cue slices when filling missing cue end times", func() {
|
||||
start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000)
|
||||
lines := []Line{
|
||||
{
|
||||
Start: &start0,
|
||||
Value: "Some lyrics",
|
||||
Cue: []Cue{
|
||||
{Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4},
|
||||
{Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
Start: &nextLineStart,
|
||||
Value: "Next line",
|
||||
},
|
||||
}
|
||||
|
||||
normalized := NormalizeCueLines(lines)
|
||||
|
||||
Expect(normalized[0].Cue[0].End).To(Equal(&start1))
|
||||
Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart))
|
||||
Expect(lines[0].Cue[0].End).To(BeNil())
|
||||
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"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
1256
model/lyrics_ttml.go
Normal file
1256
model/lyrics_ttml.go
Normal file
File diff suppressed because it is too large
Load diff
429
model/lyrics_ttml_test.go
Normal file
429
model/lyrics_ttml_test.go
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseTTML", func() {
|
||||
Describe("Multi-language and timing", func() {
|
||||
It("should parse multiple language divs with inherited offsets and frame/tick timing", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10">
|
||||
<body>
|
||||
<div xml:lang="eng" begin="1s">
|
||||
<p begin="2s">Line one</p>
|
||||
<p begin="00:00:04:15.1"><span>Line two</span><br/>with break</p>
|
||||
</div>
|
||||
<div xml:lang="por">
|
||||
<p begin="45t">Linha</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(2))
|
||||
|
||||
By("parsing the English track")
|
||||
eng := list[0]
|
||||
Expect(eng.Lang).To(Equal("eng"))
|
||||
Expect(eng.Synced).To(BeTrue())
|
||||
Expect(eng.Line[0].Start).To(Equal(new(int64(3000))))
|
||||
Expect(eng.Line[0].Value).To(Equal("Line one"))
|
||||
Expect(eng.Line[1].Start).To(Equal(new(int64(4517))))
|
||||
Expect(eng.Line[1].Value).To(Equal("Line two\nwith break"))
|
||||
|
||||
By("parsing the Portuguese track")
|
||||
por := list[1]
|
||||
Expect(por.Lang).To(Equal("por"))
|
||||
Expect(por.Line[0].Start).To(Equal(new(int64(4500))))
|
||||
Expect(por.Line[0].Value).To(Equal("Linha"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Unsupported cue handling", func() {
|
||||
It("should skip wallclock cues and keep valid ones", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml">
|
||||
<body xml:lang="eng">
|
||||
<div>
|
||||
<p begin="wallclock(2026-01-01T00:00:00Z)">Skip me</p>
|
||||
<p begin="1s">Keep me</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Start).To(Equal(new(int64(1000))))
|
||||
Expect(list[0].Line[0].Value).To(Equal("Keep me"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Begin/End/Dur with inheritance", func() {
|
||||
It("should correctly accumulate nested timing from body, div, and p elements", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml">
|
||||
<body xml:lang="eng" begin="10s">
|
||||
<div begin="5s" dur="8s">
|
||||
<p begin="1s" dur="2s">First line</p>
|
||||
<p begin="3s" end="5s">Second line</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Lang).To(Equal("eng"))
|
||||
Expect(list[0].Line).To(HaveLen(2))
|
||||
Expect(list[0].Line[0].Start).To(Equal(new(int64(16000))))
|
||||
Expect(list[0].Line[0].Value).To(Equal("First line"))
|
||||
Expect(list[0].Line[1].Start).To(Equal(new(int64(18000))))
|
||||
Expect(list[0].Line[1].Value).To(Equal("Second line"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Non-standard bare second offsets", func() {
|
||||
It("should parse bare decimal numbers as seconds", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml">
|
||||
<body xml:lang="eng" begin="10">
|
||||
<div>
|
||||
<p begin="0.170">First line</p>
|
||||
<p begin="3.710">Second line</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(HaveLen(2))
|
||||
Expect(list[0].Line[0].Start).To(Equal(new(int64(10170))))
|
||||
Expect(list[0].Line[0].Value).To(Equal("First line"))
|
||||
Expect(list[0].Line[1].Start).To(Equal(new(int64(13710))))
|
||||
Expect(list[0].Line[1].Value).To(Equal("Second line"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Word timing tokens", func() {
|
||||
It("should extract timed tokens from spans including background role", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
|
||||
<body xml:lang="eng">
|
||||
<div>
|
||||
<p begin="00:01.000" end="00:03.000">
|
||||
<span begin="00:01.000" end="00:01.400">He</span><span begin="00:01.400" end="00:01.800">llo</span>
|
||||
<span ttm:role="x-bg"><span begin="00:02.000" end="00:02.500">echo</span></span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
{ID: "main", Role: "main"},
|
||||
{ID: "__nd_bg__|main", Role: "bg"},
|
||||
}))
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
|
||||
line := list[0].Line[0]
|
||||
Expect(line.Start).To(Equal(new(int64(1000))))
|
||||
Expect(line.Value).To(Equal("Hello\necho"))
|
||||
Expect(line.End).To(Equal(new(int64(3000))))
|
||||
Expect(line.Cue).To(HaveLen(3))
|
||||
|
||||
Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(1000)), End: new(int64(1400)), Value: "He", ByteStart: 0, ByteEnd: 1, AgentID: "main"}))
|
||||
Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(1400)), End: new(int64(1800)), Value: "llo", ByteStart: 2, ByteEnd: 4, AgentID: "main"}))
|
||||
Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2500)), Value: "echo", ByteStart: 6, ByteEnd: 9, AgentID: "__nd_bg__|main"}))
|
||||
})
|
||||
|
||||
It("should append role tokens exactly instead of using substring matches", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
|
||||
<body xml:lang="eng">
|
||||
<div>
|
||||
<p begin="00:01.000" end="00:03.000" ttm:role="not-x-bg"><span begin="00:01.000" end="00:01.400">Lead</span><span ttm:role="x-bg"><span begin="00:02.000" end="00:02.500">Echo</span></span></p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
{ID: "main", Role: "main"},
|
||||
{ID: "__nd_bg__|main", Role: "bg"},
|
||||
}))
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Cue).To(HaveLen(2))
|
||||
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("main"))
|
||||
Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|main"))
|
||||
})
|
||||
|
||||
It("should parse named TTML agents into main, voice, and group roles", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
|
||||
<head>
|
||||
<metadata>
|
||||
<ttm:agent xml:id="v1" type="person"><ttm:name>Chris Martin</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="v2" type="person"><ttm:name>Jin</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="v1000" type="group"><ttm:name>All</ttm:name></ttm:agent>
|
||||
</metadata>
|
||||
</head>
|
||||
<body xml:lang="eng">
|
||||
<div>
|
||||
<p begin="1s" end="2s" ttm:agent="v1"><span begin="1s" end="1.5s">You</span></p>
|
||||
<p begin="2s" end="3s" ttm:agent="v2"><span begin="2s" end="2.5s">and</span></p>
|
||||
<p begin="3s" end="4s" ttm:agent="v1000"><span begin="3s" end="3.5s">All</span></p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
{ID: "v1", Role: "main", Name: "Chris Martin"},
|
||||
{ID: "v2", Role: "voice", Name: "Jin"},
|
||||
{ID: "v1000", Role: "group", Name: "All"},
|
||||
}))
|
||||
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("v1"))
|
||||
Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("v2"))
|
||||
Expect(list[0].Line[2].Cue[0].AgentID).To(Equal("v1000"))
|
||||
})
|
||||
|
||||
It("should avoid collisions between derived background agents and explicit TTML agent ids", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
|
||||
<head>
|
||||
<metadata>
|
||||
<ttm:agent xml:id="lead" type="person"><ttm:name>Lead</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="lead__bg" type="person"><ttm:name>Existing Background Id</ttm:name></ttm:agent>
|
||||
</metadata>
|
||||
</head>
|
||||
<body xml:lang="eng">
|
||||
<div>
|
||||
<p begin="1s" end="2s" ttm:agent="lead">
|
||||
<span begin="1s" end="1.4s">Lead</span>
|
||||
<span ttm:role="x-bg"><span begin="1.5s" end="1.8s">Echo</span></span>
|
||||
</p>
|
||||
<p begin="2s" end="3s" ttm:agent="lead__bg">
|
||||
<span begin="2s" end="2.5s">Named</span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
{ID: "lead", Role: "main", Name: "Lead"},
|
||||
{ID: "__nd_bg__|lead", Role: "bg", Name: "Lead"},
|
||||
{ID: "lead__bg", Role: "voice", Name: "Existing Background Id"},
|
||||
}))
|
||||
Expect(list[0].Line).To(HaveLen(2))
|
||||
Expect(list[0].Line[0].Cue).To(HaveLen(2))
|
||||
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("lead"))
|
||||
Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|lead"))
|
||||
Expect(list[0].Line[1].Cue).To(HaveLen(1))
|
||||
Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("lead__bg"))
|
||||
})
|
||||
|
||||
It("should fill missing cue agent ids with the resolved main agent", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
|
||||
<head>
|
||||
<metadata>
|
||||
<ttm:agent xml:id="guest" type="person"><ttm:name>Guest Vocal</ttm:name></ttm:agent>
|
||||
</metadata>
|
||||
</head>
|
||||
<body xml:lang="eng">
|
||||
<div>
|
||||
<p begin="1s" end="3s">
|
||||
<span begin="1s" end="1.4s">Lead</span>
|
||||
<span begin="2s" end="2.4s" ttm:agent="guest">Guest</span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
{ID: "guest", Role: "main", Name: "Guest Vocal"},
|
||||
}))
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Cue).To(HaveLen(2))
|
||||
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("guest"))
|
||||
Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("guest"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Ambiguous decimal timing", func() {
|
||||
It("should prefer absolute timing when values fall inside parent window", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml">
|
||||
<body xml:lang="eng">
|
||||
<div begin="37.870" end="45.570">
|
||||
<p begin="43.444" end="45.570">
|
||||
<span begin="43.444" end="43.716">go</span>
|
||||
<span begin="43.716" end="43.887">go</span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
|
||||
line := list[0].Line[0]
|
||||
Expect(line.Start).To(Equal(new(int64(43444))))
|
||||
Expect(line.Value).To(Equal("go\ngo"))
|
||||
Expect(line.End).To(Equal(new(int64(45570))))
|
||||
Expect(line.Cue).To(HaveLen(2))
|
||||
Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1}))
|
||||
Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(43716)), End: new(int64(43887)), Value: "go", ByteStart: 3, ByteEnd: 4}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Unsynced fallback", func() {
|
||||
It("should return unsynced lyrics when no timing is present", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml">
|
||||
<body>
|
||||
<div>
|
||||
<p>No timing here</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Lang).To(Equal("xxx"))
|
||||
Expect(list[0].Synced).To(BeFalse())
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Start).To(BeNil())
|
||||
Expect(list[0].Line[0].Value).To(Equal("No timing here"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Metadata tracks", func() {
|
||||
It("should produce main, translation, and pronunciation tracks from iTunesMetadata", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
|
||||
<head>
|
||||
<metadata>
|
||||
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
|
||||
<translations>
|
||||
<translation xml:lang="es">
|
||||
<text for="L1">Hola</text>
|
||||
<text for="MISSING">Skip me</text>
|
||||
</translation>
|
||||
</translations>
|
||||
<transliterations>
|
||||
<transliteration xml:lang="ja-Latn">
|
||||
<text for="L2"><span begin="00:02.000" end="00:02.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:02.300" end="00:02.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text>
|
||||
</transliteration>
|
||||
</transliterations>
|
||||
</iTunesMetadata>
|
||||
</metadata>
|
||||
</head>
|
||||
<body xml:lang="ja">
|
||||
<div>
|
||||
<p begin="00:01.000" end="00:01.500" itunes:key="L1">こんにちは</p>
|
||||
<p begin="00:02.000" end="00:02.700" itunes:key="L2">こんばんは</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(3))
|
||||
|
||||
By("checking the main track")
|
||||
main := list[0]
|
||||
Expect(main.Kind).To(Equal("main"))
|
||||
Expect(main.Lang).To(Equal("ja"))
|
||||
Expect(main.Line).To(HaveLen(2))
|
||||
|
||||
By("checking the translation track")
|
||||
translation := list[1]
|
||||
Expect(translation.Kind).To(Equal("translation"))
|
||||
Expect(translation.Lang).To(Equal("es"))
|
||||
Expect(translation.Line).To(HaveLen(1))
|
||||
Expect(translation.Line[0].Start).To(Equal(new(int64(1000))))
|
||||
Expect(translation.Line[0].Value).To(Equal("Hola"))
|
||||
Expect(translation.Line[0].End).To(Equal(new(int64(1500))))
|
||||
|
||||
By("checking the pronunciation track")
|
||||
pronunciation := list[2]
|
||||
Expect(pronunciation.Kind).To(Equal("pronunciation"))
|
||||
Expect(pronunciation.Lang).To(Equal("ja-latn"))
|
||||
Expect(pronunciation.Line).To(HaveLen(1))
|
||||
Expect(pronunciation.Line[0].Start).To(Equal(new(int64(2000))))
|
||||
Expect(pronunciation.Line[0].Value).To(Equal("konni"))
|
||||
Expect(pronunciation.Line[0].End).To(Equal(new(int64(2600))))
|
||||
Expect(pronunciation.Line[0].Cue).To(HaveLen(2))
|
||||
Expect(pronunciation.Line[0].Cue[0]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2300)), Value: "ko", ByteStart: 0, ByteEnd: 1}))
|
||||
Expect(pronunciation.Line[0].Cue[1]).To(Equal(Cue{Start: new(int64(2300)), End: new(int64(2600)), Value: "nni", ByteStart: 2, ByteEnd: 4}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Pronunciation with bare decimal end times", func() {
|
||||
It("should correctly parse bare decimal times in transliteration spans", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
|
||||
<head>
|
||||
<metadata>
|
||||
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
|
||||
<transliterations>
|
||||
<transliteration xml:lang="ja-Latn">
|
||||
<text for="L1"><span begin="2.747" end="3.018" xmlns="http://www.w3.org/ns/ttml">I</span> <span begin="3.018" end="3.179" xmlns="http://www.w3.org/ns/ttml">woke</span> <span begin="3.179" end="3.582" xmlns="http://www.w3.org/ns/ttml">up</span></text>
|
||||
</transliteration>
|
||||
</transliterations>
|
||||
</iTunesMetadata>
|
||||
</metadata>
|
||||
</head>
|
||||
<body xml:lang="ja">
|
||||
<div>
|
||||
<p begin="00:02.747" end="00:04.000" itunes:key="L1">起きた</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var pronunciation *Lyrics
|
||||
for i := range list {
|
||||
if list[i].Kind == "pronunciation" {
|
||||
pronunciation = &list[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(pronunciation).ToNot(BeNil())
|
||||
Expect(pronunciation.Line).To(HaveLen(1))
|
||||
|
||||
line := pronunciation.Line[0]
|
||||
Expect(line.Start).To(Equal(new(int64(2747))))
|
||||
Expect(line.Value).To(Equal("I woke up"))
|
||||
Expect(line.Cue).To(HaveLen(3))
|
||||
Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(2747)), End: new(int64(3018)), Value: "I", ByteStart: 0, ByteEnd: 0}))
|
||||
Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(3018)), End: new(int64(3179)), Value: "woke", ByteStart: 2, ByteEnd: 5}))
|
||||
Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8}))
|
||||
})
|
||||
})
|
||||
})
|
||||
276
model/lyricsfile.go
Normal file
276
model/lyricsfile.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ParseLyricsfile parses a LRCLIB Lyricsfile YAML document
|
||||
// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md)
|
||||
// into a model.LyricList containing a single main Lyrics entry. Returns
|
||||
// (nil, nil) when the input parses as YAML but does not declare Lyricsfile
|
||||
// version 1.0.
|
||||
//
|
||||
// When the source contains per-word timing via lines[].words[], each word
|
||||
// becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and
|
||||
// overlapping lines are attributed to synthetic voice agents via lowest-free
|
||||
// voice ID assignment so the OpenSubsonic v2 enhanced response can split
|
||||
// parallel vocals.
|
||||
func ParseLyricsfile(text string) (LyricList, 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 Lyricsfile YAML: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(doc.Version) != lyricsfileVersion {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lyrics := Lyrics{
|
||||
DisplayArtist: str.SanitizeText(doc.Metadata.Artist),
|
||||
DisplayTitle: str.SanitizeText(doc.Metadata.Title),
|
||||
Lang: normalizeLyricLang(doc.Metadata.Language),
|
||||
Kind: LyricKindMain,
|
||||
}
|
||||
if doc.Metadata.OffsetMs != 0 {
|
||||
off := doc.Metadata.OffsetMs
|
||||
lyrics.Offset = &off
|
||||
}
|
||||
|
||||
if doc.Metadata.Instrumental {
|
||||
return LyricList{NormalizeLyrics(lyrics)}, nil
|
||||
}
|
||||
|
||||
if len(doc.Lines) == 0 {
|
||||
lines := buildPlainLyricsfileLines(doc.Plain)
|
||||
if len(lines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
lyrics.Line = lines
|
||||
return LyricList{NormalizeLyrics(lyrics)}, nil
|
||||
}
|
||||
|
||||
lines, agents := buildLyricsfileLines(doc.Lines)
|
||||
lyrics.Line = lines
|
||||
lyrics.Agents = agents
|
||||
lyrics.Synced = true
|
||||
return LyricList{NormalizeLyrics(lyrics)}, nil
|
||||
}
|
||||
|
||||
const lyricsfileVersion = "1.0"
|
||||
|
||||
type lyricsfileDocument struct {
|
||||
Version string `yaml:"version"`
|
||||
Metadata lyricsfileMetadata `yaml:"metadata"`
|
||||
Lines []lyricsfileLineEntry `yaml:"lines"`
|
||||
Plain string `yaml:"plain"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// buildLyricsfileLines converts YAML line entries to model.Line entries with
|
||||
// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free
|
||||
// voice ID). The Agents slice is emitted only when at least one cue carries
|
||||
// attribution AND more than one voice is used; otherwise AgentIDs are
|
||||
// stripped so the wire shape stays simple per the OpenSubsonic spec rule
|
||||
// "agents should not be emitted without cueLine data".
|
||||
func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Resolved end timestamps per entry: explicit end_ms, final word end_ms,
|
||||
// then the next entry's start. The last entry's end stays nil when no
|
||||
// explicit or word-level end is available.
|
||||
ends := make([]*int64, len(entries))
|
||||
for i := range entries {
|
||||
var nextStart *int64
|
||||
if i+1 < len(entries) {
|
||||
v := entries[i+1].StartMs
|
||||
nextStart = &v
|
||||
}
|
||||
ends[i] = lyricsfileLineEnd(entries[i], nextStart)
|
||||
}
|
||||
|
||||
active := map[int]int64{}
|
||||
maxVoice := -1
|
||||
anyCues := false
|
||||
lines := make([]Line, 0, len(entries))
|
||||
|
||||
for i, entry := range entries {
|
||||
for vID, vEnd := range active {
|
||||
if vEnd <= entry.StartMs {
|
||||
delete(active, vID)
|
||||
}
|
||||
}
|
||||
|
||||
voiceID := 0
|
||||
for {
|
||||
if _, busy := active[voiceID]; !busy {
|
||||
break
|
||||
}
|
||||
voiceID++
|
||||
}
|
||||
if voiceID > maxVoice {
|
||||
maxVoice = voiceID
|
||||
}
|
||||
|
||||
agentID := fmt.Sprintf("voice-%d", voiceID)
|
||||
cues, value := wordsToLineCues(entry, agentID)
|
||||
if len(cues) > 0 {
|
||||
anyCues = true
|
||||
}
|
||||
|
||||
startMs := entry.StartMs
|
||||
line := Line{
|
||||
Start: &startMs,
|
||||
End: ends[i],
|
||||
Value: value,
|
||||
Cue: cues,
|
||||
}
|
||||
lines = append(lines, line)
|
||||
|
||||
var endMs int64
|
||||
if ends[i] != nil {
|
||||
endMs = *ends[i]
|
||||
} else {
|
||||
endMs = entry.StartMs
|
||||
}
|
||||
active[voiceID] = endMs
|
||||
}
|
||||
|
||||
// Monophonic source, or attribution that has nowhere to land: emit no
|
||||
// agents and strip per-cue AgentIDs to keep the wire shape simple.
|
||||
if maxVoice <= 0 || !anyCues {
|
||||
for i := range lines {
|
||||
for j := range lines[i].Cue {
|
||||
lines[i].Cue[j].AgentID = ""
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
agents := make([]Agent, 0, maxVoice+1)
|
||||
for v := 0; v <= maxVoice; v++ {
|
||||
role := "voice"
|
||||
if v == 0 {
|
||||
role = "main"
|
||||
}
|
||||
agents = append(agents, Agent{
|
||||
ID: fmt.Sprintf("voice-%d", v),
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
return lines, agents
|
||||
}
|
||||
|
||||
func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 {
|
||||
if entry.EndMs != nil {
|
||||
v := *entry.EndMs
|
||||
return &v
|
||||
}
|
||||
if len(entry.Words) > 0 {
|
||||
lastWord := entry.Words[len(entry.Words)-1]
|
||||
if lastWord.EndMs != nil {
|
||||
v := *lastWord.EndMs
|
||||
return &v
|
||||
}
|
||||
}
|
||||
if nextStart != nil {
|
||||
v := *nextStart
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildPlainLyricsfileLines(plain string) []Line {
|
||||
plain = str.SanitizeText(plain)
|
||||
rawLines := strings.Split(plain, "\n")
|
||||
lines := make([]Line, 0, len(rawLines))
|
||||
for _, raw := range rawLines {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, Line{Value: value})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue
|
||||
// entries with inclusive UTF-8 byte offsets into the reconstructed line
|
||||
// value. The line value is built from cue text concatenation rather than
|
||||
// trusting entry.Text, because the Lyricsfile spec only requires word.text
|
||||
// to "approximate" line.text - byte offsets must always land inside
|
||||
// Line.Value.
|
||||
func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) {
|
||||
if len(entry.Words) == 0 {
|
||||
return nil, str.SanitizeText(entry.Text)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, w := range entry.Words {
|
||||
sb.WriteString(w.Text)
|
||||
}
|
||||
lineValue := sb.String()
|
||||
|
||||
cues := make([]Cue, len(entry.Words))
|
||||
cursor := 0
|
||||
for i, w := range entry.Words {
|
||||
valueBytes := len(w.Text)
|
||||
bs := cursor
|
||||
be := bs
|
||||
if valueBytes > 0 {
|
||||
be = bs + valueBytes - 1
|
||||
cursor = be + 1
|
||||
}
|
||||
|
||||
s := w.StartMs
|
||||
cue := Cue{
|
||||
Start: &s,
|
||||
Value: w.Text,
|
||||
ByteStart: bs,
|
||||
ByteEnd: be,
|
||||
AgentID: agentID,
|
||||
}
|
||||
if w.EndMs != nil {
|
||||
e := *w.EndMs
|
||||
cue.End = &e
|
||||
}
|
||||
cues[i] = cue
|
||||
}
|
||||
|
||||
for i := 0; i < len(cues)-1; i++ {
|
||||
if cues[i].End == nil && cues[i+1].Start != nil {
|
||||
v := *cues[i+1].Start
|
||||
cues[i].End = &v
|
||||
}
|
||||
}
|
||||
return cues, lineValue
|
||||
}
|
||||
283
model/lyricsfile_test.go
Normal file
283
model/lyricsfile_test.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
package model_test
|
||||
|
||||
import (
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseLyricsfile", func() {
|
||||
DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker",
|
||||
func(input string) {
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(BeNil())
|
||||
},
|
||||
Entry("arbitrary YAML", "hello: world\n"),
|
||||
Entry("Lyricsfile-shaped but unversioned", `metadata:
|
||||
title: 'Looks close'
|
||||
lines:
|
||||
- text: "But should not be claimed"
|
||||
start_ms: 1000
|
||||
`),
|
||||
)
|
||||
|
||||
It("returns an error for invalid YAML", func() {
|
||||
_, err := ParseLyricsfile("not: valid: yaml: [")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("parses line-level metadata without cues", func() {
|
||||
input := `version: '1.0'
|
||||
metadata:
|
||||
title: 'Sample Track'
|
||||
artist: 'Test Artist'
|
||||
language: 'eng'
|
||||
offset_ms: -100
|
||||
lines:
|
||||
- text: "We're no strangers to love"
|
||||
start_ms: 18800
|
||||
- text: "You know the rules and so do I"
|
||||
start_ms: 22801
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
l := lyrics[0]
|
||||
Expect(l.Kind).To(Equal("main"))
|
||||
Expect(l.Lang).To(Equal("eng"))
|
||||
Expect(l.DisplayArtist).To(Equal("Test Artist"))
|
||||
Expect(l.DisplayTitle).To(Equal("Sample Track"))
|
||||
Expect(l.Synced).To(BeTrue())
|
||||
Expect(l.Offset).ToNot(BeNil())
|
||||
Expect(*l.Offset).To(Equal(int64(-100)))
|
||||
Expect(l.Agents).To(BeNil())
|
||||
|
||||
Expect(l.Line).To(HaveLen(2))
|
||||
Expect(*l.Line[0].Start).To(Equal(int64(18800)))
|
||||
Expect(l.Line[0].End).ToNot(BeNil())
|
||||
Expect(*l.Line[0].End).To(Equal(int64(22801)))
|
||||
Expect(l.Line[0].Value).To(Equal("We're no strangers to love"))
|
||||
Expect(l.Line[0].Cue).To(BeNil())
|
||||
|
||||
Expect(*l.Line[1].Start).To(Equal(int64(22801)))
|
||||
Expect(l.Line[1].End).To(BeNil())
|
||||
Expect(l.Line[1].Value).To(Equal("You know the rules and so do I"))
|
||||
Expect(l.Line[1].Cue).To(BeNil())
|
||||
})
|
||||
|
||||
It("parses plain-only Lyricsfile lyrics as unsynced lines", func() {
|
||||
input := `version: '1.0'
|
||||
metadata:
|
||||
title: 'Plain Track'
|
||||
artist: 'Plain Artist'
|
||||
language: 'en'
|
||||
lines: []
|
||||
plain: |
|
||||
[Verse 1]
|
||||
First line
|
||||
|
||||
Second line
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
l := lyrics[0]
|
||||
Expect(l.Kind).To(Equal("main"))
|
||||
Expect(l.Lang).To(Equal("en"))
|
||||
Expect(l.DisplayArtist).To(Equal("Plain Artist"))
|
||||
Expect(l.DisplayTitle).To(Equal("Plain Track"))
|
||||
Expect(l.Synced).To(BeFalse())
|
||||
Expect(l.Agents).To(BeNil())
|
||||
Expect(l.Line).To(Equal([]Line{
|
||||
{Value: "[Verse 1]"},
|
||||
{Value: "First line"},
|
||||
{Value: "Second line"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() {
|
||||
input := `version: '1.0'
|
||||
metadata:
|
||||
title: 'Karaoke'
|
||||
artist: 'Singer'
|
||||
language: 'eng'
|
||||
lines:
|
||||
- text: "Hello world"
|
||||
start_ms: 1000
|
||||
end_ms: 3000
|
||||
words:
|
||||
- text: "Hello "
|
||||
start_ms: 1000
|
||||
end_ms: 1500
|
||||
- text: "world"
|
||||
start_ms: 1500
|
||||
end_ms: 3000
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
l := lyrics[0]
|
||||
Expect(l.Synced).To(BeTrue())
|
||||
Expect(l.Agents).To(BeNil())
|
||||
Expect(l.Line).To(HaveLen(1))
|
||||
|
||||
line := l.Line[0]
|
||||
Expect(*line.Start).To(Equal(int64(1000)))
|
||||
Expect(*line.End).To(Equal(int64(3000)))
|
||||
Expect(line.Value).To(Equal("Hello world"))
|
||||
Expect(line.Cue).To(HaveLen(2))
|
||||
|
||||
Expect(*line.Cue[0].Start).To(Equal(int64(1000)))
|
||||
Expect(*line.Cue[0].End).To(Equal(int64(1500)))
|
||||
Expect(line.Cue[0].Value).To(Equal("Hello "))
|
||||
Expect(line.Cue[0].ByteStart).To(Equal(0))
|
||||
Expect(line.Cue[0].ByteEnd).To(Equal(5))
|
||||
Expect(line.Cue[0].AgentID).To(Equal(""))
|
||||
|
||||
Expect(*line.Cue[1].Start).To(Equal(int64(1500)))
|
||||
Expect(*line.Cue[1].End).To(Equal(int64(3000)))
|
||||
Expect(line.Cue[1].Value).To(Equal("world"))
|
||||
Expect(line.Cue[1].ByteStart).To(Equal(6))
|
||||
Expect(line.Cue[1].ByteEnd).To(Equal(10))
|
||||
Expect(line.Cue[1].AgentID).To(Equal(""))
|
||||
})
|
||||
|
||||
It("prefers final word end_ms over next line start when inferring line end", func() {
|
||||
input := `version: '1.0'
|
||||
metadata:
|
||||
title: 'Overlap From Words'
|
||||
lines:
|
||||
- text: "Long vocal"
|
||||
start_ms: 1000
|
||||
words:
|
||||
- text: "Long "
|
||||
start_ms: 1000
|
||||
end_ms: 2000
|
||||
- text: "vocal"
|
||||
start_ms: 2000
|
||||
end_ms: 4000
|
||||
- text: "echo"
|
||||
start_ms: 3000
|
||||
end_ms: 3500
|
||||
words:
|
||||
- text: "echo"
|
||||
start_ms: 3000
|
||||
end_ms: 3500
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
l := lyrics[0]
|
||||
Expect(l.Agents).To(Equal([]Agent{
|
||||
{ID: "voice-0", Role: "main"},
|
||||
{ID: "voice-1", Role: "voice"},
|
||||
}))
|
||||
Expect(l.Line).To(HaveLen(2))
|
||||
Expect(l.Line[0].End).ToNot(BeNil())
|
||||
Expect(*l.Line[0].End).To(Equal(int64(4000)))
|
||||
Expect(l.Line[0].Cue[1].End).To(Equal(l.Line[0].End))
|
||||
Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1"))
|
||||
})
|
||||
|
||||
It("synthesises voice agents for overlapping lines and attributes per-cue", func() {
|
||||
input := `version: '1.0'
|
||||
metadata:
|
||||
title: 'Duet'
|
||||
lines:
|
||||
- text: "Lead vocal"
|
||||
start_ms: 1000
|
||||
end_ms: 4000
|
||||
words:
|
||||
- text: "Lead "
|
||||
start_ms: 1000
|
||||
end_ms: 2000
|
||||
- text: "vocal"
|
||||
start_ms: 2000
|
||||
end_ms: 4000
|
||||
- text: "echo"
|
||||
start_ms: 2000
|
||||
end_ms: 3000
|
||||
words:
|
||||
- text: "echo"
|
||||
start_ms: 2000
|
||||
end_ms: 3000
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
l := lyrics[0]
|
||||
Expect(l.Agents).To(Equal([]Agent{
|
||||
{ID: "voice-0", Role: "main"},
|
||||
{ID: "voice-1", Role: "voice"},
|
||||
}))
|
||||
Expect(l.Line).To(HaveLen(2))
|
||||
|
||||
Expect(l.Line[0].Value).To(Equal("Lead vocal"))
|
||||
Expect(*l.Line[0].Start).To(Equal(int64(1000)))
|
||||
Expect(*l.Line[0].End).To(Equal(int64(4000)))
|
||||
Expect(l.Line[0].Cue).To(HaveLen(2))
|
||||
Expect(l.Line[0].Cue[0].AgentID).To(Equal("voice-0"))
|
||||
Expect(l.Line[0].Cue[1].AgentID).To(Equal("voice-0"))
|
||||
Expect(l.Line[0].Cue[0].ByteStart).To(Equal(0))
|
||||
Expect(l.Line[0].Cue[0].ByteEnd).To(Equal(4))
|
||||
Expect(l.Line[0].Cue[1].ByteStart).To(Equal(5))
|
||||
Expect(l.Line[0].Cue[1].ByteEnd).To(Equal(9))
|
||||
|
||||
Expect(l.Line[1].Value).To(Equal("echo"))
|
||||
Expect(*l.Line[1].Start).To(Equal(int64(2000)))
|
||||
Expect(*l.Line[1].End).To(Equal(int64(3000)))
|
||||
Expect(l.Line[1].Cue).To(HaveLen(1))
|
||||
Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1"))
|
||||
Expect(l.Line[1].Cue[0].ByteStart).To(Equal(0))
|
||||
Expect(l.Line[1].Cue[0].ByteEnd).To(Equal(3))
|
||||
})
|
||||
|
||||
It("emits empty lines with Synced=false for instrumental tracks", func() {
|
||||
input := `version: '1.0'
|
||||
metadata:
|
||||
title: 'Solo Piano'
|
||||
artist: 'Composer'
|
||||
language: 'eng'
|
||||
instrumental: true
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
l := lyrics[0]
|
||||
Expect(l.Kind).To(Equal("main"))
|
||||
Expect(l.Lang).To(Equal("eng"))
|
||||
Expect(l.DisplayArtist).To(Equal("Composer"))
|
||||
Expect(l.DisplayTitle).To(Equal("Solo Piano"))
|
||||
Expect(l.Synced).To(BeFalse())
|
||||
Expect(l.Line).To(BeEmpty())
|
||||
Expect(l.Agents).To(BeNil())
|
||||
})
|
||||
|
||||
It("strips agent attribution when overlapping lines carry no cues", func() {
|
||||
input := `version: '1.0'
|
||||
lines:
|
||||
- text: "Lead"
|
||||
start_ms: 1000
|
||||
end_ms: 4000
|
||||
- text: "echo"
|
||||
start_ms: 2000
|
||||
end_ms: 3000
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
l := lyrics[0]
|
||||
Expect(l.Line).To(HaveLen(2))
|
||||
Expect(l.Agents).To(BeNil())
|
||||
Expect(l.Line[0].Cue).To(BeNil())
|
||||
Expect(l.Line[1].Cue).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
|
@ -143,13 +143,15 @@ func (md Metadata) mapLyrics() string {
|
|||
lang := raw.Key()
|
||||
text := raw.Value()
|
||||
|
||||
lyrics, err := model.ToLyrics(lang, text)
|
||||
lyrics, err := model.ParseEmbedded(lang, text)
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err)
|
||||
continue
|
||||
}
|
||||
if !lyrics.IsEmpty() {
|
||||
lyricList = append(lyricList, *lyrics)
|
||||
for _, lyric := range lyrics {
|
||||
if !lyric.IsEmpty() {
|
||||
lyricList = append(lyricList, lyric)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ var _ = Describe("Metadata", func() {
|
|||
props.Tags = model.RawTags{
|
||||
"Title": {strings.Repeat("a", 2048)},
|
||||
"Comment": {strings.Repeat("a", 8192)},
|
||||
"lyrics:xxx": {strings.Repeat("a", 60000)},
|
||||
"lyrics:xxx": {strings.Repeat("a", 2_000_000)},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
|
|
@ -116,9 +116,10 @@ var _ = Describe("Metadata", func() {
|
|||
Expect(pair).To(HaveLen(1))
|
||||
Expect(pair[0].Key()).To(Equal("xxx"))
|
||||
|
||||
// Lyrics keep a much larger cap so word-timed karaoke survives.
|
||||
// Note: a total of 6 characters are lost from maxLength from
|
||||
// the key portion and separator
|
||||
Expect(pair[0].Value()).To(HaveLen(32762))
|
||||
// the key portion and separator.
|
||||
Expect(pair[0].Value()).To(HaveLen(1048570))
|
||||
})
|
||||
|
||||
It("should split multiple values", func() {
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
|||
return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin)
|
||||
}
|
||||
|
||||
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
|
||||
return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ main:
|
|||
lyrics:
|
||||
# Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx`
|
||||
aliases: [ uslt:description, lyrics, unsyncedlyrics ]
|
||||
maxLength: 32768
|
||||
# Generous cap to fit word-timed TTML/Enhanced-LRC karaoke for a full song,
|
||||
# while still bounding against pathological tags.
|
||||
maxLength: 1048576
|
||||
type: pair # ex: lyrics:eng, lyrics:xxx
|
||||
comment:
|
||||
aliases: [ comm:description, comment, ©cmt, description, icmt ]
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ func setupTestDB() {
|
|||
core.NewShare(ds),
|
||||
playback.PlaybackServer(nil),
|
||||
metrics.NewNoopInstance(),
|
||||
lyrics.NewLyrics(nil),
|
||||
lyrics.NewLyrics(ds, nil),
|
||||
decider,
|
||||
nil,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
|
|||
core.NewShare(ds),
|
||||
playback.PlaybackServer(nil),
|
||||
metrics.NewNoopInstance(),
|
||||
lyrics.NewLyrics(nil),
|
||||
lyrics.NewLyrics(ds, nil),
|
||||
decider,
|
||||
sonicSvc,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -106,21 +106,6 @@ func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options {
|
|||
return addDefaultFilters(options)
|
||||
}
|
||||
|
||||
func SongsByArtistTitleWithLyricsFirst(artist, title string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "lyrics, updated_at",
|
||||
Order: "desc",
|
||||
Max: 1,
|
||||
Filters: And{
|
||||
Eq{"title": title},
|
||||
Or{
|
||||
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
|
||||
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options {
|
||||
if len(musicFolderIds) == 0 {
|
||||
return opts
|
||||
|
|
|
|||
|
|
@ -495,48 +495,6 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
structured := responses.StructuredLyric{
|
||||
DisplayArtist: lyrics.DisplayArtist,
|
||||
DisplayTitle: lyrics.DisplayTitle,
|
||||
Lang: lyrics.Lang,
|
||||
Line: lines,
|
||||
Offset: lyrics.Offset,
|
||||
Synced: lyrics.Synced,
|
||||
}
|
||||
|
||||
if structured.DisplayArtist == "" {
|
||||
structured.DisplayArtist = mf.Artist
|
||||
}
|
||||
if structured.DisplayTitle == "" {
|
||||
structured.DisplayTitle = mf.Title
|
||||
}
|
||||
|
||||
return structured
|
||||
}
|
||||
|
||||
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList {
|
||||
lyricList := make(responses.StructuredLyrics, len(lyricsList))
|
||||
|
||||
for i, lyrics := range lyricsList {
|
||||
lyricList[i] = buildStructuredLyric(mf, lyrics)
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
181
server/subsonic/lyrics.go
Normal file
181
server/subsonic/lyrics.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
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 l.IsMainKind() {
|
||||
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}
|
||||
}
|
||||
|
||||
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 = lyrics.EffectiveKind()
|
||||
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
|
||||
}
|
||||
618
server/subsonic/lyrics_test.go
Normal file
618
server/subsonic/lyrics_test.go
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GetLyricsBySongId", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}}
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil)
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
})
|
||||
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
|
||||
var times = []int64{18800, 22801}
|
||||
|
||||
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
|
||||
Expect(actual).ToNot(BeNil())
|
||||
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
|
||||
for i, realLyric := range actual.StructuredLyrics {
|
||||
expectedLyric := expected.StructuredLyrics[i]
|
||||
|
||||
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
|
||||
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
|
||||
Expect(realLyric.Kind).To(Equal(expectedLyric.Kind))
|
||||
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
|
||||
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
|
||||
Expect(realLyric.Agents).To(Equal(expectedLyric.Agents))
|
||||
|
||||
if expectedLyric.Offset == nil {
|
||||
Expect(realLyric.Offset).To(BeNil())
|
||||
} else {
|
||||
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
|
||||
}
|
||||
|
||||
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
|
||||
for j, realLine := range realLyric.Line {
|
||||
expectedLine := expectedLyric.Line[j]
|
||||
Expect(realLine.Value).To(Equal(expectedLine.Value))
|
||||
|
||||
if expectedLine.Start == nil {
|
||||
Expect(realLine.Start).To(BeNil())
|
||||
} else {
|
||||
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
|
||||
}
|
||||
}
|
||||
|
||||
Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine)))
|
||||
for j, realCueLine := range realLyric.CueLine {
|
||||
expectedCueLine := expectedLyric.CueLine[j]
|
||||
Expect(realCueLine.Index).To(Equal(expectedCueLine.Index))
|
||||
Expect(realCueLine.Value).To(Equal(expectedCueLine.Value))
|
||||
Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID))
|
||||
if expectedCueLine.Start == nil {
|
||||
Expect(realCueLine.Start).To(BeNil())
|
||||
} else {
|
||||
Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start))
|
||||
}
|
||||
if expectedCueLine.End == nil {
|
||||
Expect(realCueLine.End).To(BeNil())
|
||||
} else {
|
||||
Expect(*realCueLine.End).To(Equal(*expectedCueLine.End))
|
||||
}
|
||||
|
||||
Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue)))
|
||||
for k, realCue := range realCueLine.Cue {
|
||||
expectedCue := expectedCueLine.Cue[k]
|
||||
Expect(realCue.Value).To(Equal(expectedCue.Value))
|
||||
Expect(realCue.Start).To(Equal(expectedCue.Start))
|
||||
Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart))
|
||||
Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd))
|
||||
if expectedCue.End == nil {
|
||||
Expect(realCue.End).To(BeNil())
|
||||
} else {
|
||||
Expect(*realCue.End).To(Equal(*expectedCue.End))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It("should return mixed lyrics", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*synced, *unsynced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
Lang: "eng",
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[1],
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Lang: "xxx",
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Synced: false,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should parse lrc metadata", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*synced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[1],
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: new(int64(-100)),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should return multilingual TTML sidecar lyrics", func() {
|
||||
conf.Server.LyricsPriority = ".ttml,embedded"
|
||||
r := newGetRequest("id=1")
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
porTime := int64(18800)
|
||||
ttmlTime := int64(22800)
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: &ttmlTime,
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Lang: "por",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: &porTime,
|
||||
Value: "Nao somos estranhos ao amor",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should return metadata-linked translation and pronunciation tracks from TTML", func() {
|
||||
conf.Server.LyricsPriority = ".ttml,embedded"
|
||||
r := newGetRequest("id=1&enhanced=true")
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Path: "tests/fixtures/test-metadata.mp3",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mainStartA := int64(1000)
|
||||
mainStartB := int64(2000)
|
||||
tokenStartA := int64(2000)
|
||||
tokenEndA := int64(2300)
|
||||
tokenStartB := int64(2300)
|
||||
tokenEndB := int64(2600)
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Kind: "main",
|
||||
Lang: "ja",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: &mainStartA,
|
||||
Value: "こんにちは",
|
||||
},
|
||||
{
|
||||
Start: &mainStartB,
|
||||
Value: "こんばんは",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Kind: "translation",
|
||||
Lang: "es",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: &mainStartA,
|
||||
Value: "Hola",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Kind: "pronunciation",
|
||||
Lang: "ja-latn",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: &mainStartB,
|
||||
Value: "konni",
|
||||
},
|
||||
},
|
||||
CueLine: []responses.CueLine{
|
||||
{
|
||||
Index: 0,
|
||||
Start: &mainStartB,
|
||||
End: &tokenEndB,
|
||||
Value: "konni",
|
||||
Cue: []responses.LyricCue{
|
||||
{
|
||||
Start: tokenStartA,
|
||||
End: &tokenEndA,
|
||||
ByteStart: 0,
|
||||
ByteEnd: 1,
|
||||
Value: "ko",
|
||||
},
|
||||
{
|
||||
Start: tokenStartB,
|
||||
End: &tokenEndB,
|
||||
ByteStart: 2,
|
||||
ByteEnd: 4,
|
||||
Value: "nni",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should return cue lines for songLyrics v2 clients with enhanced=true", func() {
|
||||
r := newGetRequest("id=1&enhanced=true")
|
||||
|
||||
lineStart := int64(1000)
|
||||
lineEnd := int64(3000)
|
||||
tokenStartA := int64(1000)
|
||||
tokenEndA := int64(1400)
|
||||
tokenStartB := int64(2000)
|
||||
tokenEndB := int64(2500)
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
{
|
||||
Lang: "eng",
|
||||
Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}},
|
||||
Synced: true,
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: &lineStart,
|
||||
End: &lineEnd,
|
||||
Value: "Hello echo",
|
||||
Cue: []model.Cue{
|
||||
{
|
||||
Start: &tokenStartA,
|
||||
End: &tokenEndA,
|
||||
Value: "Hello",
|
||||
ByteStart: 0,
|
||||
ByteEnd: 4,
|
||||
AgentID: "lead",
|
||||
},
|
||||
{
|
||||
Start: &tokenStartB,
|
||||
End: &tokenEndB,
|
||||
Value: "echo",
|
||||
ByteStart: 6,
|
||||
ByteEnd: 9,
|
||||
AgentID: "__nd_bg__|lead",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Agents: []responses.Agent{
|
||||
{ID: "lead", Role: "main"},
|
||||
{ID: "__nd_bg__|lead", Role: "bg"},
|
||||
},
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: &lineStart,
|
||||
Value: "Hello echo",
|
||||
},
|
||||
},
|
||||
CueLine: []responses.CueLine{
|
||||
{
|
||||
Index: 0,
|
||||
Start: &lineStart,
|
||||
End: &lineEnd,
|
||||
Value: "Hello echo",
|
||||
AgentID: "lead",
|
||||
Cue: []responses.LyricCue{
|
||||
{
|
||||
Start: tokenStartA,
|
||||
End: &tokenEndA,
|
||||
ByteStart: 0,
|
||||
ByteEnd: 4,
|
||||
Value: "Hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Index: 0,
|
||||
Start: &lineStart,
|
||||
End: &lineEnd,
|
||||
Value: "Hello echo",
|
||||
AgentID: "__nd_bg__|lead",
|
||||
Cue: []responses.LyricCue{
|
||||
{
|
||||
Start: tokenStartB,
|
||||
End: &tokenEndB,
|
||||
ByteStart: 6,
|
||||
ByteEnd: 9,
|
||||
Value: "echo",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should keep enhanced line-level lyrics when no cue data is available", func() {
|
||||
r := newGetRequest("id=1&enhanced=true")
|
||||
|
||||
lineStart := int64(1000)
|
||||
lineEnd := int64(3000)
|
||||
lyricsJSON, err := json.Marshal(model.LyricList{
|
||||
{
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: &lineStart,
|
||||
End: &lineEnd,
|
||||
Value: "Line without word timing",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJSON),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: &lineStart,
|
||||
Value: "Line without word timing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() {
|
||||
r := newGetRequest("id=1&enhanced=true")
|
||||
|
||||
asciiLineStart := int64(0)
|
||||
asciiLineEnd := int64(2400)
|
||||
asciiCueStartA := int64(0)
|
||||
asciiCueEndA := int64(300)
|
||||
asciiCueStartB := int64(900)
|
||||
asciiCueEndB := int64(1300)
|
||||
asciiCueStartC := int64(1300)
|
||||
asciiCueEndC := int64(1600)
|
||||
asciiCueStartD := int64(1600)
|
||||
|
||||
utfLineStart := int64(2747)
|
||||
utfLineEnd := int64(6214)
|
||||
utfCueStartA := int64(2747)
|
||||
utfCueEndA := int64(3018)
|
||||
utfCueStartB := int64(3018)
|
||||
utfCueEndB := int64(3179)
|
||||
utfCueStartC := int64(3582)
|
||||
utfCueEndC := int64(4100)
|
||||
utfCueStartD := int64(4500)
|
||||
utfCueEndD := int64(6214)
|
||||
|
||||
lyricsJSON, err := json.Marshal(model.LyricList{
|
||||
{
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: &asciiLineStart,
|
||||
End: &asciiLineEnd,
|
||||
Value: "Oh love love me tonight",
|
||||
Cue: []model.Cue{
|
||||
{Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1},
|
||||
{Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11},
|
||||
{Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14},
|
||||
{Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22},
|
||||
},
|
||||
},
|
||||
{
|
||||
Start: &utfLineStart,
|
||||
End: &utfLineEnd,
|
||||
Value: "눈을 뜬 순간",
|
||||
Cue: []model.Cue{
|
||||
{Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2},
|
||||
{Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5},
|
||||
{Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9},
|
||||
{Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJSON),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{Start: &asciiLineStart, Value: "Oh love love me tonight"},
|
||||
{Start: &utfLineStart, Value: "눈을 뜬 순간"},
|
||||
},
|
||||
CueLine: []responses.CueLine{
|
||||
{
|
||||
Index: 0,
|
||||
Start: &asciiLineStart,
|
||||
End: &asciiLineEnd,
|
||||
Value: "Oh love love me tonight",
|
||||
Cue: []responses.LyricCue{
|
||||
{Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1},
|
||||
{Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11},
|
||||
{Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14},
|
||||
{Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22},
|
||||
},
|
||||
},
|
||||
{
|
||||
Index: 1,
|
||||
Start: &utfLineStart,
|
||||
End: &utfLineEnd,
|
||||
Value: "눈을 뜬 순간",
|
||||
Cue: []responses.LyricCue{
|
||||
{Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2},
|
||||
{Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5},
|
||||
{Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9},
|
||||
{Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -13,7 +13,6 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/gravatar"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
|
|
@ -98,22 +97,13 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
|||
response := newResponse()
|
||||
lyricsResponse := responses.Lyrics{}
|
||||
response.Lyrics = &lyricsResponse
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title))
|
||||
|
||||
structuredLyrics, err := api.lyrics.GetLyricsByArtistTitle(r.Context(), artist, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(mediaFiles) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(structuredLyrics) == 0 {
|
||||
mainLyric, ok := structuredLyrics.Main()
|
||||
if !ok {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
|
@ -121,10 +111,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
|||
lyricsResponse.Title = title
|
||||
|
||||
var lyricsText strings.Builder
|
||||
for _, line := range structuredLyrics[0].Line {
|
||||
for _, line := range mainLyric.Line {
|
||||
lyricsText.WriteString(line.Value + "\n")
|
||||
}
|
||||
|
||||
lyricsResponse.Value = lyricsText.String()
|
||||
|
||||
return response, nil
|
||||
|
|
@ -146,8 +135,10 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
|
|||
return nil, err
|
||||
}
|
||||
|
||||
enhanced, _ := req.Params(r).Bool("enhanced")
|
||||
|
||||
response := newResponse()
|
||||
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics)
|
||||
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import (
|
|||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -34,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
artwork = &fakeArtwork{data: "image data"}
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil)
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
|
|
@ -119,28 +118,12 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "2",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer
|
||||
},
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older
|
||||
},
|
||||
{
|
||||
ID: "3",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
},
|
||||
})
|
||||
response, err := router.GetLyrics(r)
|
||||
|
|
@ -149,6 +132,26 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
|
||||
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
|
||||
})
|
||||
It("should surface the main-kind track when translation tracks are present", func() {
|
||||
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
||||
start := int64(0)
|
||||
lyricsJSON, err := json.Marshal(model.LyricList{
|
||||
{Kind: model.LyricKindTranslation, Lang: "por", Line: []model.Line{{Start: &start, Value: "Nunca vou te decepcionar"}}},
|
||||
{Kind: model.LyricKindMain, Lang: "eng", Line: []model.Line{{Start: &start, Value: "Never gonna let you down"}}},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJSON),
|
||||
},
|
||||
})
|
||||
response, err := router.GetLyrics(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Lyrics.Value).To(Equal("Never gonna let you down\n"))
|
||||
})
|
||||
It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() {
|
||||
r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa")
|
||||
mockRepo.SetData(model.MediaFiles{})
|
||||
|
|
@ -167,12 +170,6 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
},
|
||||
{
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
ID: "2",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
},
|
||||
})
|
||||
response, err := router.GetLyrics(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -181,142 +178,6 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetLyricsBySongId", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
|
||||
var times = []int64{18800, 22801}
|
||||
|
||||
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
|
||||
Expect(actual).ToNot(BeNil())
|
||||
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
|
||||
for i, realLyric := range actual.StructuredLyrics {
|
||||
expectedLyric := expected.StructuredLyrics[i]
|
||||
|
||||
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
|
||||
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
|
||||
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
|
||||
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
|
||||
|
||||
if expectedLyric.Offset == nil {
|
||||
Expect(realLyric.Offset).To(BeNil())
|
||||
} else {
|
||||
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
|
||||
}
|
||||
|
||||
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
|
||||
for j, realLine := range realLyric.Line {
|
||||
expectedLine := expectedLyric.Line[j]
|
||||
Expect(realLine.Value).To(Equal(expectedLine.Value))
|
||||
|
||||
if expectedLine.Start == nil {
|
||||
Expect(realLine.Start).To(BeNil())
|
||||
} else {
|
||||
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It("should return mixed lyrics", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*synced, *unsynced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
Lang: "eng",
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[1],
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Lang: "xxx",
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Synced: false,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should parse lrc metadata", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*synced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJson),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[1],
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: new(int64(-100)),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeArtwork struct {
|
||||
|
|
|
|||
|
|
@ -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 LyricCue struct {
|
||||
Start int64 `xml:"start,attr" json:"start"`
|
||||
End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"`
|
||||
ByteStart int `xml:"byteStart,attr" json:"byteStart"`
|
||||
ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"`
|
||||
Value string `xml:",chardata" json:"value"`
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
ID string `xml:"id,attr" json:"id"`
|
||||
Role string `xml:"role,attr" json:"role"`
|
||||
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type CueLine struct {
|
||||
Index int32 `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 []LyricCue `xml:"cue,omitempty" json:"cue,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"`
|
||||
Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"`
|
||||
Lang string `xml:"lang,attr" json:"lang"`
|
||||
Line []Line `xml:"line" json:"line"`
|
||||
Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"`
|
||||
CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"`
|
||||
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
|
||||
Synced bool `xml:"synced,attr" json:"synced"`
|
||||
}
|
||||
|
||||
type StructuredLyrics []StructuredLyric
|
||||
|
|
|
|||
2
tests/fixtures/bom-test.ttml
vendored
Normal file
2
tests/fixtures/bom-test.ttml
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml"><body><div xml:lang="eng"><p begin="00:00:00.00">BOM test line</p></div></body></tt>
|
||||
BIN
tests/fixtures/bom-utf16-test.ttml
vendored
Normal file
BIN
tests/fixtures/bom-utf16-test.ttml
vendored
Normal file
Binary file not shown.
6
tests/fixtures/test-enhanced.lrc
vendored
Normal file
6
tests/fixtures/test-enhanced.lrc
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[ar:Test Artist]
|
||||
[ti:Enhanced Test]
|
||||
[lang:eng]
|
||||
[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here
|
||||
[00:03.00]<00:03.00>More <00:03.50>words
|
||||
[00:05.00]Plain line without inline markers
|
||||
6
tests/fixtures/test-instrumental.yaml
vendored
Normal file
6
tests/fixtures/test-instrumental.yaml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
version: '1.0'
|
||||
metadata:
|
||||
title: 'Solo Piano'
|
||||
artist: 'Composer'
|
||||
language: 'eng'
|
||||
instrumental: true
|
||||
25
tests/fixtures/test-metadata.ttml
vendored
Normal file
25
tests/fixtures/test-metadata.ttml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
|
||||
<head>
|
||||
<metadata>
|
||||
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
|
||||
<translations>
|
||||
<translation xml:lang="es">
|
||||
<text for="L1">Hola</text>
|
||||
</translation>
|
||||
</translations>
|
||||
<transliterations>
|
||||
<transliteration xml:lang="ja-Latn">
|
||||
<text for="L2"><span begin="00:02.000" end="00:02.300" xmlns="http://www.w3.org/ns/ttml">ko</span><span begin="00:02.300" end="00:02.600" xmlns="http://www.w3.org/ns/ttml">nni</span></text>
|
||||
</transliteration>
|
||||
</transliterations>
|
||||
</iTunesMetadata>
|
||||
</metadata>
|
||||
</head>
|
||||
<body xml:lang="ja">
|
||||
<div>
|
||||
<p begin="00:01.000" end="00:01.500" itunes:key="L1">こんにちは</p>
|
||||
<p begin="00:02.000" end="00:02.700" itunes:key="L2">こんばんは</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>
|
||||
24
tests/fixtures/test-overlapping.yaml
vendored
Normal file
24
tests/fixtures/test-overlapping.yaml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
version: '1.0'
|
||||
metadata:
|
||||
title: 'Duet'
|
||||
artist: 'Lead and Echo'
|
||||
language: 'eng'
|
||||
|
||||
lines:
|
||||
- text: "Lead vocal"
|
||||
start_ms: 1000
|
||||
end_ms: 4000
|
||||
words:
|
||||
- text: "Lead "
|
||||
start_ms: 1000
|
||||
end_ms: 2000
|
||||
- text: "vocal"
|
||||
start_ms: 2000
|
||||
end_ms: 4000
|
||||
- text: "echo"
|
||||
start_ms: 2000
|
||||
end_ms: 3000
|
||||
words:
|
||||
- text: "echo"
|
||||
start_ms: 2000
|
||||
end_ms: 3000
|
||||
17
tests/fixtures/test-words.yaml
vendored
Normal file
17
tests/fixtures/test-words.yaml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
version: '1.0'
|
||||
metadata:
|
||||
title: 'Karaoke Test'
|
||||
artist: 'Test Artist'
|
||||
language: 'eng'
|
||||
|
||||
lines:
|
||||
- text: "Hello world"
|
||||
start_ms: 1000
|
||||
end_ms: 3000
|
||||
words:
|
||||
- text: "Hello "
|
||||
start_ms: 1000
|
||||
end_ms: 1500
|
||||
- text: "world"
|
||||
start_ms: 1500
|
||||
end_ms: 3000
|
||||
5
tests/fixtures/test.elrc
vendored
Normal file
5
tests/fixtures/test.elrc
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[ar:ELRC Artist]
|
||||
[ti:ELRC Song]
|
||||
[lang:eng]
|
||||
[00:01.00]<00:01.00>Lead <00:01.50>words
|
||||
[00:03.00]Fallback line
|
||||
7
tests/fixtures/test.srt
vendored
Normal file
7
tests/fixtures/test.srt
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
1
|
||||
00:00:18,800 --> 00:00:22,800
|
||||
We're from subtitles
|
||||
|
||||
2
|
||||
00:00:22,801 --> 00:00:26,000
|
||||
Another subtitle line
|
||||
12
tests/fixtures/test.ttml
vendored
Normal file
12
tests/fixtures/test.ttml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10">
|
||||
<body>
|
||||
<div xml:lang="eng">
|
||||
<p begin="00:00:18.80">We're no strangers to love</p>
|
||||
<p begin="00:00:22:24">You know the rules and so do I</p>
|
||||
</div>
|
||||
<div xml:lang="por">
|
||||
<p begin="188t">Nao somos estranhos ao amor</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>
|
||||
12
tests/fixtures/test.yaml
vendored
Normal file
12
tests/fixtures/test.yaml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
version: '1.0'
|
||||
metadata:
|
||||
title: 'Sample Track'
|
||||
artist: 'Test Artist'
|
||||
language: 'eng'
|
||||
offset_ms: -100
|
||||
|
||||
lines:
|
||||
- text: "We're no strangers to love"
|
||||
start_ms: 18800
|
||||
- text: "You know the rules and so do I"
|
||||
start_ms: 22801
|
||||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed build/*
|
||||
//go:embed all:build
|
||||
var filesystem embed.FS
|
||||
|
||||
func BuildAssets() fs.FS {
|
||||
|
|
|
|||
|
|
@ -16,3 +16,13 @@ func If[T any](cond bool, v1, v2 T) T {
|
|||
}
|
||||
return v2
|
||||
}
|
||||
|
||||
// Clone returns a pointer to a fresh copy of *p, or nil if p is nil. Use it to
|
||||
// avoid aliasing the pointed-to value when a separate *T is needed.
|
||||
func Clone[T any](p *T) *T {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := *p
|
||||
return &v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,4 +46,25 @@ var _ = Describe("GG", func() {
|
|||
Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Clone", func() {
|
||||
It("returns a pointer to a copy of the value", func() {
|
||||
original := 123
|
||||
cloned := gg.Clone(&original)
|
||||
Expect(cloned).To(HaveValue(Equal(123)))
|
||||
Expect(cloned).NotTo(BeIdenticalTo(&original))
|
||||
})
|
||||
|
||||
It("does not alias the original value", func() {
|
||||
original := 123
|
||||
cloned := gg.Clone(&original)
|
||||
original = 456
|
||||
Expect(*cloned).To(Equal(123))
|
||||
})
|
||||
|
||||
It("returns nil when the input is nil", func() {
|
||||
var v *int
|
||||
Expect(gg.Clone(v)).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue