diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index b3e89a9dc..521d240e6 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -64,7 +64,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { } chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.httpClient = chc - l.client = newClient(l.apiKey, l.secret, chc) + l.client = newClient(l.apiKey, l.secret, conf.Server.LastFM.BaseURL, chc) return l } diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94788b8bd..9aefc60e1 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -72,7 +72,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) + client := newClient("API_KEY", "SECRET", "", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -114,7 +114,7 @@ var _ = Describe("lastfmAgent", func() { 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) + agent.client = newClient("API_KEY", "SECRET", "", httpClient) // Portuguese biography available f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") @@ -131,7 +131,7 @@ var _ = Describe("lastfmAgent", func() { 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) + 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") @@ -152,7 +152,7 @@ var _ = Describe("lastfmAgent", func() { 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) + 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") @@ -179,7 +179,7 @@ var _ = Describe("lastfmAgent", func() { 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) + 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") @@ -201,7 +201,7 @@ var _ = Describe("lastfmAgent", func() { 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) + agent.client = newClient("API_KEY", "SECRET", "", httpClient) // Both languages return album without description fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") @@ -224,7 +224,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) + client := newClient("API_KEY", "SECRET", "", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -262,7 +262,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) + client := newClient("API_KEY", "SECRET", "", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -300,7 +300,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) + client := newClient("API_KEY", "SECRET", "", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -350,7 +350,7 @@ var _ = Describe("lastfmAgent", func() { BeforeEach(func() { _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) + client := newClient("API_KEY", "SECRET", "", httpClient) agent = lastFMConstructor(ds) agent.client = client track = &model.MediaFile{ @@ -524,7 +524,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) + client := newClient("API_KEY", "SECRET", "", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -594,7 +594,7 @@ var _ = Describe("lastfmAgent", func() { BeforeEach(func() { apiClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", apiClient) + client := newClient("API_KEY", "SECRET", "", apiClient) agent = lastFMConstructor(ds) agent.client = client agent.httpClient = httpClient diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 162ae9037..769196273 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -31,6 +31,7 @@ type Router struct { client *client apiKey string secret string + authURL string } func NewRouter(ds model.DataStore) *Router { @@ -38,13 +39,14 @@ func NewRouter(ds model.DataStore) *Router { ds: ds, apiKey: conf.Server.LastFM.ApiKey, secret: conf.Server.LastFM.Secret, + authURL: conf.Server.LastFM.AuthURL, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() hc := &http.Client{ Timeout: consts.DefaultHttpClientTimeOut, } - r.client = newClient(r.apiKey, r.secret, hc) + r.client = newClient(r.apiKey, r.secret, conf.Server.LastFM.BaseURL, hc) return r } @@ -66,7 +68,8 @@ func (s *Router) routes() http.Handler { func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ - "apiKey": s.apiKey, + "apiKey": s.apiKey, + "authUrl": s.authURL, } u, _ := request.UserFrom(r.Context()) key, err := s.sessionKeys.Get(r.Context(), u.ID) diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index 726df1360..ec856f7a4 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -14,13 +14,10 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" ) -const ( - apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" -) - type lastFMError struct { Code int Message string @@ -34,14 +31,15 @@ type httpDoer interface { Do(req *http.Request) (*http.Response, error) } -func newClient(apiKey string, secret string, hc httpDoer) *client { - return &client{apiKey, secret, hc} +func newClient(apiKey string, secret string, baseURL string, hc httpDoer) *client { + return &client{apiKey, secret, normalizeLastFMBaseURL(baseURL), hc} } type client struct { - apiKey string - secret string - hc httpDoer + apiKey string + secret string + baseURL string + hc httpDoer } func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) { @@ -200,10 +198,10 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu var req *http.Request if method == http.MethodPost { body := strings.NewReader(params.Encode()) - req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, body) + req, _ = http.NewRequestWithContext(ctx, method, c.baseURL, body) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { - req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil) + req, _ = http.NewRequestWithContext(ctx, method, c.baseURL, nil) req.URL.RawQuery = params.Encode() } @@ -231,6 +229,17 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu return &response, nil } +func normalizeLastFMBaseURL(baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return consts.DefaultLastFMBaseURL + } + if !strings.HasSuffix(baseURL, "/") { + return baseURL + "/" + } + return baseURL +} + func (c *client) sign(params url.Values) { // the parameters must be in order before hashing keys := make([]string, 0, len(params)) diff --git a/adapters/lastfm/client_test.go b/adapters/lastfm/client_test.go index 271ae1419..486f5ab1f 100644 --- a/adapters/lastfm/client_test.go +++ b/adapters/lastfm/client_test.go @@ -11,6 +11,7 @@ import ( "net/url" "os" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -22,7 +23,7 @@ var _ = Describe("client", func() { BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client = newClient("API_KEY", "SECRET", httpClient) + client = newClient("API_KEY", "SECRET", "", httpClient) }) Describe("albumGetInfo", func() { @@ -33,7 +34,7 @@ var _ = Describe("client", func() { album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt") Expect(err).To(BeNil()) Expect(album.Name).To(Equal("Believe")) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo")) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(consts.DefaultLastFMBaseURL + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo")) }) }) @@ -45,7 +46,7 @@ var _ = Describe("client", func() { artist, err := client.artistGetInfo(context.Background(), "U2", "pt") Expect(err).To(BeNil()) Expect(artist.Name).To(Equal("U2")) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo")) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(consts.DefaultLastFMBaseURL + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo")) }) It("fails if Last.fm returns an http status != 200", func() { @@ -105,7 +106,7 @@ var _ = Describe("client", func() { similar, err := client.artistGetSimilar(context.Background(), "U2", 2) Expect(err).To(BeNil()) Expect(len(similar.Artists)).To(Equal(2)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar")) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(consts.DefaultLastFMBaseURL + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar")) }) }) @@ -117,7 +118,7 @@ var _ = Describe("client", func() { top, err := client.artistGetTopTracks(context.Background(), "U2", 2) Expect(err).To(BeNil()) Expect(len(top.Track)).To(Equal(2)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks")) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(consts.DefaultLastFMBaseURL + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks")) }) }) @@ -132,7 +133,7 @@ var _ = Describe("client", func() { Expect(similar.Track[0].Name).To(Equal("Dreaming of Me")) Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode")) Expect(similar.Track[0].Match).To(Equal(1.0)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough")) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(consts.DefaultLastFMBaseURL + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough")) }) It("returns empty list when no similar tracks found", func() { diff --git a/conf/configuration.go b/conf/configuration.go index 1c4829d82..d52931d0b 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -185,6 +185,8 @@ type lastfmOptions struct { Enabled bool ApiKey string //nolint:gosec Secret string //nolint:gosec + BaseURL string + AuthURL string Language string ScrobbleFirstArtistOnly bool @@ -822,6 +824,8 @@ func setViperDefaults() { viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.secret", "") + viper.SetDefault("lastfm.baseurl", consts.DefaultLastFMBaseURL) + viper.SetDefault("lastfm.authurl", consts.DefaultLastFMAuthURL) viper.SetDefault("lastfm.scrobblefirstartistonly", false) viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) diff --git a/consts/consts.go b/consts/consts.go index bf32006d6..35ca8638e 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -75,6 +75,8 @@ const ( DefaultUIPlaybackReportInterval = time.Minute DefaultHttpClientTimeOut = 10 * time.Second + DefaultLastFMBaseURL = "https://ws.audioscrobbler.com/2.0/" + DefaultLastFMAuthURL = "https://www.last.fm/api/auth/" DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/" DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index 67018d2bb..883efe86d 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -12,8 +12,15 @@ import { useInterval } from '../common' import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' +const buildAuthUrl = (authUrl, apiKey, callbackUrl) => { + const url = new URL(authUrl) + url.searchParams.set('api_key', apiKey) + url.searchParams.set('cb', callbackUrl) + return url.toString() +} + const Progress = (props) => { - const { setLinked, setCheckingLink, apiKey } = props + const { setLinked, setCheckingLink, apiKey, authUrl } = props const notify = useNotify() let linkCheckDelay = 2000 let linkChecks = 30 @@ -24,10 +31,16 @@ const Progress = (props) => { `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`, ) const callbackUrl = `${window.location.origin}${callbackEndpoint}` - openedTab.current = openInNewTab( - `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`, - ) - }, [apiKey]) + try { + openedTab.current = openInNewTab( + buildAuthUrl(authUrl, apiKey, callbackUrl), + ) + } catch { + setCheckingLink(false) + setLinked(false) + notify('message.lastfmLinkFailure', 'warning') + } + }, [apiKey, authUrl, notify, setCheckingLink, setLinked]) const endChecking = (success) => { linkCheckDelay = null @@ -76,12 +89,14 @@ export const LastfmScrobbleToggle = (props) => { const [linked, setLinked] = useState(null) const [checkingLink, setCheckingLink] = useState(false) const [apiKey, setApiKey] = useState(false) + const [authUrl, setAuthUrl] = useState(null) useEffect(() => { httpClient('/api/lastfm/link') .then((response) => { setLinked(response.json.status === true) setApiKey(response.json.apiKey) + setAuthUrl(response.json.authUrl || null) }) .catch(() => { setLinked(false) @@ -90,6 +105,10 @@ export const LastfmScrobbleToggle = (props) => { const toggleScrobble = () => { if (!linked) { + if (!authUrl) { + notify('message.lastfmLinkFailure', 'warning') + return + } setCheckingLink(true) } else { httpClient('/api/lastfm/link', { method: 'DELETE' }) @@ -109,7 +128,7 @@ export const LastfmScrobbleToggle = (props) => { id={'lastfm'} color="primary" checked={linked || checkingLink} - disabled={!apiKey || linked === null || checkingLink} + disabled={!apiKey || !authUrl || linked === null || checkingLink} onChange={toggleScrobble} /> } @@ -122,6 +141,7 @@ export const LastfmScrobbleToggle = (props) => { setLinked={setLinked} setCheckingLink={setCheckingLink} apiKey={apiKey} + authUrl={authUrl} /> )} {!apiKey && (