Compare commits
4 Commits
e5bd04cf02
...
10060fe407
Author | SHA1 | Date |
---|---|---|
Kendall Garner | 10060fe407 | |
Kendall Garner | 4621e9106f | |
Kendall Garner | 1490e9c1e6 | |
Kendall Garner | 8d284baa4e |
|
@ -104,6 +104,7 @@ type configOptions struct {
|
|||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevLyricsTimeToLive time.Duration
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
|
@ -229,7 +230,7 @@ func disableExternalServices() {
|
|||
Server.LastFM.Enabled = false
|
||||
Server.Spotify.ID = ""
|
||||
Server.ListenBrainz.Enabled = false
|
||||
Server.Agents = ""
|
||||
Server.Agents = "filesystem"
|
||||
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
|
||||
Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
||||
}
|
||||
|
@ -338,7 +339,7 @@ func init() {
|
|||
viper.SetDefault("scanner.genreseparators", ";/,")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("agents", "filesystem,lastfm,spotify,lrclib")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
|
@ -365,6 +366,7 @@ func init() {
|
|||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
viper.SetDefault("devlyricstimetolive", consts.LyricsInfoTimeToLive)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
|
|
|
@ -52,6 +52,7 @@ const (
|
|||
|
||||
ArtistInfoTimeToLive = 24 * time.Hour
|
||||
AlbumInfoTimeToLive = 7 * 24 * time.Hour
|
||||
LyricsInfoTimeToLive = 30 * 24 * time.Hour
|
||||
|
||||
I18nFolder = "i18n"
|
||||
SkipScanFile = ".ndignore"
|
||||
|
|
|
@ -22,7 +22,18 @@ func New(ds model.DataStore) *Agents {
|
|||
if conf.Server.Agents != "" {
|
||||
order = strings.Split(conf.Server.Agents, ",")
|
||||
}
|
||||
order = append(order, LocalAgentName)
|
||||
hasLocal := false
|
||||
for _, agent := range order {
|
||||
if agent == LocalAgentName {
|
||||
hasLocal = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasLocal {
|
||||
order = append(order, LocalAgentName)
|
||||
}
|
||||
|
||||
var res []Interface
|
||||
for _, name := range order {
|
||||
init, ok := Map[name]
|
||||
|
@ -218,6 +229,25 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
|
|||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetSongLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(LyricsRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lyrics, err := agent.GetSongLyrics(ctx, mf)
|
||||
if err == nil && (lyrics == nil || len(lyrics) > 0) {
|
||||
log.Debug(ctx, "Got lyrics", "agent", ag.AgentName(), "id", mf.ID, "elapsed", time.Since(start))
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
var _ Interface = (*Agents)(nil)
|
||||
var _ ArtistMBIDRetriever = (*Agents)(nil)
|
||||
var _ ArtistURLRetriever = (*Agents)(nil)
|
||||
|
@ -226,3 +256,4 @@ var _ ArtistSimilarRetriever = (*Agents)(nil)
|
|||
var _ ArtistImageRetriever = (*Agents)(nil)
|
||||
var _ ArtistTopSongsRetriever = (*Agents)(nil)
|
||||
var _ AlbumInfoRetriever = (*Agents)(nil)
|
||||
var _ LyricsRetriever = (*Agents)(nil)
|
||||
|
|
|
@ -39,6 +39,30 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("Settings", func() {
|
||||
It("does not duplicate local agent", func() {
|
||||
conf.Server.Agents = LocalAgentName
|
||||
ag := New(ds)
|
||||
Expect(ag.agents).To(HaveLen(1))
|
||||
Expect(ag.agents[0].AgentName()).To(Equal(LocalAgentName))
|
||||
})
|
||||
|
||||
It("uses orders local correctly", func() {
|
||||
for _, agent := range []string{"lastfm", "spotify", "lrclib"} {
|
||||
Register(agent, func(ds model.DataStore) Interface {
|
||||
return struct {
|
||||
Interface
|
||||
}{}
|
||||
})
|
||||
}
|
||||
|
||||
conf.Server.Agents = "lastfm,spotify,local,lrclib"
|
||||
ag := New(ds)
|
||||
Expect(ag.agents).To(HaveLen(4))
|
||||
Expect(ag.agents[2].AgentName()).To(Equal(LocalAgentName))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Agents", func() {
|
||||
var ag *Agents
|
||||
var mock *mockAgent
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
const filesystemAgentName = "filesystem"
|
||||
|
||||
var (
|
||||
supportedExtensions = []string{"lrc", "txt"}
|
||||
)
|
||||
|
||||
type filesystemAgent struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func filesystemConstructor(ds model.DataStore) *filesystemAgent {
|
||||
return &filesystemAgent{
|
||||
ds: ds,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *filesystemAgent) AgentName() string {
|
||||
return filesystemAgentName
|
||||
}
|
||||
|
||||
func (f *filesystemAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
_, err := f.ds.Artist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
als, err := f.ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_artist_id": id}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var paths []string
|
||||
for _, al := range als {
|
||||
paths = append(paths, strings.Split(al.Paths, consts.Zwsp)...)
|
||||
}
|
||||
artistFolder := utils.LongestCommonPrefix(paths)
|
||||
if !strings.HasSuffix(artistFolder, string(filepath.Separator)) {
|
||||
artistFolder, _ = filepath.Split(artistFolder)
|
||||
}
|
||||
artistBioPath := filepath.Join(artistFolder, "artist.txt")
|
||||
contents, err := os.ReadFile(artistBioPath)
|
||||
if err != nil {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
return string(contents), nil
|
||||
}
|
||||
|
||||
func (f *filesystemAgent) GetSongLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
lyrics := model.LyricList{}
|
||||
extension := filepath.Ext(mf.Path)
|
||||
basePath := mf.Path[0 : len(mf.Path)-len(extension)]
|
||||
|
||||
for _, ext := range supportedExtensions {
|
||||
lrcPath := fmt.Sprintf("%s.%s", basePath, ext)
|
||||
contents, err := os.ReadFile(lrcPath)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lyric, err := model.ToLyrics("xxx", string(contents))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lyrics = append(lyrics, *lyric)
|
||||
}
|
||||
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(filesystemAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return filesystemConstructor(ds)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var _ agents.ArtistBiographyRetriever = (*filesystemAgent)(nil)
|
||||
var _ agents.LyricsRetriever = (*filesystemAgent)(nil)
|
|
@ -0,0 +1,146 @@
|
|||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("localAgent", func() {
|
||||
var ds model.DataStore
|
||||
var ctx context.Context
|
||||
var agent *filesystemAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
agent = filesystemConstructor(ds)
|
||||
})
|
||||
|
||||
Describe("GetArtistBiography", func() {
|
||||
BeforeEach(func() {
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
model.Artist{ID: "ar-1234",
|
||||
Name: "artist"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should fetch artist biography", func() {
|
||||
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
model.Album{ID: "al-1234",
|
||||
AlbumArtistID: "ar-1234",
|
||||
Name: "album",
|
||||
Paths: "tests/fixtures/artist/an-album",
|
||||
},
|
||||
})
|
||||
|
||||
bio, err := agent.GetArtistBiography(ctx, "ar-1234", "album", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(Equal("This is an artist biography"))
|
||||
})
|
||||
|
||||
It("should fetch artist biography with slash", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
model.Album{ID: "al-1234",
|
||||
AlbumArtistID: "ar-1234",
|
||||
Name: "album",
|
||||
Paths: "tests/fixtures/artist/",
|
||||
},
|
||||
})
|
||||
|
||||
bio, err := agent.GetArtistBiography(ctx, "ar-1234", "album", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(Equal("This is an artist biography"))
|
||||
})
|
||||
|
||||
It("should error when file doesn't exist", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
model.Album{ID: "al-1234",
|
||||
AlbumArtistID: "ar-1234",
|
||||
Name: "album",
|
||||
Paths: "tests/fixtures/fake-artist/fake-album",
|
||||
},
|
||||
})
|
||||
|
||||
bio, err := agent.GetArtistBiography(ctx, "ar-1234", "album", "")
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
Expect(bio).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSongLyrics", func() {
|
||||
It("should parse LRC file", func() {
|
||||
mf := model.MediaFile{
|
||||
Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3",
|
||||
}
|
||||
|
||||
lyrics, err := agent.GetSongLyrics(ctx, &mf)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
{
|
||||
DisplayArtist: "",
|
||||
DisplayTitle: "",
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{Start: P(int64(0)), Value: "Line 1"},
|
||||
{Start: P(int64(5210)), Value: "Line 2"},
|
||||
{Start: P(int64(12450)), Value: "Line 3"},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should parse both LRC and TXT", func() {
|
||||
mf := model.MediaFile{
|
||||
Path: "tests/fixtures/test.wav",
|
||||
}
|
||||
|
||||
lyrics, err := agent.GetSongLyrics(ctx, &mf)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
{
|
||||
DisplayArtist: "Artist",
|
||||
DisplayTitle: "Title",
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{Start: P(int64(0)), Value: "Line 1"},
|
||||
{Start: P(int64(5210)), Value: "Line 2"},
|
||||
{Start: P(int64(12450)), Value: "Line 5"},
|
||||
},
|
||||
Offset: P(int64(100)),
|
||||
Synced: true,
|
||||
},
|
||||
{
|
||||
|
||||
DisplayArtist: "",
|
||||
DisplayTitle: "",
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: nil,
|
||||
Value: "Unsynchronized lyric line 1",
|
||||
},
|
||||
{
|
||||
Start: nil,
|
||||
Value: "Unsynchronized lyric line 2",
|
||||
},
|
||||
{
|
||||
Start: nil,
|
||||
Value: "Unsynchronized lyric line 3",
|
||||
},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,17 @@
|
|||
package filesystem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestFilesystem(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Spotify Test Suite")
|
||||
}
|
|
@ -69,6 +69,14 @@ type ArtistTopSongsRetriever interface {
|
|||
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
|
||||
}
|
||||
|
||||
type LyricsRetriever interface {
|
||||
// There are three possible results:
|
||||
// 1. nil, err: Any error
|
||||
// 2. lyrics, nil: track has one or more lyrics
|
||||
// 3. nil, nil: track was found, but is instrumental
|
||||
GetSongLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
|
||||
}
|
||||
|
||||
var Map map[string]Constructor
|
||||
|
||||
func Register(name string, init Constructor) {
|
||||
|
|
|
@ -50,3 +50,5 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
|||
func init() {
|
||||
Register(LocalAgentName, localsConstructor)
|
||||
}
|
||||
|
||||
var _ ArtistTopSongsRetriever = (*localAgent)(nil)
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
package lrclib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
lrclibAgentName = "lrclib"
|
||||
)
|
||||
|
||||
type lrclibAgent struct {
|
||||
client *client
|
||||
}
|
||||
|
||||
func (l *lrclibAgent) GetSongLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
lyrics, err := l.client.getLyrics(ctx, mf.Title, mf.AlbumArtist, mf.Album, mf.Duration)
|
||||
|
||||
if err != nil {
|
||||
var lrclibError *lrclibError
|
||||
isLrclibError := errors.As(err, &lrclibError)
|
||||
|
||||
if isLrclibError && lrclibError.Code == 404 {
|
||||
log.Info(ctx, "Track not present in LrcLib")
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
|
||||
log.Error(ctx, "Error fetching lyrics", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
songLyrics := model.LyricList{}
|
||||
|
||||
if lyrics.Instrumental {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if lyrics.SyncedLyrics != "" {
|
||||
lyrics, err := model.ToLyrics("xxx", lyrics.SyncedLyrics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
songLyrics = append(songLyrics, *lyrics)
|
||||
}
|
||||
|
||||
if lyrics.PlainLyrics != "" {
|
||||
lyrics, err := model.ToLyrics("xxx", lyrics.PlainLyrics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
songLyrics = append(songLyrics, *lyrics)
|
||||
}
|
||||
|
||||
return songLyrics, nil
|
||||
}
|
||||
|
||||
func lrclibConstructor() *lrclibAgent {
|
||||
l := &lrclibAgent{}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(chc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *lrclibAgent) AgentName() string {
|
||||
return lrclibAgentName
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lrclibAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lrclibConstructor()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var _ agents.LyricsRetriever = (lrclibConstructor)()
|
|
@ -0,0 +1,106 @@
|
|||
package lrclib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("lrclibAgent", func() {
|
||||
var ctx context.Context
|
||||
var agent *lrclibAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
agent = lrclibConstructor()
|
||||
agent.client = newClient(httpClient)
|
||||
})
|
||||
|
||||
Describe("getLyrics", func() {
|
||||
It("handles parses lyrics successfully", func() {
|
||||
f, _ := os.Open("tests/fixtures/lrclib.get.success.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
mf := model.MediaFile{Album: "album", AlbumArtist: "artist", Title: "title", Duration: 233.5}
|
||||
lyrics, err := agent.GetSongLyrics(ctx, &mf)
|
||||
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("https://lrclib.net/api/get?album_name=album&artist_name=artist&duration=233&track_name=title"))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
{
|
||||
DisplayArtist: "",
|
||||
DisplayTitle: "",
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: P(int64(17120)),
|
||||
Value: "I feel your breath upon my neck...",
|
||||
},
|
||||
{
|
||||
Start: P(int64(200310)),
|
||||
Value: "The clock won't stop and this is what we get",
|
||||
},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: true,
|
||||
},
|
||||
{
|
||||
DisplayArtist: "",
|
||||
DisplayTitle: "",
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: nil,
|
||||
Value: "I feel your breath upon my neck...",
|
||||
},
|
||||
{
|
||||
Start: nil,
|
||||
Value: "The clock won't stop and this is what we get",
|
||||
},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns nil for instrumental tracks", func() {
|
||||
f, _ := os.Open("tests/fixtures/lrclib.get.instrumental.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
mf := model.MediaFile{Album: "album", AlbumArtist: "artist", Title: "title"}
|
||||
lyrics, err := agent.GetSongLyrics(ctx, &mf)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(BeNil())
|
||||
})
|
||||
|
||||
It("handles error correctly", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 404, "name": "TrackNotFound", "message": "Failed to find specified track"}`)),
|
||||
StatusCode: 404,
|
||||
}
|
||||
|
||||
mf := model.MediaFile{Album: "album", AlbumArtist: "artist", Title: "title"}
|
||||
lyrics, err := agent.GetSongLyrics(ctx, &mf)
|
||||
|
||||
Expect(lyrics).To(BeNil())
|
||||
Expect(err).To(Equal(lrclibError{
|
||||
Code: 404,
|
||||
Name: "TrackNotFound",
|
||||
Message: "Failed to find specified track",
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,85 @@
|
|||
package lrclib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const apiBaseUrl = "https://lrclib.net/api/"
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newClient(hc httpDoer) *client {
|
||||
return &client{hc}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
type lyricInfo struct {
|
||||
Id int `json:"id"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics,omitempty"`
|
||||
SyncedLyrics string `json:"syncedLyrics,omitempty"`
|
||||
}
|
||||
|
||||
type lrclibError struct {
|
||||
Code int `json:"code"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (e lrclibError) Error() string {
|
||||
return fmt.Sprintf("lrclib error(%d, %s): %s", e.Code, e.Name, e.Message)
|
||||
}
|
||||
|
||||
func (c *client) getLyrics(ctx context.Context, trackName, artistName, albumName string, durationSec float32) (*lyricInfo, error) {
|
||||
params := url.Values{}
|
||||
params.Add("track_name", trackName)
|
||||
params.Add("artist_name", artistName)
|
||||
params.Add("album_name", albumName)
|
||||
params.Add("duration", strconv.Itoa(int(durationSec)))
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"get", nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, c.parseError(data)
|
||||
}
|
||||
|
||||
var lyricData lyricInfo
|
||||
err = json.Unmarshal(data, &lyricData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &lyricData, nil
|
||||
}
|
||||
|
||||
func (c *client) parseError(data []byte) error {
|
||||
var e lrclibError
|
||||
err := json.Unmarshal(data, &e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
package lrclib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("client", func() {
|
||||
lrcError := `{"code": 404, "name": "TrackNotFound", "message": "Failed to find specified track"}`
|
||||
|
||||
Describe("responses", func() {
|
||||
It("parses a successful response correctly", func() {
|
||||
var response lyricInfo
|
||||
simpleLyrics, _ := os.ReadFile("tests/fixtures/lrclib.get.success.json")
|
||||
err := json.Unmarshal(simpleLyrics, &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Id).To(Equal(3396226))
|
||||
Expect(response.Instrumental).To(Equal(false))
|
||||
Expect(response.PlainLyrics).To(Equal("I feel your breath upon my neck...\nThe clock won't stop and this is what we get\n"))
|
||||
Expect(response.SyncedLyrics).To(Equal("[00:17.12] I feel your breath upon my neck...\n[03:20.31] The clock won't stop and this is what we get\n"))
|
||||
})
|
||||
|
||||
It("parses error successfully", func() {
|
||||
var response lrclibError
|
||||
err := json.Unmarshal([]byte(lrcError), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Code).To(Equal(404))
|
||||
Expect(response.Message).To(Equal("Failed to find specified track"))
|
||||
Expect(response.Name).To(Equal("TrackNotFound"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("client", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var client *client
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client = newClient(httpClient)
|
||||
})
|
||||
|
||||
Describe("getLyrics", func() {
|
||||
It("should return lyrics properly", func() {
|
||||
f, _ := os.Open("tests/fixtures/lrclib.get.success.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
lyrics, err := client.getLyrics(context.Background(), "trackName", "artistName", "albumName", 100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics.Id).To(Equal(3396226))
|
||||
Expect(lyrics.Instrumental).To(Equal(false))
|
||||
Expect(lyrics.PlainLyrics).To(Equal("I feel your breath upon my neck...\nThe clock won't stop and this is what we get\n"))
|
||||
Expect(lyrics.SyncedLyrics).To(Equal("[00:17.12] I feel your breath upon my neck...\n[03:20.31] The clock won't stop and this is what we get\n"))
|
||||
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("https://lrclib.net/api/get?album_name=albumName&artist_name=artistName&duration=100&track_name=trackName"))
|
||||
})
|
||||
|
||||
It("should parse instrumental possible", func() {
|
||||
f, _ := os.Open("tests/fixtures/lrclib.get.instrumental.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
lyrics, err := client.getLyrics(context.Background(), "trackName", "artistName", "albumName", 100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics.Id).To(Equal(3396226))
|
||||
Expect(lyrics.Instrumental).To(Equal(true))
|
||||
Expect(lyrics.PlainLyrics).To(Equal(""))
|
||||
Expect(lyrics.SyncedLyrics).To(Equal(""))
|
||||
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("https://lrclib.net/api/get?album_name=albumName&artist_name=artistName&duration=100&track_name=trackName"))
|
||||
})
|
||||
|
||||
It("should handle error", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(lrcError)),
|
||||
StatusCode: 404,
|
||||
}
|
||||
|
||||
lyrics, err := client.getLyrics(context.Background(), "trackName", "artistName", "albumName", 100)
|
||||
Expect(lyrics).To(BeNil())
|
||||
Expect(err).To(Equal(lrclibError{
|
||||
Code: 404,
|
||||
Name: "TrackNotFound",
|
||||
Message: "Failed to find specified track",
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,17 @@
|
|||
package lrclib
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestLrcLib(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Spotify Test Suite")
|
||||
}
|
|
@ -2,6 +2,7 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
@ -12,8 +13,10 @@ import (
|
|||
"github.com/deluan/sanitize"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/filesystem"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lrclib"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
@ -37,6 +40,7 @@ type ExternalMetadata interface {
|
|||
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
|
||||
ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
ExternalLyrics(ctx context.Context, id string) (model.LyricList, error)
|
||||
}
|
||||
|
||||
type externalMetadata struct {
|
||||
|
@ -44,6 +48,7 @@ type externalMetadata struct {
|
|||
ag *agents.Agents
|
||||
artistQueue chan<- *auxArtist
|
||||
albumQueue chan<- *auxAlbum
|
||||
lyricsQueue chan<- *model.MediaFile
|
||||
}
|
||||
|
||||
type auxAlbum struct {
|
||||
|
@ -60,6 +65,7 @@ func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMeta
|
|||
e := &externalMetadata{ds: ds, ag: agents}
|
||||
e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
e.lyricsQueue = startRefreshQueue(context.TODO(), e.populateSongLyrics)
|
||||
return e
|
||||
}
|
||||
|
||||
|
@ -561,6 +567,69 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
|
|||
return nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) ExternalLyrics(ctx context.Context, id string) (model.LyricList, error) {
|
||||
mf, err := e.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updatedAt := V(mf.ExternalLyricsUpdatedAt)
|
||||
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "Lyrics not cached. Retrieving it now", "updatedAt", updatedAt, "id", mf.ID, "title", mf.Title)
|
||||
err := e.populateSongLyrics(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if time.Since(updatedAt) > conf.Server.DevLyricsTimeToLive {
|
||||
log.Debug("Found expired cached lyrics, refreshing in the background", "updatedAt", updatedAt, "title", mf.Title)
|
||||
enqueueRefresh(e.lyricsQueue, mf)
|
||||
}
|
||||
|
||||
return mf.StructuredExternalLyrics()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) populateSongLyrics(ctx context.Context, mf *model.MediaFile) error {
|
||||
start := time.Now()
|
||||
lyrics, err := e.ag.GetSongLyrics(ctx, mf)
|
||||
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to fetch external lyrics", "id", mf.ID,
|
||||
"title", mf.Title, "elapsed", time.Since(start), err)
|
||||
return err
|
||||
}
|
||||
|
||||
mf.ExternalLyricsUpdatedAt = P(time.Now())
|
||||
if lyrics != nil {
|
||||
content, err := json.Marshal(lyrics)
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error marshalling lyrics", "id", mf.ID,
|
||||
"title", mf.Title, "elapsed", time.Since(start), err)
|
||||
return err
|
||||
}
|
||||
|
||||
mf.ExternalLyrics = string(content)
|
||||
} else {
|
||||
mf.ExternalLyrics = ""
|
||||
}
|
||||
|
||||
err = e.ds.MediaFile(ctx).Put(mf)
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update external lyrics", "id", mf.ID,
|
||||
"title", mf.Title, "elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "External lyrics collected", "title", mf.ID, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T {
|
||||
queue := make(chan T, refreshQueueLength)
|
||||
go func() {
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddExternalLyrics, downAddExternalLyrics)
|
||||
}
|
||||
|
||||
func upAddExternalLyrics(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add external_lyrics text default '' not null;
|
||||
alter table media_file
|
||||
add external_lyrics_updated_at datetime;
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddExternalLyrics(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
|
@ -71,6 +71,9 @@ type MediaFile struct {
|
|||
RgAlbumPeak float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
|
||||
RgTrackGain float64 `structs:"rg_track_gain" json:"rgTrackGain"`
|
||||
RgTrackPeak float64 `structs:"rg_track_peak" json:"rgTrackPeak"`
|
||||
ExternalLyrics string `structs:"external_lyrics" json:"externalLyrics,omitempty"`
|
||||
|
||||
ExternalLyricsUpdatedAt *time.Time `structs:"external_lyrics_updated_at" json:"externalLyricsUpdatedAt"`
|
||||
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Time this entry was created in the DB
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime)
|
||||
|
@ -102,6 +105,15 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) {
|
|||
return lyrics, nil
|
||||
}
|
||||
|
||||
func (mf MediaFile) StructuredExternalLyrics() (LyricList, error) {
|
||||
lyrics := LyricList{}
|
||||
err := json.Unmarshal([]byte(mf.ExternalLyrics), &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
|
||||
|
|
|
@ -111,6 +111,17 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if len(structuredLyrics) == 0 {
|
||||
newLyrics, err := api.externalMetadata.ExternalLyrics(r.Context(), mediaFiles[0].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if newLyrics != nil {
|
||||
structuredLyrics = newLyrics
|
||||
}
|
||||
}
|
||||
|
||||
if len(structuredLyrics) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
|
@ -144,6 +155,17 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if len(lyrics) == 0 {
|
||||
newLyrics, err := api.externalMetadata.ExternalLyrics(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if newLyrics != nil {
|
||||
lyrics = newLyrics
|
||||
}
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.LyricsList = buildLyricsList(mediaFile, lyrics)
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
@ -24,13 +25,15 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
mockRepo := &mockedMediaFile{}
|
||||
var artwork *fakeArtwork
|
||||
var w *httptest.ResponseRecorder
|
||||
var mockedMetadata *tests.MockExternalMetadata
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
artwork = &fakeArtwork{}
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
mockedMetadata = tests.CreateMockExternalMetadata()
|
||||
router = New(ds, artwork, nil, nil, nil, mockedMetadata, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
@ -109,6 +112,59 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
Expect(response.Lyrics.Title).To(Equal(""))
|
||||
Expect(response.Lyrics.Value).To(Equal(""))
|
||||
})
|
||||
It("should return lyrics from external metadata", func() {
|
||||
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
},
|
||||
})
|
||||
mockedMetadata.SetLyrics(model.LyricList{
|
||||
{
|
||||
DisplayArtist: "Artist",
|
||||
DisplayTitle: "Title",
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{Start: P(int64(0)), Value: "Line 1"},
|
||||
{Start: P(int64(5210)), Value: "Line 2"},
|
||||
{Start: P(int64(12450)), Value: "Line 5"},
|
||||
},
|
||||
Offset: P(int64(100)),
|
||||
Synced: true,
|
||||
},
|
||||
})
|
||||
response, err := router.GetLyrics(r)
|
||||
if err != nil {
|
||||
log.Error("You're missing something.", err)
|
||||
}
|
||||
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("Line 1\nLine 2\nLine 5\n"))
|
||||
})
|
||||
It("should return nothing if no external metadata", func() {
|
||||
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
},
|
||||
})
|
||||
mockedMetadata.SetLyrics(nil)
|
||||
response, err := router.GetLyrics(r)
|
||||
if err != nil {
|
||||
log.Error("You're missing something.", err)
|
||||
}
|
||||
Expect(err).To(BeNil())
|
||||
Expect(response.Lyrics.Artist).To(Equal(""))
|
||||
Expect(response.Lyrics.Title).To(Equal(""))
|
||||
Expect(response.Lyrics.Value).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getLyricsBySongId", func() {
|
||||
|
@ -246,6 +302,68 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should get lyrics from external metadata", func() {
|
||||
r := newGetRequest("id=1")
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
},
|
||||
})
|
||||
mockedMetadata.SetLyrics(model.LyricList{
|
||||
{
|
||||
DisplayArtist: "Artist",
|
||||
DisplayTitle: "Title",
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{Start: P(int64(0)), Value: "Line 1"},
|
||||
{Start: P(int64(5210)), Value: "Line 2"},
|
||||
{Start: P(int64(12450)), Value: "Line 5"},
|
||||
},
|
||||
Offset: P(int64(100)),
|
||||
Synced: true,
|
||||
},
|
||||
})
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Artist",
|
||||
DisplayTitle: "Title",
|
||||
Lang: "xxx",
|
||||
Line: []responses.Line{
|
||||
{Start: P(int64(0)), Value: "Line 1"},
|
||||
{Start: P(int64(5210)), Value: "Line 2"},
|
||||
{Start: P(int64(12450)), Value: "Line 5"},
|
||||
},
|
||||
Offset: P(int64(100)),
|
||||
Synced: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should get have no lyrics if external metadata returns nil", func() {
|
||||
r := newGetRequest("id=1")
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: "[]",
|
||||
},
|
||||
})
|
||||
mockedMetadata.SetLyrics(nil)
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
[00:00.00]Line 1
|
||||
[00:05.21]Line 2
|
||||
[00:12.45]Line 3
|
|
@ -0,0 +1 @@
|
|||
This is an artist biography
|
|
@ -0,0 +1 @@
|
|||
{"id":3396226,"trackName":"I Want to Live","artistName":"Borislav Slavov","albumName": "Baldur's Gate 3 (Original Game Soundtrack)","duration": 233, "instrumental":true,"plainLyrics":"","syncedLyrics": ""}
|
|
@ -0,0 +1 @@
|
|||
{"id":3396226,"trackName":"I Want to Live","artistName":"Borislav Slavov","albumName": "Baldur's Gate 3 (Original Game Soundtrack)","duration": 233, "instrumental":false,"plainLyrics":"I feel your breath upon my neck...\nThe clock won't stop and this is what we get\n","syncedLyrics": "[00:17.12] I feel your breath upon my neck...\n[03:20.31] The clock won't stop and this is what we get\n"}
|
|
@ -0,0 +1,6 @@
|
|||
[ti:Title]
|
||||
[ar:Artist]
|
||||
[offset:100]
|
||||
[00:00.00]Line 1
|
||||
[00:05.21]Line 2
|
||||
[00:12.45]Line 5
|
|
@ -0,0 +1,3 @@
|
|||
Unsynchronized lyric line 1
|
||||
Unsynchronized lyric line 2
|
||||
Unsynchronized lyric line 3
|
|
@ -0,0 +1,106 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMockMetadata = errors.New("mock metadata error")
|
||||
)
|
||||
|
||||
type MockExternalMetadata struct {
|
||||
album *model.Album
|
||||
artist *model.Artist
|
||||
err bool
|
||||
lyrics model.LyricList
|
||||
mf model.MediaFiles
|
||||
url *url.URL
|
||||
}
|
||||
|
||||
func CreateMockExternalMetadata() *MockExternalMetadata {
|
||||
return &MockExternalMetadata{}
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) SetAlbum(album *model.Album) {
|
||||
m.album = album
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) SetArtist(artist *model.Artist) {
|
||||
m.artist = artist
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) SetLyrics(lyrics model.LyricList) {
|
||||
m.lyrics = lyrics
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) SetMediaFiles(mf model.MediaFiles) {
|
||||
m.mf = mf
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) SetUrl(url *url.URL) {
|
||||
m.url = url
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
if m.err {
|
||||
return nil, ErrMockMetadata
|
||||
}
|
||||
|
||||
return m.url, nil
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
if m.err {
|
||||
return nil, ErrMockMetadata
|
||||
}
|
||||
|
||||
return m.url, nil
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) ExternalLyrics(ctx context.Context, id string) (model.LyricList, error) {
|
||||
if m.err {
|
||||
return nil, ErrMockMetadata
|
||||
}
|
||||
|
||||
return m.lyrics, nil
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
if m.err {
|
||||
return nil, ErrMockMetadata
|
||||
}
|
||||
|
||||
return m.mf, nil
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) {
|
||||
if m.err {
|
||||
return nil, ErrMockMetadata
|
||||
}
|
||||
|
||||
return m.mf, nil
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
||||
if m.err {
|
||||
return nil, ErrMockMetadata
|
||||
}
|
||||
|
||||
return m.album, nil
|
||||
}
|
||||
|
||||
func (m *MockExternalMetadata) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) {
|
||||
if m.err {
|
||||
return nil, ErrMockMetadata
|
||||
}
|
||||
|
||||
return m.artist, nil
|
||||
}
|
Loading…
Reference in New Issue