Merge 5c5cae09705842e4edbebca8f0518e2d3e378346 into 13c48b38a0737236b79af02b4a7bd42cb6ee1b27

This commit is contained in:
maybe developer 2026-05-02 22:33:35 +04:00 committed by GitHub
commit 0d3820ad92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 77 additions and 38 deletions

View File

@ -64,7 +64,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
} }
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc 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 return l
} }

View File

@ -72,7 +72,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient) client := newClient("API_KEY", "SECRET", "", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@ -114,7 +114,7 @@ var _ = Describe("lastfmAgent", func() {
It("returns content in first language when available (1 API call)", func() { It("returns content in first language when available (1 API call)", func() {
conf.Server.LastFM.Languages = []string{"pt", "en"} conf.Server.LastFM.Languages = []string{"pt", "en"}
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient) agent.client = newClient("API_KEY", "SECRET", "", httpClient)
// Portuguese biography available // Portuguese biography available
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") 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() { It("falls back to second language when first returns empty (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"} conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds) 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) // Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json") 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() { It("returns ErrNotFound when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"} conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds) 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) // Both languages return empty/ignored biography (using actual Last.fm response format)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json") 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() { It("falls back to second language when first returns empty description (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"} conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds) 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) // Japanese returns album without wiki/description (actual Last.fm response)
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") 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() { It("returns album without description when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"} conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient) agent.client = newClient("API_KEY", "SECRET", "", httpClient)
// Both languages return album without description // Both languages return album without description
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
@ -224,7 +224,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient) client := newClient("API_KEY", "SECRET", "", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@ -262,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient) client := newClient("API_KEY", "SECRET", "", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@ -300,7 +300,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient) client := newClient("API_KEY", "SECRET", "", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@ -350,7 +350,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() { BeforeEach(func() {
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient) client := newClient("API_KEY", "SECRET", "", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
track = &model.MediaFile{ track = &model.MediaFile{
@ -524,7 +524,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient) client := newClient("API_KEY", "SECRET", "", httpClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
}) })
@ -594,7 +594,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() { BeforeEach(func() {
apiClient = &tests.FakeHttpClient{} apiClient = &tests.FakeHttpClient{}
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", apiClient) client := newClient("API_KEY", "SECRET", "", apiClient)
agent = lastFMConstructor(ds) agent = lastFMConstructor(ds)
agent.client = client agent.client = client
agent.httpClient = httpClient agent.httpClient = httpClient

View File

@ -31,6 +31,7 @@ type Router struct {
client *client client *client
apiKey string apiKey string
secret string secret string
authURL string
} }
func NewRouter(ds model.DataStore) *Router { func NewRouter(ds model.DataStore) *Router {
@ -38,13 +39,14 @@ func NewRouter(ds model.DataStore) *Router {
ds: ds, ds: ds,
apiKey: conf.Server.LastFM.ApiKey, apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret, secret: conf.Server.LastFM.Secret,
authURL: conf.Server.LastFM.AuthURL,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
} }
r.Handler = r.routes() r.Handler = r.routes()
hc := &http.Client{ hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut, 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 return r
} }
@ -66,7 +68,8 @@ func (s *Router) routes() http.Handler {
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{ resp := map[string]any{
"apiKey": s.apiKey, "apiKey": s.apiKey,
"authUrl": s.authURL,
} }
u, _ := request.UserFrom(r.Context()) u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID) key, err := s.sessionKeys.Get(r.Context(), u.ID)

View File

@ -14,13 +14,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
) )
const (
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
)
type lastFMError struct { type lastFMError struct {
Code int Code int
Message string Message string
@ -34,14 +31,15 @@ type httpDoer interface {
Do(req *http.Request) (*http.Response, error) Do(req *http.Request) (*http.Response, error)
} }
func newClient(apiKey string, secret string, hc httpDoer) *client { func newClient(apiKey string, secret string, baseURL string, hc httpDoer) *client {
return &client{apiKey, secret, hc} return &client{apiKey, secret, normalizeLastFMBaseURL(baseURL), hc}
} }
type client struct { type client struct {
apiKey string apiKey string
secret string secret string
hc httpDoer baseURL string
hc httpDoer
} }
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) { 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 var req *http.Request
if method == http.MethodPost { if method == http.MethodPost {
body := strings.NewReader(params.Encode()) 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") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else { } else {
req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil) req, _ = http.NewRequestWithContext(ctx, method, c.baseURL, nil)
req.URL.RawQuery = params.Encode() req.URL.RawQuery = params.Encode()
} }
@ -231,6 +229,17 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
return &response, nil 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) { func (c *client) sign(params url.Values) {
// the parameters must be in order before hashing // the parameters must be in order before hashing
keys := make([]string, 0, len(params)) keys := make([]string, 0, len(params))

View File

@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@ -22,7 +23,7 @@ var _ = Describe("client", func() {
BeforeEach(func() { BeforeEach(func() {
httpClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{}
client = newClient("API_KEY", "SECRET", httpClient) client = newClient("API_KEY", "SECRET", "", httpClient)
}) })
Describe("albumGetInfo", func() { Describe("albumGetInfo", func() {
@ -33,7 +34,7 @@ var _ = Describe("client", func() {
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt") album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(album.Name).To(Equal("Believe")) 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") artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(artist.Name).To(Equal("U2")) 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() { 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) similar, err := client.artistGetSimilar(context.Background(), "U2", 2)
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(len(similar.Artists)).To(Equal(2)) 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) top, err := client.artistGetTopTracks(context.Background(), "U2", 2)
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(len(top.Track)).To(Equal(2)) 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].Name).To(Equal("Dreaming of Me"))
Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode")) Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
Expect(similar.Track[0].Match).To(Equal(1.0)) 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() { It("returns empty list when no similar tracks found", func() {

View File

@ -185,6 +185,8 @@ type lastfmOptions struct {
Enabled bool Enabled bool
ApiKey string //nolint:gosec ApiKey string //nolint:gosec
Secret string //nolint:gosec Secret string //nolint:gosec
BaseURL string
AuthURL string
Language string Language string
ScrobbleFirstArtistOnly bool ScrobbleFirstArtistOnly bool
@ -822,6 +824,8 @@ func setViperDefaults() {
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "") viper.SetDefault("lastfm.secret", "")
viper.SetDefault("lastfm.baseurl", consts.DefaultLastFMBaseURL)
viper.SetDefault("lastfm.authurl", consts.DefaultLastFMAuthURL)
viper.SetDefault("lastfm.scrobblefirstartistonly", false) viper.SetDefault("lastfm.scrobblefirstartistonly", false)
viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)

View File

@ -75,6 +75,8 @@ const (
DefaultUIPlaybackReportInterval = time.Minute DefaultUIPlaybackReportInterval = time.Minute
DefaultHttpClientTimeOut = 10 * time.Second DefaultHttpClientTimeOut = 10 * time.Second
DefaultLastFMBaseURL = "https://ws.audioscrobbler.com/2.0/"
DefaultLastFMAuthURL = "https://www.last.fm/api/auth/"
DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/" DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/"
DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"

View File

@ -12,8 +12,15 @@ import { useInterval } from '../common'
import { baseUrl, openInNewTab } from '../utils' import { baseUrl, openInNewTab } from '../utils'
import { httpClient } from '../dataProvider' 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 Progress = (props) => {
const { setLinked, setCheckingLink, apiKey } = props const { setLinked, setCheckingLink, apiKey, authUrl } = props
const notify = useNotify() const notify = useNotify()
let linkCheckDelay = 2000 let linkCheckDelay = 2000
let linkChecks = 30 let linkChecks = 30
@ -24,10 +31,16 @@ const Progress = (props) => {
`/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`, `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`,
) )
const callbackUrl = `${window.location.origin}${callbackEndpoint}` const callbackUrl = `${window.location.origin}${callbackEndpoint}`
openedTab.current = openInNewTab( try {
`https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`, openedTab.current = openInNewTab(
) buildAuthUrl(authUrl, apiKey, callbackUrl),
}, [apiKey]) )
} catch {
setCheckingLink(false)
setLinked(false)
notify('message.lastfmLinkFailure', 'warning')
}
}, [apiKey, authUrl, notify, setCheckingLink, setLinked])
const endChecking = (success) => { const endChecking = (success) => {
linkCheckDelay = null linkCheckDelay = null
@ -76,12 +89,14 @@ export const LastfmScrobbleToggle = (props) => {
const [linked, setLinked] = useState(null) const [linked, setLinked] = useState(null)
const [checkingLink, setCheckingLink] = useState(false) const [checkingLink, setCheckingLink] = useState(false)
const [apiKey, setApiKey] = useState(false) const [apiKey, setApiKey] = useState(false)
const [authUrl, setAuthUrl] = useState(null)
useEffect(() => { useEffect(() => {
httpClient('/api/lastfm/link') httpClient('/api/lastfm/link')
.then((response) => { .then((response) => {
setLinked(response.json.status === true) setLinked(response.json.status === true)
setApiKey(response.json.apiKey) setApiKey(response.json.apiKey)
setAuthUrl(response.json.authUrl || null)
}) })
.catch(() => { .catch(() => {
setLinked(false) setLinked(false)
@ -90,6 +105,10 @@ export const LastfmScrobbleToggle = (props) => {
const toggleScrobble = () => { const toggleScrobble = () => {
if (!linked) { if (!linked) {
if (!authUrl) {
notify('message.lastfmLinkFailure', 'warning')
return
}
setCheckingLink(true) setCheckingLink(true)
} else { } else {
httpClient('/api/lastfm/link', { method: 'DELETE' }) httpClient('/api/lastfm/link', { method: 'DELETE' })
@ -109,7 +128,7 @@ export const LastfmScrobbleToggle = (props) => {
id={'lastfm'} id={'lastfm'}
color="primary" color="primary"
checked={linked || checkingLink} checked={linked || checkingLink}
disabled={!apiKey || linked === null || checkingLink} disabled={!apiKey || !authUrl || linked === null || checkingLink}
onChange={toggleScrobble} onChange={toggleScrobble}
/> />
} }
@ -122,6 +141,7 @@ export const LastfmScrobbleToggle = (props) => {
setLinked={setLinked} setLinked={setLinked}
setCheckingLink={setCheckingLink} setCheckingLink={setCheckingLink}
apiKey={apiKey} apiKey={apiKey}
authUrl={authUrl}
/> />
)} )}
{!apiKey && ( {!apiKey && (