mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-12 20:37:12 +02:00
refactor(lyrics): consolidate parsers into model.ParseLyrics
This commit is contained in:
parent
873c41b465
commit
ff1695d074
18 changed files with 259 additions and 165 deletions
|
|
@ -22,8 +22,9 @@ var _ = Describe("Lyrics", func() {
|
|||
var ctx context.Context
|
||||
|
||||
const badLyrics = "This is a set of lyrics\nThat is not good"
|
||||
unsynced, _ := model.ToLyrics("xxx", badLyrics)
|
||||
embeddedLyrics := model.LyricList{*unsynced}
|
||||
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(badLyrics))
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
embeddedLyrics := model.LyricList{unsynced}
|
||||
|
||||
syncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
|
|
@ -385,9 +386,10 @@ var _ = Describe("Lyrics", func() {
|
|||
})
|
||||
|
||||
It("resolves lyrics from the matched media files", func() {
|
||||
embedded, err := model.ToLyrics("eng", "Embedded lyrics line")
|
||||
embeddedList, err := model.ParseLyrics(".lrc", "eng", []byte("Embedded lyrics line"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
embeddedJSON, err := json.Marshal(model.LyricList{*embedded})
|
||||
embedded, _ := embeddedList.Main()
|
||||
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)},
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
|||
return nil, err
|
||||
}
|
||||
|
||||
list, err := model.ParseLyricsFile(suffix, contents)
|
||||
list, err := model.ParseLyrics(suffix, "xxx", contents)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err)
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ var _ = Describe("sources", 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"
|
||||
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics))
|
||||
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics))
|
||||
synced, _ := syncedList.Main()
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
|
||||
expectedList := model.LyricList{*synced, *unsynced}
|
||||
expectedList := model.LyricList{synced, unsynced}
|
||||
lyricsJson, err := json.Marshal(expectedList)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -74,7 +76,7 @@ var _ = Describe("sources", func() {
|
|||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
// fromExternalFile delegates format parsing to model.ParseLyricsFile; the
|
||||
// fromExternalFile delegates format parsing to model.ParseLyrics; the
|
||||
// per-format parser output is covered exhaustively in the model package.
|
||||
// Here we only verify each suffix is read from the library FS and routed.
|
||||
DescribeTable("should read the sidecar file and route its suffix to a parser",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package model
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
|
@ -89,7 +88,7 @@ func (l Lyrics) EffectiveKind() string {
|
|||
return l.Kind
|
||||
}
|
||||
|
||||
func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
func parseLRC(language, text string) (*Lyrics, error) {
|
||||
text = str.SanitizeText(text)
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
|
|
@ -236,33 +235,6 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
||||
if lyricsList, err := ParseLyricsfile(text); err == nil && len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
105
model/lyrics_parse.go
Normal file
105
model/lyrics_parse.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// ParseLyrics is the single entry point for parsing lyrics. When suffix names a
|
||||
// known format (.ttml/.srt/.yaml/.yml/.lrc) it routes to that parser, falling
|
||||
// back to plain text on failure. When suffix is empty or "auto" it content-sniffs
|
||||
// (TTML → SRT → YAML/Lyricsfile → LRC/plain) — used for tag-embedded lyrics and
|
||||
// plugin responses.
|
||||
func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) {
|
||||
switch {
|
||||
case suffix == "" || strings.EqualFold(suffix, "auto"):
|
||||
return sniffLyrics(lang, contents)
|
||||
case strings.EqualFold(suffix, ".ttml"):
|
||||
return parseWithPlainFallback(lang, contents, parseTTMLKnown(lang))
|
||||
case strings.EqualFold(suffix, ".srt"):
|
||||
return parseWithPlainFallback(lang, contents, parseSRTKnown(lang))
|
||||
case strings.EqualFold(suffix, ".yaml"), strings.EqualFold(suffix, ".yml"):
|
||||
return parseWithPlainFallback(lang, contents, func(c []byte) (LyricList, error) {
|
||||
return parseLyricsfile(string(c))
|
||||
})
|
||||
default: // .lrc and any unknown suffix: LRC/plain is the floor
|
||||
return plainLRC(lang, contents)
|
||||
}
|
||||
}
|
||||
|
||||
// parseWithPlainFallback runs a known-format parser; on error or empty result it
|
||||
// falls back to the LRC/plain floor (never another structured format).
|
||||
func parseWithPlainFallback(lang string, contents []byte, parse func([]byte) (LyricList, error)) (LyricList, error) {
|
||||
list, err := parse(contents)
|
||||
if err == nil && len(list) > 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn("Error parsing lyrics by suffix, falling back to plain text", "error", err)
|
||||
}
|
||||
return plainLRC(lang, contents)
|
||||
}
|
||||
|
||||
func parseTTMLKnown(lang string) func([]byte) (LyricList, error) {
|
||||
return func(c []byte) (LyricList, error) { return parseTTMLWithDefaultLang(c, lang) }
|
||||
}
|
||||
|
||||
func parseSRTKnown(lang string) func([]byte) (LyricList, error) {
|
||||
return func(c []byte) (LyricList, error) { return parseSRTWithLanguage(c, lang) }
|
||||
}
|
||||
|
||||
func plainLRC(lang string, contents []byte) (LyricList, error) {
|
||||
lyric, err := parseLRC(lang, string(contents))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing lyrics: %w", err)
|
||||
}
|
||||
if lyric == nil || lyric.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return LyricList{*lyric}, nil
|
||||
}
|
||||
|
||||
// sniffLyrics detects the format from content. Order: TTML → SRT → YAML → LRC/plain.
|
||||
func sniffLyrics(lang string, contents []byte) (LyricList, error) {
|
||||
text := strings.TrimPrefix(string(contents), "\ufeff")
|
||||
|
||||
if isTTMLDocument(text) {
|
||||
list, err := parseTTMLWithDefaultLang([]byte(text), lang)
|
||||
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), lang)
|
||||
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)
|
||||
}
|
||||
|
||||
if yamlList, yErr := parseLyricsfile(text); yErr == nil && len(yamlList) > 0 {
|
||||
return yamlList, nil
|
||||
}
|
||||
|
||||
return plainLRC(lang, []byte(text))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,48 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseEmbedded", func() {
|
||||
var _ = Describe("ParseLyrics", func() {
|
||||
DescribeTable("known suffix routes to the matching parser",
|
||||
func(suffix, contents string, wantSynced bool, wantFirst string) {
|
||||
list, err := ParseLyrics(suffix, "eng", []byte(contents))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Synced).To(Equal(wantSynced))
|
||||
Expect(list[0].Line[0].Value).To(Equal(wantFirst))
|
||||
},
|
||||
Entry(".lrc", ".lrc", "[00:01.00]lrc line", true, "lrc line"),
|
||||
Entry(".txt plain", ".txt", "plain line", false, "plain line"),
|
||||
Entry(".srt", ".srt", "1\n00:00:01,000 --> 00:00:02,000\nsrt line\n", true, "srt line"),
|
||||
Entry(".ttml", ".ttml", `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">ttml line</p></div></body></tt>`, true, "ttml line"),
|
||||
Entry(".yaml", ".yaml", "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: yaml line\n start_ms: 1000\n", true, "yaml line"),
|
||||
)
|
||||
|
||||
It("empty suffix content-sniffs (TTML)", func() {
|
||||
ttml := `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">auto ttml</p></div></body></tt>`
|
||||
list, err := ParseLyrics("", "eng", []byte(ttml))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Value).To(Equal("auto ttml"))
|
||||
})
|
||||
|
||||
It("empty suffix content-sniffs (YAML)", func() {
|
||||
yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n"
|
||||
list, err := ParseLyrics("auto", "eng", []byte(yaml))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Value).To(Equal("auto yaml"))
|
||||
})
|
||||
|
||||
It("falls back to plain text when a known suffix fails to parse structurally", func() {
|
||||
list, err := ParseLyrics(".srt", "eng", []byte("not actually an srt file"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Synced).To(BeFalse())
|
||||
Expect(list[0].Line[0].Value).To(Equal("not actually an srt file"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ParseLyrics content-sniffing", 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>
|
||||
|
|
@ -26,9 +67,9 @@ var _ = Describe("ParseEmbedded", func() {
|
|||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseEmbedded("ENG", content)
|
||||
list, err := ParseLyrics("", "ENG", []byte(content))
|
||||
|
||||
// ParseEmbedded's job is to detect TTML and apply the tag language as the
|
||||
// ParseLyrics'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))
|
||||
|
|
@ -63,7 +104,7 @@ var _ = Describe("ParseEmbedded", func() {
|
|||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseEmbedded("eng", content)
|
||||
list, err := ParseLyrics("", "eng", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(3))
|
||||
|
|
@ -88,7 +129,7 @@ We're from subtitles
|
|||
00:00:22,801 --> 00:00:26,000
|
||||
Another subtitle line`
|
||||
|
||||
list, err := ParseEmbedded("POR", content)
|
||||
list, err := ParseLyrics("", "POR", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(Equal(LyricList{
|
||||
|
|
@ -114,7 +155,7 @@ Another subtitle line`
|
|||
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)
|
||||
list, err := ParseLyrics("", "eng", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
|
|
@ -127,7 +168,7 @@ Another subtitle line`
|
|||
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)
|
||||
list, err := ParseLyrics("", "eng", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
|
|
@ -144,7 +185,7 @@ Another subtitle line`
|
|||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseEmbedded("eng", content)
|
||||
list, err := ParseLyrics("", "eng", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
|
|
@ -161,7 +202,7 @@ Another subtitle line`
|
|||
It("detects a Lyricsfile YAML payload via content-sniffing", func() {
|
||||
yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n"
|
||||
|
||||
list, err := ParseEmbedded("eng", yaml)
|
||||
list, err := ParseLyrics("", "eng", []byte(yaml))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
|
|
@ -14,7 +14,7 @@ var (
|
|||
srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`)
|
||||
)
|
||||
|
||||
func ParseSRT(contents []byte) (LyricList, error) {
|
||||
func parseSRT(contents []byte) (LyricList, error) {
|
||||
return parseSRTWithLanguage(contents, "xxx")
|
||||
}
|
||||
|
||||
|
|
|
|||
30
model/lyrics_srt_test.go
Normal file
30
model/lyrics_srt_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("parseSRT", func() {
|
||||
It("parses SRT blocks with the default language", func() {
|
||||
content := []byte("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 := parseSRT(content)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Lang).To(Equal("xxx"))
|
||||
Expect(list[0].Synced).To(BeTrue())
|
||||
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("returns nil for input with no valid blocks", func() {
|
||||
list, err := parseSRT([]byte("not actually an srt file"))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
package model_test
|
||||
package model
|
||||
|
||||
import (
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ToLyrics", func() {
|
||||
var _ = Describe("parseLRC", func() {
|
||||
It("should parse tags with spaces", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
lyrics, err := parseLRC("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Lang).To(Equal("eng"))
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
|
|
@ -18,13 +17,13 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("Should ignore bad offset", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there")
|
||||
lyrics, err := parseLRC("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Offset).To(BeNil())
|
||||
})
|
||||
|
||||
It("should accept lines with no text and weird times", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
|
||||
lyrics, err := parseLRC("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -36,7 +35,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("Should support multiple timestamps per line", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
|
||||
lyrics, err := parseLRC("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -48,7 +47,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("Should support parsing multiline string", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
|
||||
lyrics, err := parseLRC("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -58,7 +57,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("Does not match timestamp in middle of line", func() {
|
||||
lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file")
|
||||
lyrics, err := parseLRC("xxx", "This could [00:00:00] be a synced file")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeFalse())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -67,7 +66,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("Allows timestamp in middle of line if also at beginning", func() {
|
||||
lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
|
||||
lyrics, err := parseLRC("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -77,7 +76,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("Ignores lines in synchronized lyric prior to first timestamp", func() {
|
||||
lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
|
||||
lyrics, err := parseLRC("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -86,7 +85,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("Handles all possible ms cases", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
|
||||
lyrics, err := parseLRC("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -97,7 +96,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("Properly sorts repeated lyrics out of order", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated")
|
||||
lyrics, err := parseLRC("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -110,7 +109,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
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")
|
||||
lyrics, err := parseLRC("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))
|
||||
|
|
@ -140,7 +139,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
It("should not parse malformed Enhanced LRC timing markers", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01a50>Not a marker")
|
||||
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01a50>Not a marker")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
|
|
@ -149,7 +148,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
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")
|
||||
lyrics, err := parseLRC("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))
|
||||
|
||||
|
|
@ -174,7 +173,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
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")
|
||||
lyrics, err := parseLRC("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))
|
||||
|
||||
|
|
@ -190,7 +189,7 @@ var _ = Describe("ToLyrics", func() {
|
|||
})
|
||||
|
||||
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")
|
||||
lyrics, err := parseLRC("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))
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ type ttmlParser struct {
|
|||
metadataSeq int
|
||||
}
|
||||
|
||||
func ParseTTML(contents []byte) (LyricList, error) {
|
||||
func parseTTML(contents []byte) (LyricList, error) {
|
||||
return parseTTMLWithDefaultLang(contents, "xxx")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseTTML", func() {
|
||||
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"?>
|
||||
|
|
@ -21,7 +21,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(2))
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
|
|
@ -75,7 +75,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Lang).To(Equal("eng"))
|
||||
|
|
@ -99,7 +99,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(HaveLen(2))
|
||||
|
|
@ -124,7 +124,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
|
|
@ -154,7 +154,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
|
|
@ -187,7 +187,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
|
|
@ -222,7 +222,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
|
|
@ -256,7 +256,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]Agent{
|
||||
|
|
@ -283,7 +283,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
|
|
@ -309,7 +309,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Lang).To(Equal("xxx"))
|
||||
|
|
@ -349,7 +349,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(3))
|
||||
|
||||
|
|
@ -404,7 +404,7 @@ var _ = Describe("ParseTTML", func() {
|
|||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := ParseTTML(content)
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var pronunciation *Lyrics
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ParseLyricsfile parses a LRCLIB Lyricsfile YAML document
|
||||
// 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
|
||||
|
|
@ -19,7 +19,7 @@ import (
|
|||
// 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) {
|
||||
func parseLyricsfile(text string) (LyricList, error) {
|
||||
var doc lyricsfileDocument
|
||||
dec := yaml.NewDecoder(strings.NewReader(text))
|
||||
dec.KnownFields(false)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package model_test
|
||||
package model
|
||||
|
||||
import (
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseLyricsfile", func() {
|
||||
var _ = Describe("parseLyricsfile", func() {
|
||||
DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker",
|
||||
func(input string) {
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
lyrics, err := parseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(BeNil())
|
||||
},
|
||||
|
|
@ -23,7 +22,7 @@ lines:
|
|||
)
|
||||
|
||||
It("returns an error for invalid YAML", func() {
|
||||
_, err := ParseLyricsfile("not: valid: yaml: [")
|
||||
_, err := parseLyricsfile("not: valid: yaml: [")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
|
|
@ -40,7 +39,7 @@ lines:
|
|||
- text: "You know the rules and so do I"
|
||||
start_ms: 22801
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
lyrics, err := parseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
|
|
@ -80,7 +79,7 @@ plain: |
|
|||
|
||||
Second line
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
lyrics, err := parseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
|
|
@ -116,7 +115,7 @@ lines:
|
|||
start_ms: 1500
|
||||
end_ms: 3000
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
lyrics, err := parseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
|
|
@ -168,7 +167,7 @@ lines:
|
|||
start_ms: 3000
|
||||
end_ms: 3500
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
lyrics, err := parseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
|
|
@ -207,7 +206,7 @@ lines:
|
|||
start_ms: 2000
|
||||
end_ms: 3000
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
lyrics, err := parseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
|
|
@ -246,7 +245,7 @@ metadata:
|
|||
language: 'eng'
|
||||
instrumental: true
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
lyrics, err := parseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
|
|
@ -270,7 +269,7 @@ lines:
|
|||
start_ms: 2000
|
||||
end_ms: 3000
|
||||
`
|
||||
lyrics, err := ParseLyricsfile(input)
|
||||
lyrics, err := parseLyricsfile(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ func (md Metadata) mapLyrics() string {
|
|||
lang := raw.Key()
|
||||
text := raw.Value()
|
||||
|
||||
lyrics, err := model.ParseEmbedded(lang, text)
|
||||
lyrics, err := model.ParseLyrics("", lang, []byte(text))
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -32,8 +32,7 @@ type LyricsPlugin struct {
|
|||
}
|
||||
|
||||
// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response
|
||||
// via model.ParseEmbedded (TTML/SRT/YAML/LRC/plain). A later task retargets this
|
||||
// to ParseLyrics once that function is introduced.
|
||||
// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain).
|
||||
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
req := capabilities.GetLyricsRequest{
|
||||
Track: mediaFileToTrackInfo(l.plugin, mf),
|
||||
|
|
@ -51,7 +50,7 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode
|
|||
if lang == "" {
|
||||
lang = "xxx"
|
||||
}
|
||||
parsed, err := model.ParseEmbedded(lang, lt.Text)
|
||||
parsed, err := model.ParseLyrics("", lang, []byte(lt.Text))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -99,10 +99,12 @@ var _ = Describe("GetLyricsBySongId", func() {
|
|||
|
||||
It("should return mixed lyrics", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics))
|
||||
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics))
|
||||
synced, _ := syncedList.Main()
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*synced, *unsynced,
|
||||
synced, unsynced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
|
|
@ -155,9 +157,10 @@ var _ = Describe("GetLyricsBySongId", func() {
|
|||
|
||||
It("should parse lrc metadata", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
|
||||
syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(metadata+"\n"+syncedLyrics))
|
||||
synced, _ := syncedList.Main()
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*synced,
|
||||
synced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
|
|
|
|||
|
|
@ -112,9 +112,10 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
Describe("GetLyrics", func() {
|
||||
It("should return data for given artist & title", func() {
|
||||
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
||||
lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")
|
||||
lyricsList, _ := model.ParseLyrics(".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I"))
|
||||
lyrics, _ := lyricsList.Main()
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
*lyrics,
|
||||
lyrics,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue