From 2e6319d2965cd25d9116cb56b55af1158fb70c4a Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 22 Nov 2023 11:52:28 +0100 Subject: [PATCH] Spotify: Loves export --- backends/spotify/spotify.go | 157 ++++++++++++++++++++------- backends/spotify/spotify_test.go | 13 +++ backends/spotify/testdata/track.json | 80 ++++++++++++++ 3 files changed, 212 insertions(+), 38 deletions(-) create mode 100644 backends/spotify/testdata/track.json diff --git a/backends/spotify/spotify.go b/backends/spotify/spotify.go index 5ff8611..c352b3c 100644 --- a/backends/spotify/spotify.go +++ b/backends/spotify/spotify.go @@ -18,6 +18,7 @@ Scotty. If not, see . package spotify import ( + "math" "net/url" "sort" "strconv" @@ -128,51 +129,131 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha progress <- p.Complete() } +func (b *SpotifyApiBackend) 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)} + +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) + offset = result.Total - perPage + if offset < 0 { + offset = 0 + } + continue + } + + count := len(result.Items) + if count == 0 { + break out + } + + loves := make(models.LovesList, 0, perPage) + for _, track := range result.Items { + love := track.ToLove() + if love.Created.Unix() > oldestTimestamp.Unix() { + p.Elapsed += 1 + loves = append(loves, love) + } else { + break + } + } + + sort.Sort(loves) + results <- models.LovesResult{Loves: loves} + 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 (l Listen) ToListen() models.Listen { - track := l.Track + listenedAt, _ := time.Parse(time.RFC3339, l.PlayedAt) listen := models.Listen{ - Track: models.Track{ - TrackName: track.Name, - ReleaseName: track.Album.Name, - ArtistNames: make([]string, 0, len(track.Artists)), - Duration: time.Duration(track.DurationMs * int(time.Millisecond)), - TrackNumber: track.TrackNumber, - Isrc: track.ExternalIds.ISRC, - AdditionalInfo: map[string]any{}, - }, - } - - listen.ListenedAt, _ = time.Parse(time.RFC3339, l.PlayedAt) - - for _, artist := range track.Artists { - listen.Track.ArtistNames = append(listen.Track.ArtistNames, artist.Name) - } - - info := listen.AdditionalInfo - if !l.Track.IsLocal { - info["music_service"] = "spotify.com" - } - - if track.ExternalUrls.Spotify != "" { - info["origin_url"] = track.ExternalUrls.Spotify - info["spotify_id"] = track.ExternalUrls.Spotify - } - - if track.Album.ExternalUrls.Spotify != "" { - info["spotify_album_id"] = track.Album.ExternalUrls.Spotify - } - - if len(track.Artists) > 0 { - info["spotify_artist_ids"] = extractArtistIds(track.Artists) - } - - if len(track.Album.Artists) > 0 { - info["spotify_album_artist_ids"] = extractArtistIds(track.Album.Artists) + ListenedAt: listenedAt, + Track: l.Track.ToTrack(), } return listen } +func (t SavedTrack) ToLove() models.Love { + addedAt, _ := time.Parse(time.RFC3339, t.AddedAt) + love := models.Love{ + Created: addedAt, + Track: t.Track.ToTrack(), + } + + return love +} + +func (t Track) ToTrack() models.Track { + track := models.Track{ + TrackName: t.Name, + ReleaseName: t.Album.Name, + ArtistNames: make([]string, 0, len(t.Artists)), + Duration: time.Duration(t.DurationMs * int(time.Millisecond)), + TrackNumber: t.TrackNumber, + Isrc: t.ExternalIds.ISRC, + AdditionalInfo: map[string]any{}, + } + + for _, artist := range t.Artists { + track.ArtistNames = append(track.ArtistNames, artist.Name) + } + + info := track.AdditionalInfo + if !t.IsLocal { + info["music_service"] = "spotify.com" + } + + if t.ExternalUrls.Spotify != "" { + info["origin_url"] = t.ExternalUrls.Spotify + info["spotify_id"] = t.ExternalUrls.Spotify + } + + if t.Album.ExternalUrls.Spotify != "" { + info["spotify_album_id"] = t.Album.ExternalUrls.Spotify + } + + if len(t.Artists) > 0 { + info["spotify_artist_ids"] = extractArtistIds(t.Artists) + } + + if len(t.Album.Artists) > 0 { + info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists) + } + + return track +} + func extractArtistIds(artists []Artist) []string { artistIds := make([]string, len(artists)) for i, artist := range artists { diff --git a/backends/spotify/spotify_test.go b/backends/spotify/spotify_test.go index edb7472..8180349 100644 --- a/backends/spotify/spotify_test.go +++ b/backends/spotify/spotify_test.go @@ -51,3 +51,16 @@ func TestSpotifyListenToListen(t *testing.T) { assert.Equal(t, []string{"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"}, info["spotify_artist_ids"]) assert.Equal(t, []string{"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"}, info["spotify_album_artist_ids"]) } + +func TestSavedTrackToLove(t *testing.T) { + data, err := os.ReadFile("testdata/track.json") + require.NoError(t, err) + track := spotify.SavedTrack{} + err = json.Unmarshal(data, &track) + require.NoError(t, err) + love := track.ToLove() + created, _ := time.Parse(time.RFC3339, "2022-02-13T21:46:08Z") + assert.Equal(t, created, love.Created) + assert.Equal(t, time.Duration(187680*time.Millisecond), love.Duration) + assert.Equal(t, "Death to the Holy", love.TrackName) +} diff --git a/backends/spotify/testdata/track.json b/backends/spotify/testdata/track.json new file mode 100644 index 0000000..afa398a --- /dev/null +++ b/backends/spotify/testdata/track.json @@ -0,0 +1,80 @@ +{ + "added_at": "2022-02-13T21:46:08Z", + "track": { + "album": { + "album_type": "album", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y" + }, + "href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y", + "id": "6yCjbLFZ9qAnWfsy9ujm5Y", + "name": "Zeal & Ardor", + "type": "artist", + "uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y" + } + ], + "available_markets": [], + "external_urls": { + "spotify": "https://open.spotify.com/album/34u4cq27YP6837IcmzTTgX" + }, + "href": "https://api.spotify.com/v1/albums/34u4cq27YP6837IcmzTTgX", + "id": "34u4cq27YP6837IcmzTTgX", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab67616d0000b2731fedf861c139b6d8bebdc840", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67616d00001e021fedf861c139b6d8bebdc840", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab67616d000048511fedf861c139b6d8bebdc840", + "width": 64 + } + ], + "name": "Zeal & Ardor", + "release_date": "2022-02-11", + "release_date_precision": "day", + "total_tracks": 14, + "type": "album", + "uri": "spotify:album:34u4cq27YP6837IcmzTTgX" + }, + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y" + }, + "href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y", + "id": "6yCjbLFZ9qAnWfsy9ujm5Y", + "name": "Zeal & Ardor", + "type": "artist", + "uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y" + } + ], + "available_markets": [], + "disc_number": 1, + "duration_ms": 187680, + "explicit": false, + "external_ids": { + "isrc": "CH8092101707" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/07K2e1PXNra3Zd5SGCsLuZ" + }, + "href": "https://api.spotify.com/v1/tracks/07K2e1PXNra3Zd5SGCsLuZ", + "id": "07K2e1PXNra3Zd5SGCsLuZ", + "is_local": false, + "name": "Death to the Holy", + "popularity": 0, + "preview_url": null, + "track_number": 3, + "type": "track", + "uri": "spotify:track:07K2e1PXNra3Zd5SGCsLuZ" + } +}