draft commit

This commit is contained in:
Kendall Garner 2023-11-25 20:16:10 -08:00
commit c529c47054
No known key found for this signature in database
GPG key ID: 18D2767419676C87
20 changed files with 567 additions and 33 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,15 +3,19 @@
#include <typeinfo>
#define TAGLIB_STATIC
#include <aifffile.h>
#include <asffile.h>
#include <fileref.h>
#include <flacfile.h>
#include <id3v2tag.h>
#include <unsynchronizedlyricsframe.h>
#include <synchronizedlyricsframe.h>
#include <mp4file.h>
#include <mpegfile.h>
#include <opusfile.h>
#include <tpropertymap.h>
#include <vorbisfile.h>
#include <wavfile.h>
#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<TagLib::MPEG::File *>(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<TagLib::RIFF::WAV::File *>(f.file()));
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
id3Tags = wavFile->ID3v2Tag();
}
}
if (id3Tags == NULL) {
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(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<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(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

View file

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

View file

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

View file

@ -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: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[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: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[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
}

View file

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

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/fixtures/test.wv vendored

Binary file not shown.

View file

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