From 3a364b6ae4db16c69bd1bc0aa6013f4b1917698c Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 23 Nov 2023 15:30:43 +0100 Subject: [PATCH] Deezer authentication and loves export --- backends/backends.go | 2 + backends/deezer/auth.go | 83 +++++++++++ backends/deezer/client.go | 80 +++++++++++ backends/deezer/client_test.go | 71 ++++++++++ backends/deezer/deezer.go | 161 ++++++++++++++++++++++ backends/deezer/deezer_test.go | 50 +++++++ backends/deezer/models.go | 66 +++++++++ backends/deezer/models_test.go | 45 ++++++ backends/deezer/testdata/track.json | 37 +++++ backends/deezer/testdata/user-tracks.json | 80 +++++++++++ scotty.example.toml | 10 ++ 11 files changed, 685 insertions(+) create mode 100644 backends/deezer/auth.go create mode 100644 backends/deezer/client.go create mode 100644 backends/deezer/client_test.go create mode 100644 backends/deezer/deezer.go create mode 100644 backends/deezer/deezer_test.go create mode 100644 backends/deezer/models.go create mode 100644 backends/deezer/models_test.go create mode 100644 backends/deezer/testdata/track.json create mode 100644 backends/deezer/testdata/user-tracks.json diff --git a/backends/backends.go b/backends/backends.go index 3df9f77..aad5af7 100644 --- a/backends/backends.go +++ b/backends/backends.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/spf13/viper" + "go.uploadedlobster.com/scotty/backends/deezer" "go.uploadedlobster.com/scotty/backends/dump" "go.uploadedlobster.com/scotty/backends/funkwhale" "go.uploadedlobster.com/scotty/backends/jspf" @@ -75,6 +76,7 @@ func GetBackends() []BackendInfo { } var knownBackends = map[string]func() models.Backend{ + "deezer": func() models.Backend { return &deezer.DeezerApiBackend{} }, "dump": func() models.Backend { return &dump.DumpBackend{} }, "funkwhale": func() models.Backend { return &funkwhale.FunkwhaleApiBackend{} }, "jspf": func() models.Backend { return &jspf.JSPFBackend{} }, diff --git a/backends/deezer/auth.go b/backends/deezer/auth.go new file mode 100644 index 0000000..53bfd98 --- /dev/null +++ b/backends/deezer/auth.go @@ -0,0 +1,83 @@ +/* +Copyright © 2023 Philipp Wolfer + +Scotty is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later version. + +Scotty is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +Scotty. If not, see . +*/ + +package deezer + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "go.uploadedlobster.com/scotty/internal/auth" + "golang.org/x/oauth2" +) + +type deezerStrategy struct { + conf oauth2.Config +} + +func (s deezerStrategy) Config() oauth2.Config { + return s.conf +} + +func (s deezerStrategy) AuthCodeURL(verifier string, state string) string { + return s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) +} + +func (s deezerStrategy) ExchangeToken(code auth.CodeResponse, verifier string) (*oauth2.Token, error) { + // Deezer has a non-standard token exchange, expecting all parameters in the URL's query + req, err := http.NewRequest(http.MethodGet, s.conf.Endpoint.TokenURL, nil) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Add("app_id", s.conf.ClientID) + q.Add("secret", s.conf.ClientSecret) + q.Add("code", code.Code) + q.Add("output", "json") + req.URL.RawQuery = q.Encode() + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + reqBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + token := deezerToken{} + if err = json.Unmarshal(reqBody, &token); err != nil { + return nil, err + } + + return token.Token(), nil +} + +type deezerToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires"` +} + +func (t deezerToken) Token() *oauth2.Token { + token := &oauth2.Token{AccessToken: t.AccessToken} + if t.ExpiresIn > 0 { + token.Expiry = time.Now().Add(time.Duration(t.ExpiresIn * time.Second.Nanoseconds())) + } + return token +} diff --git a/backends/deezer/client.go b/backends/deezer/client.go new file mode 100644 index 0000000..45119a5 --- /dev/null +++ b/backends/deezer/client.go @@ -0,0 +1,80 @@ +/* +Copyright © 2023 Philipp Wolfer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package deezer + +import ( + "errors" + "strconv" + + "github.com/go-resty/resty/v2" + "golang.org/x/oauth2" +) + +const baseURL = "https://api.deezer.com/" +const MaxItemsPerGet = 50 +const DefaultRateLimitWaitSeconds = 5 + +type Client struct { + HttpClient *resty.Client + token oauth2.TokenSource +} + +func NewClient(token oauth2.TokenSource) Client { + client := resty.New() + client.SetBaseURL(baseURL) + client.SetHeader("Accept", "application/json") + client.SetRetryCount(5) + return Client{ + HttpClient: client, + token: token, + } +} + +func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) { + const path = "/user/me/tracks" + request := c.HttpClient.R(). + SetQueryParams(map[string]string{ + "index": strconv.Itoa(offset), + "limit": strconv.Itoa(limit), + }). + SetResult(&result) + c.setToken(request) + response, err := request.Get(path) + + if response.StatusCode() != 200 { + err = errors.New(response.String()) + } else if result.Error != nil { + err = errors.New(result.Error.Message) + } + return +} + +func (c Client) setToken(req *resty.Request) error { + tok, err := c.token.Token() + if err != nil { + return err + } + + req.SetQueryParam("access_token", tok.AccessToken) + return nil +} diff --git a/backends/deezer/client_test.go b/backends/deezer/client_test.go new file mode 100644 index 0000000..a1df199 --- /dev/null +++ b/backends/deezer/client_test.go @@ -0,0 +1,71 @@ +/* +Copyright © 2023 Philipp Wolfer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package deezer_test + +import ( + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uploadedlobster.com/scotty/backends/deezer" + "golang.org/x/oauth2" +) + +func TestNewClient(t *testing.T) { + token := oauth2.StaticTokenSource(&oauth2.Token{}) + client := deezer.NewClient(token) + assert.IsType(t, deezer.Client{}, client) +} + +func TestGetUserTracks(t *testing.T) { + defer httpmock.DeactivateAndReset() + + token := oauth2.StaticTokenSource(&oauth2.Token{}) + client := deezer.NewClient(token) + setupHttpMock(t, client.HttpClient.GetClient(), + "https://api.deezer.com/user/me/tracks", + "testdata/user-tracks.json") + + result, err := client.UserTracks(0, 2) + require.NoError(t, err) + + assert := assert.New(t) + assert.Equal(4, result.Total) + require.Len(t, result.Tracks, 2) + track1 := result.Tracks[0] + assert.Equal(int64(1700743848), track1.AddedAt) + assert.Equal("Never Take Me Alive", track1.Track.Title) + assert.Equal("Outland", track1.Track.Album.Title) +} + +func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { + httpmock.ActivateNonDefault(client) + + responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) + if err != nil { + t.Fatal(err) + } + httpmock.RegisterResponder("GET", url, responder) +} diff --git a/backends/deezer/deezer.go b/backends/deezer/deezer.go new file mode 100644 index 0000000..d834080 --- /dev/null +++ b/backends/deezer/deezer.go @@ -0,0 +1,161 @@ +/* +Copyright © 2023 Philipp Wolfer + +Scotty is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later version. + +Scotty is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +Scotty. If not, see . +*/ + +package deezer + +import ( + "fmt" + "math" + "net/url" + "sort" + "time" + + "github.com/spf13/viper" + "go.uploadedlobster.com/scotty/internal/auth" + "go.uploadedlobster.com/scotty/models" + "golang.org/x/oauth2" +) + +type DeezerApiBackend struct { + client Client + clientId string + clientSecret string +} + +func (b *DeezerApiBackend) Name() string { return "deezer" } + +func (b *DeezerApiBackend) FromConfig(config *viper.Viper) models.Backend { + b.clientId = config.GetString("client-id") + b.clientSecret = config.GetString("client-secret") + return b +} + +func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { + conf := oauth2.Config{ + ClientID: b.clientId, + ClientSecret: b.clientSecret, + Scopes: []string{ + "offline_access,basic_access,listening_history", + }, + RedirectURL: redirectUrl.String(), + Endpoint: oauth2.Endpoint{ + AuthURL: "https://connect.deezer.com/oauth/auth.php", + TokenURL: "https://connect.deezer.com/oauth/access_token.php", + }, + } + + return deezerStrategy{conf: conf} +} + +func (b *DeezerApiBackend) OAuth2Setup(token oauth2.TokenSource) error { + b.client = NewClient(token) + return nil +} + +func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { + // Choose a high offset, we attempt to search the loves backwards starting + // at the oldest one. + offset := math.MaxInt32 + perPage := MaxItemsPerGet + + defer close(results) + defer close(progress) + + p := models.Progress{Total: int64(perPage)} + var totalCount int + +out: + for { + result, err := b.client.UserTracks(offset, perPage) + if err != nil { + progress <- p.Complete() + results <- models.LovesResult{Error: err} + return + } + + // The offset was higher then the actual number of tracks. Adjust the offset + // and continue. + if offset >= result.Total { + p.Total = int64(result.Total) + totalCount = result.Total + offset = result.Total - perPage + if offset < 0 { + offset = 0 + } + continue + } + + count := len(result.Tracks) + if count == 0 { + break out + } + + loves := make(models.LovesList, 0, perPage) + for _, track := range result.Tracks { + love := track.AsLove() + if love.Created.Unix() > oldestTimestamp.Unix() { + loves = append(loves, love) + } else { + totalCount -= 1 + break + } + } + + sort.Sort(loves) + results <- models.LovesResult{Loves: loves, Total: totalCount} + p.Elapsed += int64(count) + progress <- p + + if offset <= 0 { + // This was the last request, no further results + break out + } + + offset -= perPage + if offset < 0 { + offset = 0 + } + } + + progress <- p.Complete() +} + +func (t LovedTrack) AsLove() models.Love { + love := models.Love{ + Created: time.Unix(t.AddedAt, 0), + Track: t.Track.AsTrack(), + } + + return love +} + +func (t Track) AsTrack() models.Track { + track := models.Track{ + TrackName: t.Title, + ReleaseName: t.Album.Title, + ArtistNames: []string{t.Artist.Name}, + Duration: time.Duration(t.Duration * int(time.Second)), + AdditionalInfo: map[string]any{}, + } + + info := track.AdditionalInfo + info["music_service"] = "deezer.com" + info["origin_url"] = t.Link + info["deezer_id"] = t.Link + info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Album.Id) + info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Artist.Id) + + return track +} diff --git a/backends/deezer/deezer_test.go b/backends/deezer/deezer_test.go new file mode 100644 index 0000000..09e240b --- /dev/null +++ b/backends/deezer/deezer_test.go @@ -0,0 +1,50 @@ +/* +Copyright © 2023 Philipp Wolfer + +Scotty is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later version. + +Scotty is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +Scotty. If not, see . +*/ + +package deezer_test + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uploadedlobster.com/scotty/backends/deezer" +) + +func TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("client-id", "someclientid") + config.Set("client-secret", "someclientsecret") + backend := (&deezer.DeezerApiBackend{}).FromConfig(config) + assert.IsType(t, &deezer.DeezerApiBackend{}, backend) +} + +func TestLovedTrackAsLove(t *testing.T) { + data, err := os.ReadFile("testdata/track.json") + require.NoError(t, err) + track := deezer.LovedTrack{} + err = json.Unmarshal(data, &track) + require.NoError(t, err) + love := track.AsLove() + assert.Equal(t, time.Unix(1700743848, 0), love.Created) + assert.Equal(t, time.Duration(255*time.Second), love.Duration) + assert.Equal(t, "Never Take Me Alive", love.TrackName) + assert.Equal(t, "Outland", love.ReleaseName) + assert.Equal(t, "Spear Of Destiny", love.ArtistName()) +} diff --git a/backends/deezer/models.go b/backends/deezer/models.go new file mode 100644 index 0000000..7b54546 --- /dev/null +++ b/backends/deezer/models.go @@ -0,0 +1,66 @@ +/* +Copyright © 2023 Philipp Wolfer + +Scotty is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later version. + +Scotty is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +Scotty. If not, see . +*/ + +package deezer + +type Result struct { + Error *Error `json:"error,omitempty"` +} + +type Error struct { + // {"error":{"type":"OAuthException","message":"Invalid OAuth access token.","code":300}} + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` +} + +type TracksResult struct { + Result + Next string `json:"next"` + Previous string `json:"prev"` + Total int `json:"total"` + Tracks []LovedTrack `json:"data"` +} + +type Track struct { + Id int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Link string `json:"link"` + Duration int `json:"duration"` + Rank int `json:"rank"` + Readable bool `json:"readable"` + Explicit bool `json:"explicit_lyrics"` + Album Album `json:"album"` + Artist Artist `json:"artist"` +} + +type LovedTrack struct { + Track + AddedAt int64 `json:"time_add"` +} + +type Album struct { + Id int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + TrackList string `json:"tracklist"` +} + +type Artist struct { + Id int `json:"id"` + Type string `json:"type"` + Name string `json:"name"` +} diff --git a/backends/deezer/models_test.go b/backends/deezer/models_test.go new file mode 100644 index 0000000..0973338 --- /dev/null +++ b/backends/deezer/models_test.go @@ -0,0 +1,45 @@ +/* +Copyright © 2023 Philipp Wolfer + +Scotty is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later version. + +Scotty is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +Scotty. If not, see . +*/ + +package deezer_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uploadedlobster.com/scotty/backends/deezer" +) + +func TestUserTracksResult(t *testing.T) { + data, err := os.ReadFile("testdata/user-tracks.json") + require.NoError(t, err) + result := deezer.TracksResult{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + assert := assert.New(t) + assert.Equal(4, result.Total) + assert.Equal("https://api.deezer.com/user/me/tracks?limit=2&index=2", + result.Next) + require.Len(t, result.Tracks, 2) + track1 := result.Tracks[0] + assert.Equal(int64(1700743848), track1.AddedAt) + assert.Equal("Never Take Me Alive", track1.Title) + assert.Equal("Outland", track1.Album.Title) + assert.Equal("Spear Of Destiny", track1.Artist.Name) +} diff --git a/backends/deezer/testdata/track.json b/backends/deezer/testdata/track.json new file mode 100644 index 0000000..fd451c8 --- /dev/null +++ b/backends/deezer/testdata/track.json @@ -0,0 +1,37 @@ +{ + "id": 3265090, + "readable": true, + "title": "Never Take Me Alive", + "link": "https:\/\/www.deezer.com\/track\/3265090", + "duration": 255, + "rank": 72294, + "explicit_lyrics": false, + "explicit_content_lyrics": 0, + "explicit_content_cover": 0, + "md5_image": "193e4db0eb58117978059acbffe79e93", + "time_add": 1700743848, + "album": { + "id": 311576, + "title": "Outland", + "cover": "https:\/\/api.deezer.com\/album\/311576\/image", + "cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/56x56-000000-80-0-0.jpg", + "cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/250x250-000000-80-0-0.jpg", + "cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/500x500-000000-80-0-0.jpg", + "cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/1000x1000-000000-80-0-0.jpg", + "md5_image": "193e4db0eb58117978059acbffe79e93", + "tracklist": "https:\/\/api.deezer.com\/album\/311576\/tracks", + "type": "album" + }, + "artist": { + "id": 94057, + "name": "Spear Of Destiny", + "picture": "https:\/\/api.deezer.com\/artist\/94057\/image", + "picture_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/56x56-000000-80-0-0.jpg", + "picture_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/250x250-000000-80-0-0.jpg", + "picture_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/500x500-000000-80-0-0.jpg", + "picture_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/1000x1000-000000-80-0-0.jpg", + "tracklist": "https:\/\/api.deezer.com\/artist\/94057\/top?limit=50", + "type": "artist" + }, + "type": "track" +} diff --git a/backends/deezer/testdata/user-tracks.json b/backends/deezer/testdata/user-tracks.json new file mode 100644 index 0000000..59fe685 --- /dev/null +++ b/backends/deezer/testdata/user-tracks.json @@ -0,0 +1,80 @@ +{ + "data": [ + { + "id": 3265090, + "readable": true, + "title": "Never Take Me Alive", + "link": "https:\/\/www.deezer.com\/track\/3265090", + "duration": 255, + "rank": 72294, + "explicit_lyrics": false, + "explicit_content_lyrics": 0, + "explicit_content_cover": 0, + "md5_image": "193e4db0eb58117978059acbffe79e93", + "time_add": 1700743848, + "album": { + "id": 311576, + "title": "Outland", + "cover": "https:\/\/api.deezer.com\/album\/311576\/image", + "cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/56x56-000000-80-0-0.jpg", + "cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/250x250-000000-80-0-0.jpg", + "cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/500x500-000000-80-0-0.jpg", + "cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/1000x1000-000000-80-0-0.jpg", + "md5_image": "193e4db0eb58117978059acbffe79e93", + "tracklist": "https:\/\/api.deezer.com\/album\/311576\/tracks", + "type": "album" + }, + "artist": { + "id": 94057, + "name": "Spear Of Destiny", + "picture": "https:\/\/api.deezer.com\/artist\/94057\/image", + "picture_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/56x56-000000-80-0-0.jpg", + "picture_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/250x250-000000-80-0-0.jpg", + "picture_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/500x500-000000-80-0-0.jpg", + "picture_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/1000x1000-000000-80-0-0.jpg", + "tracklist": "https:\/\/api.deezer.com\/artist\/94057\/top?limit=50", + "type": "artist" + }, + "type": "track" + }, + { + "id": 2510418, + "readable": true, + "title": "Voodoo Lady", + "link": "https:\/\/www.deezer.com\/track\/2510418", + "duration": 259, + "rank": 196860, + "explicit_lyrics": true, + "explicit_content_lyrics": 1, + "explicit_content_cover": 2, + "md5_image": "ca459f264d682177d1c8f7620100a8bc", + "time_add": 1700747083, + "album": { + "id": 246602, + "title": "The Distance To Here", + "cover": "https:\/\/api.deezer.com\/album\/246602\/image", + "cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/ca459f264d682177d1c8f7620100a8bc\/56x56-000000-80-0-0.jpg", + "cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/ca459f264d682177d1c8f7620100a8bc\/250x250-000000-80-0-0.jpg", + "cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/ca459f264d682177d1c8f7620100a8bc\/500x500-000000-80-0-0.jpg", + "cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/ca459f264d682177d1c8f7620100a8bc\/1000x1000-000000-80-0-0.jpg", + "md5_image": "ca459f264d682177d1c8f7620100a8bc", + "tracklist": "https:\/\/api.deezer.com\/album\/246602\/tracks", + "type": "album" + }, + "artist": { + "id": 168, + "name": "LIVE", + "picture": "https:\/\/api.deezer.com\/artist\/168\/image", + "picture_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/5d102eed174da4b807345125b3d955ef\/56x56-000000-80-0-0.jpg", + "picture_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/5d102eed174da4b807345125b3d955ef\/250x250-000000-80-0-0.jpg", + "picture_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/5d102eed174da4b807345125b3d955ef\/500x500-000000-80-0-0.jpg", + "picture_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/5d102eed174da4b807345125b3d955ef\/1000x1000-000000-80-0-0.jpg", + "tracklist": "https:\/\/api.deezer.com\/artist\/168\/top?limit=50", + "type": "artist" + }, + "type": "track" + } + ], + "total": 4, + "next": "https:\/\/api.deezer.com\/user\/me\/tracks?limit=2&index=2" +} diff --git a/scotty.example.toml b/scotty.example.toml index e358bb5..0e09b70 100644 --- a/scotty.example.toml +++ b/scotty.example.toml @@ -76,6 +76,16 @@ backend = "spotify" client-id = "" client-secret = "" +[service.deezer] +# Read listens and loves from a Deezer account +backend = "deezer" +# You need to register an application on https://developers.deezer.com/myapps +# and set the client ID and client secret below. +# When registering use "http://127.0.0.1:2222/callback/deezer" as the +# callback URI. +client-id = "" +client-secret = "" + [service.dump] # This backend allows writing listens and loves as console output. Useful for # debugging the export from other services.