mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-02 00:17:49 +01:00
store as json now
This commit is contained in:
parent
66ca4ccecf
commit
6e6f7304ed
15 changed files with 357 additions and 301 deletions
134
model/lyrics.go
Normal file
134
model/lyrics.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type Line struct {
|
||||
Start *int64 `structs:"start,omitempty" xml:"start,attr,omitempty" json:"start,omitempty"`
|
||||
Value string `structs:"value" xml:"value" json:"value"`
|
||||
}
|
||||
|
||||
type Lyric struct {
|
||||
DisplayArtist string `structs:"displayArtist,omitempty" xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `structs:"displayTitle,omitempty" xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
|
||||
Lang string `structs:"lang" xml:"lang,attr" json:"lang"`
|
||||
Line []Line `structs:"line" xml:"line" json:"line"`
|
||||
Offset *int64 `structs:"offset,omitempty" xml:"offset,attr,omitempty" json:"offset,omitempty"`
|
||||
Synced bool `structs:"synced" xml:"synced,attr" json:"synced"`
|
||||
}
|
||||
|
||||
// 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}))?\])`
|
||||
|
||||
var (
|
||||
lineRegex = regexp.MustCompile(timeRegexString + "([^\n]+)")
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^\]]+)\]`)
|
||||
)
|
||||
|
||||
func ToLyrics(language, text string) (*Lyric, error) {
|
||||
text = utils.SanitizeText(text)
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
synced := true
|
||||
|
||||
artist := ""
|
||||
title := ""
|
||||
var offset *int64 = nil
|
||||
structuredLines := []Line{}
|
||||
|
||||
for _, line := range lines {
|
||||
line := strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
var time *int64 = nil
|
||||
|
||||
if synced {
|
||||
idTag := lrcIdRegex.FindStringSubmatch(line)
|
||||
if idTag != nil {
|
||||
switch idTag[1] {
|
||||
case "ar":
|
||||
artist = idTag[2]
|
||||
case "offset":
|
||||
{
|
||||
off, err := strconv.ParseInt(idTag[2], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset = &off
|
||||
}
|
||||
case "ti":
|
||||
title = idTag[2]
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
syncedMatch := lineRegex.FindStringSubmatch(line)
|
||||
if syncedMatch == nil {
|
||||
synced = false
|
||||
text = line
|
||||
} else {
|
||||
var hours int64
|
||||
var err error
|
||||
|
||||
if syncedMatch[3] != "" {
|
||||
hours, err = strconv.ParseInt(syncedMatch[3], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
min, err := strconv.ParseInt(syncedMatch[4], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sec, err := strconv.ParseInt(syncedMatch[5], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
millis, err := strconv.ParseInt(syncedMatch[7], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(syncedMatch[7]) == 2 {
|
||||
millis *= 10
|
||||
}
|
||||
|
||||
timeInMillis := (((((hours * 60) + min) * 60) + sec) * 1000) + millis
|
||||
time = &timeInMillis
|
||||
text = syncedMatch[8]
|
||||
}
|
||||
} else {
|
||||
text = line
|
||||
}
|
||||
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: time,
|
||||
Value: text,
|
||||
})
|
||||
}
|
||||
|
||||
lyric := Lyric{
|
||||
DisplayArtist: artist,
|
||||
DisplayTitle: title,
|
||||
Lang: language,
|
||||
Line: structuredLines,
|
||||
Offset: offset,
|
||||
Synced: synced,
|
||||
}
|
||||
|
||||
return &lyric, nil
|
||||
}
|
||||
|
||||
type Lyrics []Lyric
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
|
@ -56,7 +57,7 @@ type MediaFile struct {
|
|||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||
Compilation bool `structs:"compilation" json:"compilation"`
|
||||
Comment string `structs:"comment" json:"comment,omitempty"`
|
||||
Lyrics string `structs:"lyrics" json:"lyrics,omitempty"`
|
||||
Lyrics string `structs:"lyrics" json:"lyrics"`
|
||||
Bpm int `structs:"bpm" json:"bpm,omitempty"`
|
||||
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
||||
MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"`
|
||||
|
|
@ -92,6 +93,15 @@ func (mf MediaFile) AlbumCoverArtID() ArtworkID {
|
|||
return artworkIDFromAlbum(Album{ID: mf.AlbumID})
|
||||
}
|
||||
|
||||
func (mf MediaFile) StructuredLyrics() (Lyrics, error) {
|
||||
lyrics := Lyrics{}
|
||||
err := json.Unmarshal([]byte(mf.Lyrics), &lyrics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
type MediaFiles []MediaFile
|
||||
|
||||
// Dirs returns a deduped list of all directories from the MediaFiles' paths
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile {
|
|||
mf.RGTrackGain = md.RGTrackGain()
|
||||
mf.RGTrackPeak = md.RGTrackPeak()
|
||||
mf.Comment = utils.SanitizeText(md.Comment())
|
||||
mf.Lyrics = utils.SanitizeText(md.Lyrics())
|
||||
mf.Lyrics = md.Lyrics()
|
||||
mf.Bpm = md.Bpm()
|
||||
mf.CreatedAt = md.BirthTime()
|
||||
mf.UpdatedAt = md.ModificationTime()
|
||||
|
|
|
|||
|
|
@ -75,8 +75,6 @@ var (
|
|||
// Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
|
||||
bitRateRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+: Audio:.*, (\d+) kb/s`)
|
||||
|
||||
lyricTagRx = regexp.MustCompile(`(?i)lyrics(-([a-zA-Z]+))?`)
|
||||
|
||||
// Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
|
||||
// Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
|
||||
audioStreamRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+.*: Audio: (.*), (.* Hz), ([\w.]+),*(.*.,)*`)
|
||||
|
|
@ -113,8 +111,6 @@ func (e *Extractor) parseInfo(info string) map[string][]string {
|
|||
reader := strings.NewReader(info)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
lastTag := ""
|
||||
lastEncoding := ""
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if len(line) == 0 {
|
||||
|
|
@ -125,23 +121,7 @@ func (e *Extractor) parseInfo(info string) map[string][]string {
|
|||
tagName := strings.TrimSpace(strings.ToLower(match[1]))
|
||||
if tagName != "" {
|
||||
tagValue := strings.TrimSpace(match[2])
|
||||
lyricMatch := lyricTagRx.FindStringSubmatch(tagName)
|
||||
if len(lyricMatch) > 0 {
|
||||
var encoding string
|
||||
if lyricMatch[2] != "" {
|
||||
encoding = lyricMatch[2]
|
||||
tags[metadata.ENCODED_LYRICS_KEY] = []string{"1"}
|
||||
} else {
|
||||
encoding = "xxx"
|
||||
}
|
||||
lastEncoding = encoding
|
||||
tagName = "lyrics"
|
||||
tags["lyrics"] = append(tags["lyrics"], encoding)
|
||||
} else {
|
||||
lastEncoding = ""
|
||||
}
|
||||
tags[tagName] = append(tags[tagName], tagValue)
|
||||
|
||||
lastTag = tagName
|
||||
continue
|
||||
}
|
||||
|
|
@ -150,20 +130,11 @@ func (e *Extractor) parseInfo(info string) map[string][]string {
|
|||
if lastTag != "" {
|
||||
match = continuationRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
if lastEncoding != "" {
|
||||
if tags[lastTag] == nil {
|
||||
tags[lastTag] = []string{lastEncoding}
|
||||
}
|
||||
tagList := tags[lastTag]
|
||||
tags[lastTag][len(tagList)-1] += "\n" + strings.TrimSpace(match[1])
|
||||
} else {
|
||||
if tags[lastTag] == nil {
|
||||
tags[lastTag] = []string{""}
|
||||
}
|
||||
tagValue := tags[lastTag][0]
|
||||
tags[lastTag][0] = tagValue + "\n" + strings.TrimSpace(match[1])
|
||||
if tags[lastTag] == nil {
|
||||
tags[lastTag] = []string{""}
|
||||
}
|
||||
|
||||
tagValue := tags[lastTag][0]
|
||||
tags[lastTag][0] = tagValue + "\n" + strings.TrimSpace(match[1])
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/scanner/metadata"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -328,11 +327,10 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu
|
|||
: [00:02.50]unspecified
|
||||
`
|
||||
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md).To(HaveKeyWithValue(metadata.ENCODED_LYRICS_KEY, []string{"1"}))
|
||||
Expect(md).To(HaveKeyWithValue("lyrics", []string{
|
||||
"eng",
|
||||
Expect(md).To(HaveKeyWithValue("lyrics-eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
"xxx",
|
||||
}))
|
||||
Expect(md).To(HaveKeyWithValue("lyrics-xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
}))
|
||||
})
|
||||
|
|
@ -346,7 +344,6 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu
|
|||
`
|
||||
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md).To(HaveKeyWithValue("lyrics", []string{
|
||||
"xxx",
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package metadata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
|
|
@ -15,11 +16,9 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const ENCODED_LYRICS_KEY = "__navidrome__lyrics"
|
||||
const NAVIDROME_SYNCHRONIZED_KEY = "__navidrome__synched__lyrics"
|
||||
|
||||
type Extractor interface {
|
||||
Parse(files ...string) (map[string]ParsedTags, error)
|
||||
CustomMappings() ParsedTags
|
||||
|
|
@ -108,27 +107,47 @@ func (t Tags) OriginalDate() (int, string) { return t.getDate("originaldate") }
|
|||
func (t Tags) ReleaseDate() (int, string) { return t.getDate("releasedate") }
|
||||
func (t Tags) Comment() string { return t.getFirstTagValue("comment") }
|
||||
func (t Tags) Lyrics() string {
|
||||
result := ""
|
||||
lyrics := model.Lyrics{}
|
||||
basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics")
|
||||
|
||||
if t.getFirstTagValue(ENCODED_LYRICS_KEY) == "1" {
|
||||
tags := t.getAllTagValues("lyrics", NAVIDROME_SYNCHRONIZED_KEY)
|
||||
|
||||
for i := 0; i < len(tags); i += 2 {
|
||||
result += fmt.Sprintf("%s\u200b%s\u200b", tags[i], tags[i+1])
|
||||
for _, value := range basicLyrics {
|
||||
lyric, err := model.ToLyrics("xxx", value)
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
tags := t.getAllTagValues("lyrics", "lyrics-eng", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics")
|
||||
|
||||
for _, tag := range tags {
|
||||
result += fmt.Sprintf("xxx\u200b%s\u200b", tag)
|
||||
lyrics = append(lyrics, *lyric)
|
||||
}
|
||||
|
||||
for tag, value := range t.tags {
|
||||
if strings.HasPrefix(tag, "lyrics-") {
|
||||
language := strings.TrimSpace(strings.TrimPrefix(tag, "lyrics-"))
|
||||
|
||||
if language == "" {
|
||||
language = "xxx"
|
||||
}
|
||||
|
||||
for _, text := range value {
|
||||
lyric, err := model.ToLyrics(language, text)
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
lyrics = append(lyrics, *lyric)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) > 0 {
|
||||
result = strings.TrimSuffix(result, consts.Zwsp)
|
||||
res, err := json.Marshal(lyrics)
|
||||
if err != nil {
|
||||
log.Warn("Unexpected error occurred when serializing lyrics", "file", t.filePath, "error", err)
|
||||
return ""
|
||||
}
|
||||
return result
|
||||
return string(res[:])
|
||||
}
|
||||
|
||||
func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation", "wm/iscompilation") }
|
||||
func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") }
|
||||
func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") }
|
||||
|
|
|
|||
|
|
@ -1,20 +1,61 @@
|
|||
package metadata_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner/metadata"
|
||||
_ "github.com/navidrome/navidrome/scanner/metadata/ffmpeg"
|
||||
_ "github.com/navidrome/navidrome/scanner/metadata/taglib"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var _ = Describe("Tags", func() {
|
||||
const englishLyric = "[00:00.00]This is\n[00:02.50]English"
|
||||
const unspecifiedLyric = "[00:00.00]This is\n[00:02.50]unspecified"
|
||||
var zero int64 = 0
|
||||
var secondTs int64 = 2500
|
||||
|
||||
makeLyrics := func(synced bool, lang, secondLine string) model.Lyric {
|
||||
lines := []model.Line{
|
||||
{Value: "This is"},
|
||||
{Value: secondLine},
|
||||
}
|
||||
|
||||
if synced {
|
||||
lines[0].Start = &zero
|
||||
lines[1].Start = &secondTs
|
||||
}
|
||||
|
||||
lyric := model.Lyric{
|
||||
Lang: lang,
|
||||
Line: lines,
|
||||
Synced: synced,
|
||||
}
|
||||
|
||||
return lyric
|
||||
}
|
||||
|
||||
sortLyrics := func(lines model.Lyrics) model.Lyrics {
|
||||
slices.SortFunc(lines, func(a, b model.Lyric) bool {
|
||||
langDiff := strings.Compare(a.Lang, b.Lang)
|
||||
if langDiff == 0 {
|
||||
return strings.Compare(a.Line[1].Value, b.Line[1].Value) < 0
|
||||
} else {
|
||||
return langDiff < 0
|
||||
}
|
||||
})
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
compareLyrics := func(m metadata.Tags, expected model.Lyrics) {
|
||||
lyrics := model.Lyrics{}
|
||||
Expect(json.Unmarshal([]byte(m.Lyrics()), &lyrics)).To(BeNil())
|
||||
Expect(sortLyrics(lyrics)).To(Equal(sortLyrics(expected)))
|
||||
}
|
||||
|
||||
Context("Extract", func() {
|
||||
BeforeEach(func() {
|
||||
|
|
@ -92,14 +133,14 @@ var _ = Describe("Tags", func() {
|
|||
Expect(mds).To(HaveLen(1))
|
||||
|
||||
m := mds[path]
|
||||
var lyrics string
|
||||
if langEncoded {
|
||||
lyrics = strings.Join([]string{"eng", englishLyric, "xxx", unspecifiedLyric}, consts.Zwsp)
|
||||
} else {
|
||||
lyrics = strings.Join([]string{"xxx", unspecifiedLyric, "xxx", englishLyric}, consts.Zwsp)
|
||||
lyrics := model.Lyrics{
|
||||
makeLyrics(true, "xxx", "English"),
|
||||
makeLyrics(true, "xxx", "unspecified"),
|
||||
}
|
||||
println(m.Lyrics())
|
||||
Expect(m.Lyrics()).To(Equal(lyrics))
|
||||
if langEncoded {
|
||||
lyrics[0].Lang = "eng"
|
||||
}
|
||||
compareLyrics(m, lyrics)
|
||||
},
|
||||
|
||||
Entry("Parses AIFF file", "test.aiff", true),
|
||||
|
|
@ -118,9 +159,12 @@ var _ = Describe("Tags", func() {
|
|||
Expect(mds).To(HaveLen(1))
|
||||
|
||||
m := mds[path]
|
||||
lyrics := strings.Join([]string{"eng", englishLyric, "xxx", unspecifiedLyric, "eng", englishLyric + "\n", "xxx", unspecifiedLyric + "\n"}, consts.Zwsp)
|
||||
Expect(m.Lyrics()).To(Equal(lyrics))
|
||||
|
||||
compareLyrics(m, model.Lyrics{
|
||||
makeLyrics(true, "eng", "English SYLT"),
|
||||
makeLyrics(true, "eng", "English"),
|
||||
makeLyrics(true, "xxx", "unspecified SYLT"),
|
||||
makeLyrics(true, "xxx", "unspecified"),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -137,9 +181,10 @@ var _ = Describe("Tags", func() {
|
|||
Expect(mds).To(HaveLen(1))
|
||||
|
||||
m := mds[path]
|
||||
lyrics := strings.Join([]string{"eng", englishLyric, "xxx", unspecifiedLyric}, consts.Zwsp)
|
||||
|
||||
Expect(m.Lyrics()).To(Equal(lyrics))
|
||||
compareLyrics(m, model.Lyrics{
|
||||
makeLyrics(true, "eng", "English"),
|
||||
makeLyrics(true, "xxx", "unspecified"),
|
||||
})
|
||||
},
|
||||
|
||||
Entry("Parses AIFF file", "test.aiff"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/scanner/metadata"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -54,19 +53,21 @@ var _ = Describe("Extractor", func() {
|
|||
Expect(m).To(HaveKeyWithValue("bitrate", []string{"192"}))
|
||||
Expect(m).To(HaveKeyWithValue("channels", []string{"2"}))
|
||||
Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||
Expect(m).To(HaveKeyWithValue("lyrics", []string{
|
||||
"eng",
|
||||
Expect(m).ToNot(HaveKey("lyrics"))
|
||||
Expect(m).To(Or(HaveKeyWithValue("lyrics-eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
"xxx",
|
||||
}), HaveKeyWithValue("lyrics-eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||
})))
|
||||
Expect(m).To(Or(HaveKeyWithValue("lyrics-xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
}))
|
||||
Expect(m).To(HaveKeyWithValue(metadata.NAVIDROME_SYNCHRONIZED_KEY, []string{
|
||||
"eng",
|
||||
"[00:00.00]This is\n[00:02.50]English\n",
|
||||
"xxx",
|
||||
"[00:00.00]This is\n[00:02.50]unspecified\n",
|
||||
}))
|
||||
Expect(m).To(HaveKeyWithValue(metadata.ENCODED_LYRICS_KEY, []string{"1"}))
|
||||
}), HaveKeyWithValue("lyrics-xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||
})))
|
||||
Expect(m).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||
Expect(m).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
|
||||
Expect(m).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
|
||||
|
|
@ -131,15 +132,13 @@ var _ = Describe("Extractor", func() {
|
|||
Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||
|
||||
if id3Lyrics {
|
||||
Expect(m).To(HaveKeyWithValue(metadata.ENCODED_LYRICS_KEY, []string{"1"}))
|
||||
Expect(m).To(HaveKeyWithValue("lyrics", []string{
|
||||
"eng",
|
||||
Expect(m).To(HaveKeyWithValue("lyrics-eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
"xxx",
|
||||
}))
|
||||
Expect(m).To(HaveKeyWithValue("lyrics-xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
}))
|
||||
} else {
|
||||
Expect(m).ToNot(HaveKey(metadata.ENCODED_LYRICS_KEY))
|
||||
Expect(m).To(HaveKeyWithValue("lyrics", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@
|
|||
|
||||
#include "taglib_wrapper.h"
|
||||
|
||||
char *LYRICS_KEY = (char *) "lyrics";
|
||||
char *CUSTOM_LYRICS = (char *) "__navidrome__lyrics";
|
||||
char *LYRICS_KEY = (char *) "lyrics-";
|
||||
|
||||
char has_cover(const TagLib::FileRef f);
|
||||
|
||||
|
|
@ -92,7 +91,6 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
|||
|
||||
if (id3Tags != NULL) {
|
||||
const auto &frames = id3Tags->frameListMap();
|
||||
bool hasLyrics = false;
|
||||
|
||||
for (const auto &kv: frames) {
|
||||
if (kv.first == "USLT") {
|
||||
|
|
@ -100,19 +98,15 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
|||
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
|
||||
if (frame == NULL) continue;
|
||||
|
||||
hasLyrics = true;
|
||||
tags.erase("LYRICS");
|
||||
|
||||
char lang[4];
|
||||
strncpy(lang, frame->language().data(), 3);
|
||||
lang[3] = '\0';
|
||||
char lyricsTag[11];
|
||||
strncpy(lyricsTag, LYRICS_KEY, 7);
|
||||
strncpy(lyricsTag+7, frame->language().data(), 3);
|
||||
lyricsTag[10] = '\0';
|
||||
char *val = (char *)frame->text().toCString(true);
|
||||
|
||||
go_map_put_str(id, LYRICS_KEY, lang);
|
||||
go_map_put_str(id, LYRICS_KEY, val);
|
||||
}
|
||||
|
||||
if (hasLyrics) {
|
||||
tags.erase("LYRICS");
|
||||
go_map_put_str(id, lyricsTag, val);
|
||||
}
|
||||
} else if (kv.first == "SYLT") {
|
||||
for (const auto &tag: kv.second) {
|
||||
|
|
@ -120,27 +114,26 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
|||
if (frame == NULL) continue;
|
||||
|
||||
|
||||
char lang[4];
|
||||
strncpy(lang, frame->language().data(), 3);
|
||||
lang[3] = '\0';
|
||||
char lyricsTag[11];
|
||||
strncpy(lyricsTag, LYRICS_KEY, 7);
|
||||
strncpy(lyricsTag+7, frame->language().data(), 3);
|
||||
lyricsTag[10] = '\0';
|
||||
|
||||
const auto format = frame->timestampFormat();
|
||||
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
|
||||
hasLyrics = true;
|
||||
|
||||
for (const auto &line: frame->synchedText()) {
|
||||
char *text = (char *)line.text.toCString(true);
|
||||
go_map_put_lyric_line(id, lang, text, line.time);
|
||||
go_map_put_lyric_line(id, lyricsTag, text, line.time);
|
||||
}
|
||||
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
|
||||
const int sampleRate = props->sampleRate();
|
||||
|
||||
if (sampleRate != 0) {
|
||||
hasLyrics = true;
|
||||
for (const auto &line: frame->synchedText()) {
|
||||
const int timeInMs = (line.time * 1000) / sampleRate;
|
||||
char *text = (char *)line.text.toCString(true);
|
||||
go_map_put_lyric_line(id, lang, text, timeInMs);
|
||||
go_map_put_lyric_line(id, lyricsTag, text, timeInMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -149,10 +142,6 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
|||
tags.insert(kv.first, kv.second.front()->toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLyrics) {
|
||||
go_map_put_int(id, CUSTOM_LYRICS, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// M4A may have some iTunes specific tags
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import (
|
|||
"unsafe"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/scanner/metadata"
|
||||
)
|
||||
|
||||
const iTunesKeyPrefix = "----:com.apple.itunes:"
|
||||
|
|
@ -133,16 +132,10 @@ func go_map_put_lyric_line(id C.ulong, lang *C.char, text *C.char, time C.int) {
|
|||
defer lock.RUnlock()
|
||||
|
||||
m := maps[uint32(id)]
|
||||
existing, ok := m[metadata.NAVIDROME_SYNCHRONIZED_KEY]
|
||||
existing, ok := m[language]
|
||||
if ok {
|
||||
length := len(existing)
|
||||
|
||||
if existing[length-2] == language {
|
||||
existing[length-1] += formatted_line
|
||||
} else {
|
||||
m[metadata.NAVIDROME_SYNCHRONIZED_KEY] = append(existing, language, formatted_line)
|
||||
}
|
||||
existing[0] += formatted_line
|
||||
} else {
|
||||
m[metadata.NAVIDROME_SYNCHRONIZED_KEY] = append(existing, language, formatted_line)
|
||||
m[language] = []string{formatted_line}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import (
|
|||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -90,21 +87,6 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons
|
|||
return nil, err
|
||||
}
|
||||
|
||||
const timeRegexString = `(\[(([0-9]{1,2}):)?([0-9]{1,2}):([0-9]{1,2})(\.([0-9]{1,3}))?\])`
|
||||
|
||||
var (
|
||||
timeRegex = regexp.MustCompile(timeRegexString)
|
||||
lineRegex = regexp.MustCompile(timeRegexString + "([^\n]+)")
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^\]]+)\]`)
|
||||
)
|
||||
|
||||
func isSynced(rawLyrics string) bool {
|
||||
// Eg: [04:02:50.85]
|
||||
// [02:50.85]
|
||||
// [02:50]
|
||||
return timeRegex.MatchString(rawLyrics)
|
||||
}
|
||||
|
||||
func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
artist := utils.ParamString(r, "artist")
|
||||
title := utils.ParamString(r, "title")
|
||||
|
|
@ -121,15 +103,25 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
|||
return response, nil
|
||||
}
|
||||
|
||||
structuredLyrics, err := mediaFiles[0].StructuredLyrics()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(structuredLyrics) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
lyrics.Artist = artist
|
||||
lyrics.Title = title
|
||||
|
||||
if isSynced(mediaFiles[0].Lyrics) {
|
||||
lyrics.Value = timeRegex.ReplaceAllString(mediaFiles[0].Lyrics, "")
|
||||
} else {
|
||||
lyrics.Value = mediaFiles[0].Lyrics
|
||||
lyricsText := ""
|
||||
for _, line := range structuredLyrics[0].Line {
|
||||
lyricsText += line.Value + "\n"
|
||||
}
|
||||
|
||||
lyrics.Value = lyricsText
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
|
@ -141,112 +133,16 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
|
|||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
allLyrics := responses.LyricsList{}
|
||||
response.LyricsList = &allLyrics
|
||||
|
||||
if mediaFile.Lyrics != "" {
|
||||
lyricsResult := strings.Split(mediaFile.Lyrics, consts.Zwsp)
|
||||
|
||||
for i := 0; i < len(lyricsResult); i += 2 {
|
||||
encoding := lyricsResult[i]
|
||||
textLines := strings.Split(lyricsResult[i+1], "\n")
|
||||
|
||||
lines := []responses.Line{}
|
||||
synced := true
|
||||
|
||||
artist := ""
|
||||
title := ""
|
||||
var offset *int64 = nil
|
||||
|
||||
for _, line := range textLines {
|
||||
line := strings.TrimSpace(line)
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var text string
|
||||
var time *int64 = nil
|
||||
|
||||
if synced {
|
||||
idTag := lrcIdRegex.FindStringSubmatch(line)
|
||||
if idTag != nil {
|
||||
switch idTag[1] {
|
||||
case "ar":
|
||||
artist = idTag[2]
|
||||
case "offset":
|
||||
{
|
||||
off, err := strconv.ParseInt(idTag[2], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset = &off
|
||||
}
|
||||
case "ti":
|
||||
title = idTag[2]
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
syncedMatch := lineRegex.FindStringSubmatch(line)
|
||||
if syncedMatch == nil {
|
||||
synced = false
|
||||
text = line
|
||||
} else {
|
||||
var hours int64
|
||||
if syncedMatch[3] != "" {
|
||||
hours, err = strconv.ParseInt(syncedMatch[3], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
min, err := strconv.ParseInt(syncedMatch[4], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sec, err := strconv.ParseInt(syncedMatch[5], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
millis, err := strconv.ParseInt(syncedMatch[7], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(syncedMatch[7]) == 2 {
|
||||
millis *= 10
|
||||
}
|
||||
|
||||
timeInMillis := (((((hours * 60) + min) * 60) + sec) * 1000) + millis
|
||||
time = &timeInMillis
|
||||
text = syncedMatch[8]
|
||||
}
|
||||
} else {
|
||||
text = line
|
||||
}
|
||||
|
||||
lines = append(lines, responses.Line{
|
||||
Start: time,
|
||||
Value: text,
|
||||
})
|
||||
}
|
||||
|
||||
response.LyricsList.StructuredLyrics = append(response.LyricsList.StructuredLyrics, responses.StructuredLyrics{
|
||||
DisplayArtist: artist,
|
||||
DisplayTitle: title,
|
||||
Lang: encoding,
|
||||
Line: lines,
|
||||
Offset: offset,
|
||||
Synced: synced,
|
||||
})
|
||||
}
|
||||
lyrics, err := mediaFile.StructuredLyrics()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
allLyrics := responses.LyricsList{
|
||||
StructuredLyrics: lyrics,
|
||||
}
|
||||
response.LyricsList = &allLyrics
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package subsonic
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
|
@ -74,12 +74,18 @@ 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")
|
||||
lyricsJson, err := json.Marshal(model.Lyrics{
|
||||
*lyrics,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I",
|
||||
Lyrics: string(lyricsJson[:]),
|
||||
},
|
||||
})
|
||||
response, err := router.GetLyrics(r)
|
||||
|
|
@ -89,7 +95,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
Expect(err).To(BeNil())
|
||||
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
|
||||
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"))
|
||||
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\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")
|
||||
|
|
@ -144,23 +150,30 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
|
||||
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.Lyrics{
|
||||
*synced, *unsynced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: fmt.Sprintf("eng\u200b%s\u200bxxx\u200b%s", syncedLyrics, unsyncedLyrics),
|
||||
Lyrics: string(lyricsJson[:]),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: []responses.StructuredLyrics{
|
||||
StructuredLyrics: model.Lyrics{
|
||||
{
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
|
|
@ -174,7 +187,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
{
|
||||
Lang: "xxx",
|
||||
Synced: false,
|
||||
Line: []responses.Line{
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
|
|
@ -189,12 +202,17 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
|
||||
It("should parse lrc metadata", func() {
|
||||
r := newGetRequest("id=1")
|
||||
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
|
||||
lyricsJson, err := json.Marshal(model.Lyrics{
|
||||
*synced,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: fmt.Sprintf("eng\u200b%s\n%s", metadata, syncedLyrics),
|
||||
Lyrics: string(lyricsJson[:]),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -203,13 +221,13 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
|
||||
offset := int64(-100)
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: []responses.StructuredLyrics{
|
||||
StructuredLyrics: model.Lyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
|
|
@ -244,26 +262,6 @@ func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int) (
|
|||
return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil
|
||||
}
|
||||
|
||||
var _ = Describe("isSynced", func() {
|
||||
It("returns false if lyrics contain no timestamps", func() {
|
||||
Expect(isSynced("Just in case my car goes off the highway")).To(Equal(false))
|
||||
Expect(isSynced("[02.50] Just in case my car goes off the highway")).To(Equal(false))
|
||||
})
|
||||
It("returns false if lyrics is an empty string", func() {
|
||||
Expect(isSynced("")).To(Equal(false))
|
||||
})
|
||||
It("returns true if lyrics contain timestamps", func() {
|
||||
Expect(isSynced(`NF Real Music
|
||||
[00:00] First line
|
||||
[00:00.85] JUST LIKE YOU
|
||||
[00:00.85] Just in case my car goes off the highway`)).To(Equal(true))
|
||||
Expect(isSynced("[04:02:50.85] Never gonna give you up")).To(Equal(true))
|
||||
Expect(isSynced("[02:50.85] Never gonna give you up")).To(Equal(true))
|
||||
Expect(isSynced("[02:50] Never gonna give you up")).To(Equal(true))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
type mockedMediaFile struct {
|
||||
model.MediaFileRepository
|
||||
data model.MediaFiles
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Subsonic struct {
|
||||
|
|
@ -446,23 +448,8 @@ type JukeboxPlaylist struct {
|
|||
JukeboxStatus
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
}
|
||||
|
||||
type Line struct {
|
||||
Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"`
|
||||
Value string `xml:"value" json:"value"`
|
||||
}
|
||||
|
||||
type StructuredLyrics 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"`
|
||||
}
|
||||
|
||||
type LyricsList struct {
|
||||
StructuredLyrics []StructuredLyrics `xml:"structuredLyrics,omitempty" json:"structuredLyrics,omitempty"`
|
||||
StructuredLyrics model.Lyrics `xml:"structuredLyrics,omitempty" json:"structuredLyrics,omitempty"`
|
||||
}
|
||||
|
||||
type OpenSubsonicExtension struct {
|
||||
|
|
|
|||
BIN
tests/fixtures/test.mp3
vendored
BIN
tests/fixtures/test.mp3
vendored
Binary file not shown.
|
|
@ -20,8 +20,14 @@ const initialState = {
|
|||
savedPlayIndex: 0,
|
||||
}
|
||||
|
||||
const timestampRegex =
|
||||
/(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g
|
||||
const pad = (value) => {
|
||||
const str = value.toString()
|
||||
if (str.length === 1) {
|
||||
return `0${str}`
|
||||
} else {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
const mapToAudioLists = (item) => {
|
||||
// If item comes from a playlist, trackId is mediaFileId
|
||||
|
|
@ -43,9 +49,21 @@ const mapToAudioLists = (item) => {
|
|||
let lyricText = ''
|
||||
|
||||
if (lyrics) {
|
||||
const split = lyrics.split('\u200B')
|
||||
if (split.length > 1) {
|
||||
lyricText = split[1]
|
||||
const structured = JSON.parse(lyrics)
|
||||
for (const structuredLyric of structured) {
|
||||
if (structuredLyric.synced) {
|
||||
for (const line of structuredLyric.line) {
|
||||
let time = Math.floor(line.start / 10)
|
||||
const ms = time % 100
|
||||
time = Math.floor(time / 100)
|
||||
const sec = time % 60
|
||||
time = Math.floor(time / 60)
|
||||
const min = time % 60
|
||||
|
||||
ms.toString()
|
||||
lyricText += `[${pad(min)}:${pad(sec)}.${pad(ms)}] ${line.value}\n`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +72,7 @@ const mapToAudioLists = (item) => {
|
|||
uuid: uuidv4(),
|
||||
song: item,
|
||||
name: item.title,
|
||||
lyric: timestampRegex.test(lyricText) ? lyricText : '',
|
||||
lyric: lyricText,
|
||||
singer: item.artist,
|
||||
duration: item.duration,
|
||||
musicSrc: subsonic.streamUrl(trackId),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue