mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 21:37:09 +02:00
draft commit
This commit is contained in:
parent
c3efc57259
commit
c529c47054
20 changed files with 567 additions and 33 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
BIN
tests/fixtures/01 Invisible (RED) Edit Version.m4a
vendored
BIN
tests/fixtures/01 Invisible (RED) Edit Version.m4a
vendored
Binary file not shown.
BIN
tests/fixtures/test.aiff
vendored
BIN
tests/fixtures/test.aiff
vendored
Binary file not shown.
BIN
tests/fixtures/test.flac
vendored
BIN
tests/fixtures/test.flac
vendored
Binary file not shown.
BIN
tests/fixtures/test.mp3
vendored
BIN
tests/fixtures/test.mp3
vendored
Binary file not shown.
BIN
tests/fixtures/test.ogg
vendored
BIN
tests/fixtures/test.ogg
vendored
Binary file not shown.
BIN
tests/fixtures/test.wav
vendored
BIN
tests/fixtures/test.wav
vendored
Binary file not shown.
BIN
tests/fixtures/test.wma
vendored
BIN
tests/fixtures/test.wma
vendored
Binary file not shown.
BIN
tests/fixtures/test.wv
vendored
BIN
tests/fixtures/test.wv
vendored
Binary file not shown.
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue