diff --git a/backends/deezer/client.go b/backends/deezer/client.go index 45119a5..ef48a4c 100644 --- a/backends/deezer/client.go +++ b/backends/deezer/client.go @@ -31,7 +31,7 @@ import ( ) const baseURL = "https://api.deezer.com/" -const MaxItemsPerGet = 50 +const MaxItemsPerGet = 1000 const DefaultRateLimitWaitSeconds = 5 type Client struct { @@ -50,23 +50,14 @@ func NewClient(token oauth2.TokenSource) Client { } } -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) +func (c Client) UserHistory(offset int, limit int) (result HistoryResult, err error) { + const path = "/user/me/history" + return listRequest[HistoryResult](c, path, offset, limit) +} - if response.StatusCode() != 200 { - err = errors.New(response.String()) - } else if result.Error != nil { - err = errors.New(result.Error.Message) - } - return +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 { @@ -78,3 +69,21 @@ func (c Client) setToken(req *resty.Request) error { 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 +} diff --git a/backends/deezer/client_test.go b/backends/deezer/client_test.go index a1df199..b7b7f66 100644 --- a/backends/deezer/client_test.go +++ b/backends/deezer/client_test.go @@ -39,6 +39,27 @@ func TestNewClient(t *testing.T) { 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() diff --git a/backends/deezer/deezer.go b/backends/deezer/deezer.go index d834080..0c2fa63 100644 --- a/backends/deezer/deezer.go +++ b/backends/deezer/deezer.go @@ -64,6 +64,74 @@ func (b *DeezerApiBackend) OAuth2Setup(token oauth2.TokenSource) error { 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) + defer close(progress) + + 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. @@ -132,6 +200,15 @@ out: 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), diff --git a/backends/deezer/deezer_test.go b/backends/deezer/deezer_test.go index 09e240b..4d1d50c 100644 --- a/backends/deezer/deezer_test.go +++ b/backends/deezer/deezer_test.go @@ -35,6 +35,23 @@ func TestFromConfig(t *testing.T) { 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) @@ -47,4 +64,7 @@ func TestLovedTrackAsLove(t *testing.T) { 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"]) } diff --git a/backends/deezer/models.go b/backends/deezer/models.go index 7b54546..712cd73 100644 --- a/backends/deezer/models.go +++ b/backends/deezer/models.go @@ -15,8 +15,16 @@ Scotty. If not, see . package deezer -type Result struct { - Error *Error `json:"error,omitempty"` +type Result interface { + Error() *Error +} + +type BaseResult struct { + Err *Error `json:"error,omitempty"` +} + +func (r BaseResult) Error() *Error { + return r.Err } type Error struct { @@ -27,24 +35,38 @@ type Error struct { } type TracksResult struct { - Result + 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"` - 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"` + 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 { @@ -55,6 +77,7 @@ type LovedTrack struct { type Album struct { Id int `json:"id"` Type string `json:"type"` + Link string `json:"link"` Title string `json:"title"` TrackList string `json:"tracklist"` } @@ -62,5 +85,6 @@ type Album struct { type Artist struct { Id int `json:"id"` Type string `json:"type"` + Link string `json:"link"` Name string `json:"name"` } diff --git a/backends/deezer/models_test.go b/backends/deezer/models_test.go index 0973338..ca2fa18 100644 --- a/backends/deezer/models_test.go +++ b/backends/deezer/models_test.go @@ -43,3 +43,23 @@ func TestUserTracksResult(t *testing.T) { 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) +} diff --git a/backends/deezer/testdata/listen.json b/backends/deezer/testdata/listen.json new file mode 100644 index 0000000..08dd374 --- /dev/null +++ b/backends/deezer/testdata/listen.json @@ -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" +} diff --git a/backends/deezer/testdata/user-history.json b/backends/deezer/testdata/user-history.json new file mode 100644 index 0000000..e45ac34 --- /dev/null +++ b/backends/deezer/testdata/user-history.json @@ -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" +}