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)
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
}

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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() {

View File

@ -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)

View File

@ -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"

View File

@ -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 && (