Restructured code, moved all modules into internal

For now all modules are considered internal. This might change later
This commit is contained in:
Philipp Wolfer 2023-11-24 00:07:47 +01:00
parent f94e0f1e85
commit 857661ebf9
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
76 changed files with 121 additions and 68 deletions

View file

@ -0,0 +1,83 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -0,0 +1,89 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
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 = 1000
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) UserHistory(offset int, limit int) (result HistoryResult, err error) {
const path = "/user/me/history"
return listRequest[HistoryResult](c, path, offset, limit)
}
func (c Client) UserTracks(offset int, limit int) (TracksResult, error) {
const path = "/user/me/tracks"
return listRequest[TracksResult](c, path, offset, limit)
}
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
}
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
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
}

View file

@ -0,0 +1,92 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
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/internal/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 TestGetUserHistory(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/history",
"testdata/user-history.json")
result, err := client.UserHistory(0, 2)
require.NoError(t, err)
assert := assert.New(t)
assert.Equal(12, result.Total)
require.Len(t, result.Tracks, 2)
track1 := result.Tracks[0]
assert.Equal(int64(1700753817), track1.Timestamp)
assert.Equal("New Divide", track1.Track.Title)
assert.Equal("Linkin Park", track1.Track.Artist.Name)
}
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)
}

View file

@ -0,0 +1,236 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
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 <https://www.gnu.org/licenses/>.
*/
package deezer
import (
"fmt"
"math"
"net/url"
"sort"
"time"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/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) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, 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)
p := models.Progress{Total: int64(perPage)}
var totalCount int
out:
for {
result, err := b.client.UserHistory(offset, perPage)
if err != nil {
progress <- p.Complete()
results <- models.ListensResult{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
}
listens := make(models.ListensList, 0, perPage)
for _, track := range result.Tracks {
listen := track.AsListen()
if listen.ListenedAt.Unix() > oldestTimestamp.Unix() {
listens = append(listens, listen)
} else {
totalCount -= 1
break
}
}
sort.Sort(listens)
results <- models.ListensResult{Listens: listens, 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 (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)
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 Listen) AsListen() models.Listen {
love := models.Listen{
ListenedAt: time.Unix(t.Timestamp, 0),
Track: t.Track.AsTrack(),
}
return love
}
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
}

View file

@ -0,0 +1,70 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
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 <https://www.gnu.org/licenses/>.
*/
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/internal/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 TestListenAsListen(t *testing.T) {
data, err := os.ReadFile("testdata/listen.json")
require.NoError(t, err)
track := deezer.Listen{}
err = json.Unmarshal(data, &track)
require.NoError(t, err)
listen := track.AsListen()
assert.Equal(t, time.Unix(1700753817, 0), listen.ListenedAt)
assert.Equal(t, time.Duration(268*time.Second), listen.Duration)
assert.Equal(t, "New Divide", listen.TrackName)
assert.Equal(t, "New Divide (Int'l DMD Maxi)", listen.ReleaseName)
assert.Equal(t, "Linkin Park", listen.ArtistName())
assert.Equal(t, "deezer.com", listen.AdditionalInfo["music_service"])
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["origin_url"])
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["deezer_id"])
}
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())
assert.Equal(t, "deezer.com", love.AdditionalInfo["music_service"])
assert.Equal(t, "https://www.deezer.com/track/3265090", love.AdditionalInfo["origin_url"])
assert.Equal(t, "https://www.deezer.com/track/3265090", love.AdditionalInfo["deezer_id"])
}

View file

@ -0,0 +1,90 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
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 <https://www.gnu.org/licenses/>.
*/
package deezer
type Result interface {
Error() *Error
}
type BaseResult struct {
Err *Error `json:"error,omitempty"`
}
func (r BaseResult) Error() *Error {
return r.Err
}
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 {
BaseResult
Next string `json:"next"`
Previous string `json:"prev"`
Total int `json:"total"`
Tracks []LovedTrack `json:"data"`
}
type HistoryResult struct {
BaseResult
Next string `json:"next"`
Previous string `json:"prev"`
Total int `json:"total"`
Tracks []Listen `json:"data"`
}
type Track struct {
Id int `json:"id"`
Type string `json:"type"`
Link string `json:"link"`
Title string `json:"title"`
TitleVersion string `json:"title_version"`
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 Listen struct {
Track
Timestamp int64 `json:"timestamp"`
}
type LovedTrack struct {
Track
AddedAt int64 `json:"time_add"`
}
type Album struct {
Id int `json:"id"`
Type string `json:"type"`
Link string `json:"link"`
Title string `json:"title"`
TrackList string `json:"tracklist"`
}
type Artist struct {
Id int `json:"id"`
Type string `json:"type"`
Link string `json:"link"`
Name string `json:"name"`
}

View file

@ -0,0 +1,65 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
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 <https://www.gnu.org/licenses/>.
*/
package deezer_test
import (
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/scotty/internal/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)
}
func TestUserHistoryResult(t *testing.T) {
data, err := os.ReadFile("testdata/user-history.json")
require.NoError(t, err)
result := deezer.HistoryResult{}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert := assert.New(t)
assert.Equal(12, result.Total)
assert.Equal("https://api.deezer.com/user/me/history?limit=2&index=2",
result.Next)
require.Len(t, result.Tracks, 2)
track1 := result.Tracks[0]
assert.Equal(int64(1700753817), track1.Timestamp)
assert.Equal("New Divide", track1.Title)
assert.Equal("https://www.deezer.com/album/1346960", track1.Album.Link)
assert.Equal("Linkin Park", track1.Artist.Name)
assert.Equal("https://www.deezer.com/artist/92", track1.Artist.Link)
}

View file

@ -0,0 +1,37 @@
{
"id": 14631511,
"readable": true,
"title": "New Divide",
"title_short": "New Divide",
"title_version": "",
"link": "https:\/\/www.deezer.com\/track\/14631511",
"duration": 268,
"rank": 530579,
"explicit_lyrics": false,
"explicit_content_lyrics": 6,
"explicit_content_cover": 0,
"preview": "https:\/\/cdns-preview-7.dzcdn.net\/stream\/c-7174e274381557670dda8d4851f4c854-6.mp3",
"md5_image": "cb21d305beb247a8f7c79998a96779d4",
"timestamp": 1700753817,
"artist": {
"id": 92,
"name": "Linkin Park",
"link": "https:\/\/www.deezer.com\/artist\/92",
"tracklist": "https:\/\/api.deezer.com\/artist\/92\/top?limit=50",
"type": "artist"
},
"album": {
"id": 1346960,
"title": "New Divide (Int'l DMD Maxi)",
"link": "https:\/\/www.deezer.com\/album\/1346960",
"cover": "https:\/\/api.deezer.com\/album\/1346960\/image",
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/56x56-000000-80-0-0.jpg",
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/250x250-000000-80-0-0.jpg",
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/500x500-000000-80-0-0.jpg",
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/1000x1000-000000-80-0-0.jpg",
"md5_image": "cb21d305beb247a8f7c79998a96779d4",
"tracklist": "https:\/\/api.deezer.com\/album\/1346960\/tracks",
"type": "album"
},
"type": "track"
}

View file

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

View file

@ -0,0 +1,80 @@
{
"data": [
{
"id": 14631511,
"readable": true,
"title": "New Divide",
"title_short": "New Divide",
"title_version": "",
"link": "https:\/\/www.deezer.com\/track\/14631511",
"duration": 268,
"rank": 530579,
"explicit_lyrics": false,
"explicit_content_lyrics": 6,
"explicit_content_cover": 0,
"preview": "https:\/\/cdns-preview-7.dzcdn.net\/stream\/c-7174e274381557670dda8d4851f4c854-6.mp3",
"md5_image": "cb21d305beb247a8f7c79998a96779d4",
"timestamp": 1700753817,
"artist": {
"id": 92,
"name": "Linkin Park",
"link": "https:\/\/www.deezer.com\/artist\/92",
"tracklist": "https:\/\/api.deezer.com\/artist\/92\/top?limit=50",
"type": "artist"
},
"album": {
"id": 1346960,
"title": "New Divide (Int'l DMD Maxi)",
"link": "https:\/\/www.deezer.com\/album\/1346960",
"cover": "https:\/\/api.deezer.com\/album\/1346960\/image",
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/56x56-000000-80-0-0.jpg",
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/250x250-000000-80-0-0.jpg",
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/500x500-000000-80-0-0.jpg",
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/1000x1000-000000-80-0-0.jpg",
"md5_image": "cb21d305beb247a8f7c79998a96779d4",
"tracklist": "https:\/\/api.deezer.com\/album\/1346960\/tracks",
"type": "album"
},
"type": "track"
},
{
"id": 3819908,
"readable": true,
"title": "Duality",
"title_short": "Duality",
"title_version": "",
"link": "https:\/\/www.deezer.com\/track\/3819908",
"duration": 252,
"rank": 811964,
"explicit_lyrics": false,
"explicit_content_lyrics": 6,
"explicit_content_cover": 0,
"preview": "https:\/\/cdns-preview-e.dzcdn.net\/stream\/c-eaebaf19e890ee649f519c1b47f551b8-12.mp3",
"md5_image": "35b093d22fe1539003d5d18dd8f309eb",
"timestamp": 1700753544,
"artist": {
"id": 117,
"name": "Slipknot",
"link": "https:\/\/www.deezer.com\/artist\/117",
"tracklist": "https:\/\/api.deezer.com\/artist\/117\/top?limit=50",
"type": "artist"
},
"album": {
"id": 356130,
"title": "Vol. 3: The Subliminal Verses",
"link": "https:\/\/www.deezer.com\/album\/356130",
"cover": "https:\/\/api.deezer.com\/album\/356130\/image",
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/35b093d22fe1539003d5d18dd8f309eb\/56x56-000000-80-0-0.jpg",
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/35b093d22fe1539003d5d18dd8f309eb\/250x250-000000-80-0-0.jpg",
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/35b093d22fe1539003d5d18dd8f309eb\/500x500-000000-80-0-0.jpg",
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/35b093d22fe1539003d5d18dd8f309eb\/1000x1000-000000-80-0-0.jpg",
"md5_image": "35b093d22fe1539003d5d18dd8f309eb",
"tracklist": "https:\/\/api.deezer.com\/album\/356130\/tracks",
"type": "album"
},
"type": "track"
}
],
"total": 12,
"next": "https:\/\/api.deezer.com\/user\/me\/history?limit=2&index=2"
}

View file

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