commit c251f174edc08e071b989b0f32ae3c14ad227b33
Author: Dongho Kim
+ master branch, please reply with all of the information you have about it in order to keep the issue open.
+
+ If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.
+
+ This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
+ stale-pr-message: This PR has been automatically marked as stale because it has not had
+ recent activity. The resources of the Navidrome team are limited, and so we are asking for your help.
+
+ Please check https://github.com/navidrome/navidrome/blob/master/CONTRIBUTING.md#pull-requests and verify that this code contribution fits with the description. If yes, tell it in a comment.
+
+ This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
+ stale-issue-label: 'stale'
+ exempt-issue-labels: 'keep,security'
+ stale-pr-label: 'stale'
+ exempt-pr-labels: 'keep,security'
diff --git a/.github/workflows/update-translations.sh b/.github/workflows/update-translations.sh
new file mode 100755
index 0000000..23d0ef2
--- /dev/null
+++ b/.github/workflows/update-translations.sh
@@ -0,0 +1,93 @@
+#!/bin/sh
+
+set -e
+
+I18N_DIR=resources/i18n
+
+# Function to process JSON: remove empty attributes and sort
+process_json() {
+ jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
+}
+
+# Function to check differences between local and remote translations
+check_lang_diff() {
+ filename=${I18N_DIR}/"$1".json
+ url=$(curl -s -X POST https://poeditor.com/api/ \
+ -d api_token="${POEDITOR_APIKEY}" \
+ -d action="export" \
+ -d id="${POEDITOR_PROJECTID}" \
+ -d language="$1" \
+ -d type="key_value_json" | jq -r .item)
+ if [ -z "$url" ]; then
+ echo "Failed to export $1"
+ return 1
+ fi
+ curl -sSL "$url" > poeditor.json
+
+ process_json "$filename" > "$filename".tmp
+ process_json poeditor.json > poeditor.tmp
+
+ diff=$(diff -u "$filename".tmp poeditor.tmp) || true
+ if [ -n "$diff" ]; then
+ echo "$diff"
+ mv poeditor.json "$filename"
+ fi
+
+ rm -f poeditor.json poeditor.tmp "$filename".tmp
+}
+
+# Function to get the list of languages
+get_language_list() {
+ response=$(curl -s -X POST https://api.poeditor.com/v2/languages/list \
+ -d api_token="${POEDITOR_APIKEY}" \
+ -d id="${POEDITOR_PROJECTID}")
+
+ echo $response
+}
+
+# Function to get the language name from the language code
+get_language_name() {
+ lang_code="$1"
+ lang_list="$2"
+
+ lang_name=$(echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name")
+
+ if [ -z "$lang_name" ]; then
+ echo "Error: Language code '$lang_code' not found" >&2
+ return 1
+ fi
+
+ echo "$lang_name"
+}
+
+# Function to get the language code from the file path
+get_lang_code() {
+ filepath="$1"
+ # Extract just the filename
+ filename=$(basename "$filepath")
+
+ # Remove the extension
+ lang_code="${filename%.*}"
+
+ echo "$lang_code"
+}
+
+lang_list=$(get_language_list)
+
+# Check differences for each language
+for file in ${I18N_DIR}/*.json; do
+ code=$(get_lang_code "$file")
+ lang=$(jq -r .languageName < "$file")
+ lang_name=$(get_language_name "$code" "$lang_list")
+ echo "Downloading $lang_name - $lang ($code)"
+ check_lang_diff "$code"
+done
+
+# List changed languages to stderr
+languages=""
+for file in $(git diff --name-only --exit-code | grep json); do
+ lang_code=$(get_lang_code "$file")
+ lang_name=$(get_language_name "$lang_code" "$lang_list")
+ languages="${languages}$(echo "$lang_name" | tr -d '\n'), "
+done
+echo "${languages%??}" 1>&2
\ No newline at end of file
diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml
new file mode 100644
index 0000000..cc120cb
--- /dev/null
+++ b/.github/workflows/update-translations.yml
@@ -0,0 +1,33 @@
+name: POEditor import
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '0 10 * * *'
+jobs:
+ update-translations:
+ runs-on: ubuntu-latest
+ if: ${{ github.repository_owner == 'navidrome' }}
+ steps:
+ - uses: actions/checkout@v6
+ - name: Get updated translations
+ id: poeditor
+ env:
+ POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
+ POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
+ run: |
+ .github/workflows/update-translations.sh 2> title.tmp
+ title=$(cat title.tmp)
+ echo "::set-output name=title::$title"
+ rm title.tmp
+ - name: Show changes, if any
+ run: |
+ git status --porcelain
+ git diff
+ - name: Create Pull Request
+ uses: peter-evans/create-pull-request@v7
+ with:
+ token: ${{ secrets.PAT }}
+ author: "navidrome-bot
+
+# Navidrome Music Server [](https://twitter.com/intent/tweet?text=Tired%20of%20paying%20for%20music%20subscriptions%2C%20and%20not%20finding%20what%20you%20really%20like%3F%20Roll%20your%20own%20streaming%20service%21&url=https://navidrome.org&via=navidrome)
+
+[](https://github.com/navidrome/navidrome/releases)
+[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
+[](https://github.com/navidrome/navidrome/releases/latest)
+[](https://hub.docker.com/r/deluan/navidrome)
+[](https://discord.gg/xh7j7yF)
+[](https://www.reddit.com/r/navidrome/)
+[](CODE_OF_CONDUCT.md)
+[](https://gurubase.io/g/navidrome)
+
+Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
+music collection from any browser or mobile device. It's like your personal Spotify!
+
+This is a modified version of the [original Navidrome](https://github.com/navidrome/navidrome), enhanced with Meilisearch support.
+
+
+**Note**: The `master` branch may be in an unstable or even broken state during development.
+Please use [releases](https://github.com/navidrome/navidrome/releases) instead of
+the `master` branch in order to get a stable set of binaries.
+
+## [Check out our Live Demo!](https://www.navidrome.org/demo/)
+
+__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
+please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
+[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
+([ui/backend dev](https://www.navidrome.org/docs/developers/),
+[translations](https://www.navidrome.org/docs/developers/translations/),
+[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
+[Discord server](https://discord.gg/xh7j7yF).
+
+## Installation
+
+See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
+
+## Cloud Hosting
+
+[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
+[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
+A share of the revenue helps fund the development of Navidrome at no additional cost for you.
+
+[](https://www.pikapods.com/pods?run=navidrome)
+
+## Features
+
+ - Handles very **large music collections**
+ - Streams virtually **any audio format** available
+ - Reads and uses all your beautifully curated **metadata**
+ - Great support for **compilations** (Various Artists albums) and **box sets** (multi-disc albums)
+ - **Multi-user**, each user has their own play counts, playlists, favourites, etc...
+ - Very **low resource usage**
+ - **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
+ - Ready to use binaries for all major platforms, including **Raspberry Pi**
+ - Automatically **monitors your library** for changes, importing new files and reloading new metadata
+ - **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
+ - **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
+ - **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
+ - **Meilisearch Integration** for high-performance full-text search (optional)
+ - Translated to **various languages**
+
+## Translations
+
+Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
+for [more contributors](https://www.navidrome.org/docs/developers/translations/)
+
+
+
+
+
+## Documentation
+All documentation can be found in the project's website: https://www.navidrome.org/docs.
+Here are some useful direct links:
+
+- [Overview](https://www.navidrome.org/docs/overview/)
+- [Installation](https://www.navidrome.org/docs/installation/)
+ - [Docker](https://www.navidrome.org/docs/installation/docker/)
+ - [Binaries](https://www.navidrome.org/docs/installation/pre-built-binaries/)
+ - [Build from source](https://www.navidrome.org/docs/installation/build-from-source/)
+- [Development](https://www.navidrome.org/docs/developers/)
+- [Subsonic API Compatibility](https://www.navidrome.org/docs/developers/subsonic-api/)
+
+## Screenshots
+
+
+
+
+
+
")) + Expect(bio).ToNot(ContainSubstring("
")) + }) + + It("uses the configured language", func() { + client = newClient(httpClient, "fr") + // Mock JWT token for the new client instance with a valid JWT + testJWT := createTestJWT(5 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))), + }) + f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + _, err = client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(BeNil()) + Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr")) + }) + + It("includes the JWT token in the request", func() { + f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + _, err = client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(BeNil()) + // Verify that the Authorization header has the Bearer token format + authHeader := httpClient.lastRequest.Header.Get("Authorization") + Expect(authHeader).To(HavePrefix("Bearer ")) + Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars + }) + + It("handles GraphQL errors", func() { + errorResponse := `{ + "data": { + "artist": { + "bio": { + "full": "" + } + } + }, + "errors": [ + { + "message": "Artist not found" + }, + { + "message": "Invalid artist ID" + } + ] + }` + httpClient.mock("https://pipe.deezer.com/api", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(errorResponse)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 999) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("GraphQL error")) + Expect(err.Error()).To(ContainSubstring("Artist not found")) + Expect(err.Error()).To(ContainSubstring("Invalid artist ID")) + }) + + It("handles empty biography", func() { + emptyBioResponse := `{ + "data": { + "artist": { + "bio": { + "full": "" + } + } + } + }` + httpClient.mock("https://pipe.deezer.com/api", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(MatchError("deezer: biography not found")) + }) + + It("handles JWT token fetch failure", func() { + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 500, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get JWT")) + }) + + It("handles JWT token that expires too soon", func() { + // Create a JWT that expires in 30 seconds (less than the 1-minute buffer) + expiredJWT := createTestJWT(30 * time.Second) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))), + }) + + _, err := client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) + }) + }) +}) + +type fakeHttpClient struct { + responses map[string]*http.Response + lastRequest *http.Request +} + +func (c *fakeHttpClient) mock(url string, response http.Response) { + if c.responses == nil { + c.responses = make(map[string]*http.Response) + } + c.responses[url] = &response +} + +func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) { + c.lastRequest = req + u := req.URL + u.RawQuery = "" + if resp, ok := c.responses[u.String()]; ok { + return resp, nil + } + panic("URL not mocked: " + u.String()) +} diff --git a/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go new file mode 100644 index 0000000..8f3e505 --- /dev/null +++ b/core/agents/deezer/deezer.go @@ -0,0 +1,148 @@ +package deezer + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/slice" +) + +const deezerAgentName = "deezer" +const deezerApiPictureXlSize = 1000 +const deezerApiPictureBigSize = 500 +const deezerApiPictureMediumSize = 250 +const deezerApiPictureSmallSize = 56 +const deezerArtistSearchLimit = 50 + +type deezerAgent struct { + dataStore model.DataStore + client *client +} + +func deezerConstructor(dataStore model.DataStore) agents.Interface { + agent := &deezerAgent{dataStore: dataStore} + httpClient := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) + agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language) + return agent +} + +func (s *deezerAgent) AgentName() string { + return deezerAgentName +} + +func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) { + artist, err := s.searchArtist(ctx, name) + if err != nil { + if errors.Is(err, agents.ErrNotFound) { + log.Warn(ctx, "Artist not found in deezer", "artist", name) + } else { + log.Error(ctx, "Error calling deezer", "artist", name, err) + } + return nil, err + } + + var res []agents.ExternalImage + possibleImages := []struct { + URL string + Size int + }{ + {artist.PictureXl, deezerApiPictureXlSize}, + {artist.PictureBig, deezerApiPictureBigSize}, + {artist.PictureMedium, deezerApiPictureMediumSize}, + {artist.PictureSmall, deezerApiPictureSmallSize}, + } + for _, imgData := range possibleImages { + if imgData.URL != "" { + res = append(res, agents.ExternalImage{ + URL: imgData.URL, + Size: imgData.Size, + }) + } + } + return res, nil +} + +func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { + artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) + if errors.Is(err, ErrNotFound) || len(artists) == 0 { + return nil, agents.ErrNotFound + } + if err != nil { + return nil, err + } + + // If the first one has the same name, that's the one + if !strings.EqualFold(artists[0].Name, name) { + return nil, agents.ErrNotFound + } + return &artists[0], err +} + +func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { + artist, err := s.searchArtist(ctx, name) + if err != nil { + return nil, err + } + + related, err := s.client.getRelatedArtists(ctx, artist.ID) + if err != nil { + return nil, err + } + + res := slice.Map(related, func(r Artist) agents.Artist { + return agents.Artist{ + Name: r.Name, + } + }) + if len(res) > limit { + res = res[:limit] + } + return res, nil +} + +func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) { + artist, err := s.searchArtist(ctx, artistName) + if err != nil { + return nil, err + } + + tracks, err := s.client.getTopTracks(ctx, artist.ID, count) + if err != nil { + return nil, err + } + + res := slice.Map(tracks, func(r Track) agents.Song { + return agents.Song{ + Name: r.Title, + } + }) + return res, nil +} + +func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) { + artist, err := s.searchArtist(ctx, name) + if err != nil { + return "", err + } + + return s.client.getArtistBio(ctx, artist.ID) +} + +func init() { + conf.AddHook(func() { + if conf.Server.Deezer.Enabled { + agents.Register(deezerAgentName, deezerConstructor) + } + }) +} diff --git a/core/agents/deezer/deezer_suite_test.go b/core/agents/deezer/deezer_suite_test.go new file mode 100644 index 0000000..a42282d --- /dev/null +++ b/core/agents/deezer/deezer_suite_test.go @@ -0,0 +1,17 @@ +package deezer + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDeezer(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Deezer Test Suite") +} diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go new file mode 100644 index 0000000..266c44c --- /dev/null +++ b/core/agents/deezer/responses.go @@ -0,0 +1,66 @@ +package deezer + +type SearchArtistResults struct { + Data []Artist `json:"data"` + Total int `json:"total"` + Next string `json:"next"` +} + +type Artist struct { + ID int `json:"id"` + Name string `json:"name"` + Link string `json:"link"` + Picture string `json:"picture"` + PictureSmall string `json:"picture_small"` + PictureMedium string `json:"picture_medium"` + PictureBig string `json:"picture_big"` + PictureXl string `json:"picture_xl"` + NbAlbum int `json:"nb_album"` + NbFan int `json:"nb_fan"` + Radio bool `json:"radio"` + Tracklist string `json:"tracklist"` + Type string `json:"type"` +} + +type Error struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` +} + +type RelatedArtists struct { + Data []Artist `json:"data"` + Total int `json:"total"` +} + +type TopTracks struct { + Data []Track `json:"data"` + Total int `json:"total"` + Next string `json:"next"` +} + +type Track struct { + ID int `json:"id"` + Title string `json:"title"` + Link string `json:"link"` + Duration int `json:"duration"` + Rank int `json:"rank"` + Preview string `json:"preview"` + Artist Artist `json:"artist"` + Album Album `json:"album"` + Contributors []Artist `json:"contributors"` +} + +type Album struct { + ID int `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + CoverSmall string `json:"cover_small"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXl string `json:"cover_xl"` + Tracklist string `json:"tracklist"` + Type string `json:"type"` +} diff --git a/core/agents/deezer/responses_test.go b/core/agents/deezer/responses_test.go new file mode 100644 index 0000000..a9de5c5 --- /dev/null +++ b/core/agents/deezer/responses_test.go @@ -0,0 +1,69 @@ +package deezer + +import ( + "encoding/json" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Responses", func() { + Describe("Search type=artist", func() { + It("parses the artist search result correctly ", func() { + var resp SearchArtistResults + body, err := os.ReadFile("tests/fixtures/deezer.search.artist.json") + Expect(err).To(BeNil()) + err = json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Data).To(HaveLen(17)) + michael := resp.Data[0] + Expect(michael.Name).To(Equal("Michael Jackson")) + Expect(michael.PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg")) + }) + }) + + Describe("Error", func() { + It("parses the error response correctly", func() { + var errorResp Error + body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`) + err := json.Unmarshal(body, &errorResp) + Expect(err).To(BeNil()) + + Expect(errorResp.Error.Code).To(Equal(501)) + Expect(errorResp.Error.Message).To(Equal("Missing parameters: q")) + }) + }) + + Describe("Related Artists", func() { + It("parses the related artists response correctly", func() { + var resp RelatedArtists + body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json") + Expect(err).To(BeNil()) + err = json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Data).To(HaveLen(20)) + justice := resp.Data[0] + Expect(justice.Name).To(Equal("Justice")) + Expect(justice.ID).To(Equal(6404)) + }) + }) + + Describe("Top Tracks", func() { + It("parses the top tracks response correctly", func() { + var resp TopTracks + body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json") + Expect(err).To(BeNil()) + err = json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Data).To(HaveLen(5)) + track := resp.Data[0] + Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)")) + Expect(track.ID).To(Equal(67238732)) + Expect(track.Album.Title).To(Equal("Random Access Memories")) + }) + }) +}) diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go new file mode 100644 index 0000000..e60c619 --- /dev/null +++ b/core/agents/interfaces.go @@ -0,0 +1,84 @@ +package agents + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/model" +) + +type Constructor func(ds model.DataStore) Interface + +type Interface interface { + AgentName() string +} + +// AlbumInfo contains album metadata (no images) +type AlbumInfo struct { + Name string + MBID string + Description string + URL string +} + +type Artist struct { + Name string + MBID string +} + +type ExternalImage struct { + URL string + Size int +} + +type Song struct { + Name string + MBID string +} + +var ( + ErrNotFound = errors.New("not found") +) + +// AlbumInfoRetriever provides album info (no images) +type AlbumInfoRetriever interface { + GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) +} + +// AlbumImageRetriever provides album images +type AlbumImageRetriever interface { + GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) +} + +type ArtistMBIDRetriever interface { + GetArtistMBID(ctx context.Context, id string, name string) (string, error) +} + +type ArtistURLRetriever interface { + GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) +} + +type ArtistBiographyRetriever interface { + GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) +} + +type ArtistSimilarRetriever interface { + GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) +} + +type ArtistImageRetriever interface { + GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) +} + +type ArtistTopSongsRetriever interface { + GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) +} + +var Map map[string]Constructor + +func Register(name string, init Constructor) { + if Map == nil { + Map = make(map[string]Constructor) + } + Map[name] = init +} diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go new file mode 100644 index 0000000..e3e53b2 --- /dev/null +++ b/core/agents/lastfm/agent.go @@ -0,0 +1,383 @@ +package lastfm + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/andybalholm/cascadia" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" + "golang.org/x/net/html" +) + +const ( + lastFMAgentName = "lastfm" + sessionKeyProperty = "LastFMSessionKey" +) + +var ignoredBiographies = []string{ + // Unknown Artist + ` head > meta[property="og:image"]`) + artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name +) + +func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { + log.Debug(ctx, "Getting artist images from Last.fm", "name", name) + a, err := l.callArtistGetInfo(ctx, name) + if err != nil { + return nil, fmt.Errorf("get artist info: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil) + if err != nil { + return nil, fmt.Errorf("create artist image request: %w", err) + } + resp, err := l.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("get artist url: %w", err) + } + defer resp.Body.Close() + + node, err := html.Parse(resp.Body) + if err != nil { + return nil, fmt.Errorf("parse html: %w", err) + } + + var res []agents.ExternalImage + n := cascadia.Query(node, artistOpenGraphQuery) + if n == nil { + return res, nil + } + for _, attr := range n.Attr { + if attr.Key != "content" { + continue + } + if strings.Contains(attr.Val, artistIgnoredImage) { + log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val) + return res, nil + } + + res = []agents.ExternalImage{ + {URL: attr.Val}, + } + } + return res, nil +} + +func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) { + a, err := l.client.albumGetInfo(ctx, name, artist, mbid) + var lfErr *lastFMError + isLastFMError := errors.As(err, &lfErr) + + if mbid != "" && (isLastFMError && lfErr.Code == 6) { + log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) + return l.callAlbumGetInfo(ctx, name, artist, "") + } + + if err != nil { + if isLastFMError && lfErr.Code == 6 { + log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err) + } else { + log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err) + } + return nil, err + } + return a, nil +} + +func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) { + l.getInfoMutex.Lock() + defer l.getInfoMutex.Unlock() + + a, err := l.client.artistGetInfo(ctx, name) + if err != nil { + log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err) + return nil, err + } + return a, nil +} + +func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, limit int) ([]Artist, error) { + s, err := l.client.artistGetSimilar(ctx, name, limit) + if err != nil { + log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, err) + return nil, err + } + return s.Artists, nil +} + +func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName string, count int) ([]Track, error) { + t, err := l.client.artistGetTopTracks(ctx, artistName, count) + if err != nil { + log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, err) + return nil, err + } + return t.Track, nil +} + +func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string { + if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 { + return track.Participants[role][0].Name + } + return displayName +} + +func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { + sk, err := l.sessionKeys.Get(ctx, userId) + if err != nil || sk == "" { + return scrobbler.ErrNotAuthorized + } + + err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{ + artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist), + track: track.Title, + album: track.Album, + trackNumber: track.TrackNumber, + mbid: track.MbzRecordingID, + duration: int(track.Duration), + albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist), + }) + if err != nil { + log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err) + return errors.Join(err, scrobbler.ErrUnrecoverable) + } + return nil +} + +func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error { + sk, err := l.sessionKeys.Get(ctx, userId) + if err != nil || sk == "" { + return errors.Join(err, scrobbler.ErrNotAuthorized) + } + + if s.Duration <= 30 { + log.Debug(ctx, "Skipping Last.fm scrobble for short song", "track", s.Title, "duration", s.Duration) + return nil + } + err = l.client.scrobble(ctx, sk, ScrobbleInfo{ + artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist), + track: s.Title, + album: s.Album, + trackNumber: s.TrackNumber, + mbid: s.MbzRecordingID, + duration: int(s.Duration), + albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist), + timestamp: s.TimeStamp, + }) + if err == nil { + return nil + } + var lfErr *lastFMError + isLastFMError := errors.As(err, &lfErr) + if !isLastFMError { + log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err) + return errors.Join(err, scrobbler.ErrRetryLater) + } + if lfErr.Code == 11 || lfErr.Code == 16 { + return errors.Join(err, scrobbler.ErrRetryLater) + } + return errors.Join(err, scrobbler.ErrUnrecoverable) +} + +func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool { + sk, err := l.sessionKeys.Get(ctx, userId) + return err == nil && sk != "" +} + +func init() { + conf.AddHook(func() { + agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface { + // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil) + // See https://go.dev/doc/faq#nil_error + a := lastFMConstructor(ds) + if a != nil { + return a + } + return nil + }) + scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler { + // Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil) + // See https://go.dev/doc/faq#nil_error + a := lastFMConstructor(ds) + if a != nil { + return a + } + return nil + }) + }) +} diff --git a/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go new file mode 100644 index 0000000..fc62384 --- /dev/null +++ b/core/agents/lastfm/agent_test.go @@ -0,0 +1,487 @@ +package lastfm + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "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 language", func() { + conf.Server.LastFM.Language = "pt" + agent := lastFMConstructor(ds) + Expect(agent.apiKey).To(Equal("123")) + Expect(agent.secret).To(Equal("secret")) + Expect(agent.lang).To(Equal("pt")) + }) + }) + 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", "pt", 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. Read more on Last.fm")) + 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")) + }) + }) + + Describe("GetSimilarArtists", func() { + var agent *lastfmAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("API_KEY", "SECRET", "pt", 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", "pt", 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("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", "en", 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)) + sentParams := httpClient.SavedRequest.URL.Query() + 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()) + sentParams := httpClient.SavedRequest.URL.Query() + 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)) + sentParams := httpClient.SavedRequest.URL.Query() + 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()) + sentParams := httpClient.SavedRequest.URL.Query() + 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 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", "pt", 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 Read more on Last.fm.", + URL: "https://www.last.fm/music/Cher/Believe", + })) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62")) + }) + + 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(Equal("mbid-1234")) + }) + + 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(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, err := agent.GetAlbumInfo(ctx, "123", "U2", "") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + }) + + Context("MBID non existent in Last.fm", func() { + It("calls again when last.fm returns an error 6", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") + Expect(httpClient.RequestCount).To(Equal(2)) + 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", "pt", 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("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("returns empty list if 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} + + images, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(BeEmpty()) + }) + + 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")) + }) + }) +}) diff --git a/core/agents/lastfm/auth_router.go b/core/agents/lastfm/auth_router.go new file mode 100644 index 0000000..290caaa --- /dev/null +++ b/core/agents/lastfm/auth_router.go @@ -0,0 +1,132 @@ +package lastfm + +import ( + "bytes" + "context" + _ "embed" + "errors" + "net/http" + "time" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/utils/req" +) + +//go:embed token_received.html +var tokenReceivedPage []byte + +type Router struct { + http.Handler + ds model.DataStore + sessionKeys *agents.SessionKeys + client *client + apiKey string + secret string +} + +func NewRouter(ds model.DataStore) *Router { + r := &Router{ + ds: ds, + apiKey: conf.Server.LastFM.ApiKey, + secret: conf.Server.LastFM.Secret, + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + r.Handler = r.routes() + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + r.client = newClient(r.apiKey, r.secret, "en", hc) + return r +} + +func (s *Router) routes() http.Handler { + r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(server.Authenticator(s.ds)) + r.Use(server.JWTRefresher) + + r.Get("/link", s.getLinkStatus) + r.Delete("/link", s.unlink) + }) + + r.Get("/link/callback", s.callback) + + return r +} + +func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "apiKey": s.apiKey, + } + u, _ := request.UserFrom(r.Context()) + key, err := s.sessionKeys.Get(r.Context(), u.ID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + resp["error"] = err + resp["status"] = false + _ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp) + return + } + resp["status"] = key != "" + _ = rest.RespondWithJSON(w, http.StatusOK, resp) +} + +func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + err := s.sessionKeys.Delete(r.Context(), u.ID) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + } else { + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{}) + } +} + +func (s *Router) callback(w http.ResponseWriter, r *http.Request) { + p := req.Params(r) + token, err := p.String("token") + if err != nil { + _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") + return + } + uid, err := p.String("uid") + if err != nil { + _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") + return + } + + // Need to add user to context, as this is a non-authenticated endpoint, so it does not + // automatically contain any user info + ctx := request.WithUser(r.Context(), model.User{ID: uid}) + err = s.fetchSessionKey(ctx, uid, token) + if err != nil { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) + return + } + + http.ServeContent(w, r, "response", time.Now(), bytes.NewReader(tokenReceivedPage)) +} + +func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error { + sessionKey, err := s.client.getSession(ctx, token) + if err != nil { + log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token, + "requestId", middleware.GetReqID(ctx), err) + return err + } + err = s.sessionKeys.Put(ctx, uid, sessionKey) + if err != nil { + log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err) + } + return err +} diff --git a/core/agents/lastfm/client.go b/core/agents/lastfm/client.go new file mode 100644 index 0000000..6a24ac8 --- /dev/null +++ b/core/agents/lastfm/client.go @@ -0,0 +1,233 @@ +package lastfm + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/log" +) + +const ( + apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" +) + +type lastFMError struct { + Code int + Message string +} + +func (e *lastFMError) Error() string { + return fmt.Sprintf("last.fm error(%d): %s", e.Code, e.Message) +} + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +func newClient(apiKey string, secret string, lang string, hc httpDoer) *client { + return &client{apiKey, secret, lang, hc} +} + +type client struct { + apiKey string + secret string + lang string + hc httpDoer +} + +func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) { + params := url.Values{} + params.Add("method", "album.getInfo") + params.Add("album", name) + params.Add("artist", artist) + params.Add("mbid", mbid) + params.Add("lang", c.lang) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.Album, nil +} + +func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) { + params := url.Values{} + params.Add("method", "artist.getInfo") + params.Add("artist", name) + params.Add("lang", c.lang) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.Artist, nil +} + +func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) { + params := url.Values{} + params.Add("method", "artist.getSimilar") + params.Add("artist", name) + params.Add("limit", strconv.Itoa(limit)) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.SimilarArtists, nil +} + +func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) { + params := url.Values{} + params.Add("method", "artist.getTopTracks") + params.Add("artist", name) + params.Add("limit", strconv.Itoa(limit)) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.TopTracks, nil +} + +func (c *client) GetToken(ctx context.Context) (string, error) { + params := url.Values{} + params.Add("method", "auth.getToken") + c.sign(params) + response, err := c.makeRequest(ctx, http.MethodGet, params, true) + if err != nil { + return "", err + } + return response.Token, nil +} + +func (c *client) getSession(ctx context.Context, token string) (string, error) { + params := url.Values{} + params.Add("method", "auth.getSession") + params.Add("token", token) + response, err := c.makeRequest(ctx, http.MethodGet, params, true) + if err != nil { + return "", err + } + return response.Session.Key, nil +} + +type ScrobbleInfo struct { + artist string + track string + album string + trackNumber int + mbid string + duration int + albumArtist string + timestamp time.Time +} + +func (c *client) updateNowPlaying(ctx context.Context, sessionKey string, info ScrobbleInfo) error { + params := url.Values{} + params.Add("method", "track.updateNowPlaying") + params.Add("artist", info.artist) + params.Add("track", info.track) + params.Add("album", info.album) + params.Add("trackNumber", strconv.Itoa(info.trackNumber)) + params.Add("mbid", info.mbid) + params.Add("duration", strconv.Itoa(info.duration)) + params.Add("albumArtist", info.albumArtist) + params.Add("sk", sessionKey) + resp, err := c.makeRequest(ctx, http.MethodPost, params, true) + if err != nil { + return err + } + if resp.NowPlaying.IgnoredMessage.Code != "0" { + log.Warn(ctx, "LastFM: NowPlaying was ignored", "code", resp.NowPlaying.IgnoredMessage.Code, + "text", resp.NowPlaying.IgnoredMessage.Text) + } + return nil +} + +func (c *client) scrobble(ctx context.Context, sessionKey string, info ScrobbleInfo) error { + params := url.Values{} + params.Add("method", "track.scrobble") + params.Add("timestamp", strconv.FormatInt(info.timestamp.Unix(), 10)) + params.Add("artist", info.artist) + params.Add("track", info.track) + params.Add("album", info.album) + params.Add("trackNumber", strconv.Itoa(info.trackNumber)) + params.Add("mbid", info.mbid) + params.Add("duration", strconv.Itoa(info.duration)) + params.Add("albumArtist", info.albumArtist) + params.Add("sk", sessionKey) + resp, err := c.makeRequest(ctx, http.MethodPost, params, true) + if err != nil { + return err + } + if resp.Scrobbles.Scrobble.IgnoredMessage.Code != "0" { + log.Warn(ctx, "LastFM: scrobble was ignored", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code, + "text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info) + } + if resp.Scrobbles.Attr.Accepted != 1 { + log.Warn(ctx, "LastFM: scrobble was not accepted", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code, + "text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info) + } + return nil +} + +func (c *client) makeRequest(ctx context.Context, method string, params url.Values, signed bool) (*Response, error) { + params.Add("format", "json") + params.Add("api_key", c.apiKey) + + if signed { + c.sign(params) + } + + req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil) + req.URL.RawQuery = params.Encode() + + log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response Response + jsonErr := decoder.Decode(&response) + if resp.StatusCode != 200 && jsonErr != nil { + return nil, fmt.Errorf("last.fm http status: (%d)", resp.StatusCode) + } + if jsonErr != nil { + return nil, jsonErr + } + if response.Error != 0 { + return &response, &lastFMError{Code: response.Error, Message: response.Message} + } + + return &response, nil +} + +func (c *client) sign(params url.Values) { + // the parameters must be in order before hashing + keys := make([]string, 0, len(params)) + for k := range params { + if slices.Contains([]string{"format", "callback"}, k) { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + msg := strings.Builder{} + for _, k := range keys { + msg.WriteString(k) + msg.WriteString(params[k][0]) + } + msg.WriteString(c.secret) + hash := md5.Sum([]byte(msg.String())) + params.Add("api_sig", hex.EncodeToString(hash[:])) +} diff --git a/core/agents/lastfm/client_test.go b/core/agents/lastfm/client_test.go new file mode 100644 index 0000000..85ec115 --- /dev/null +++ b/core/agents/lastfm/client_test.go @@ -0,0 +1,173 @@ +package lastfm + +import ( + "bytes" + "context" + "crypto/md5" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("client", func() { + var httpClient *tests.FakeHttpClient + var client *client + + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client = newClient("API_KEY", "SECRET", "pt", httpClient) + }) + + Describe("albumGetInfo", func() { + It("returns an album on successful response", func() { + f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234") + 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")) + }) + }) + + Describe("artistGetInfo", func() { + It("returns an artist for a successful response", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + artist, err := client.artistGetInfo(context.Background(), "U2") + 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")) + }) + + It("fails if Last.fm returns an http status != 200", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)), + StatusCode: 500, + } + + _, err := client.artistGetInfo(context.Background(), "U2") + Expect(err).To(MatchError("last.fm http status: (500)")) + }) + + It("fails if Last.fm returns an http status != 200", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)), + StatusCode: 400, + } + + _, err := client.artistGetInfo(context.Background(), "U2") + Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"})) + }) + + It("fails if Last.fm returns an error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)), + StatusCode: 200, + } + + _, err := client.artistGetInfo(context.Background(), "U2") + Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"})) + }) + + It("fails if HttpClient.Do() returns error", func() { + httpClient.Err = errors.New("generic error") + + _, err := client.artistGetInfo(context.Background(), "U2") + Expect(err).To(MatchError("generic error")) + }) + + It("fails if returned body is not a valid JSON", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.
" + } + } + } +} diff --git a/tests/fixtures/deezer.artist.related.json b/tests/fixtures/deezer.artist.related.json new file mode 100644 index 0000000..2a55b30 --- /dev/null +++ b/tests/fixtures/deezer.artist.related.json @@ -0,0 +1 @@ +{"data":[{"id":6404,"name":"Justice","link":"https:\/\/www.deezer.com\/artist\/6404","picture":"https:\/\/api.deezer.com\/artist\/6404\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/1000x1000-000000-80-0-0.jpg","nb_album":41,"nb_fan":774236,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/6404\/top?limit=50","type":"artist"},{"id":2049,"name":"Cassius","link":"https:\/\/www.deezer.com\/artist\/2049","picture":"https:\/\/api.deezer.com\/artist\/2049\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":127692,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2049\/top?limit=50","type":"artist"},{"id":2318,"name":"Etienne de Cr\u00e9cy","link":"https:\/\/www.deezer.com\/artist\/2318","picture":"https:\/\/api.deezer.com\/artist\/2318\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/1000x1000-000000-80-0-0.jpg","nb_album":58,"nb_fan":104626,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2318\/top?limit=50","type":"artist"},{"id":72041,"name":"Yuksek","link":"https:\/\/www.deezer.com\/artist\/72041","picture":"https:\/\/api.deezer.com\/artist\/72041\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/1000x1000-000000-80-0-0.jpg","nb_album":102,"nb_fan":115772,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/72041\/top?limit=50","type":"artist"},{"id":81,"name":"The Chemical Brothers","link":"https:\/\/www.deezer.com\/artist\/81","picture":"https:\/\/api.deezer.com\/artist\/81\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/1000x1000-000000-80-0-0.jpg","nb_album":83,"nb_fan":1433333,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/81\/top?limit=50","type":"artist"},{"id":3771,"name":"Mr. Oizo","link":"https:\/\/www.deezer.com\/artist\/3771","picture":"https:\/\/api.deezer.com\/artist\/3771\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/1000x1000-000000-80-0-0.jpg","nb_album":31,"nb_fan":172085,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3771\/top?limit=50","type":"artist"},{"id":9905,"name":"Alex Gopher","link":"https:\/\/www.deezer.com\/artist\/9905","picture":"https:\/\/api.deezer.com\/artist\/9905\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/1000x1000-000000-80-0-0.jpg","nb_album":46,"nb_fan":10430,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/9905\/top?limit=50","type":"artist"},{"id":7914,"name":"Demon","link":"https:\/\/www.deezer.com\/artist\/7914","picture":"https:\/\/api.deezer.com\/artist\/7914\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/1000x1000-000000-80-0-0.jpg","nb_album":21,"nb_fan":9286,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7914\/top?limit=50","type":"artist"},{"id":8937,"name":"SebastiAn","link":"https:\/\/www.deezer.com\/artist\/8937","picture":"https:\/\/api.deezer.com\/artist\/8937\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/1000x1000-000000-80-0-0.jpg","nb_album":48,"nb_fan":74884,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/8937\/top?limit=50","type":"artist"},{"id":2508,"name":"Digitalism","link":"https:\/\/www.deezer.com\/artist\/2508","picture":"https:\/\/api.deezer.com\/artist\/2508\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/1000x1000-000000-80-0-0.jpg","nb_album":79,"nb_fan":158628,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2508\/top?limit=50","type":"artist"},{"id":11703,"name":"Alan Braxe","link":"https:\/\/www.deezer.com\/artist\/11703","picture":"https:\/\/api.deezer.com\/artist\/11703\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":12595,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11703\/top?limit=50","type":"artist"},{"id":574,"name":"Para One","link":"https:\/\/www.deezer.com\/artist\/574","picture":"https:\/\/api.deezer.com\/artist\/574\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/1000x1000-000000-80-0-0.jpg","nb_album":40,"nb_fan":30828,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/574\/top?limit=50","type":"artist"},{"id":4397,"name":"Kojak","link":"https:\/\/www.deezer.com\/artist\/4397","picture":"https:\/\/api.deezer.com\/artist\/4397\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/1000x1000-000000-80-0-0.jpg","nb_album":55,"nb_fan":1522,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4397\/top?limit=50","type":"artist"},{"id":12439,"name":"Busy P","link":"https:\/\/www.deezer.com\/artist\/12439","picture":"https:\/\/api.deezer.com\/artist\/12439\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/1000x1000-000000-80-0-0.jpg","nb_album":12,"nb_fan":65585,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/12439\/top?limit=50","type":"artist"},{"id":11656979,"name":"Mr Flash","link":"https:\/\/www.deezer.com\/artist\/11656979","picture":"https:\/\/api.deezer.com\/artist\/11656979\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":769,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11656979\/top?limit=50","type":"artist"},{"id":76,"name":"Fatboy Slim","link":"https:\/\/www.deezer.com\/artist\/76","picture":"https:\/\/api.deezer.com\/artist\/76\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/1000x1000-000000-80-0-0.jpg","nb_album":76,"nb_fan":1231355,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/76\/top?limit=50","type":"artist"},{"id":11265,"name":"Lifelike","link":"https:\/\/www.deezer.com\/artist\/11265","picture":"https:\/\/api.deezer.com\/artist\/11265\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/1000x1000-000000-80-0-0.jpg","nb_album":38,"nb_fan":8316,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11265\/top?limit=50","type":"artist"},{"id":2048,"name":"Groove Armada","link":"https:\/\/www.deezer.com\/artist\/2048","picture":"https:\/\/api.deezer.com\/artist\/2048\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/1000x1000-000000-80-0-0.jpg","nb_album":92,"nb_fan":173879,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2048\/top?limit=50","type":"artist"},{"id":71708,"name":"Surkin","link":"https:\/\/www.deezer.com\/artist\/71708","picture":"https:\/\/api.deezer.com\/artist\/71708\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/1000x1000-000000-80-0-0.jpg","nb_album":15,"nb_fan":23101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/71708\/top?limit=50","type":"artist"},{"id":166713,"name":"Fred Falke","link":"https:\/\/www.deezer.com\/artist\/166713","picture":"https:\/\/api.deezer.com\/artist\/166713\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/1000x1000-000000-80-0-0.jpg","nb_album":67,"nb_fan":9688,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/166713\/top?limit=50","type":"artist"}],"total":20} \ No newline at end of file diff --git a/tests/fixtures/deezer.artist.top.json b/tests/fixtures/deezer.artist.top.json new file mode 100644 index 0000000..e3f22a1 --- /dev/null +++ b/tests/fixtures/deezer.artist.top.json @@ -0,0 +1 @@ +{"data":[{"id":67238732,"readable":true,"title":"Instant Crush (feat. Julian Casablancas)","title_short":"Instant Crush","title_version":"(feat. Julian Casablancas)","link":"https:\/\/www.deezer.com\/track\/67238732","duration":337,"rank":944042,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3*~data=user_id=0,application_id=42~hmac=66213cecf953c7ef8b4d89e3539a1355d318679c5ab54cac2007d4effa6c3bf4","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":295821,"name":"Julian Casablancas","link":"https:\/\/www.deezer.com\/artist\/295821","share":"https:\/\/www.deezer.com\/artist\/295821?utm_source=deezer&utm_content=artist-295821&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/295821\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/295821\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3135553,"readable":true,"title":"One More Time","title_short":"One More Time","title_version":"","link":"https:\/\/www.deezer.com\/track\/3135553","duration":320,"rank":888570,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3*~data=user_id=0,application_id=42~hmac=0824ec7ad045b82c04904fcd5f2a8ec2175acbe3d1649030d457023fdef45620","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":302127,"title":"Discovery","cover":"https:\/\/api.deezer.com\/album\/302127\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/1000x1000-000000-80-0-0.jpg","md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","tracklist":"https:\/\/api.deezer.com\/album\/302127\/tracks","type":"album"},"type":"track"},{"id":66609426,"readable":true,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(Radio Edit - feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/66609426","duration":248,"rank":952197,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3*~data=user_id=0,application_id=42~hmac=c6dfe58571df62f41e7b326dd9afebf87015541c06a521ebc88fc18671d8d06d","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6516139,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","cover":"https:\/\/api.deezer.com\/album\/6516139\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/1000x1000-000000-80-0-0.jpg","md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","tracklist":"https:\/\/api.deezer.com\/album\/6516139\/tracks","type":"album"},"type":"track"},{"id":67238735,"readable":true,"title":"Get Lucky (feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/67238735","duration":367,"rank":873875,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3*~data=user_id=0,application_id=42~hmac=92002e6bade5ff82dd44751e8998beaa60844210df1d73b8f1bf7dafb02dc5c3","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3129775,"readable":true,"title":"Around the World","title_short":"Around the World","title_version":"","link":"https:\/\/www.deezer.com\/track\/3129775","duration":429,"rank":829911,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3*~data=user_id=0,application_id=42~hmac=9b7aa12b647cabd3219779e0270e51e639dc326442071fceb6d723c331059a67","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"b870579c8650cd59b1cce656dde2ef17","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":301775,"title":"Homework","cover":"https:\/\/api.deezer.com\/album\/301775\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/1000x1000-000000-80-0-0.jpg","md5_image":"b870579c8650cd59b1cce656dde2ef17","tracklist":"https:\/\/api.deezer.com\/album\/301775\/tracks","type":"album"},"type":"track"}],"total":100,"next":"https:\/\/api.deezer.com\/artist\/27\/top?index=5"} \ No newline at end of file diff --git a/tests/fixtures/deezer.search.artist.json b/tests/fixtures/deezer.search.artist.json new file mode 100644 index 0000000..29f138d --- /dev/null +++ b/tests/fixtures/deezer.search.artist.json @@ -0,0 +1 @@ +{"data":[{"id":259,"name":"Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/259","picture":"https:\/\/api.deezer.com\/artist\/259\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/1000x1000-000000-80-0-0.jpg","nb_album":43,"nb_fan":12074101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/259\/top?limit=50","type":"artist"},{"id":719,"name":"Bob Marley & The Wailers","link":"https:\/\/www.deezer.com\/artist\/719","picture":"https:\/\/api.deezer.com\/artist\/719\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/1000x1000-000000-80-0-0.jpg","nb_album":80,"nb_fan":12014466,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/719\/top?limit=50","type":"artist"},{"id":14031649,"name":"jay emcee, Micheal Jackson","link":"https:\/\/www.deezer.com\/artist\/14031649","picture":"https:\/\/api.deezer.com\/artist\/14031649\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":104,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/14031649\/top?limit=50","type":"artist"},{"id":137159102,"name":"Micheal Collins The Mic Jackson Of Rap","link":"https:\/\/www.deezer.com\/artist\/137159102","picture":"https:\/\/api.deezer.com\/artist\/137159102\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":13,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/137159102\/top?limit=50","type":"artist"},{"id":259786511,"name":"Consev","link":"https:\/\/www.deezer.com\/artist\/259786511","picture":"https:\/\/api.deezer.com\/artist\/259786511\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":1,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/259786511\/top?limit=50","type":"artist"},{"id":262255,"name":"Michael Jackson Tribute","link":"https:\/\/www.deezer.com\/artist\/262255","picture":"https:\/\/api.deezer.com\/artist\/262255\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":9339,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/262255\/top?limit=50","type":"artist"},{"id":193820797,"name":"Michael Jackman","link":"https:\/\/www.deezer.com\/artist\/193820797","picture":"https:\/\/api.deezer.com\/artist\/193820797\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":0,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/193820797\/top?limit=50","type":"artist"},{"id":374060,"name":"Simply The Best Sax: The Hits Of Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/374060","picture":"https:\/\/api.deezer.com\/artist\/374060\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":1507,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/374060\/top?limit=50","type":"artist"},{"id":4969823,"name":"Jackson Michael","link":"https:\/\/www.deezer.com\/artist\/4969823","picture":"https:\/\/api.deezer.com\/artist\/4969823\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":17,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4969823\/top?limit=50","type":"artist"},{"id":1278001,"name":"David Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/1278001","picture":"https:\/\/api.deezer.com\/artist\/1278001\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/1000x1000-000000-80-0-0.jpg","nb_album":54,"nb_fan":178,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1278001\/top?limit=50","type":"artist"},{"id":4142968,"name":"Cheyenne Jackson, Michael Feinstein","link":"https:\/\/www.deezer.com\/artist\/4142968","picture":"https:\/\/api.deezer.com\/artist\/4142968\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":251,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4142968\/top?limit=50","type":"artist"},{"id":766502,"name":"Michael Jackson Tribute Band","link":"https:\/\/www.deezer.com\/artist\/766502","picture":"https:\/\/api.deezer.com\/artist\/766502\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":623,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/766502\/top?limit=50","type":"artist"},{"id":1394615,"name":"Michael Jameson","link":"https:\/\/www.deezer.com\/artist\/1394615","picture":"https:\/\/api.deezer.com\/artist\/1394615\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":78,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1394615\/top?limit=50","type":"artist"},{"id":490836,"name":"Michael Blackson","link":"https:\/\/www.deezer.com\/artist\/490836","picture":"https:\/\/api.deezer.com\/artist\/490836\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":391,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/490836\/top?limit=50","type":"artist"},{"id":1229617,"name":"The Michael Jackson Tribute Band","link":"https:\/\/www.deezer.com\/artist\/1229617","picture":"https:\/\/api.deezer.com\/artist\/1229617\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":344,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1229617\/top?limit=50","type":"artist"},{"id":3662911,"name":"Fran London feat. Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/3662911","picture":"https:\/\/api.deezer.com\/artist\/3662911\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":247,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3662911\/top?limit=50","type":"artist"},{"id":13014917,"name":"Scott Michael Bennett, Naomi Jackson, Gary Sewell & The Emmanuel Quartet","link":"https:\/\/www.deezer.com\/artist\/13014917","picture":"https:\/\/api.deezer.com\/artist\/13014917\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":66,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/13014917\/top?limit=50","type":"artist"}],"total":17} \ No newline at end of file diff --git a/tests/fixtures/empty.txt b/tests/fixtures/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/empty_folder/not_an_audio_file.txt b/tests/fixtures/empty_folder/not_an_audio_file.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/ignored_folder/.ndignore b/tests/fixtures/ignored_folder/.ndignore new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/index.html b/tests/fixtures/index.html new file mode 100644 index 0000000..53915d8 --- /dev/null +++ b/tests/fixtures/index.html @@ -0,0 +1,15 @@ + + + + + +
+It looks like we are having trouble connecting.
+
+Please check your internet connection and try again.
{children}
, + required: () => () => null, + email: () => () => null, + useMutation: () => [vi.fn()], + useNotify: () => vi.fn(), + useRedirect: () => vi.fn(), + useRefresh: () => vi.fn(), + usePermissions: () => ({ permissions: 'admin' }), + useTranslate: () => (key) => key, +})) + +vi.mock('./LibrarySelectionField.jsx', () => ({ + LibrarySelectionField: () => , +})) + +vi.mock('./DeleteUserButton', () => ({ + __esModule: true, + default: () => , +})) + +vi.mock('../common', () => ({ + Title: ({ subTitle }) =>{children}
, +})) + +describe('