store as json now

This commit is contained in:
Kendall Garner 2023-12-09 20:46:57 -08:00
commit 6e6f7304ed
No known key found for this signature in database
GPG key ID: 18D2767419676C87
15 changed files with 357 additions and 301 deletions

134
model/lyrics.go Normal file
View 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

View file

@ -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

View file

@ -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()

View file

@ -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
}
}

View file

@ -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",
}))
})

View file

@ -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") }

View file

@ -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"),

View file

@ -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",

View file

@ -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

View file

@ -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}
}
}

View file

@ -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
}

View file

@ -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: &times[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: &times[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

View file

@ -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 {

Binary file not shown.

View file

@ -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),