diff --git a/backends/backends.go b/backends/backends.go index 99ad3f9..98949cf 100644 --- a/backends/backends.go +++ b/backends/backends.go @@ -34,6 +34,7 @@ import ( "go.uploadedlobster.com/scotty/backends/listenbrainz" "go.uploadedlobster.com/scotty/backends/maloja" "go.uploadedlobster.com/scotty/backends/scrobblerlog" + "go.uploadedlobster.com/scotty/backends/spotify" "go.uploadedlobster.com/scotty/backends/subsonic" "go.uploadedlobster.com/scotty/models" ) @@ -84,6 +85,7 @@ var knownBackends = map[string]func() models.Backend{ "listenbrainz": func() models.Backend { return &listenbrainz.ListenBrainzApiBackend{} }, "maloja": func() models.Backend { return &maloja.MalojaApiBackend{} }, "scrobbler-log": func() models.Backend { return &scrobblerlog.ScrobblerLogBackend{} }, + "spotify": func() models.Backend { return &spotify.SpotifyApiBackend{} }, "subsonic": func() models.Backend { return &subsonic.SubsonicApiBackend{} }, } diff --git a/backends/spotify/client.go b/backends/spotify/client.go new file mode 100644 index 0000000..13a5af2 --- /dev/null +++ b/backends/spotify/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 spotify + +import ( + "context" + "errors" + "strconv" + "time" + + "github.com/go-resty/resty/v2" + "golang.org/x/oauth2" +) + +const baseURL = "https://api.spotify.com/v1/" +const MaxItemsPerGet = 50 + +type Client struct { + HttpClient *resty.Client +} + +func NewClient(conf oauth2.Config, token *oauth2.Token) Client { + ctx := context.Background() + httpClient := conf.Client(ctx, token) + client := resty.NewWithClient(httpClient) + client.SetBaseURL(baseURL) + client.SetHeader("Accept", "application/json") + client.SetRetryCount(5) + return Client{ + HttpClient: client, + } +} + +func (c Client) RecentlyPlayedAfter(after time.Time, limit int) (RecentlyPlayedResult, error) { + return c.recentlyPlayed(&after, nil, limit) +} + +func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlayedResult, error) { + return c.recentlyPlayed(nil, &before, limit) +} + +func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) { + const path = "/me/player/recently-played" + request := c.HttpClient.R(). + SetQueryParam("limit", strconv.Itoa(limit)). + SetResult(&result) + if after != nil { + request.SetQueryParam("after", strconv.FormatInt(after.Unix(), 10)) + } else if before != nil { + request.SetQueryParam("before", strconv.FormatInt(before.Unix(), 10)) + + } + response, err := request.Get(path) + + if response.StatusCode() != 200 { + err = errors.New(response.String()) + return + } + return +} diff --git a/backends/spotify/client_test.go b/backends/spotify/client_test.go new file mode 100644 index 0000000..5e16c0e --- /dev/null +++ b/backends/spotify/client_test.go @@ -0,0 +1,37 @@ +/* +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 spotify_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uploadedlobster.com/scotty/backends/spotify" + "golang.org/x/oauth2" +) + +func TestNewClient(t *testing.T) { + conf := oauth2.Config{} + token := &oauth2.Token{} + client := spotify.NewClient(conf, token) + assert.IsType(t, spotify.Client{}, client) +} diff --git a/backends/spotify/models.go b/backends/spotify/models.go new file mode 100644 index 0000000..1c1fa4b --- /dev/null +++ b/backends/spotify/models.go @@ -0,0 +1,99 @@ +/* +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 spotify + +type RecentlyPlayedResult struct { + Href string `json:"href"` + Limit int `json:"limit"` + Next string `json:"next"` + Cursors Cursors `json:"cursors"` + Items []Listen `json:"items"` +} + +type Cursors struct { + After string `json:"after"` + Before string `json:"before"` +} + +type Listen struct { + Track Track `json:"track"` + PlayedAt string `json:"played_at"` +} + +type Track struct { + Id string `json:"id"` + Name string `json:"name"` + Href string `json:"href"` + Uri string `json:"uri"` + Type string `json:"type"` + DiscNumber int `json:"disc_number"` + TrackNumber int `json:"track_number"` + DurationMs int `json:"duration_ms"` + Explicit bool `json:"explicit"` + IsLocal bool `json:"is_local"` + Popularity int `json:"popularity"` + ExternalIds ExternalIds `json:"external_ids"` + ExternalUrls ExternalUrls `json:"external_urls"` + Album Album `json:"album"` + Artists []Artist `json:"artists"` +} + +type Album struct { + Id string `json:"id"` + Name string `json:"name"` + Href string `json:"href"` + Uri string `json:"uri"` + Type string `json:"type"` + TotalTracks int `json:"total_tracks"` + ReleaseDate string `json:"release_date"` + ReleaseDatePrecision string `json:"release_date_precision"` + AlbumType string `json:"album_type"` + ExternalUrls ExternalUrls `json:"external_urls"` + Artists []Artist `json:"artists"` + Images []Image `json:"images"` +} + +type Artist struct { + Id string `json:"id"` + Name string `json:"name"` + Href string `json:"href"` + Uri string `json:"uri"` + Type string `json:"type"` + ExternalUrls ExternalUrls `json:"external_urls"` +} + +type ExternalIds struct { + ISRC string `json:"isrc"` + EAN string `json:"ean"` + UPC string `json:"upc"` +} + +type ExternalUrls struct { + Spotify string `json:"spotify"` +} + +type Image struct { + Url string `json:"url"` + Height int `json:"height"` + Width int `json:"width"` +} diff --git a/backends/spotify/models_test.go b/backends/spotify/models_test.go new file mode 100644 index 0000000..0aa8fdb --- /dev/null +++ b/backends/spotify/models_test.go @@ -0,0 +1,51 @@ +/* +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 spotify_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uploadedlobster.com/scotty/backends/spotify" +) + +func TestRecentlyPlayedResult(t *testing.T) { + data, err := os.ReadFile("testdata/recently-played.json") + require.NoError(t, err) + result := spotify.RecentlyPlayedResult{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + assert := assert.New(t) + assert.Equal(3, result.Limit) + assert.Equal("1700578807229", result.Cursors.After) + require.Len(t, result.Items, 3) + track1 := result.Items[0].Track + assert.Equal("Evidence", track1.Name) + assert.Equal("Viva Emptiness", track1.Album.Name) + require.Len(t, track1.Artists, 1) + assert.Equal("Katatonia", track1.Artists[0].Name) +} diff --git a/backends/spotify/spotify.go b/backends/spotify/spotify.go new file mode 100644 index 0000000..d40db39 --- /dev/null +++ b/backends/spotify/spotify.go @@ -0,0 +1,168 @@ +/* +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 spotify + +import ( + "errors" + "sort" + "strconv" + "time" + + "github.com/spf13/viper" + "go.uploadedlobster.com/scotty/models" + "golang.org/x/oauth2" + "golang.org/x/oauth2/spotify" +) + +type SpotifyApiBackend struct { + client Client + clientId string + clientSecret string +} + +func (b *SpotifyApiBackend) FromConfig(config *viper.Viper) models.Backend { + b.clientId = config.GetString("client-id") + b.clientSecret = config.GetString("client-secret") + return b +} + +func (b *SpotifyApiBackend) OAuth2Config(redirectUrl string) oauth2.Config { + return oauth2.Config{ + ClientID: b.clientId, + ClientSecret: b.clientSecret, + Scopes: []string{"user-read-recently-played"}, + RedirectURL: redirectUrl, + Endpoint: spotify.Endpoint, + } +} + +func (b *SpotifyApiBackend) OAuth2Setup(redirectUrl string, token *oauth2.Token) error { + config := b.OAuth2Config(redirectUrl) + b.client = NewClient(config, token) + return nil +} + +func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { + startTime := time.Now() + minTime := oldestTimestamp + + totalDuration := startTime.Sub(oldestTimestamp) + + defer close(results) + defer close(progress) + + p := models.Progress{Total: int64(totalDuration.Seconds())} + + for { + result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet) + if err != nil { + progress <- p.Complete() + results <- models.ListensResult{Error: err} + return + } + + count := len(result.Items) + if count == 0 { + break + } + + listens := make(models.ListensList, 0, len(result.Items)) + + // Set minTime to the newest returned listen + after, err := strconv.ParseInt(result.Cursors.After, 10, 64) + if err != nil && after <= minTime.Unix() { + err = errors.New("new cursor timestamp did not progress") + } + if err != nil { + progress <- p.Complete() + results <- models.ListensResult{Error: err} + return + } + minTime = time.Unix(after, 0) + remainingTime := startTime.Sub(minTime) + + for _, listen := range result.Items { + listens = append(listens, listen.ToListen()) + } + + sort.Sort(listens) + p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) + progress <- p + results <- models.ListensResult{Listens: listens, OldestTimestamp: oldestTimestamp} + } + + progress <- p.Complete() +} + +func (l Listen) ToListen() models.Listen { + track := l.Track + 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) + } + + return listen +} + +func extractArtistIds(artists []Artist) []string { + artistIds := make([]string, len(artists)) + for i, artist := range artists { + artistIds[i] = artist.ExternalUrls.Spotify + } + return artistIds +} diff --git a/backends/spotify/spotify_test.go b/backends/spotify/spotify_test.go new file mode 100644 index 0000000..41e9ea4 --- /dev/null +++ b/backends/spotify/spotify_test.go @@ -0,0 +1,58 @@ +/* +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 spotify_test + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uploadedlobster.com/scotty/backends/spotify" +) + +func TestSpotifyListenToListen(t *testing.T) { + data, err := os.ReadFile("testdata/listen.json") + require.NoError(t, err) + spListen := spotify.Listen{} + err = json.Unmarshal(data, &spListen) + require.NoError(t, err) + listen := spListen.ToListen() + listenedAt, _ := time.Parse(time.RFC3339, "2023-11-21T15:24:33.361Z") + assert.Equal(t, listenedAt, listen.ListenedAt) + assert.Equal(t, time.Duration(413826*time.Millisecond), listen.Duration) + assert.Equal(t, "Oweynagat", listen.TrackName) + assert.Equal(t, "Here Now, There Then", listen.ReleaseName) + assert.Equal(t, []string{"Dool"}, listen.ArtistNames) + assert.Equal(t, 5, listen.TrackNumber) + assert.Equal(t, "DES561620801", listen.Isrc) + info := listen.AdditionalInfo + assert.Equal(t, "spotify.com", info["music_service"]) + assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"]) + assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["spotify_id"]) + assert.Equal(t, "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA", info["spotify_album_id"]) + 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"]) +} diff --git a/backends/spotify/testdata/listen.json b/backends/spotify/testdata/listen.json new file mode 100644 index 0000000..e1bde30 --- /dev/null +++ b/backends/spotify/testdata/listen.json @@ -0,0 +1,458 @@ +{ + "track": { + "album": { + "album_type": "album", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz" + }, + "href": "https://api.spotify.com/v1/artists/101HSR6JTJqe3DBh6rb8kz", + "id": "101HSR6JTJqe3DBh6rb8kz", + "name": "Dool", + "type": "artist", + "uri": "spotify:artist:101HSR6JTJqe3DBh6rb8kz" + } + ], + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA" + }, + "href": "https://api.spotify.com/v1/albums/5U1umzRH4EONHWsFgPtRbA", + "id": "5U1umzRH4EONHWsFgPtRbA", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab67616d0000b273c7b579ace1f3f56381d83aad", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67616d00001e02c7b579ace1f3f56381d83aad", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab67616d00004851c7b579ace1f3f56381d83aad", + "width": 64 + } + ], + "name": "Here Now, There Then", + "release_date": "2017-02-17", + "release_date_precision": "day", + "total_tracks": 8, + "type": "album", + "uri": "spotify:album:5U1umzRH4EONHWsFgPtRbA" + }, + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz" + }, + "href": "https://api.spotify.com/v1/artists/101HSR6JTJqe3DBh6rb8kz", + "id": "101HSR6JTJqe3DBh6rb8kz", + "name": "Dool", + "type": "artist", + "uri": "spotify:artist:101HSR6JTJqe3DBh6rb8kz" + } + ], + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "disc_number": 1, + "duration_ms": 413826, + "explicit": false, + "external_ids": { + "isrc": "DES561620801" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V" + }, + "href": "https://api.spotify.com/v1/tracks/2JKUgGuXK3dEvyuIJ4Yj2V", + "id": "2JKUgGuXK3dEvyuIJ4Yj2V", + "is_local": false, + "name": "Oweynagat", + "popularity": 28, + "preview_url": "https://p.scdn.co/mp3-preview/5f01ec09c7a470b9899dc4b4bb427302dc648f24?cid=5433a04d90a946f2a0e5175b1383604a", + "track_number": 5, + "type": "track", + "uri": "spotify:track:2JKUgGuXK3dEvyuIJ4Yj2V" + }, + "played_at": "2023-11-21T15:24:33.361Z", + "context": { + "type": "playlist", + "external_urls": { + "spotify": "https://open.spotify.com/playlist/37i9dQZF1E4odrTa1nmoN6" + }, + "href": "https://api.spotify.com/v1/playlists/37i9dQZF1E4odrTa1nmoN6", + "uri": "spotify:playlist:37i9dQZF1E4odrTa1nmoN6" + } +} diff --git a/backends/spotify/testdata/recently-played.json b/backends/spotify/testdata/recently-played.json new file mode 100644 index 0000000..e56ef93 --- /dev/null +++ b/backends/spotify/testdata/recently-played.json @@ -0,0 +1,1385 @@ +{ + "items": [ + { + "track": { + "album": { + "album_type": "album", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/2CWWgbxApjbyByxBBCvGTm" + }, + "href": "https://api.spotify.com/v1/artists/2CWWgbxApjbyByxBBCvGTm", + "id": "2CWWgbxApjbyByxBBCvGTm", + "name": "Katatonia", + "type": "artist", + "uri": "spotify:artist:2CWWgbxApjbyByxBBCvGTm" + } + ], + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/1bpHfzPfbM3vdcVRLIJXvf" + }, + "href": "https://api.spotify.com/v1/albums/1bpHfzPfbM3vdcVRLIJXvf", + "id": "1bpHfzPfbM3vdcVRLIJXvf", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab67616d0000b273c806cc5f50d324c723104001", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67616d00001e02c806cc5f50d324c723104001", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab67616d00004851c806cc5f50d324c723104001", + "width": 64 + } + ], + "name": "Viva Emptiness", + "release_date": "2003", + "release_date_precision": "year", + "total_tracks": 13, + "type": "album", + "uri": "spotify:album:1bpHfzPfbM3vdcVRLIJXvf" + }, + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/2CWWgbxApjbyByxBBCvGTm" + }, + "href": "https://api.spotify.com/v1/artists/2CWWgbxApjbyByxBBCvGTm", + "id": "2CWWgbxApjbyByxBBCvGTm", + "name": "Katatonia", + "type": "artist", + "uri": "spotify:artist:2CWWgbxApjbyByxBBCvGTm" + } + ], + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "disc_number": 1, + "duration_ms": 276040, + "explicit": false, + "external_ids": { + "isrc": "GBCQV0300472" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/7csIkxf9PhFkwNk9nXM4HP" + }, + "href": "https://api.spotify.com/v1/tracks/7csIkxf9PhFkwNk9nXM4HP", + "id": "7csIkxf9PhFkwNk9nXM4HP", + "is_local": false, + "name": "Evidence", + "popularity": 34, + "preview_url": "https://p.scdn.co/mp3-preview/29deeb77e427d87fbb7962c404a97035cbcf2e75?cid=5433a04d90a946f2a0e5175b1383604a", + "track_number": 11, + "type": "track", + "uri": "spotify:track:7csIkxf9PhFkwNk9nXM4HP" + }, + "played_at": "2023-11-21T15:00:07.229Z", + "context": { + "type": "playlist", + "external_urls": { + "spotify": "https://open.spotify.com/playlist/37i9dQZF1E4odrTa1nmoN6" + }, + "href": "https://api.spotify.com/v1/playlists/37i9dQZF1E4odrTa1nmoN6", + "uri": "spotify:playlist:37i9dQZF1E4odrTa1nmoN6" + } + }, + { + "track": { + "album": { + "album_type": "album", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/7iMWWdRNiCJwGOGEIYr02z" + }, + "href": "https://api.spotify.com/v1/artists/7iMWWdRNiCJwGOGEIYr02z", + "id": "7iMWWdRNiCJwGOGEIYr02z", + "name": "My Dying Bride", + "type": "artist", + "uri": "spotify:artist:7iMWWdRNiCJwGOGEIYr02z" + } + ], + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/5sjGax2ZylCiWZdJh1k1Pd" + }, + "href": "https://api.spotify.com/v1/albums/5sjGax2ZylCiWZdJh1k1Pd", + "id": "5sjGax2ZylCiWZdJh1k1Pd", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab67616d0000b27376b8962c59f35dd6b9f88017", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67616d00001e0276b8962c59f35dd6b9f88017", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab67616d0000485176b8962c59f35dd6b9f88017", + "width": 64 + } + ], + "name": "Songs Of Darkness, Words Of Light", + "release_date": "2004-03-09", + "release_date_precision": "day", + "total_tracks": 8, + "type": "album", + "uri": "spotify:album:5sjGax2ZylCiWZdJh1k1Pd" + }, + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/7iMWWdRNiCJwGOGEIYr02z" + }, + "href": "https://api.spotify.com/v1/artists/7iMWWdRNiCJwGOGEIYr02z", + "id": "7iMWWdRNiCJwGOGEIYr02z", + "name": "My Dying Bride", + "type": "artist", + "uri": "spotify:artist:7iMWWdRNiCJwGOGEIYr02z" + } + ], + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "disc_number": 1, + "duration_ms": 353546, + "explicit": true, + "external_ids": { + "isrc": "GBCQV0400955" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/6ZAuCrqQWJUgSh4Ttdwlob" + }, + "href": "https://api.spotify.com/v1/tracks/6ZAuCrqQWJUgSh4Ttdwlob", + "id": "6ZAuCrqQWJUgSh4Ttdwlob", + "is_local": false, + "name": "My Wine In Silence", + "popularity": 37, + "preview_url": "https://p.scdn.co/mp3-preview/cc6d57e92e5293854f74349d1b4c96548cfbaf22?cid=5433a04d90a946f2a0e5175b1383604a", + "track_number": 4, + "type": "track", + "uri": "spotify:track:6ZAuCrqQWJUgSh4Ttdwlob" + }, + "played_at": "2023-11-21T14:55:31.183Z", + "context": { + "type": "playlist", + "external_urls": { + "spotify": "https://open.spotify.com/playlist/37i9dQZF1E4odrTa1nmoN6" + }, + "href": "https://api.spotify.com/v1/playlists/37i9dQZF1E4odrTa1nmoN6", + "uri": "spotify:playlist:37i9dQZF1E4odrTa1nmoN6" + } + }, + { + "track": { + "album": { + "album_type": "album", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/0gIo6kGl4KsCeIbqtZVHYp" + }, + "href": "https://api.spotify.com/v1/artists/0gIo6kGl4KsCeIbqtZVHYp", + "id": "0gIo6kGl4KsCeIbqtZVHYp", + "name": "Paradise Lost", + "type": "artist", + "uri": "spotify:artist:0gIo6kGl4KsCeIbqtZVHYp" + } + ], + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/6WnufMaR2nUQ8qDvJgBN0f" + }, + "href": "https://api.spotify.com/v1/albums/6WnufMaR2nUQ8qDvJgBN0f", + "id": "6WnufMaR2nUQ8qDvJgBN0f", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab67616d0000b273856d88741eac30a482942bfa", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67616d00001e02856d88741eac30a482942bfa", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab67616d00004851856d88741eac30a482942bfa", + "width": 64 + } + ], + "name": "Paradise Lost", + "release_date": "2005-03-17", + "release_date_precision": "day", + "total_tracks": 12, + "type": "album", + "uri": "spotify:album:6WnufMaR2nUQ8qDvJgBN0f" + }, + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/0gIo6kGl4KsCeIbqtZVHYp" + }, + "href": "https://api.spotify.com/v1/artists/0gIo6kGl4KsCeIbqtZVHYp", + "id": "0gIo6kGl4KsCeIbqtZVHYp", + "name": "Paradise Lost", + "type": "artist", + "uri": "spotify:artist:0gIo6kGl4KsCeIbqtZVHYp" + } + ], + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "disc_number": 1, + "duration_ms": 207453, + "explicit": false, + "external_ids": { + "isrc": "DEC760500027" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/3k9vdxG9VEzN6hwTHXOY5a" + }, + "href": "https://api.spotify.com/v1/tracks/3k9vdxG9VEzN6hwTHXOY5a", + "id": "3k9vdxG9VEzN6hwTHXOY5a", + "is_local": false, + "name": "Grey", + "popularity": 32, + "preview_url": "https://p.scdn.co/mp3-preview/90a7c8abce0097a5253d6e09a05d6ec043102c39?cid=5433a04d90a946f2a0e5175b1383604a", + "track_number": 3, + "type": "track", + "uri": "spotify:track:3k9vdxG9VEzN6hwTHXOY5a" + }, + "played_at": "2023-11-21T14:49:37.641Z", + "context": { + "type": "playlist", + "external_urls": { + "spotify": "https://open.spotify.com/playlist/37i9dQZF1E4odrTa1nmoN6" + }, + "href": "https://api.spotify.com/v1/playlists/37i9dQZF1E4odrTa1nmoN6", + "uri": "spotify:playlist:37i9dQZF1E4odrTa1nmoN6" + } + } + ], + "next": "https://api.spotify.com/v1/me/player/recently-played?before=1700578177641&limit=3", + "cursors": { + "after": "1700578807229", + "before": "1700578177641" + }, + "limit": 3, + "href": "https://api.spotify.com/v1/me/player/recently-played?limit=3" +} diff --git a/cmd/listens.go b/cmd/listens.go index 395391e..d593033 100644 --- a/cmd/listens.go +++ b/cmd/listens.go @@ -43,15 +43,19 @@ var listensCmd = &cobra.Command{ targetName, targetConfig := getConfigFromFlag(cmd, "to") fmt.Printf("Transferring listens from %s to %s...\n", sourceName, targetName) + // Setup database + db, err := storage.New(viper.GetString("database")) + cobra.CheckErr(err) + // Initialize backends exportBackend, err := backends.ResolveBackend[models.ListensExport](sourceConfig) cobra.CheckErr(err) importBackend, err := backends.ResolveBackend[models.ListensImport](targetConfig) cobra.CheckErr(err) - // Setup database - db, err := storage.New(viper.GetString("database")) - cobra.CheckErr(err) + // Authenticate backends, if needed + backends.Authenticate(exportBackend, db, viper.GetViper()) + backends.Authenticate(importBackend, db, viper.GetViper()) // Read timestamp timestamp := time.Unix(getInt64FromFlag(cmd, "timestamp"), 0) diff --git a/models/interfaces.go b/models/interfaces.go index 14d3abb..81dc4a5 100644 --- a/models/interfaces.go +++ b/models/interfaces.go @@ -25,6 +25,7 @@ import ( "time" "github.com/spf13/viper" + "golang.org/x/oauth2" ) // A listen service backend. @@ -34,6 +35,15 @@ type Backend interface { FromConfig(config *viper.Viper) Backend } +type OAuth2Backend interface { + Backend + // Returns OAuth2 config suitable for this backend + OAuth2Config(redirectUrl string) oauth2.Config + + // Setup the OAuth2 client + OAuth2Setup(redirectUrl string, token *oauth2.Token) error +} + type ImportBackend interface { Backend