mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-02 00:17:49 +01:00
GetArtistImages scrapes the og:image tag off the Last.fm artist page, because the API only ever returns the placeholder image. Last.fm now answers non-browser clients with a Fastly bot challenge, served as a 200 with valid HTML, so the query found no og:image and the agent returned an empty list with no error. The artwork worker read that as a definitive "this artist has no image" and settled the state as absent, silently and with nothing in the log. A real artist page always carries an og:image, so its absence now returns an error instead. The worker keeps the previous state, other agents still get their turn, and its per-agent circuit breaker bounds the retries. The error is deliberately not a RetryLaterError: that would park the whole Last.fm agent, including the API-backed biography, similar-artists and top-songs calls, which the page block does not affect. The new fixture is the real 3038-byte challenge page. Fixes #6192
731 lines
30 KiB
Go
731 lines
30 KiB
Go
package lastfm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
const (
|
|
lastfmError3 = `{"error":3,"message":"Invalid Method - No method with that name in this package","links":[]}`
|
|
lastfmError6 = `{"error":6,"message":"The artist you supplied could not be found","links":[]}`
|
|
)
|
|
|
|
var _ = Describe("lastfmAgent", func() {
|
|
var ds model.DataStore
|
|
var ctx context.Context
|
|
BeforeEach(func() {
|
|
ds = &tests.MockDataStore{}
|
|
ctx = context.Background()
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.LastFM.Enabled = true
|
|
conf.Server.LastFM.ApiKey = "123"
|
|
conf.Server.LastFM.Secret = "secret"
|
|
})
|
|
Describe("lastFMConstructor", func() {
|
|
When("Agent is properly configured", func() {
|
|
It("uses configured api key and languages", func() {
|
|
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
|
agent := lastFMConstructor(ds)
|
|
Expect(agent.apiKey).To(Equal("123"))
|
|
Expect(agent.secret).To(Equal("secret"))
|
|
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
|
})
|
|
})
|
|
When("Agent is disabled", func() {
|
|
It("returns nil", func() {
|
|
conf.Server.LastFM.Enabled = false
|
|
Expect(lastFMConstructor(ds)).To(BeNil())
|
|
})
|
|
})
|
|
When("ApiKey is empty", func() {
|
|
It("returns nil", func() {
|
|
conf.Server.LastFM.ApiKey = ""
|
|
Expect(lastFMConstructor(ds)).To(BeNil())
|
|
})
|
|
})
|
|
When("Secret is empty", func() {
|
|
It("returns nil", func() {
|
|
conf.Server.LastFM.Secret = ""
|
|
Expect(lastFMConstructor(ds)).To(BeNil())
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("GetArtistBiography", func() {
|
|
var agent *lastfmAgent
|
|
var httpClient *tests.FakeHttpClient
|
|
BeforeEach(func() {
|
|
httpClient = &tests.FakeHttpClient{}
|
|
client := newClient("API_KEY", "SECRET", httpClient)
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = client
|
|
})
|
|
|
|
It("returns the biography", func() {
|
|
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
|
|
It("returns an error if Last.fm call fails", func() {
|
|
httpClient.Err = errors.New("error")
|
|
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
|
|
It("returns an error if Last.fm call returns an error", func() {
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
|
|
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
|
httpClient.Res = http.Response{
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
|
StatusCode: 200,
|
|
}
|
|
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
|
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Describe("Language Fallback", func() {
|
|
Describe("GetArtistBiography", func() {
|
|
var agent *lastfmAgent
|
|
var httpClient *langAwareHttpClient
|
|
|
|
BeforeEach(func() {
|
|
httpClient = newLangAwareHttpClient()
|
|
})
|
|
|
|
It("returns content in first language when available (1 API call)", func() {
|
|
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
|
|
// Portuguese biography available
|
|
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
|
|
|
|
bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
|
|
Expect(httpClient.requestCount).To(Equal(1))
|
|
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
|
|
})
|
|
|
|
It("falls back to second language when first returns empty (2 API calls)", func() {
|
|
conf.Server.LastFM.Languages = []string{"ja", "en"}
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
|
|
// Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
|
|
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
|
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
|
// English returns full biography
|
|
fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
|
|
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
|
|
|
|
bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
|
|
Expect(httpClient.requestCount).To(Equal(2))
|
|
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
|
|
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
|
|
})
|
|
|
|
It("returns ErrNotFound when all languages return empty", func() {
|
|
conf.Server.LastFM.Languages = []string{"ja", "xx"}
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
|
|
// Both languages return empty/ignored biography (using actual Last.fm response format)
|
|
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
|
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
|
// Second language also returns empty
|
|
fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
|
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
|
|
|
|
_, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
|
|
|
|
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
Expect(httpClient.requestCount).To(Equal(2))
|
|
})
|
|
})
|
|
|
|
Describe("GetAlbumInfo", func() {
|
|
var agent *lastfmAgent
|
|
var httpClient *langAwareHttpClient
|
|
|
|
BeforeEach(func() {
|
|
httpClient = newLangAwareHttpClient()
|
|
})
|
|
|
|
It("falls back to second language when first returns empty description (2 API calls)", func() {
|
|
conf.Server.LastFM.Languages = []string{"ja", "en"}
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
|
|
// Japanese returns album without wiki/description (actual Last.fm response)
|
|
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
|
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
|
// English returns album with description
|
|
fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
|
|
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
|
|
|
|
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(albumInfo.Name).To(Equal("Dois"))
|
|
Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
|
|
Expect(httpClient.requestCount).To(Equal(2))
|
|
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
|
|
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
|
|
})
|
|
|
|
It("returns album without description when all languages return empty", func() {
|
|
conf.Server.LastFM.Languages = []string{"ja", "xx"}
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
|
|
// Both languages return album without description
|
|
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
|
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
|
fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
|
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
|
|
|
|
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(albumInfo.Name).To(Equal("Dois"))
|
|
Expect(albumInfo.Description).To(BeEmpty())
|
|
Expect(httpClient.requestCount).To(Equal(2))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("GetSimilarArtists", func() {
|
|
var agent *lastfmAgent
|
|
var httpClient *tests.FakeHttpClient
|
|
BeforeEach(func() {
|
|
httpClient = &tests.FakeHttpClient{}
|
|
client := newClient("API_KEY", "SECRET", httpClient)
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = client
|
|
})
|
|
|
|
It("returns similar artists", func() {
|
|
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
Expect(agent.GetSimilarArtists(ctx, "123", "U2", "", 2)).To(Equal([]agents.Artist{
|
|
{Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
|
|
{Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
|
|
}))
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
|
|
It("returns an error if Last.fm call fails", func() {
|
|
httpClient.Err = errors.New("error")
|
|
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
|
|
It("returns an error if Last.fm call returns an error", func() {
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
})
|
|
|
|
Describe("GetArtistTopSongs", func() {
|
|
var agent *lastfmAgent
|
|
var httpClient *tests.FakeHttpClient
|
|
BeforeEach(func() {
|
|
httpClient = &tests.FakeHttpClient{}
|
|
client := newClient("API_KEY", "SECRET", httpClient)
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = client
|
|
})
|
|
|
|
It("returns top songs", func() {
|
|
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)).To(Equal([]agents.Song{
|
|
{Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
|
|
{Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
|
|
}))
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
|
|
It("returns an error if Last.fm call fails", func() {
|
|
httpClient.Err = errors.New("error")
|
|
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
|
|
It("returns an error if Last.fm call returns an error", func() {
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
})
|
|
})
|
|
|
|
Describe("GetSimilarSongsByTrack", func() {
|
|
var agent *lastfmAgent
|
|
var httpClient *tests.FakeHttpClient
|
|
BeforeEach(func() {
|
|
httpClient = &tests.FakeHttpClient{}
|
|
client := newClient("API_KEY", "SECRET", httpClient)
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = client
|
|
})
|
|
|
|
It("returns similar songs", func() {
|
|
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
|
|
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
|
|
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
|
|
{Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}},
|
|
{Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}},
|
|
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}},
|
|
}))
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
|
|
})
|
|
|
|
It("returns ErrNotFound when no similar songs found", func() {
|
|
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
|
|
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
})
|
|
|
|
It("returns an error if Last.fm call fails", func() {
|
|
httpClient.Err = errors.New("error")
|
|
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
})
|
|
|
|
It("returns an error if Last.fm call returns an error", func() {
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
Describe("Scrobbling", func() {
|
|
var agent *lastfmAgent
|
|
var httpClient *tests.FakeHttpClient
|
|
var track *model.MediaFile
|
|
BeforeEach(func() {
|
|
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
|
httpClient = &tests.FakeHttpClient{}
|
|
client := newClient("API_KEY", "SECRET", httpClient)
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = client
|
|
track = &model.MediaFile{
|
|
ID: "123",
|
|
Title: "Track Title",
|
|
Album: "Track Album",
|
|
Artist: "Track Artist",
|
|
AlbumArtist: "Track AlbumArtist",
|
|
TrackNumber: 1,
|
|
Duration: 180,
|
|
MbzRecordingID: "mbz-123",
|
|
Participants: map[model.Role]model.ParticipantList{
|
|
model.RoleArtist: []model.Participant{
|
|
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
|
|
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
|
|
},
|
|
model.RoleAlbumArtist: []model.Participant{
|
|
{Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
|
|
{Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
|
|
},
|
|
},
|
|
}
|
|
})
|
|
|
|
Describe("NowPlaying", func() {
|
|
It("calls Last.fm with correct params", func() {
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
|
|
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
|
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
sentParams, _ := url.ParseQuery(string(body))
|
|
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
|
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
|
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
|
Expect(sentParams.Get("album")).To(Equal(track.Album))
|
|
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
|
|
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
|
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
|
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
|
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
|
|
})
|
|
|
|
It("returns ErrNotAuthorized if user is not linked", func() {
|
|
err := agent.NowPlaying(ctx, "user-2", track, 0)
|
|
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
|
})
|
|
|
|
When("ScrobbleFirstArtistOnly is true", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
|
})
|
|
|
|
It("uses only the first artist", func() {
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
|
|
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
sentParams, _ := url.ParseQuery(string(body))
|
|
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
|
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("scrobble", func() {
|
|
It("calls Last.fm with correct params", func() {
|
|
ts := time.Now()
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
|
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
|
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
sentParams, _ := url.ParseQuery(string(body))
|
|
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
|
|
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
|
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
|
Expect(sentParams.Get("album")).To(Equal(track.Album))
|
|
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
|
|
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
|
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
|
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
|
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
|
|
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
|
|
})
|
|
|
|
When("ScrobbleFirstArtistOnly is true", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
|
})
|
|
|
|
It("uses only the first artist", func() {
|
|
ts := time.Now()
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
|
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
sentParams, _ := url.ParseQuery(string(body))
|
|
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
|
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
|
})
|
|
})
|
|
|
|
It("skips songs with less than 31 seconds", func() {
|
|
track.Duration = 29
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
|
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(httpClient.SavedRequest).To(BeNil())
|
|
})
|
|
|
|
It("returns ErrNotAuthorized if user is not linked", func() {
|
|
err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
|
})
|
|
|
|
It("returns ErrRetryLater on error 11", func() {
|
|
httpClient.Res = http.Response{
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)),
|
|
StatusCode: 400,
|
|
}
|
|
|
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
})
|
|
|
|
It("returns ErrRetryLater on error 16", func() {
|
|
httpClient.Res = http.Response{
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)),
|
|
StatusCode: 400,
|
|
}
|
|
|
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
})
|
|
|
|
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
|
httpClient.Res = http.Response{
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
|
StatusCode: 200,
|
|
}
|
|
|
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
|
})
|
|
|
|
It("returns ErrRetryLater on http errors", func() {
|
|
httpClient.Res = http.Response{
|
|
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
|
StatusCode: 500,
|
|
}
|
|
|
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
})
|
|
|
|
It("returns ErrUnrecoverable on other errors", func() {
|
|
httpClient.Res = http.Response{
|
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)),
|
|
StatusCode: 400,
|
|
}
|
|
|
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("GetAlbumInfo", func() {
|
|
var agent *lastfmAgent
|
|
var httpClient *tests.FakeHttpClient
|
|
BeforeEach(func() {
|
|
httpClient = &tests.FakeHttpClient{}
|
|
client := newClient("API_KEY", "SECRET", httpClient)
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = client
|
|
})
|
|
|
|
It("returns the biography", func() {
|
|
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
|
|
Name: "Believe",
|
|
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
|
|
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
|
|
URL: "https://www.last.fm/music/Cher/Believe",
|
|
}))
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
// MBID is deliberately not sent — album.getInfo matches on name+artist only.
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe"))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher"))
|
|
})
|
|
|
|
It("returns empty images if no images are available", func() {
|
|
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json")
|
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{
|
|
Name: "The Definitive Less Damage And More Joy",
|
|
URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
|
|
}))
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))
|
|
})
|
|
|
|
It("returns an error if Last.fm call fails", func() {
|
|
httpClient.Err = errors.New("error")
|
|
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
|
})
|
|
|
|
It("returns an error if Last.fm call returns an error", func() {
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
|
})
|
|
|
|
It("returns an error when Last.fm returns an error 6 (album not found)", func() {
|
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
|
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
|
Expect(err).To(HaveOccurred())
|
|
// A definitive not-found must satisfy the sentinel, or the artwork worker retries it.
|
|
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
|
Expect(httpClient.RequestCount).To(Equal(1))
|
|
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("GetArtistImages", func() {
|
|
var agent *lastfmAgent
|
|
var apiClient *tests.FakeHttpClient
|
|
var httpClient *tests.FakeHttpClient
|
|
|
|
BeforeEach(func() {
|
|
apiClient = &tests.FakeHttpClient{}
|
|
httpClient = &tests.FakeHttpClient{}
|
|
client := newClient("API_KEY", "SECRET", apiClient)
|
|
agent = lastFMConstructor(ds)
|
|
agent.client = client
|
|
agent.httpClient = httpClient
|
|
})
|
|
|
|
It("returns the artist image from the page", func() {
|
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
|
|
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
|
|
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
|
|
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(images).To(HaveLen(1))
|
|
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
|
|
})
|
|
|
|
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
|
|
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
|
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
|
|
// Not a fault: runs of missing artists must not trip the worker's circuit breaker.
|
|
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
|
})
|
|
|
|
It("returns empty list if image is the ignored default image", func() {
|
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
|
|
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
|
|
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
|
|
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(images).To(BeEmpty())
|
|
})
|
|
|
|
It("errors when the page has no meta tags", func() {
|
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
|
|
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
|
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
|
|
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
Expect(err).To(MatchError(errNoArtistPage))
|
|
})
|
|
|
|
It("errors when Last.fm serves a bot challenge page", func() {
|
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
|
|
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
|
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
|
|
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
Expect(err).To(MatchError(errNoArtistPage))
|
|
Expect(images).To(BeEmpty())
|
|
})
|
|
|
|
It("does not park the agent: the failure is not a retry-later", func() {
|
|
// A RetryLaterError would cool down the agent's API-backed methods too.
|
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
|
|
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
|
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
|
|
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
|
|
})
|
|
|
|
It("returns error if API call fails", func() {
|
|
apiClient.Err = errors.New("api error")
|
|
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("get artist info"))
|
|
})
|
|
|
|
It("returns error if scraper call fails", func() {
|
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
|
|
httpClient.Err = errors.New("scraper error")
|
|
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("get artist url"))
|
|
})
|
|
})
|
|
})
|
|
|
|
// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
|
|
type langAwareHttpClient struct {
|
|
responses map[string]http.Response
|
|
requests []*http.Request
|
|
requestCount int
|
|
}
|
|
|
|
func newLangAwareHttpClient() *langAwareHttpClient {
|
|
return &langAwareHttpClient{
|
|
responses: make(map[string]http.Response),
|
|
requests: make([]*http.Request, 0),
|
|
}
|
|
}
|
|
|
|
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
|
|
c.requestCount++
|
|
c.requests = append(c.requests, req)
|
|
lang := req.URL.Query().Get("lang")
|
|
if resp, ok := c.responses[lang]; ok {
|
|
return &resp, nil
|
|
}
|
|
// Return default empty response if no specific response is configured
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
|
|
}, nil
|
|
}
|