From 21529cf03132fd59cb529a45874022c8b86a9a19 Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Fri, 27 Mar 2026 17:29:23 +0300 Subject: [PATCH 1/4] feat(lastfm): setup own custom url --- adapters/lastfm/agent.go | 2 +- adapters/lastfm/agent_test.go | 24 +++++++++--------- adapters/lastfm/auth_router.go | 7 ++++-- adapters/lastfm/client.go | 31 +++++++++++++++--------- adapters/lastfm/client_test.go | 13 +++++----- conf/configuration.go | 4 +++ consts/consts.go | 2 ++ package-lock.json | 6 +++++ ui/src/personal/LastfmScrobbleToggle.jsx | 24 +++++++++++++++--- 9 files changed, 78 insertions(+), 35 deletions(-) create mode 100644 package-lock.json 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 5f74d6db0..e0571468f 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -180,6 +180,8 @@ type lastfmOptions struct { Enabled bool ApiKey string //nolint:gosec Secret string //nolint:gosec + BaseURL string + AuthURL string Language string ScrobbleFirstArtistOnly bool @@ -732,6 +734,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 f1010a872..7c1cf511c 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -74,6 +74,8 @@ const ( DefaultUISearchDebounceMs = 200 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/package-lock.json b/package-lock.json new file mode 100644 index 000000000..37e9c9fe2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "navidrome", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index 67018d2bb..f692d8ae0 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -12,8 +12,21 @@ import { useInterval } from '../common' import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' +const defaultAuthUrl = 'https://www.last.fm/api/auth/' + +const buildAuthUrl = (authUrl, apiKey, callbackUrl) => { + try { + const url = new URL(authUrl) + url.searchParams.set('api_key', apiKey) + url.searchParams.set('cb', callbackUrl) + return url.toString() + } catch { + return `${defaultAuthUrl}?api_key=${apiKey}&cb=${callbackUrl}` + } +} + const Progress = (props) => { - const { setLinked, setCheckingLink, apiKey } = props + const { setLinked, setCheckingLink, apiKey, authUrl } = props const notify = useNotify() let linkCheckDelay = 2000 let linkChecks = 30 @@ -25,9 +38,9 @@ const Progress = (props) => { ) const callbackUrl = `${window.location.origin}${callbackEndpoint}` openedTab.current = openInNewTab( - `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`, + buildAuthUrl(authUrl || defaultAuthUrl, apiKey, callbackUrl), ) - }, [apiKey]) + }, [apiKey, authUrl]) const endChecking = (success) => { linkCheckDelay = null @@ -76,12 +89,16 @@ export const LastfmScrobbleToggle = (props) => { const [linked, setLinked] = useState(null) const [checkingLink, setCheckingLink] = useState(false) const [apiKey, setApiKey] = useState(false) + const [authUrl, setAuthUrl] = useState(defaultAuthUrl) useEffect(() => { httpClient('/api/lastfm/link') .then((response) => { setLinked(response.json.status === true) setApiKey(response.json.apiKey) + if (response.json.authUrl) { + setAuthUrl(response.json.authUrl) + } }) .catch(() => { setLinked(false) @@ -122,6 +139,7 @@ export const LastfmScrobbleToggle = (props) => { setLinked={setLinked} setCheckingLink={setCheckingLink} apiKey={apiKey} + authUrl={authUrl} /> )} {!apiKey && ( From 407d737c8ccf37dcf1ee43ce34956a7698bd7268 Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Fri, 27 Mar 2026 18:44:17 +0300 Subject: [PATCH 2/4] chore: whoopsie doopsie --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 37e9c9fe2..000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "navidrome", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From b20a268e0a5b2fb715ea3fc99d6133b61ff7e343 Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Fri, 27 Mar 2026 18:49:37 +0300 Subject: [PATCH 3/4] fix: solve review... --- ui/src/personal/LastfmScrobbleToggle.jsx | 38 +++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index f692d8ae0..e87d3ccbd 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -12,17 +12,11 @@ import { useInterval } from '../common' import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' -const defaultAuthUrl = 'https://www.last.fm/api/auth/' - const buildAuthUrl = (authUrl, apiKey, callbackUrl) => { - try { - const url = new URL(authUrl) - url.searchParams.set('api_key', apiKey) - url.searchParams.set('cb', callbackUrl) - return url.toString() - } catch { - return `${defaultAuthUrl}?api_key=${apiKey}&cb=${callbackUrl}` - } + const url = new URL(authUrl) + url.searchParams.set('api_key', apiKey) + url.searchParams.set('cb', callbackUrl) + return url.toString() } const Progress = (props) => { @@ -37,9 +31,15 @@ const Progress = (props) => { `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`, ) const callbackUrl = `${window.location.origin}${callbackEndpoint}` - openedTab.current = openInNewTab( - buildAuthUrl(authUrl || defaultAuthUrl, apiKey, callbackUrl), - ) + try { + openedTab.current = openInNewTab( + buildAuthUrl(authUrl, apiKey, callbackUrl), + ) + } catch { + setCheckingLink(false) + setLinked(false) + notify('message.lastfmLinkFailure', 'warning') + } }, [apiKey, authUrl]) const endChecking = (success) => { @@ -89,16 +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(defaultAuthUrl) + const [authUrl, setAuthUrl] = useState(null) useEffect(() => { httpClient('/api/lastfm/link') .then((response) => { setLinked(response.json.status === true) setApiKey(response.json.apiKey) - if (response.json.authUrl) { - setAuthUrl(response.json.authUrl) - } + setAuthUrl(response.json.authUrl || null) }) .catch(() => { setLinked(false) @@ -107,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' }) @@ -126,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} /> } From 5c5cae09705842e4edbebca8f0518e2d3e378346 Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Fri, 27 Mar 2026 22:32:41 +0300 Subject: [PATCH 4/4] fix lint --- ui/src/personal/LastfmScrobbleToggle.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index e87d3ccbd..883efe86d 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -40,7 +40,7 @@ const Progress = (props) => { setLinked(false) notify('message.lastfmLinkFailure', 'warning') } - }, [apiKey, authUrl]) + }, [apiKey, authUrl, notify, setCheckingLink, setLinked]) const endChecking = (success) => { linkCheckDelay = null