diff --git a/scanner/metadata/ffmpeg/ffmpeg.go b/scanner/metadata/ffmpeg/ffmpeg.go index 1139a5014..99e55700a 100644 --- a/scanner/metadata/ffmpeg/ffmpeg.go +++ b/scanner/metadata/ffmpeg/ffmpeg.go @@ -75,6 +75,8 @@ 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.]+),*(.*.,)*`) @@ -111,6 +113,8 @@ 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 { @@ -121,7 +125,23 @@ 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 } @@ -130,11 +150,20 @@ func (e *Extractor) parseInfo(info string) map[string][]string { if lastTag != "" { match = continuationRx.FindStringSubmatch(line) if len(match) > 0 { - if tags[lastTag] == nil { - tags[lastTag] = []string{""} + 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]) } - tagValue := tags[lastTag][0] - tags[lastTag][0] = tagValue + "\n" + strings.TrimSpace(match[1]) + continue } } diff --git a/scanner/metadata/ffmpeg/ffmpeg_test.go b/scanner/metadata/ffmpeg/ffmpeg_test.go index 7f1c63bf7..198a60480 100644 --- a/scanner/metadata/ffmpeg/ffmpeg_test.go +++ b/scanner/metadata/ffmpeg/ffmpeg_test.go @@ -1,6 +1,7 @@ package ffmpeg import ( + "github.com/navidrome/navidrome/scanner/metadata" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -316,4 +317,37 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu Expect(md).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"})) }) + + It("parses lyrics with language code", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + lyrics-eng : [00:00.00]This is + : [00:02.50]English + lyrics-xxx : [00:00.00]This is + : [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", + "[00:00.00]This is\n[00:02.50]English", + "xxx", + "[00:00.00]This is\n[00:02.50]unspecified", + })) + }) + + It("parses normal LYRICS tag", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + LYRICS : [00:00.00]This is + : [00:02.50]English + ` + 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", + })) + }) }) diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index 1d3b12fd0..34b52e96d 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -17,6 +17,8 @@ import ( "github.com/navidrome/navidrome/log" ) +const ENCODED_LYRICS_KEY = "__navidrome__lyrics" + type Extractor interface { Parse(files ...string) (map[string]ParsedTags, error) CustomMappings() ParsedTags @@ -105,7 +107,26 @@ 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 { - return t.getFirstTagValue("lyrics", "lyrics-eng", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") + result := "" + + if t.getFirstTagValue(ENCODED_LYRICS_KEY) == "1" { + tags := t.getAllTagValues("lyrics") + + for i := 0; i < len(tags); i += 2 { + result += fmt.Sprintf("%s\u200b%s\u200b", tags[i], tags[i+1]) + } + } else { + tags := t.getAllTagValues("lyrics", "lyrics-eng", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") + + for _, tag := range tags { + result += fmt.Sprintf("xxx\u200b%s\u200b", tag) + } + } + + if len(result) > 0 { + result = strings.TrimSuffix(result, consts.Zwsp) + } + return result } func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation") } func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") } diff --git a/scanner/metadata/metadata_test.go b/scanner/metadata/metadata_test.go index dba52ed77..a6ea2a657 100644 --- a/scanner/metadata/metadata_test.go +++ b/scanner/metadata/metadata_test.go @@ -1,7 +1,10 @@ package metadata_test import ( + "strings" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/scanner/metadata" _ "github.com/navidrome/navidrome/scanner/metadata/ffmpeg" _ "github.com/navidrome/navidrome/scanner/metadata/taglib" @@ -10,6 +13,9 @@ import ( ) 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" + Context("Extract", func() { BeforeEach(func() { conf.Server.Scanner.Extractor = "taglib" @@ -61,10 +67,65 @@ var _ = Describe("Tags", func() { Expect(m.Duration()).To(BeNumerically("~", 1.04, 0.01)) Expect(m.Suffix()).To(Equal("ogg")) Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg")) - Expect(m.Size()).To(Equal(int64(6333))) + Expect(m.Size()).To(Equal(int64(5534))) // TabLib 1.12 returns 18, previous versions return 39. // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b Expect(m.BitRate()).To(BeElementOf(18, 39, 40, 49)) }) + + DescribeTable("Lyrics test", + func(file string, langEncoded bool) { + path := "tests/fixtures/" + file + mds, err := metadata.Extract(path) + Expect(err).ToNot(HaveOccurred()) + 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) + } + Expect(m.Lyrics()).To(Equal(lyrics)) + }, + + Entry("Parses AIFF file", "test.aiff", true), + Entry("Parses FLAC files", "test.flac", false), + Entry("Parses M4A files", "01 Invisible (RED) Edit Version.m4a", false), + Entry("Parses MP3 files", "test.mp3", true), + Entry("Parses OGG Vorbis files", "test.ogg", false), + Entry("Parses WAV files", "test.wav", true), + Entry("Parses WMA files", "test.wma", false), + Entry("Parses WV files", "test.wv", false), + ) + }) + + Context("Extract", func() { + BeforeEach(func() { + conf.Server.Scanner.Extractor = "ffmpeg" + }) + + DescribeTable("Lyrics test", + func(file string) { + path := "tests/fixtures/" + file + mds, err := metadata.Extract(path) + Expect(err).ToNot(HaveOccurred()) + Expect(mds).To(HaveLen(1)) + + m := mds[path] + lyrics := strings.Join([]string{"eng", englishLyric, "xxx", unspecifiedLyric}, consts.Zwsp) + + Expect(m.Lyrics()).To(Equal(lyrics)) + }, + + Entry("Parses AIFF file", "test.aiff"), + Entry("Parses MP3 files", "test.mp3"), + Entry("Parses WAV files", "test.wav"), + + // FFMPEG behaves very weirdly for multivalued tags for non-ID3 + // Specifically, they are separated by ";, which is indistinguishable + // from other fields + ) }) }) diff --git a/scanner/metadata/taglib/taglib_test.go b/scanner/metadata/taglib/taglib_test.go index bc0cfd630..6754dec19 100644 --- a/scanner/metadata/taglib/taglib_test.go +++ b/scanner/metadata/taglib/taglib_test.go @@ -4,6 +4,7 @@ import ( "io/fs" "os" + "github.com/navidrome/navidrome/scanner/metadata" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -50,7 +51,13 @@ 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{"Lyrics 1\rLyrics 2"})) + Expect(m).To(HaveKeyWithValue("lyrics", []string{ + "eng", + "[00:00.00]This is\n[00:02.50]English", + "xxx", + "[00:00.00]This is\n[00:02.50]unspecified", + })) + Expect(m).To(HaveKeyWithValue(metadata.ENCODED_LYRICS_KEY, []string{"1"})) 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"})) @@ -74,7 +81,7 @@ var _ = Describe("Extractor", func() { }) DescribeTable("Format-Specific tests", - func(file, duration, channels, albumGain, albumPeak, trackGain, trackPeak string) { + func(file, duration, channels, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool) { file = "tests/fixtures/" + file mds, err := e.Parse(file) Expect(err).NotTo(HaveOccurred()) @@ -105,7 +112,23 @@ var _ = Describe("Extractor", func() { Expect(m).To(HaveKeyWithValue("channels", []string{channels})) Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) - Expect(m).To(HaveKeyWithValue("lyrics", []string{"Lyrics1\nLyrics 2"})) + + if id3Lyrics { + Expect(m).To(HaveKeyWithValue(metadata.ENCODED_LYRICS_KEY, []string{"1"})) + Expect(m).To(HaveKeyWithValue("lyrics", []string{ + "eng", + "[00:00.00]This is\n[00:02.50]English", + "xxx", + "[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", + })) + } + Expect(m).To(HaveKeyWithValue("bpm", []string{"123"})) Expect(m).To(HaveKey("tracknumber")) @@ -115,25 +138,25 @@ var _ = Describe("Extractor", func() { }, // ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac - Entry("correctly parses flac tags", "test.flac", "1.00", "1", "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948"), + Entry("correctly parses flac tags", "test.flac", "1.00", "1", "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false), - Entry("Correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48"), + Entry("Correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48", false), - Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04", "2", "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506"), + Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04", "2", "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false), // ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma - Entry("correctly parses wma/asf tags", "test.wma", "1.02", "1", "3.27 dB", "0.132914", "3.27 dB", "0.132914"), + // Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order + Entry("correctly parses wma/asf tags", "test.wma", "1.02", "1", "3.27 dB", "0.132914", "3.27 dB", "0.132914", false), // ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv - Entry("correctly parses wv (wavpak) tags", "test.wv", "1.00", "1", "3.43 dB", "0.125061", "3.43 dB", "0.125061"), + Entry("correctly parses wv (wavpak) tags", "test.wv", "1.00", "1", "3.43 dB", "0.125061", "3.43 dB", "0.125061", false), // TODO - these breaks in the pipeline as it uses TabLib 1.11. Once Ubuntu 24.04 is released we can uncomment these tests // ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav - //Entry("correctly parses wav tags", "test.wav", "1.00", "1", "3.06 dB", "0.125056", "3.06 dB", "0.125056"), + Entry("correctly parses wav tags", "test.wav", "1.00", "1", "3.06 dB", "0.125056", "3.06 dB", "0.125056", true), // ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff - //Entry("correctly parses aiff tags", "test.aiff", "1.00", "1", "2.00 dB", "0.124972", "2.00 dB", "0.124972"), - + Entry("correctly parses aiff tags", "test.aiff", "1.00", "1", "2.00 dB", "0.124972", "2.00 dB", "0.124972", true), ) }) diff --git a/scanner/metadata/taglib/taglib_wrapper.cpp b/scanner/metadata/taglib/taglib_wrapper.cpp index b5ea60569..dbd3b4d4a 100644 --- a/scanner/metadata/taglib/taglib_wrapper.cpp +++ b/scanner/metadata/taglib/taglib_wrapper.cpp @@ -3,15 +3,19 @@ #include #define TAGLIB_STATIC +#include #include #include #include #include +#include +#include #include #include #include #include #include +#include #include "taglib_wrapper.h" @@ -22,6 +26,9 @@ const char *RG_TAGS[] = { "replaygain_track_gain", "replaygain_track_peak"}; +char *LYRICS_KEY = (char *) "lyrics"; +char *CUSTOM_LYRICS = (char *) "__navidrome__lyrics"; + char has_cover(const TagLib::FileRef f); int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { @@ -64,16 +71,61 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { } } + TagLib::ID3v2::Tag *id3Tags = NULL; + // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames) TagLib::MPEG::File *mp3File(dynamic_cast(f.file())); if (mp3File != NULL) { - if (mp3File->ID3v2Tag()) { - const auto &frameListMap(mp3File->ID3v2Tag()->frameListMap()); + id3Tags = mp3File->ID3v2Tag(); + } - for (const auto &kv : frameListMap) { - if (!kv.second.isEmpty()) + if (id3Tags == NULL) { + TagLib::RIFF::WAV::File *wavFile(dynamic_cast(f.file())); + if (wavFile != NULL && wavFile->hasID3v2Tag()) { + id3Tags = wavFile->ID3v2Tag(); + } + } + + if (id3Tags == NULL) { + TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast(f.file())); + if (aiffFile && aiffFile->hasID3v2Tag()) { + id3Tags = aiffFile->tag(); + } + } + + // Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems + // with many players, so they will not be parsed + + if (id3Tags != NULL) { + const auto &frameListMap(id3Tags->frameListMap()); + + for (const auto &kv : frameListMap) { + if (!kv.second.isEmpty()) + if (kv.first == "USLT") { + bool hasLyrics = false; + + for (const auto &uslt: kv.second) { + TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast(uslt); + if (frame == NULL) continue; + + char lang[4]; + strncpy(lang, frame->language().data(), 3); + lang[3] = '\0'; + char *val = ::strdup(frame->text().toCString(true)); + + go_map_put_str(id, LYRICS_KEY, lang); + go_map_put_str(id, LYRICS_KEY, val); + tags.erase("LYRICS"); + hasLyrics = true; + free(val); + } + + if (hasLyrics) { + go_map_put_int(id, CUSTOM_LYRICS, 1); + } + } else { tags.insert(kv.first, kv.second.front()->toString()); - } + } } } @@ -108,10 +160,12 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { const auto itemListMap = asfTags->attributeListMap(); for (const auto item : itemListMap) { char *key = ::strdup(item.first.toCString(true)); - char *val = ::strdup(item.second.front().toString().toCString()); - go_map_put_str(id, key, val); + for (const auto &value: item.second) { + char *val = ::strdup(value.toString().toCString()); + go_map_put_str(id, key, val); + free(val); + } free(key); - free(val); } // Compilation tag needs to be handled differently diff --git a/server/subsonic/api.go b/server/subsonic/api.go index ae28e4ec0..1a01699bf 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -142,6 +142,7 @@ func (api *Router) routes() http.Handler { r.Group(func(r chi.Router) { hr(r, "getAvatar", api.GetAvatar) h(r, "getLyrics", api.GetLyrics) + h(r, "getLyricsBySongId", api.GetLyricsBySongId) }) r.Group(func(r chi.Router) { // configure request throttling diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 1190c6306..644d3e5d0 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "regexp" + "strconv" + "strings" "time" "github.com/navidrome/navidrome/conf" @@ -88,14 +90,19 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } -const timeStampRegex string = `(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])` +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 { - r := regexp.MustCompile(timeStampRegex) // Eg: [04:02:50.85] // [02:50.85] // [02:50] - return r.MatchString(rawLyrics) + return timeRegex.MatchString(rawLyrics) } func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { @@ -118,11 +125,128 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyrics.Title = title if isSynced(mediaFiles[0].Lyrics) { - r := regexp.MustCompile(timeStampRegex) - lyrics.Value = r.ReplaceAllString(mediaFiles[0].Lyrics, "") + lyrics.Value = timeRegex.ReplaceAllString(mediaFiles[0].Lyrics, "") } else { lyrics.Value = mediaFiles[0].Lyrics } return response, nil } + +func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, error) { + id := utils.ParamString(r, "id") + mediaFile, err := api.ds.MediaFile(r.Context()).Get(id) + + if err != nil { + 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, + }) + } + } + + return response, nil +} diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 5246fd61c..91aff8302 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net/http/httptest" "time" @@ -11,6 +12,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -100,7 +102,127 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Artist).To(Equal("")) Expect(response.Lyrics.Title).To(Equal("")) Expect(response.Lyrics.Value).To(Equal("")) + }) + }) + Describe("getLyricsBySongId", func() { + const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" + const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" + const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" + var times = []int64{int64(18800), int64(22801)} + + compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { + Expect(actual).ToNot(BeNil()) + Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) + for i, realLyric := range actual.StructuredLyrics { + expectedLyric := expected.StructuredLyrics[i] + + Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) + Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) + Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) + Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) + + if expectedLyric.Offset == nil { + Expect(realLyric.Offset).To(BeNil()) + } else { + Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) + } + + Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) + for j, realLine := range realLyric.Line { + expectedLine := expectedLyric.Line[j] + Expect(realLine.Value).To(Equal(expectedLine.Value)) + + if expectedLine.Start == nil { + Expect(realLine.Start).To(BeNil()) + } else { + Expect(*realLine.Start).To(Equal(*expectedLine.Start)) + } + } + } + } + + It("should return mixed lyrics", func() { + r := newGetRequest("id=1") + 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), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: []responses.StructuredLyrics{ + { + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + }, + { + Lang: "xxx", + Synced: false, + Line: []responses.Line{ + { + Value: "We're no strangers to love", + }, + { + Value: "You know the rules and so do I", + }, + }, + }, + }, + }) + }) + + It("should parse lrc metadata", func() { + r := newGetRequest("id=1") + 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), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + offset := int64(-100) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: []responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + Offset: &offset, + }, + }, + }) }) }) }) @@ -154,3 +276,12 @@ func (m *mockedMediaFile) SetData(mfs model.MediaFiles) { func (m *mockedMediaFile) GetAll(...model.QueryOptions) (model.MediaFiles, error) { return m.data, nil } + +func (m *mockedMediaFile) Get(id string) (*model.MediaFile, error) { + for _, mf := range m.data { + if mf.ID == id { + return &mf, nil + } + } + return nil, model.ErrNotFound +} diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 106029daf..fb4964406 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -8,6 +8,15 @@ import ( func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) { response := newResponse() - response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{} + response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{ + OpenSubsonicExtensions: []responses.OpenSubsonicExtension{ + { + Name: "songLyrics", + Versions: []responses.Version{{ + Version: 1, + }}, + }, + }, + } return response, nil } diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 87f9fa508..1a1cea2d6 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -58,6 +58,7 @@ type Subsonic struct { JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"` OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"` + LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` } type JsonWrapper struct { @@ -424,7 +425,44 @@ type JukeboxPlaylist struct { JukeboxStatus Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` } -type OpenSubsonicExtensions struct{} + +type Version struct { + Version int32 `xml:"version"` +} + +func (v Version) MarshalJSON() ([]byte, error) { + return json.Marshal(v.Version) +} + +type OpenSubsonicExtension struct { + Name string `xml:"name" json:"name"` + Versions []Version `xml:"versions" json:"versions"` +} +type OpenSubsonicExtensions struct { + OpenSubsonicExtensions []OpenSubsonicExtension `xml:"openSubsonicExtension" json:"openSubsonicExtension"` +} + +func (o OpenSubsonicExtensions) MarshalJSON() ([]byte, error) { + return json.Marshal(o.OpenSubsonicExtensions) +} + +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"` +} // OpenSubsonic response type for multi-valued genres list type ItemGenre struct { diff --git a/tests/fixtures/01 Invisible (RED) Edit Version.m4a b/tests/fixtures/01 Invisible (RED) Edit Version.m4a index 80474aa35..5aa50bb51 100644 Binary files a/tests/fixtures/01 Invisible (RED) Edit Version.m4a and b/tests/fixtures/01 Invisible (RED) Edit Version.m4a differ diff --git a/tests/fixtures/test.aiff b/tests/fixtures/test.aiff index 148de3693..220c4145c 100644 Binary files a/tests/fixtures/test.aiff and b/tests/fixtures/test.aiff differ diff --git a/tests/fixtures/test.flac b/tests/fixtures/test.flac index a3cf5e986..cd413005f 100644 Binary files a/tests/fixtures/test.flac and b/tests/fixtures/test.flac differ diff --git a/tests/fixtures/test.mp3 b/tests/fixtures/test.mp3 index 1e40de9eb..f97506554 100644 Binary files a/tests/fixtures/test.mp3 and b/tests/fixtures/test.mp3 differ diff --git a/tests/fixtures/test.ogg b/tests/fixtures/test.ogg index 306d3bb13..7c2d0efba 100644 Binary files a/tests/fixtures/test.ogg and b/tests/fixtures/test.ogg differ diff --git a/tests/fixtures/test.wav b/tests/fixtures/test.wav index 2bb8dd96c..9cf796f79 100644 Binary files a/tests/fixtures/test.wav and b/tests/fixtures/test.wav differ diff --git a/tests/fixtures/test.wma b/tests/fixtures/test.wma index 5d08ab3c4..48241d21f 100644 Binary files a/tests/fixtures/test.wma and b/tests/fixtures/test.wma differ diff --git a/tests/fixtures/test.wv b/tests/fixtures/test.wv index 2b479ef7a..49c0fca36 100644 Binary files a/tests/fixtures/test.wv and b/tests/fixtures/test.wv differ diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index be8259f26..51ebea245 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -40,12 +40,21 @@ const mapToAudioLists = (item) => { } const { lyrics } = item + let lyricText = '' + + if (lyrics) { + const split = lyrics.split('\u200B') + if (split.length > 1) { + lyricText = split[1] + } + } + return { trackId, uuid: uuidv4(), song: item, name: item.title, - lyric: timestampRegex.test(lyrics) ? lyrics : '', + lyric: timestampRegex.test(lyricText) ? lyricText : '', singer: item.artist, duration: item.duration, musicSrc: subsonic.streamUrl(trackId),